Combine source navigation and alias rename with canvas ownership guards
This commit is contained in:
@@ -18,6 +18,7 @@ serde_json.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
tempfile = "3"
|
||||
unicode-segmentation = "1.13.3"
|
||||
|
||||
[dev-dependencies]
|
||||
iced_runtime = "0.14"
|
||||
|
||||
+311
-29
@@ -10,12 +10,16 @@ mod diagram_focus_tests;
|
||||
pub(crate) mod diagram_removal;
|
||||
#[cfg(test)]
|
||||
mod diagram_removal_tests;
|
||||
mod diagram_reveal;
|
||||
#[cfg(test)]
|
||||
mod diagram_selection_tests;
|
||||
mod diagram_usages;
|
||||
mod hosted;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
pub(crate) mod source_rename;
|
||||
mod source_symbols;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
#[cfg(test)]
|
||||
@@ -438,9 +442,20 @@ pub enum Message {
|
||||
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
|
||||
SourceDiagnostic(usize),
|
||||
SourceRelated(SourceLocation),
|
||||
SourceViewport,
|
||||
SourceViewport(Option<Id>),
|
||||
SourceSymbols(
|
||||
crate::source_navigation::SymbolContext,
|
||||
crate::source_navigation::SymbolEvent,
|
||||
),
|
||||
SourceSymbolsReady(crate::source_navigation::Completion),
|
||||
SourceRename(source_rename::Event),
|
||||
SourceRenameReady(source_rename::Completion),
|
||||
DiagramUsage(crate::diagram_usage::Event),
|
||||
DiagramUsagesReady(crate::diagram_usage::Completion),
|
||||
DiagramRevealViewport(crate::diagram_usage::Context, u64, iced::Size),
|
||||
SourceForSelection,
|
||||
SourceNavigation(crate::source_navigation::Event),
|
||||
SourceFindClose(crate::source_navigation::SymbolContext, Id),
|
||||
Save(bool),
|
||||
SavePath(Option<PathBuf>),
|
||||
Saved(Result<Option<FileSnapshot>, SaveFailure>),
|
||||
@@ -520,6 +535,18 @@ pub struct App {
|
||||
pub source_jump: Option<text_editor::Cursor>,
|
||||
source_select_line: bool,
|
||||
pub source_navigation: crate::source_navigation::State,
|
||||
source_find_instance: Id,
|
||||
pub source_symbols: crate::source_navigation::SymbolState,
|
||||
pub source_rename: source_rename::State,
|
||||
pub(crate) diagram_usages: Option<crate::diagram_usage::Panel>,
|
||||
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
|
||||
source_diagram_job: Option<Id>,
|
||||
diagram_lookup_in_flight: Option<Id>,
|
||||
pending_diagram_reveal: Option<(
|
||||
crate::diagram_usage::Context,
|
||||
crate::diagram_usage::Usage,
|
||||
u64,
|
||||
)>,
|
||||
pub model_issues: Vec<validation::Issue>,
|
||||
pub migration: Option<persistence::MigrationReport>,
|
||||
source_version: u64,
|
||||
@@ -630,6 +657,14 @@ impl App {
|
||||
source_jump: None,
|
||||
source_select_line: false,
|
||||
source_navigation: crate::source_navigation::State::default(),
|
||||
source_find_instance: Id::new_v4(),
|
||||
source_symbols: crate::source_navigation::SymbolState::default(),
|
||||
source_rename: source_rename::State::default(),
|
||||
diagram_usages: None,
|
||||
diagram_navigation_focus: None,
|
||||
source_diagram_job: None,
|
||||
diagram_lookup_in_flight: None,
|
||||
pending_diagram_reveal: None,
|
||||
model_issues,
|
||||
migration: None,
|
||||
source_version: 0,
|
||||
@@ -676,18 +711,37 @@ impl App {
|
||||
|| self.behavior.has_pending()
|
||||
}
|
||||
fn sync_source(&mut self) {
|
||||
self.sync_source_with_alias_editor(None);
|
||||
}
|
||||
fn sync_source_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
|
||||
self.source_workspace.accept(self.document.bundle());
|
||||
self.show_source_file(false);
|
||||
self.show_source_file_with_alias_editor(false, hint);
|
||||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||||
self.source_version += 1;
|
||||
self.lint_requested = None;
|
||||
}
|
||||
fn show_source_file(&mut self, restore_cursor: bool) {
|
||||
self.show_source_file_with_alias_editor(restore_cursor, None);
|
||||
}
|
||||
fn show_source_file_with_alias_editor(
|
||||
&mut self,
|
||||
restore_cursor: bool,
|
||||
hint: Option<source_rename::EditorRefresh<'_>>,
|
||||
) {
|
||||
self.source_symbols.reveal = None;
|
||||
self.source_tree.reveal(&self.source_workspace.active);
|
||||
self.source_select_line = false;
|
||||
let source = self.source_workspace.text().to_owned();
|
||||
self.source_navigation.refresh(&source);
|
||||
self.source_editor = text_editor::Content::with_text(&source);
|
||||
if !hint.is_some_and(|hint| {
|
||||
hint.apply(
|
||||
&mut self.source_editor,
|
||||
&self.source_workspace.active,
|
||||
&source,
|
||||
)
|
||||
}) {
|
||||
self.source_editor = text_editor::Content::with_text(&source);
|
||||
}
|
||||
self.source_snapshot = Arc::from(source);
|
||||
self.source_jump = restore_cursor
|
||||
.then(|| {
|
||||
@@ -697,6 +751,9 @@ impl App {
|
||||
.map(|b| b.cursor)
|
||||
})
|
||||
.flatten();
|
||||
if let Some(cursor) = self.source_jump {
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
}
|
||||
fn selected_architecture_placement(&self) -> Option<Id> {
|
||||
self.active_diagram
|
||||
@@ -729,6 +786,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
fn sync(&mut self) {
|
||||
self.sync_with_alias_editor(None);
|
||||
}
|
||||
fn sync_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.model_issues = validate(self.project());
|
||||
self.normalize_creation();
|
||||
@@ -758,7 +818,7 @@ impl App {
|
||||
.map(|d| d.id);
|
||||
}
|
||||
self.refresh_draft();
|
||||
self.sync_source();
|
||||
self.sync_source_with_alias_editor(hint);
|
||||
}
|
||||
fn normalize_creation(&mut self) {
|
||||
if !self.create_kind.supports(self.layer) {
|
||||
@@ -950,6 +1010,9 @@ impl App {
|
||||
let canvas_size = self.canvas_size;
|
||||
let source_version = self.source_version + 1;
|
||||
let lint_running = self.lint_running;
|
||||
let navigation_in_flight = self.source_symbols.in_flight;
|
||||
let rename_in_flight = self.source_rename.in_flight;
|
||||
let diagram_lookup_in_flight = self.diagram_lookup_in_flight;
|
||||
let guide = std::mem::take(&mut self.guide);
|
||||
let mut hosted = std::mem::take(&mut self.hosted);
|
||||
hosted.leave_document();
|
||||
@@ -961,6 +1024,9 @@ impl App {
|
||||
self.canvas_size = canvas_size;
|
||||
self.source_version = source_version;
|
||||
self.lint_running = lint_running;
|
||||
self.source_symbols.in_flight = navigation_in_flight;
|
||||
self.source_rename.in_flight = rename_in_flight;
|
||||
self.diagram_lookup_in_flight = diagram_lookup_in_flight;
|
||||
self.guide = guide;
|
||||
self.path = path;
|
||||
self.status = "Project ready".into();
|
||||
@@ -1061,16 +1127,57 @@ impl App {
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::behavior::scenario::focus::root(
|
||||
behavior::canvas_workspace::focus_root(
|
||||
crate::hosted::view::overlay(self, self.reference_overlay(base)),
|
||||
behavior::canvas_workspace::Scope::input_owner(self),
|
||||
crate::source_navigation::interaction::observe(
|
||||
crate::behavior::scenario::focus::root(
|
||||
behavior::canvas_workspace::focus_root(
|
||||
crate::hosted::view::overlay(
|
||||
self,
|
||||
self.reference_overlay(crate::diagram_usage::view::overlay(self, base)),
|
||||
),
|
||||
behavior::canvas_workspace::Scope::input_owner(self),
|
||||
),
|
||||
self.scenario_focus_owner(),
|
||||
),
|
||||
self.scenario_focus_owner(),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
)
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
self.diagram_usages
|
||||
.as_ref()
|
||||
.map_or_else(Subscription::none, |panel| {
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modifiers,
|
||||
..
|
||||
}) = event
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if !modifiers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match key {
|
||||
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => Some(-1i8),
|
||||
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => Some(1i8),
|
||||
keyboard::Key::Named(keyboard::key::Named::Enter)
|
||||
if status == iced::event::Status::Ignored =>
|
||||
{
|
||||
Some(0i8)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.with(panel.token)
|
||||
.map(|(token, step)| {
|
||||
Message::DiagramUsage(if step == 0 {
|
||||
crate::diagram_usage::Event::Activate(token)
|
||||
} else {
|
||||
crate::diagram_usage::Event::Move(token, step)
|
||||
})
|
||||
})
|
||||
}),
|
||||
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
|
||||
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
|
||||
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
|
||||
@@ -1082,8 +1189,17 @@ impl App {
|
||||
)
|
||||
.then_some(Message::WorkDrag(allocation_drag::Event::KeyboardBoundary))
|
||||
}),
|
||||
// Use one captured-Escape bridge: a focused Find input consumes
|
||||
// Escape, while modal priority remains in the ordinary handler.
|
||||
behavior::creation::subscription(
|
||||
self.behavior.creation.has_pending() || self.behavior.source_active(),
|
||||
self.behavior.creation.has_pending()
|
||||
|| self.diagram_usages.is_some()
|
||||
|| self.behavior.source_active()
|
||||
|| (self.page == Page::Text
|
||||
&& (self.source_symbols.open
|
||||
|| self.source_navigation.open
|
||||
|| self.source_rename.open())
|
||||
&& self.architecture.is_none()),
|
||||
)
|
||||
.map(|()| Message::Escape),
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
@@ -1133,6 +1249,31 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// A queued toolbar from a previous input owner cannot retire a newer
|
||||
// navigation destination or allocation capability before dispatch.
|
||||
if let Message::BehaviorControl(intent) = &message
|
||||
&& behavior::canvas_workspace::Scope::input_owner(self) != Some(intent.scope)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Reject stale rename intents before camera/reveal reconciliation or
|
||||
// source interaction can disturb a newer navigation destination.
|
||||
if let Message::SourceRename(event) = &message
|
||||
&& !self.source_rename_envelope(event)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let usage_viewport = self.diagram_reveal_viewport(&message);
|
||||
let window_resized = matches!(&message, Message::WindowResized(_));
|
||||
// Reject stale measurements before legacy viewport handlers can change
|
||||
// dimensions or invalidate the exact pending destination epoch.
|
||||
if self.pending_diagram_reveal.is_some()
|
||||
&& Self::is_diagram_viewport(&message)
|
||||
&& usage_viewport.is_none()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.reconcile_pending_diagram_reveal(&message);
|
||||
self.allocation_before(&message);
|
||||
if let Message::WorkDrag(event) = message {
|
||||
return self.allocation_event(event);
|
||||
@@ -1151,6 +1292,19 @@ impl App {
|
||||
if self.allocation_boundary() != allocation_before {
|
||||
self.allocation_retire();
|
||||
}
|
||||
if window_resized && let Some((_, _, epoch)) = &mut self.pending_diagram_reveal {
|
||||
*epoch = self.canvas_epoch;
|
||||
}
|
||||
if let Some(surface) = usage_viewport {
|
||||
self.finish_diagram_reveal(surface);
|
||||
}
|
||||
if self
|
||||
.diagram_navigation_focus
|
||||
.as_ref()
|
||||
.is_some_and(|focus| self.diagram_focus(focus.usage.surface).is_none())
|
||||
{
|
||||
self.diagram_navigation_focus = None;
|
||||
}
|
||||
if !camera && self.camera_boundary() != before {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
@@ -1227,6 +1381,78 @@ impl App {
|
||||
)
|
||||
}
|
||||
fn update_inner(&mut self, message: Message) -> Task<Message> {
|
||||
let message = match message {
|
||||
Message::SourceFindClose(context, instance) => {
|
||||
if context != self.source_symbol_context()
|
||||
|| instance != self.source_find_instance
|
||||
|| !self.source_navigation.open
|
||||
|| !self.source_navigation_available()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Only the checked envelope may enter the legacy Close handler.
|
||||
Message::SourceNavigation(crate::source_navigation::Event::Close)
|
||||
}
|
||||
Message::SourceNavigation(crate::source_navigation::Event::Close) => {
|
||||
return Task::none();
|
||||
}
|
||||
message => message,
|
||||
};
|
||||
if let Message::SourceSymbols(context, _) = &message
|
||||
&& (context.session != self.source_workspace.session()
|
||||
|| context.path != self.source_workspace.active)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Internal results and geometry do not constitute a new user intent.
|
||||
// Process query completion even behind a modal to release its worker slot;
|
||||
// its handler independently refuses navigation outside the live editor.
|
||||
if let Message::SourceSymbolsReady(completion) = message {
|
||||
return self.source_symbols_ready(completion);
|
||||
}
|
||||
if let Message::SourceRenameReady(completion) = message {
|
||||
return self.source_rename_ready(completion);
|
||||
}
|
||||
if let Message::DiagramUsagesReady(completion) = message {
|
||||
return self.diagram_usages_ready(completion);
|
||||
}
|
||||
if !matches!(
|
||||
&message,
|
||||
Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::DiagramRevealViewport(..)
|
||||
) {
|
||||
self.source_interaction();
|
||||
}
|
||||
if let Message::DiagramUsage(event) = message {
|
||||
return self.diagram_usage_event(event);
|
||||
}
|
||||
if let Some(panel) = &self.diagram_usages {
|
||||
if matches!(&message, Message::Request(Pending::Close(_))) {
|
||||
self.diagram_usages = None;
|
||||
return self.update(message);
|
||||
}
|
||||
if matches!(&message, Message::Escape) {
|
||||
return self.diagram_usage_event(crate::diagram_usage::Event::Close(panel.token));
|
||||
}
|
||||
if !matches!(
|
||||
&message,
|
||||
Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport(_)
|
||||
) {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if let Some(task) = self.reference_message(&message) {
|
||||
return task;
|
||||
}
|
||||
@@ -1294,7 +1520,7 @@ impl App {
|
||||
| Message::CanvasResized(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
| Message::Request(Pending::Close(_))
|
||||
)
|
||||
{
|
||||
@@ -1340,7 +1566,7 @@ impl App {
|
||||
| Message::Exported(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
@@ -1380,7 +1606,7 @@ impl App {
|
||||
| Message::CheckpointLoaded(..)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::Saved(_)
|
||||
@@ -1410,6 +1636,18 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.source_rename.open() {
|
||||
if matches!(
|
||||
message,
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
) {
|
||||
self.close_source_rename();
|
||||
return Task::none();
|
||||
}
|
||||
if !modal_event && !matches!(message, Message::SourceRename(_)) {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
message,
|
||||
Message::Page(_) | Message::Layer(_) | Message::Diagram(_)
|
||||
@@ -1454,6 +1692,7 @@ impl App {
|
||||
self.last_workspace_page = self.page;
|
||||
}
|
||||
let mut task = self.handle(message);
|
||||
self.refresh_source_rename_context();
|
||||
self.reconcile_reference_chooser();
|
||||
self.route_state_diagram_workspace();
|
||||
if self.page != Page::Home {
|
||||
@@ -2001,6 +2240,8 @@ impl App {
|
||||
return self.update(Message::Add(child));
|
||||
}
|
||||
Message::Escape => {
|
||||
let restore_source_focus = self.source_navigation_available()
|
||||
&& (self.source_navigation.open || self.source_symbols.open);
|
||||
if self.behavior.source_back() {
|
||||
self.status =
|
||||
"Returned to the unchanged parent form; no source was created.".into();
|
||||
@@ -2017,6 +2258,12 @@ impl App {
|
||||
self.status = "Drawing cancelled · Select / move is active".into();
|
||||
}
|
||||
self.source_navigation.open = false;
|
||||
self.source_symbols.open = false;
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
if restore_source_focus {
|
||||
self.restore_source_editor_focus();
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
}
|
||||
@@ -3061,6 +3308,7 @@ impl App {
|
||||
if self.pending_recovery.as_ref() != Some(&receipt.target) {
|
||||
return Task::none();
|
||||
}
|
||||
self.invalidate_source_rename("Disk recovery changed this source context. Your replacement is retained; close and invoke Rename again.");
|
||||
self.pending_recovery = None;
|
||||
self.source_diagnostics
|
||||
.retain(|issue| issue.diagnostic.code != "pending_save");
|
||||
@@ -3074,6 +3322,9 @@ impl App {
|
||||
if self.path.is_none()
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
if self.path.as_ref() != Some(&receipt.target) {
|
||||
self.source_workspace.reset_navigation_session();
|
||||
}
|
||||
self.path = Some(receipt.target);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.disk_project = Some(disk);
|
||||
@@ -3142,13 +3393,30 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::SourceRelated(location) => self.jump_to_source(location),
|
||||
Message::SourceViewport => {
|
||||
if let Some(cursor) = self.source_jump.take() {
|
||||
self.source_editor.move_to(cursor);
|
||||
if self.source_select_line && cursor.selection.is_none() {
|
||||
self.source_editor.perform(text_editor::Action::SelectLine);
|
||||
}
|
||||
return iced::widget::operation::focus("source-editor");
|
||||
Message::SourceViewport(ticket) => return self.source_viewport(ticket),
|
||||
Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it
|
||||
Message::SourceSymbols(_, event) => return self.source_symbol_event(event),
|
||||
Message::SourceRename(event) => return self.source_rename_event(event),
|
||||
Message::SourceRenameReady(completion) => return self.source_rename_ready(completion),
|
||||
Message::SourceSymbolsReady(completion) => {
|
||||
return self.source_symbols_ready(completion);
|
||||
}
|
||||
Message::DiagramUsage(event) => return self.diagram_usage_event(event),
|
||||
Message::DiagramUsagesReady(completion) => {
|
||||
return self.diagram_usages_ready(completion);
|
||||
}
|
||||
Message::DiagramRevealViewport(context, epoch, size) => {
|
||||
if self
|
||||
.pending_diagram_reveal
|
||||
.as_ref()
|
||||
.is_some_and(|(c, _, e)| *c == context && *e == epoch)
|
||||
&& self
|
||||
.diagram_reveal_viewport(&Message::DiagramRevealViewport(
|
||||
context, epoch, size,
|
||||
))
|
||||
.is_some()
|
||||
{
|
||||
return self.handle(Message::CanvasResized(size));
|
||||
}
|
||||
}
|
||||
Message::ApplySource => {
|
||||
@@ -3195,6 +3463,7 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
self.page = Page::Text;
|
||||
self.source_find_instance = Id::new_v4();
|
||||
self.source_navigation.open = true;
|
||||
self.source_navigation.refresh(&self.source_editor.text());
|
||||
return iced::widget::operation::focus(if go_to_line {
|
||||
@@ -3203,13 +3472,22 @@ impl App {
|
||||
"source-find"
|
||||
});
|
||||
}
|
||||
Event::Close => self.source_navigation.open = false,
|
||||
Event::Close => {
|
||||
let was_open = self.source_navigation.open;
|
||||
self.source_navigation.open = false;
|
||||
if was_open {
|
||||
self.restore_source_editor_focus();
|
||||
}
|
||||
}
|
||||
Event::Query(query) => {
|
||||
self.source_navigation.query = query;
|
||||
self.source_navigation.refresh(&self.source_editor.text());
|
||||
}
|
||||
Event::Next(backwards) if self.page == Page::Text => {
|
||||
self.source_jump = self.source_navigation.next(backwards);
|
||||
if let Some(cursor) = self.source_jump {
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
if self.source_jump.is_none() {
|
||||
self.status = "No matching source text.".into();
|
||||
}
|
||||
@@ -3220,8 +3498,7 @@ impl App {
|
||||
.destination(self.source_editor.line_count())
|
||||
{
|
||||
Ok(cursor) => {
|
||||
self.source_jump = Some(cursor);
|
||||
self.source_select_line = true;
|
||||
self.queue_source_reveal(cursor, true);
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
},
|
||||
@@ -3304,6 +3581,9 @@ impl App {
|
||||
}
|
||||
self.sync_source();
|
||||
}
|
||||
if self.path.as_ref() != Some(&receipt.path) {
|
||||
self.source_workspace.reset_navigation_session();
|
||||
}
|
||||
self.disk_project = Some(disk);
|
||||
self.path = Some(receipt.path);
|
||||
self.hosted.session = None;
|
||||
@@ -3864,7 +4144,7 @@ impl App {
|
||||
self.path = Some(path);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.source_workspace = Workspace::new(&disk.bundle, false);
|
||||
self.source_workspace.reachable = disk.reachable.clone();
|
||||
self.source_workspace.set_reachable(disk.reachable.clone());
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = error.diagnostics().to_vec();
|
||||
self.source_version += 1;
|
||||
@@ -3916,11 +4196,13 @@ impl App {
|
||||
let column = prefix
|
||||
.rfind('\n')
|
||||
.map_or(offset, |start| offset - start - 1);
|
||||
self.source_jump = Some(text_editor::Cursor {
|
||||
position: text_editor::Position { line, column },
|
||||
selection: None,
|
||||
});
|
||||
self.source_select_line = true;
|
||||
self.queue_source_reveal(
|
||||
text_editor::Cursor {
|
||||
position: text_editor::Position { line, column },
|
||||
selection: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
fn create_source_file(&mut self) -> Result<(), String> {
|
||||
|
||||
@@ -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
@@ -1157,6 +1157,17 @@ struct Line {
|
||||
label_at: Point,
|
||||
dashed: bool,
|
||||
}
|
||||
impl Line {
|
||||
fn label_bounds(&self) -> Rectangle {
|
||||
Rectangle::new(
|
||||
self.label_at,
|
||||
Size::new(
|
||||
(self.label.chars().count() as f32 * 6.8 + 8.).min(225.),
|
||||
25.,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
struct Scene {
|
||||
boxes: Vec<BoxItem>,
|
||||
lines: Vec<Line>,
|
||||
@@ -1691,17 +1702,7 @@ impl Scene {
|
||||
.or_else(|| {
|
||||
self.lines
|
||||
.iter()
|
||||
.find(|line| {
|
||||
!line.label.is_empty()
|
||||
&& Rectangle::new(
|
||||
line.label_at,
|
||||
Size::new(
|
||||
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
|
||||
25.,
|
||||
),
|
||||
)
|
||||
.contains(point)
|
||||
})
|
||||
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
|
||||
.map(|line| line.id)
|
||||
})
|
||||
// The generous message tolerance must not steal the exposed
|
||||
|
||||
@@ -21,6 +21,7 @@ pub(crate) fn keyboard_owner_enabled(app: &crate::app::App) -> bool {
|
||||
&& !app.delete_pending
|
||||
&& app.pending.is_none()
|
||||
&& app.model_menu.is_none()
|
||||
&& app.diagram_usages.is_none()
|
||||
&& !app.reference_input_active()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
pub(crate) mod focus;
|
||||
mod usage;
|
||||
mod workspace;
|
||||
pub(crate) use workspace::NAME_INPUT;
|
||||
pub use workspace::{Shelf, view};
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Navigation is derived from the same opaque-reference scene that is painted.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{Surface, Target, Usage};
|
||||
|
||||
/// The renderer collapses whitespace before shortening. Require at least one
|
||||
/// actual character of this field, rather than an ellipsis at its start.
|
||||
fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
|
||||
if value.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let normalized = full.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let normalized_prefix = prefix.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let start = normalized_prefix.len()
|
||||
+ usize::from(prefix.ends_with(char::is_whitespace) && !normalized_prefix.is_empty());
|
||||
let painted = crate::views::short(full, 32);
|
||||
let retained = normalized
|
||||
.chars()
|
||||
.zip(painted.chars())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.map(|(c, _)| c.len_utf8())
|
||||
.sum::<usize>();
|
||||
start < retained
|
||||
}
|
||||
|
||||
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
|
||||
let mut result = Vec::new();
|
||||
let mut add = |id, bounds, role: &str| {
|
||||
// Activation bars share the occurrence identity with a labelled message;
|
||||
// they must not inflate its target count.
|
||||
if !result
|
||||
.iter()
|
||||
.any(|u: &Usage| u.target == Target::ScenarioObject(id))
|
||||
{
|
||||
result.push(Usage {
|
||||
subject,
|
||||
surface: Surface::Scenario(owner),
|
||||
target: Target::ScenarioObject(id),
|
||||
role: role.into(),
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
};
|
||||
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
|
||||
let Some(element) = p.elements.get(&item.id) else {
|
||||
continue;
|
||||
};
|
||||
if subject == item.id {
|
||||
// A fragment's selectable header is useful even when its frame is
|
||||
// much larger than the viewport.
|
||||
let mut bounds = item.rect;
|
||||
if item.marker == Some(Kind::Occurrence) {
|
||||
bounds.height = bounds.height.min(38.);
|
||||
}
|
||||
add(item.id, bounds, "scenario object");
|
||||
}
|
||||
let represented = match element.shared() {
|
||||
Some(SharedBody::Lifeline { represented }) => represented.map(|id| (id, "lifeline")),
|
||||
Some(SharedBody::Occurrence { body, .. }) => match body {
|
||||
OccurrenceBody::Action { action, .. } => action.map(|id| (id, "action occurrence")),
|
||||
OccurrenceBody::State { state, .. } => state.map(|id| (id, "state observation")),
|
||||
OccurrenceBody::SequenceReference { sequence } => {
|
||||
sequence.map(|id| (id, "process reference"))
|
||||
}
|
||||
OccurrenceBody::ScenarioUse { scenario, .. } => {
|
||||
scenario.map(|id| (id, "scenario reference"))
|
||||
}
|
||||
// An incomplete message fallback box only paints the occurrence
|
||||
// name and a repair hint, not its exchange or operation.
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let Some((id, role)) = represented.filter(|(id, _)| *id == subject) {
|
||||
let _ = id;
|
||||
add(item.id, item.rect, role);
|
||||
}
|
||||
}
|
||||
for line in scene.lines.iter().filter(|line| !line.label.is_empty()) {
|
||||
let Some(element) = p.elements.get(&line.id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
}) = element.shared()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let bounds = line.label_bounds();
|
||||
if subject == line.id || *exchange == Some(subject) {
|
||||
add(line.id, bounds, "message occurrence");
|
||||
}
|
||||
// Replies derive their operation from the exact call, matching the
|
||||
// operation label formatter rather than scanning raw reference fields.
|
||||
if let Ok(operations::OperationResolution::Selected(operation)) =
|
||||
operations::resolve(p, line.id)
|
||||
&& let Some(label) = crate::data_values::operation_label(p, element)
|
||||
&& let Some(prefix) = line.label.strip_suffix(&label)
|
||||
{
|
||||
let allocation = &p.relations[&operation.allocation];
|
||||
let operation_prefix = if matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Reply,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
) {
|
||||
"Reply operation from Call: "
|
||||
} else {
|
||||
"Operation: "
|
||||
};
|
||||
let fields = [
|
||||
(allocation.source(), format!("{prefix}{operation_prefix}")),
|
||||
(
|
||||
allocation.target(),
|
||||
format!(
|
||||
"{prefix}{operation_prefix}{} / ",
|
||||
p.named(allocation.source())
|
||||
),
|
||||
),
|
||||
(
|
||||
allocation.id,
|
||||
format!(
|
||||
"{prefix}{operation_prefix}{} / {} · ",
|
||||
p.named(allocation.source()),
|
||||
p.named(allocation.target())
|
||||
),
|
||||
),
|
||||
];
|
||||
for (id, prefix) in fields {
|
||||
if id == subject && label_field_visible(&line.label, &prefix, p.named(id)) {
|
||||
add(line.id, bounds, "operation label");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if subject == owner {
|
||||
result.insert(
|
||||
0,
|
||||
Usage {
|
||||
subject,
|
||||
surface: Surface::Scenario(owner),
|
||||
target: Target::Whole,
|
||||
role: "scenario".into(),
|
||||
bounds: Rectangle::new(Point::ORIGIN, scene.size),
|
||||
},
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
|
||||
if !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_usage(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
document: Id,
|
||||
owner: Id,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
if self.draft.is_some()
|
||||
|| !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let id = match target {
|
||||
Target::Whole => owner,
|
||||
Target::ScenarioObject(id) => *id,
|
||||
_ => return false,
|
||||
};
|
||||
let Some(usage) = self
|
||||
.diagram_usages(p, owner, id)
|
||||
.into_iter()
|
||||
.find(|u| &u.target == target)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// Preserve an existing mounted viewport across enter's owner reset.
|
||||
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.enter(document, owner);
|
||||
self.presentation.viewport = Some(viewport);
|
||||
self.canvas_tool = Some((owner, CanvasTool::Select));
|
||||
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
|
||||
self.presentation.zoom(Some(zoom));
|
||||
self.presentation.pan = pan;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,276 @@
|
||||
use super::*;
|
||||
|
||||
fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let layer = p.elements[&owner].layer();
|
||||
let id = p
|
||||
.add_element(Kind::Occurrence, layer, name, Some(owner))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
|
||||
if !steps.contains(&id) {
|
||||
steps.push(id);
|
||||
}
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn targets(state: &State, p: &Project, owner: Id, subject: Id) -> BTreeSet<Id> {
|
||||
state
|
||||
.diagram_usages(p, owner, subject)
|
||||
.into_iter()
|
||||
.filter_map(|u| match u.target {
|
||||
Target::ScenarioObject(id) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_usage_counts_nested_occurrences_and_lifelines_but_keeps_reuse_opaque() {
|
||||
for (layer, work_kind, participant_kind) in [
|
||||
(Layer::Oa, Kind::Activity, Kind::Entity),
|
||||
(Layer::Sa, Kind::Function, Kind::SystemActor),
|
||||
] {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Scenario, layer, "Caller", None)
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(Kind::Scenario, layer, "Definition", None)
|
||||
.unwrap();
|
||||
let work = p.add_element(work_kind, layer, "Respond", None).unwrap();
|
||||
let unused = p.add_element(work_kind, layer, "Respond", None).unwrap();
|
||||
let participant = p
|
||||
.add_element(participant_kind, layer, "Operator", None)
|
||||
.unwrap();
|
||||
let lanes = ["First role", "Second role"].map(|name| {
|
||||
let id = p
|
||||
.add_element(Kind::Lifeline, layer, name, Some(owner))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(participant),
|
||||
};
|
||||
id
|
||||
});
|
||||
let first = add_occurrence(
|
||||
&mut p,
|
||||
owner,
|
||||
"First action",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: Some(lanes[0]),
|
||||
},
|
||||
);
|
||||
let fragment = add_occurrence(
|
||||
&mut p,
|
||||
owner,
|
||||
"Decision",
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = add_occurrence(
|
||||
&mut p,
|
||||
fragment,
|
||||
"When ready",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(branch);
|
||||
}
|
||||
let nested = add_occurrence(
|
||||
&mut p,
|
||||
branch,
|
||||
"Again",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: Some(lanes[1]),
|
||||
},
|
||||
);
|
||||
let inside = add_occurrence(
|
||||
&mut p,
|
||||
other,
|
||||
"Hidden inside definition",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(unused),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let use_a = add_occurrence(
|
||||
&mut p,
|
||||
owner,
|
||||
"Use A",
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(other),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
let use_b = add_occurrence(
|
||||
&mut p,
|
||||
owner,
|
||||
"Use B",
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(other),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
let before = p.clone();
|
||||
let state = State::default();
|
||||
assert_eq!(
|
||||
targets(&state, &p, owner, work),
|
||||
BTreeSet::from([first, nested])
|
||||
);
|
||||
assert_eq!(
|
||||
targets(&state, &p, owner, participant),
|
||||
BTreeSet::from(lanes)
|
||||
);
|
||||
assert_eq!(
|
||||
targets(&state, &p, owner, other),
|
||||
BTreeSet::from([use_a, use_b])
|
||||
);
|
||||
assert!(targets(&state, &p, owner, unused).is_empty());
|
||||
assert!(targets(&state, &p, owner, inside).is_empty());
|
||||
assert_eq!(targets(&state, &p, owner, branch), BTreeSet::from([branch]));
|
||||
assert_eq!(
|
||||
state.diagram_usages(&p, owner, owner)[0].target,
|
||||
Target::Whole
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_usage_keeps_repeated_messages_and_reveal_is_read_only_and_revalidated() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let mut state = State::default();
|
||||
assert_eq!(
|
||||
targets(&state, &p, f.owner, f.exchange),
|
||||
BTreeSet::from([f.call, f.reply])
|
||||
);
|
||||
let before = p.clone();
|
||||
let target = Target::ScenarioObject(f.reply);
|
||||
state.viewport(Size::new(900., 500.));
|
||||
assert!(state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
|
||||
let usage = state
|
||||
.diagram_usages(&p, f.owner, f.reply)
|
||||
.into_iter()
|
||||
.find(|u| u.target == target)
|
||||
.unwrap();
|
||||
let zoom = state
|
||||
.presentation
|
||||
.scale(Scene::sequence(&p, f.owner, f.reply).size);
|
||||
let centered = Point::new(
|
||||
usage.bounds.center_x() * zoom,
|
||||
usage.bounds.center_y() * zoom,
|
||||
) + state.presentation.pan;
|
||||
assert!((centered.x - 450.).abs() < 0.01 && (centered.y - 250.).abs() < 0.01);
|
||||
assert!(zoom >= 0.5);
|
||||
assert_eq!(p, before);
|
||||
// Keeping an orphan semantic occurrence does not keep its painted target.
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != f.reply);
|
||||
let camera = state.camera_boundary();
|
||||
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
|
||||
assert!(camera == state.camera_boundary());
|
||||
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &Target::Edge(f.exchange)));
|
||||
// A message whose endpoints are incomplete paints only its repair box.
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { receiver, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&f.call).unwrap().shared_mut()
|
||||
{
|
||||
*receiver = None;
|
||||
}
|
||||
assert!(targets(&state, &p, f.owner, f.exchange).is_empty());
|
||||
assert_eq!(
|
||||
targets(&state, &p, f.owner, f.call),
|
||||
BTreeSet::from([f.call])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_operation_label_membership_excludes_ellipsis_only_and_normalizes_whitespace() {
|
||||
assert!(label_field_visible(
|
||||
"1. call · X · Operation: I / O",
|
||||
"1. call · X · Operation: ",
|
||||
"I"
|
||||
));
|
||||
assert!(!label_field_visible(
|
||||
"1234567890123456789012345678901Ixyz",
|
||||
"1234567890123456789012345678901",
|
||||
"Ixyz"
|
||||
));
|
||||
assert!(!label_field_visible(
|
||||
"A very long occurrence label · Operation: I",
|
||||
"A very long occurrence label · Operation: ",
|
||||
"I"
|
||||
));
|
||||
assert!(label_field_visible(
|
||||
"call\n\t· Operation: 界",
|
||||
"call\n\t· Operation: ",
|
||||
"界"
|
||||
));
|
||||
assert!(!label_field_visible("Operation: ", "Operation: ", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_usage_only_reports_the_resolved_operation_fields_actually_in_the_label() {
|
||||
let mut p = persistence::decode(include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
|
||||
)))
|
||||
.unwrap();
|
||||
let id = |n| Id::parse_str(&format!("52060906-0000-4000-8000-{n:012x}")).unwrap();
|
||||
let (owner, call, exchange, allocation) = (id(40), id(43), id(34), id(30));
|
||||
let (interface, item) = (
|
||||
p.relations[&allocation].source(),
|
||||
p.relations[&allocation].target(),
|
||||
);
|
||||
for (id, name) in [(call, "x"), (interface, "I"), (item, "O")] {
|
||||
p.elements.get_mut(&id).unwrap().name = name.into();
|
||||
}
|
||||
p.relations.get_mut(&exchange).unwrap().name = "x".into();
|
||||
p.relations.get_mut(&allocation).unwrap().name = "A".into();
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&call).unwrap().shared_mut()
|
||||
{
|
||||
*operation = Some(OperationUse {
|
||||
allocation: Some(allocation),
|
||||
});
|
||||
}
|
||||
assert!(matches!(
|
||||
operations::resolve(&p, call),
|
||||
Ok(operations::OperationResolution::Selected(_))
|
||||
));
|
||||
let state = State::default();
|
||||
let before = p.clone();
|
||||
assert_eq!(
|
||||
targets(&state, &p, owner, interface),
|
||||
BTreeSet::from([call])
|
||||
);
|
||||
assert_eq!(targets(&state, &p, owner, item), BTreeSet::from([call]));
|
||||
assert!(targets(&state, &p, owner, allocation).is_empty());
|
||||
assert_eq!(p, before);
|
||||
p.elements.get_mut(&call).unwrap().name = "This occurrence has a deliberately long name".into();
|
||||
assert!(targets(&state, &p, owner, interface).is_empty());
|
||||
assert!(targets(&state, &p, owner, item).is_empty());
|
||||
}
|
||||
@@ -27,6 +27,8 @@ pub struct StateCanvas<'a> {
|
||||
pub diagram: Id,
|
||||
pub context: Context,
|
||||
pub selected: Id,
|
||||
/// Presentation-only navigation marker; never an authoring placement.
|
||||
pub navigation_focus: Option<Rectangle>,
|
||||
pub tool: Tool,
|
||||
pub zoom: f32,
|
||||
pub pan: Vector,
|
||||
@@ -456,6 +458,171 @@ fn state_action_label(project: &Project, relation: &Relation) -> String {
|
||||
format!("{phase} / {}", project.named(target))
|
||||
}
|
||||
|
||||
/// Match a typed label field by its structural offset, never by a display-name
|
||||
/// substring (names may duplicate or also occur in free-form guards).
|
||||
fn retained_label_field(full: &str, painted: &str, start: usize) -> bool {
|
||||
start
|
||||
< full
|
||||
.chars()
|
||||
.zip(painted.chars())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.map(|(c, _)| c.len_utf8())
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
fn transition_label_role(
|
||||
project: &Project,
|
||||
relation: &Relation,
|
||||
subject: Id,
|
||||
width: f32,
|
||||
) -> Option<&'static str> {
|
||||
let full = routing::state_transition_summary(project, relation);
|
||||
transition_label_role_in_text(project, relation, subject, &short(&full, width, 10.))
|
||||
}
|
||||
|
||||
fn transition_label_role_in_text(
|
||||
project: &Project,
|
||||
relation: &Relation,
|
||||
subject: Id,
|
||||
painted: &str,
|
||||
) -> Option<&'static str> {
|
||||
let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
triggers,
|
||||
effects,
|
||||
effect,
|
||||
..
|
||||
} = &relation.body
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if project.named(subject).trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let full = routing::state_transition_summary(project, relation);
|
||||
let mut offset = format!("{transition_kind:?} · ").len();
|
||||
for id in triggers {
|
||||
if *id == subject && retained_label_field(&full, painted, offset) {
|
||||
return Some("transition trigger");
|
||||
}
|
||||
offset += project.named(*id).len() + " OR ".len();
|
||||
}
|
||||
if !effects.is_empty() {
|
||||
// The canonical routing summary appends typed effects before optional
|
||||
// authored effect prose. Derive its offset from that exact suffix.
|
||||
let mut suffix = format!(
|
||||
" / {}",
|
||||
effects
|
||||
.iter()
|
||||
.map(|id| project.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
if !effect.is_empty() {
|
||||
suffix.push_str(&format!(" / {effect}"));
|
||||
}
|
||||
if let Some(prefix) = full.strip_suffix(&suffix) {
|
||||
offset = prefix.len() + " / ".len();
|
||||
for id in effects {
|
||||
if *id == subject && retained_label_field(&full, painted, offset) {
|
||||
return Some("transition effect");
|
||||
}
|
||||
offset += project.named(*id).len() + ", ".len();
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Actual state compartments and painted transition-reference labels. Generic
|
||||
/// projected node/edge identities are handled by the shared saved-view query.
|
||||
pub(crate) fn diagram_usages(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
view: &ProjectedView,
|
||||
subject: Id,
|
||||
) -> Vec<crate::diagram_usage::Usage> {
|
||||
use crate::diagram_usage::{Surface, Target, Usage};
|
||||
if diagram.projection.state_machine().is_none() {
|
||||
return vec![];
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
for node in &view.nodes {
|
||||
for (relation, bounds) in state_action_rows(project, node) {
|
||||
let label = state_action_label(project, relation);
|
||||
let painted = short(&label, bounds.width - 12., 11.);
|
||||
let role = if relation.id == subject {
|
||||
Some("state action")
|
||||
} else if relation.target() == subject
|
||||
&& retained_label_field(
|
||||
&label,
|
||||
&painted,
|
||||
label.len() - project.named(subject).len(),
|
||||
)
|
||||
{
|
||||
Some("state action work")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(role) = role {
|
||||
result.push(Usage {
|
||||
subject,
|
||||
surface: Surface::Saved(diagram.id),
|
||||
target: Target::StateRow {
|
||||
placement: node.id,
|
||||
relation: relation.id,
|
||||
},
|
||||
role: role.into(),
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (relation, bounds) in internal_rows(project, node) {
|
||||
let role = if relation.id == subject {
|
||||
Some("internal transition")
|
||||
} else {
|
||||
transition_label_role(project, relation, subject, bounds.width - 12.)
|
||||
};
|
||||
if let Some(role) = role {
|
||||
result.push(Usage {
|
||||
subject,
|
||||
surface: Surface::Saved(diagram.id),
|
||||
target: Target::StateRow {
|
||||
placement: node.id,
|
||||
relation: relation.id,
|
||||
},
|
||||
role: role.into(),
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for edge in routing::route_diagram(project, diagram, view)
|
||||
.edges
|
||||
.values()
|
||||
{
|
||||
let Some(relation) = project.relations.get(&edge.id) else {
|
||||
continue;
|
||||
};
|
||||
if edge.id == subject || edge.label.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Routing already shortens external summaries before the canvas applies
|
||||
// its width limit. Compare against precisely that doubly shortened text.
|
||||
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
|
||||
if let Some(role) = transition_label_role_in_text(project, relation, subject, &painted) {
|
||||
result.push(Usage {
|
||||
subject,
|
||||
surface: Surface::Saved(diagram.id),
|
||||
target: Target::Edge(edge.id),
|
||||
role: role.into(),
|
||||
bounds: crate::diagram_usage::edge_focus_bounds(edge),
|
||||
});
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
impl StateCanvas<'_> {
|
||||
fn scale(&self) -> f32 {
|
||||
if self.zoom.is_finite() {
|
||||
@@ -1216,6 +1383,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
overlay.with_clip(content, |frame| {
|
||||
frame.translate(self.offset());
|
||||
frame.scale(self.scale());
|
||||
if let Some(bounds) = self.navigation_focus {
|
||||
frame.stroke(
|
||||
&Path::rectangle(bounds.position(), bounds.size()),
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(2.5 / self.scale()),
|
||||
);
|
||||
}
|
||||
if let Some(at) = world
|
||||
&& self.editable
|
||||
{
|
||||
@@ -1659,6 +1834,189 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_usage_state_rows_and_label_references_follow_actual_projection() {
|
||||
use crate::diagram_usage::Target;
|
||||
let mut f = fixture();
|
||||
let work = f
|
||||
.project
|
||||
.add_element(Kind::Function, Layer::Sa, "Act", None)
|
||||
.unwrap();
|
||||
let event = f
|
||||
.project
|
||||
.add_element(Kind::Event, Layer::Sa, "E", None)
|
||||
.unwrap();
|
||||
let unused = f
|
||||
.project
|
||||
.add_element(Kind::Event, Layer::Sa, "E", None)
|
||||
.unwrap();
|
||||
let action = f
|
||||
.project
|
||||
.relate(RelationKind::StateAction, "Entry", f.a, work);
|
||||
let internal = f
|
||||
.project
|
||||
.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
|
||||
let external = f
|
||||
.project
|
||||
.relate(RelationKind::StateTransition, "External", f.a, f.b);
|
||||
for (id, kind) in [
|
||||
(internal, TransitionKind::Internal),
|
||||
(external, TransitionKind::External),
|
||||
] {
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
triggers,
|
||||
effects,
|
||||
..
|
||||
} = &mut f.project.relations.get_mut(&id).unwrap().body
|
||||
{
|
||||
*transition_kind = kind;
|
||||
*triggers = vec![event];
|
||||
*effects = vec![work];
|
||||
}
|
||||
}
|
||||
let before = f.project.clone();
|
||||
let diagram = &f.project.diagrams[&f.diagram];
|
||||
let view = projection::project_diagram(&f.project, diagram);
|
||||
let placement = view.nodes.iter().find(|n| n.element == f.a).unwrap().id;
|
||||
let action_target = Target::StateRow {
|
||||
placement,
|
||||
relation: action,
|
||||
};
|
||||
let internal_target = Target::StateRow {
|
||||
placement,
|
||||
relation: internal,
|
||||
};
|
||||
assert_eq!(
|
||||
diagram_usages(&f.project, diagram, &view, action)[0].target,
|
||||
action_target
|
||||
);
|
||||
assert_eq!(
|
||||
diagram_usages(&f.project, diagram, &view, internal)[0].target,
|
||||
internal_target
|
||||
);
|
||||
let events = diagram_usages(&f.project, diagram, &view, event);
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(events.iter().any(|u| u.target == internal_target));
|
||||
assert!(events.iter().any(|u| u.target == Target::Edge(external)));
|
||||
assert!(diagram_usages(&f.project, diagram, &view, unused).is_empty());
|
||||
// External edge identity belongs to the shared saved-view adapter.
|
||||
assert!(diagram_usages(&f.project, diagram, &view, external).is_empty());
|
||||
let work_usages = diagram_usages(&f.project, diagram, &view, work);
|
||||
assert!(work_usages.iter().any(|u| u.target == action_target));
|
||||
assert!(work_usages.iter().any(|u| u.target == internal_target));
|
||||
let row = work_usages
|
||||
.iter()
|
||||
.find(|u| u.target == action_target)
|
||||
.unwrap();
|
||||
let canvas = canvas(&f, Tool::Select, 1.);
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
assert_eq!(
|
||||
canvas.hit(&scene, row.bounds.center()),
|
||||
Some(Hit::StateAction(action))
|
||||
);
|
||||
assert_eq!(f.project, before);
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&placement)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let diagram = &f.project.diagrams[&f.diagram];
|
||||
let folded = projection::project_diagram(&f.project, diagram);
|
||||
assert!(diagram_usages(&f.project, diagram, &folded, action).is_empty());
|
||||
assert!(diagram_usages(&f.project, diagram, &folded, internal).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_usage_transition_labels_do_not_match_prose_or_fields_beyond_cutoff() {
|
||||
let mut f = fixture();
|
||||
let early = f
|
||||
.project
|
||||
.add_element(Kind::Event, Layer::Sa, "A", None)
|
||||
.unwrap();
|
||||
let late = f
|
||||
.project
|
||||
.add_element(Kind::Event, Layer::Sa, "B", None)
|
||||
.unwrap();
|
||||
let relation = f
|
||||
.project
|
||||
.relate(RelationKind::StateTransition, "Transition", f.a, f.b);
|
||||
if let RelationBody::StateTransition {
|
||||
triggers, guard, ..
|
||||
} = &mut f.project.relations.get_mut(&relation).unwrap().body
|
||||
{
|
||||
*triggers = vec![early, late];
|
||||
*guard = "B".into();
|
||||
}
|
||||
let relation = &f.project.relations[&relation];
|
||||
assert_eq!(
|
||||
transition_label_role(&f.project, relation, early, 84.),
|
||||
Some("transition trigger")
|
||||
);
|
||||
assert_eq!(transition_label_role(&f.project, relation, late, 84.), None);
|
||||
assert_eq!(
|
||||
transition_label_role(&f.project, relation, late, 800.),
|
||||
Some("transition trigger")
|
||||
);
|
||||
assert!(!retained_label_field("1234A678", "1234…", 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_usage_external_labels_obey_routing_cutoff_while_internal_rows_retain_full_summary() {
|
||||
use crate::diagram_usage::Target;
|
||||
let mut f = fixture();
|
||||
let first = f
|
||||
.project
|
||||
.add_element(Kind::Event, Layer::Sa, &"A".repeat(35), None)
|
||||
.unwrap();
|
||||
let second = f
|
||||
.project
|
||||
.add_element(Kind::Event, Layer::Sa, "Second", None)
|
||||
.unwrap();
|
||||
let external = f
|
||||
.project
|
||||
.relate(RelationKind::StateTransition, "External", f.a, f.b);
|
||||
let internal = f
|
||||
.project
|
||||
.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
|
||||
for (id, kind) in [
|
||||
(external, TransitionKind::External),
|
||||
(internal, TransitionKind::Internal),
|
||||
] {
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
triggers,
|
||||
..
|
||||
} = &mut f.project.relations.get_mut(&id).unwrap().body
|
||||
{
|
||||
*transition_kind = kind;
|
||||
*triggers = vec![first, second];
|
||||
}
|
||||
}
|
||||
let diagram = &f.project.diagrams[&f.diagram];
|
||||
let view = projection::project_diagram(&f.project, diagram);
|
||||
let routed = routing::route_diagram(&f.project, diagram, &view);
|
||||
let edge = &routed.edges[&external];
|
||||
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
|
||||
// Independent painted-text oracle: 11 prefix characters + 35 As, then
|
||||
// only the space before OR survives the routing limit.
|
||||
assert_eq!(edge.label.text, format!("External · {} …", "A".repeat(35)));
|
||||
assert!(!painted.contains("Second"));
|
||||
let usages = diagram_usages(&f.project, diagram, &view, second);
|
||||
assert!(!usages.iter().any(|u| u.target == Target::Edge(external)));
|
||||
assert!(usages.iter().any(
|
||||
|u| matches!(u.target, Target::StateRow { relation, .. } if relation == internal)
|
||||
));
|
||||
assert!(
|
||||
diagram_usages(&f.project, diagram, &view, first)
|
||||
.iter()
|
||||
.any(|u| u.target == Target::Edge(external))
|
||||
);
|
||||
}
|
||||
|
||||
fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> {
|
||||
StateCanvas {
|
||||
project: &f.project,
|
||||
@@ -1670,6 +2028,7 @@ mod tests {
|
||||
epoch: 3,
|
||||
},
|
||||
selected: f.machine,
|
||||
navigation_focus: None,
|
||||
tool,
|
||||
zoom,
|
||||
pan: Vector::new(20., 30.),
|
||||
|
||||
@@ -169,6 +169,9 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
diagram: id,
|
||||
context,
|
||||
selected: app.selected,
|
||||
navigation_focus: app
|
||||
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
|
||||
.map(|usage| usage.bounds),
|
||||
tool: state.tool,
|
||||
zoom: state.zoom,
|
||||
pan: state.pan,
|
||||
|
||||
@@ -5,6 +5,7 @@ use iced::widget::{Column, text_input};
|
||||
use std::collections::BTreeSet;
|
||||
pub(crate) mod contextual;
|
||||
mod direct;
|
||||
mod usages;
|
||||
mod view;
|
||||
use direct::Input;
|
||||
pub(crate) use direct::{
|
||||
@@ -908,6 +909,13 @@ fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PreviewLink {
|
||||
relation: Id,
|
||||
source: OccurrenceEndpoint,
|
||||
target: OccurrenceEndpoint,
|
||||
}
|
||||
|
||||
struct GraphNode {
|
||||
id: Id,
|
||||
rect: Rectangle,
|
||||
@@ -916,26 +924,98 @@ struct GraphNode {
|
||||
control: Option<ControlKind>,
|
||||
source: Option<Id>,
|
||||
preview: Vec<(OccurrenceEndpoint, String)>,
|
||||
preview_links: Vec<(OccurrenceEndpoint, OccurrenceEndpoint)>,
|
||||
preview_links: Vec<PreviewLink>,
|
||||
preview_total: usize,
|
||||
}
|
||||
impl GraphNode {
|
||||
fn preview_link_points(&self, link: &PreviewLink) -> Option<[Point; 2]> {
|
||||
if ![&link.source, &link.target]
|
||||
.into_iter()
|
||||
.all(|endpoint| self.preview.iter().any(|(shown, _)| shown == endpoint))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let a = self.endpoint_rect(&link.source);
|
||||
let b = self.endpoint_rect(&link.target);
|
||||
Some([
|
||||
Point::new(a.x + a.width, a.center_y()),
|
||||
Point::new(b.x, b.center_y()),
|
||||
])
|
||||
}
|
||||
|
||||
fn preview_link_visible_segment(&self, link: &PreviewLink) -> Option<[Point; 2]> {
|
||||
visible_preview_segment(
|
||||
self.preview_link_points(link)?,
|
||||
(0..self.preview.len()).map(|index| self.preview_rect(index)),
|
||||
)
|
||||
}
|
||||
|
||||
fn preview_rect(&self, index: usize) -> Rectangle {
|
||||
Rectangle::new(
|
||||
Point::new(
|
||||
self.rect.x + 12. + (index % 2) as f32 * 125.,
|
||||
self.rect.y + 118. + (index / 2) as f32 * 44.,
|
||||
),
|
||||
Size::new(113., 32.),
|
||||
)
|
||||
}
|
||||
|
||||
fn endpoint_rect(&self, endpoint: &OccurrenceEndpoint) -> Rectangle {
|
||||
self.preview
|
||||
.iter()
|
||||
.position(|(candidate, _)| candidate == endpoint)
|
||||
.map(|i| {
|
||||
Rectangle::new(
|
||||
Point::new(
|
||||
self.rect.x + 12. + (i % 2) as f32 * 125.,
|
||||
self.rect.y + 118. + (i / 2) as f32 * 44.,
|
||||
),
|
||||
Size::new(113., 32.),
|
||||
)
|
||||
})
|
||||
.map(|index| self.preview_rect(index))
|
||||
.unwrap_or(self.rect)
|
||||
}
|
||||
}
|
||||
|
||||
/// Preview tiles are painted after their arrows. Focus only a remaining line
|
||||
/// segment, excluding opaque tile interiors and their one-pixel borders.
|
||||
fn visible_preview_segment(
|
||||
[a, b]: [Point; 2],
|
||||
tiles: impl Iterator<Item = Rectangle>,
|
||||
) -> Option<[Point; 2]> {
|
||||
let delta = b - a;
|
||||
let length = (delta.x * delta.x + delta.y * delta.y).sqrt();
|
||||
if !length.is_finite() || length <= 0.5 {
|
||||
return None;
|
||||
}
|
||||
let mut hidden = Vec::new();
|
||||
for tile in tiles {
|
||||
let mut low = 0_f32;
|
||||
let mut high = 1_f32;
|
||||
for (origin, step, min, max) in [
|
||||
(a.x, delta.x, tile.x - 1., tile.x + tile.width + 1.),
|
||||
(a.y, delta.y, tile.y - 1., tile.y + tile.height + 1.),
|
||||
] {
|
||||
if step.abs() <= f32::EPSILON {
|
||||
if origin < min || origin > max {
|
||||
high = -1.;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
let first = (min - origin) / step;
|
||||
let last = (max - origin) / step;
|
||||
low = low.max(first.min(last));
|
||||
high = high.min(first.max(last));
|
||||
}
|
||||
}
|
||||
if high > low {
|
||||
hidden.push((low, high));
|
||||
}
|
||||
}
|
||||
hidden.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut cursor = 0_f32;
|
||||
let mut best = (0_f32, 0_f32);
|
||||
for (low, high) in hidden.into_iter().chain([(1., 1.)]) {
|
||||
if low - cursor > best.1 - best.0 {
|
||||
best = (cursor, low);
|
||||
}
|
||||
cursor = cursor.max(high);
|
||||
}
|
||||
((best.1 - best.0) * length > 0.5).then(|| [a + delta * best.0, a + delta * best.1])
|
||||
}
|
||||
|
||||
struct GraphEdge {
|
||||
id: Id,
|
||||
source: Id,
|
||||
@@ -984,6 +1064,8 @@ struct Graph<'a> {
|
||||
ordered: bool,
|
||||
size: Size,
|
||||
selected: Id,
|
||||
selected_preview: Option<OccurrenceEndpoint>,
|
||||
selected_preview_link: Option<PreviewLink>,
|
||||
scale: f32,
|
||||
}
|
||||
impl<'a> Graph<'a> {
|
||||
@@ -1132,17 +1214,16 @@ impl<'a> Graph<'a> {
|
||||
occurrence: r.target(),
|
||||
})
|
||||
})
|
||||
.map(|r| {
|
||||
(
|
||||
OccurrenceEndpoint {
|
||||
references: vec![e.id],
|
||||
occurrence: r.source(),
|
||||
},
|
||||
OccurrenceEndpoint {
|
||||
references: vec![e.id],
|
||||
occurrence: r.target(),
|
||||
},
|
||||
)
|
||||
.map(|r| PreviewLink {
|
||||
relation: r.id,
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![e.id],
|
||||
occurrence: r.source(),
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![e.id],
|
||||
occurrence: r.target(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
@@ -1317,6 +1398,8 @@ impl<'a> Graph<'a> {
|
||||
ordered: !order.is_empty(),
|
||||
size,
|
||||
selected,
|
||||
selected_preview: None,
|
||||
selected_preview_link: None,
|
||||
scale: 1.,
|
||||
}
|
||||
}
|
||||
@@ -1546,39 +1629,41 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
let mini: BTreeMap<_, _> = n
|
||||
.preview
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, _))| {
|
||||
(
|
||||
id.clone(),
|
||||
Rectangle::new(
|
||||
Point::new(
|
||||
n.rect.x + 12. + (i % 2) as f32 * 125.,
|
||||
n.rect.y + 118. + (i / 2) as f32 * 44.,
|
||||
),
|
||||
Size::new(113., 32.),
|
||||
),
|
||||
)
|
||||
})
|
||||
.map(|(id, _)| (id.clone(), n.endpoint_rect(id)))
|
||||
.collect();
|
||||
for (a, b) in &n.preview_links {
|
||||
let a = mini[a];
|
||||
let b = mini[b];
|
||||
for link in &n.preview_links {
|
||||
let Some(points) = n.preview_link_points(link) else {
|
||||
continue;
|
||||
};
|
||||
let selected = self.selected_preview_link.as_ref() == Some(link);
|
||||
draw_arrow(
|
||||
&mut f,
|
||||
&[
|
||||
Point::new(a.x + a.width, a.center_y()),
|
||||
Point::new(b.x, b.center_y()),
|
||||
],
|
||||
style::MUTED,
|
||||
1.,
|
||||
&points,
|
||||
if selected { style::TEAL } else { style::MUTED },
|
||||
if selected { 3. } else { 1. },
|
||||
);
|
||||
}
|
||||
for (id, name) in &n.preview {
|
||||
let r = mini[id];
|
||||
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
||||
let selected = self.selected_preview.as_ref() == Some(id);
|
||||
f.fill_rectangle(
|
||||
r.position(),
|
||||
r.size(),
|
||||
if selected {
|
||||
Color::from_rgb8(224, 247, 242)
|
||||
} else {
|
||||
Color::WHITE
|
||||
},
|
||||
);
|
||||
f.stroke(
|
||||
&Path::rectangle(r.position(), r.size()),
|
||||
Stroke::default().with_color(style::BOUNDARY),
|
||||
Stroke::default()
|
||||
.with_color(if selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::BOUNDARY
|
||||
})
|
||||
.with_width(if selected { 2.5 } else { 1. }),
|
||||
);
|
||||
draw_text(
|
||||
&mut f,
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Read-only usages of the actual process graph, including bounded reuse tiles.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{Surface, Target, Usage};
|
||||
|
||||
/// The only presentation inputs needed by a background usage query. Drafts,
|
||||
/// focus, camera, history and widget state never cross the worker boundary.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct UsageSnapshot {
|
||||
identity: Option<(Id, Id)>,
|
||||
expanded: BTreeSet<Id>,
|
||||
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
|
||||
}
|
||||
|
||||
impl UsageSnapshot {
|
||||
pub(crate) fn into_query_state(self) -> State {
|
||||
State {
|
||||
identity: self.identity,
|
||||
expanded: self.expanded,
|
||||
contextual_expanded: self.contextual_expanded,
|
||||
..State::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn usage_snapshot(&self) -> UsageSnapshot {
|
||||
UsageSnapshot {
|
||||
identity: self.identity,
|
||||
expanded: self.expanded.clone(),
|
||||
contextual_expanded: self.contextual_expanded.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_exists(p: &Project, owner: Id) -> bool {
|
||||
p.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
}
|
||||
|
||||
fn occurrence_role(p: &Project, occurrence: Id, subject: Id) -> Option<&'static str> {
|
||||
let e = p.elements.get(&occurrence)?;
|
||||
if occurrence == subject {
|
||||
return Some("contribution");
|
||||
}
|
||||
match e.shared()? {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: Some(id), ..
|
||||
},
|
||||
..
|
||||
} if *id == subject => Some("action occurrence"),
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(id), ..
|
||||
},
|
||||
..
|
||||
} if *id == subject => Some("exchange occurrence"),
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence: Some(id) },
|
||||
..
|
||||
} if *id == subject => Some("reused process / chain"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Graph<'_> {
|
||||
fn usage_bounds(&self, target: &Target) -> Option<Rectangle> {
|
||||
match target {
|
||||
Target::Whole => Some(Rectangle::new(Point::ORIGIN, self.size)),
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
let root = endpoint
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence);
|
||||
let node = self.nodes.iter().find(|node| node.id == root)?;
|
||||
if endpoint.references.is_empty() {
|
||||
Some(node.rect)
|
||||
} else {
|
||||
// endpoint_rect intentionally has a proxy fallback for other callers;
|
||||
// a usage promises an actual tile and must never take that fallback.
|
||||
node.preview
|
||||
.iter()
|
||||
.any(|(shown, _)| shown == endpoint)
|
||||
.then(|| node.endpoint_rect(endpoint))
|
||||
}
|
||||
}
|
||||
Target::ProcessLink(id) => {
|
||||
let edge = self.edges.iter().find(|edge| edge.id == *id)?;
|
||||
if !edge.guard.is_empty() {
|
||||
Some(edge.label)
|
||||
} else {
|
||||
let segment = edge
|
||||
.points
|
||||
.windows(2)
|
||||
.nth(edge.points.len().saturating_sub(1) / 2)?;
|
||||
let center = Point::new(
|
||||
(segment[0].x + segment[1].x) / 2.,
|
||||
(segment[0].y + segment[1].y) / 2.,
|
||||
);
|
||||
Some(Rectangle::new(
|
||||
center - iced::Vector::new(16., 16.),
|
||||
Size::new(32., 32.),
|
||||
))
|
||||
}
|
||||
}
|
||||
Target::ProcessPreviewLink {
|
||||
relation,
|
||||
source,
|
||||
target,
|
||||
} => {
|
||||
let node = self
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| source.references.first() == Some(&node.id))?;
|
||||
let link = node.preview_links.iter().find(|link| {
|
||||
link.relation == *relation && link.source == *source && link.target == *target
|
||||
})?;
|
||||
let [a, b] = node.preview_link_visible_segment(link)?;
|
||||
Some(Rectangle::new(
|
||||
Point::new((a.x + b.x) / 2. - 16., (a.y + b.y) / 2. - 16.),
|
||||
Size::new(32., 32.),
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Inactive owners use the same default preview policy as entering their canvas.
|
||||
fn usage_graph<'a>(&self, p: &'a Project, owner: Id) -> Graph<'a> {
|
||||
if self.identity.is_some_and(|(_, current)| current == owner) {
|
||||
Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded)
|
||||
} else {
|
||||
Graph::new(p, owner, owner)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
|
||||
if !owner_exists(p, owner)
|
||||
|| !(p.elements.contains_key(&subject) || p.relations.contains_key(&subject))
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
let graph = self.usage_graph(p, owner);
|
||||
let mut usages = vec![];
|
||||
let mut add = |target: Target, role: &str| {
|
||||
if let Some(bounds) = graph.usage_bounds(&target) {
|
||||
usages.push(Usage {
|
||||
subject,
|
||||
surface: Surface::Process(owner),
|
||||
target,
|
||||
role: role.into(),
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
};
|
||||
if subject == owner {
|
||||
add(Target::Whole, "process / chain canvas");
|
||||
}
|
||||
for node in &graph.nodes {
|
||||
if let Some(role) = occurrence_role(p, node.id, subject) {
|
||||
add(
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: node.id,
|
||||
}),
|
||||
role,
|
||||
);
|
||||
}
|
||||
for (endpoint, _) in &node.preview {
|
||||
if let Some(role) = occurrence_role(p, endpoint.occurrence, subject) {
|
||||
add(
|
||||
Target::ProcessOccurrence(endpoint.clone()),
|
||||
&format!("reused {role}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
for link in &node.preview_links {
|
||||
if link.relation == subject {
|
||||
add(
|
||||
Target::ProcessPreviewLink {
|
||||
relation: link.relation,
|
||||
source: link.source.clone(),
|
||||
target: link.target.clone(),
|
||||
},
|
||||
"reused sequence link",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for edge in &graph.edges {
|
||||
let role = if edge.id == subject {
|
||||
Some("process link")
|
||||
} else {
|
||||
match p.relations.get(&edge.id).map(|r| &r.body) {
|
||||
Some(RelationBody::ContextualLink {
|
||||
purpose: ContextualLinkPurpose::Exchange { exchange },
|
||||
..
|
||||
}) if *exchange == subject => Some("assembly exchange"),
|
||||
Some(RelationBody::ContextualLink {
|
||||
purpose: ContextualLinkPurpose::CommonWork { work },
|
||||
..
|
||||
}) if *work == subject => Some("common work"),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(role) = role {
|
||||
add(Target::ProcessLink(edge.id), role);
|
||||
}
|
||||
}
|
||||
usages
|
||||
}
|
||||
|
||||
/// Returns success, not whether the camera changed. App must separately guard
|
||||
/// its other drafts and revalidate the queried subject's current usage.
|
||||
pub(crate) fn reveal_usage(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
document: Id,
|
||||
owner: Id,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
if !owner_exists(p, owner) || self.draft.is_some() {
|
||||
return false;
|
||||
}
|
||||
let same = self.identity == Some((document, owner));
|
||||
let graph = if same {
|
||||
self.usage_graph(p, owner)
|
||||
} else {
|
||||
Graph::new(p, owner, owner)
|
||||
};
|
||||
let Some(bounds) = graph.usage_bounds(target) else {
|
||||
return false;
|
||||
};
|
||||
let address = if let Target::ProcessOccurrence(endpoint) = target {
|
||||
let Ok(resolved) =
|
||||
oa_core::topology_context::TopologyIndex::new(p).resolve(owner, endpoint)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
(!endpoint.references.is_empty()).then_some(resolved.address)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let viewport = self.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.enter(document, owner);
|
||||
if !same {
|
||||
self.contextual_expanded.clear();
|
||||
}
|
||||
self.selected_context = address;
|
||||
self.tool = CanvasTool::Select;
|
||||
self.viewport = Some(viewport);
|
||||
let fit = ((viewport.width - 48.).max(1.) / bounds.width.max(1.))
|
||||
.min((viewport.height - 48.).max(1.) / bounds.height.max(1.));
|
||||
let scale = if matches!(target, Target::Whole) {
|
||||
fit.clamp(0.0001, 1.)
|
||||
} else {
|
||||
fit.clamp(0.6, 1.25)
|
||||
};
|
||||
self.zoom = Some(scale);
|
||||
self.fit = false;
|
||||
self.pan = iced::Vector::new(
|
||||
viewport.width / 2. - bounds.center_x() * scale,
|
||||
viewport.height / 2. - bounds.center_y() * scale,
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,439 @@
|
||||
use super::*;
|
||||
|
||||
fn add(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let e = occurrence(p, owner, name, body);
|
||||
let id = e.id;
|
||||
*p = execute_batch(p, &[Command::CreateElement(e)]).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn reused_fixture() -> (Project, Id, Id, Id, [Id; 2], Vec<Id>) {
|
||||
let f = super::super::direct_tests::fixture(Layer::Oa);
|
||||
let mut p = f.p;
|
||||
let sequence = p
|
||||
.make_element(Kind::Process, Layer::Oa, "Reviewed checks", None)
|
||||
.unwrap();
|
||||
let sequence_id = sequence.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(sequence)]).unwrap();
|
||||
let mut leaves = vec![];
|
||||
for name in [
|
||||
"Check one",
|
||||
"Check two",
|
||||
"Check three",
|
||||
"Check four",
|
||||
"Check five",
|
||||
] {
|
||||
leaves.push(add(
|
||||
&mut p,
|
||||
sequence_id,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
let first = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"First invocation",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
let second = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Second invocation",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
(p, f.owner, sequence_id, f.work[0], [first, second], leaves)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_usages_exclude_endpoint_coincidence_and_keep_actual_occurrences_and_links() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let state = State::default();
|
||||
// Both exchange endpoints have Action contributions, but there is no
|
||||
// Exchange occurrence and no exchange arrow in this process.
|
||||
assert!(state.diagram_usages(&p, f.owner, f.exchange).is_empty());
|
||||
let direct = state.diagram_usages(&p, f.owner, f.work[0]);
|
||||
assert_eq!(direct.len(), 1);
|
||||
assert_eq!(
|
||||
direct[0].target,
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.nodes[0]
|
||||
})
|
||||
);
|
||||
let link = state.diagram_usages(&p, f.owner, f.link);
|
||||
assert_eq!(link.len(), 1);
|
||||
assert_eq!(link[0].target, Target::ProcessLink(f.link));
|
||||
let first = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"First exchange",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(f.exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
let second = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Second exchange",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(f.exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
let usages = state.diagram_usages(&p, f.owner, f.exchange);
|
||||
assert_eq!(usages.len(), 2);
|
||||
assert_eq!(
|
||||
usages
|
||||
.iter()
|
||||
.map(|usage| usage.target.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: first
|
||||
}),
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: second
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_usages_keep_caller_paths_and_exclude_unshown_fifth_preview() {
|
||||
let (p, owner, sequence, work, calls, leaves) = reused_fixture();
|
||||
let before = p.clone();
|
||||
let mut state = State::default();
|
||||
let leaf = state.diagram_usages(&p, owner, leaves[0]);
|
||||
assert_eq!(leaf.len(), 2);
|
||||
for (usage, call) in leaf.iter().zip(calls) {
|
||||
assert_eq!(
|
||||
usage.target,
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![call],
|
||||
occurrence: leaves[0]
|
||||
})
|
||||
);
|
||||
}
|
||||
assert_ne!(leaf[0].bounds, leaf[1].bounds);
|
||||
assert!(state.diagram_usages(&p, owner, leaves[4]).is_empty());
|
||||
assert_eq!(state.diagram_usages(&p, owner, sequence).len(), 2);
|
||||
// One local action and four tiles in each of two caller boxes.
|
||||
assert_eq!(state.diagram_usages(&p, owner, work).len(), 9);
|
||||
state.enter(Id::new_v4(), owner);
|
||||
assert!(state.expand(&p, owner, calls[0]));
|
||||
let fifth = state.diagram_usages(&p, owner, leaves[4]);
|
||||
assert_eq!(fifth.len(), 1);
|
||||
assert_eq!(
|
||||
fifth[0].target,
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![calls[0]],
|
||||
occurrence: leaves[4]
|
||||
})
|
||||
);
|
||||
// Another active owner must not leak its expansion to this queried canvas.
|
||||
state.enter(Id::new_v4(), sequence);
|
||||
assert!(state.diagram_usages(&p, owner, leaves[4]).is_empty());
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_usage_reveal_validates_before_state_changes_and_centers_the_exact_tile() {
|
||||
let (p, owner, _, _, calls, leaves) = reused_fixture();
|
||||
let before = p.clone();
|
||||
let document = Id::new_v4();
|
||||
let mut state = State::default();
|
||||
state.enter(document, owner);
|
||||
state.viewport(Size::new(880., 460.));
|
||||
state.pan = iced::Vector::new(-3000., -900.);
|
||||
let hidden = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![calls[0]],
|
||||
occurrence: leaves[4],
|
||||
});
|
||||
let prior = (
|
||||
state.identity,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.fit,
|
||||
state.selected_context.clone(),
|
||||
);
|
||||
assert!(!state.reveal_usage(&p, document, owner, &hidden));
|
||||
assert_eq!(
|
||||
(
|
||||
state.identity,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.fit,
|
||||
state.selected_context.clone()
|
||||
),
|
||||
prior
|
||||
);
|
||||
assert!(!state.reveal_usage(&p, document, Id::new_v4(), &Target::Whole));
|
||||
assert_eq!(
|
||||
(
|
||||
state.identity,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.fit,
|
||||
state.selected_context.clone()
|
||||
),
|
||||
prior
|
||||
);
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![calls[1]],
|
||||
occurrence: leaves[0],
|
||||
};
|
||||
let target = Target::ProcessOccurrence(endpoint.clone());
|
||||
assert!(state.reveal_usage(&p, document, owner, &target));
|
||||
assert_eq!(state.selected_context.as_ref().unwrap().endpoint, endpoint);
|
||||
let bounds = state
|
||||
.diagram_usages(&p, owner, leaves[0])
|
||||
.into_iter()
|
||||
.find(|usage| usage.target == target)
|
||||
.unwrap()
|
||||
.bounds;
|
||||
let scale = state.zoom.unwrap();
|
||||
assert!(scale >= 0.6);
|
||||
assert!((bounds.center_x() * scale + state.pan.x - 440.).abs() < 0.01);
|
||||
assert!((bounds.center_y() * scale + state.pan.y - 230.).abs() < 0.01);
|
||||
assert!(state.reveal_usage(&p, document, owner, &Target::Whole));
|
||||
assert!(state.selected_context.is_none());
|
||||
state
|
||||
.update(&p, Event::Start(owner, Tool::Work, None))
|
||||
.unwrap();
|
||||
let draft = state.recovery_snapshot().unwrap();
|
||||
let camera = (state.pan, state.zoom, state.fit);
|
||||
assert!(!state.reveal_usage(&p, document, owner, &target));
|
||||
assert_eq!(state.recovery_snapshot().unwrap(), draft);
|
||||
assert_eq!((state.pan, state.zoom, state.fit), camera);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_contextual_purpose_and_painted_preview_links_keep_distinct_identities() {
|
||||
let (mut p, owner, sequence, work, calls, leaves) = reused_fixture();
|
||||
let source = OccurrenceEndpoint {
|
||||
references: vec![calls[0]],
|
||||
occurrence: leaves[0],
|
||||
};
|
||||
let target = OccurrenceEndpoint {
|
||||
references: vec![calls[1]],
|
||||
occurrence: leaves[0],
|
||||
};
|
||||
let relation = Relation {
|
||||
id: Id::new_v4(),
|
||||
alias: "same_check".into(),
|
||||
name: "One common check".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose: ContextualLinkPurpose::CommonWork { work },
|
||||
},
|
||||
};
|
||||
let contextual = relation.id;
|
||||
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
|
||||
let Command::CreateRelation(relation) = link(&p, leaves[0], leaves[1], "next") else {
|
||||
unreachable!()
|
||||
};
|
||||
let local_link = relation.id;
|
||||
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
|
||||
let state = State::default();
|
||||
assert!(
|
||||
state
|
||||
.diagram_usages(&p, owner, work)
|
||||
.iter()
|
||||
.any(|usage| usage.target == Target::ProcessLink(contextual)
|
||||
&& usage.role == "common work")
|
||||
);
|
||||
assert_eq!(state.diagram_usages(&p, owner, contextual).len(), 1);
|
||||
let previews = state.diagram_usages(&p, owner, local_link);
|
||||
assert_eq!(previews.len(), 2);
|
||||
for (usage, call) in previews.iter().zip(calls) {
|
||||
assert_eq!(
|
||||
usage.target,
|
||||
Target::ProcessPreviewLink {
|
||||
relation: local_link,
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![call],
|
||||
occurrence: leaves[0]
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![call],
|
||||
occurrence: leaves[1]
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(state.diagram_usages(&p, sequence, local_link).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_arrow_focus_is_in_the_painted_gap_and_hidden_endpoints_never_fallback() {
|
||||
let (mut p, owner, _, _, calls, leaves) = reused_fixture();
|
||||
let Command::CreateRelation(shown) = link(&p, leaves[0], leaves[1], "next") else {
|
||||
unreachable!()
|
||||
};
|
||||
let shown_id = shown.id;
|
||||
p = execute_batch(&p, &[Command::CreateRelation(shown)]).unwrap();
|
||||
let Command::CreateRelation(hidden) = link(&p, leaves[0], leaves[4], "later") else {
|
||||
unreachable!()
|
||||
};
|
||||
let hidden_id = hidden.id;
|
||||
p = execute_batch(&p, &[Command::CreateRelation(hidden)]).unwrap();
|
||||
let before = p.clone();
|
||||
let document = Id::new_v4();
|
||||
let mut state = State::default();
|
||||
state.enter(document, owner);
|
||||
state.viewport(Size::new(800., 460.));
|
||||
let rows = state.diagram_usages(&p, owner, shown_id);
|
||||
assert_eq!(rows.len(), 2);
|
||||
let graph = Graph::new(&p, owner, owner);
|
||||
for (row, call) in rows.iter().zip(calls) {
|
||||
let node = graph.nodes.iter().find(|n| n.id == call).unwrap();
|
||||
// The painter places 113px tiles 125px apart, beginning 12px in.
|
||||
// The 12px gap is centered 131px into the caller, 134px below its top.
|
||||
assert_eq!(
|
||||
row.bounds.center(),
|
||||
Point::new(node.rect.x + 131., node.rect.y + 134.)
|
||||
);
|
||||
assert!(
|
||||
node.preview
|
||||
.iter()
|
||||
.all(|(e, _)| !node.endpoint_rect(e).contains(row.bounds.center()))
|
||||
);
|
||||
}
|
||||
assert_ne!(rows[0].bounds, rows[1].bounds);
|
||||
assert!(state.diagram_usages(&p, owner, hidden_id).is_empty());
|
||||
assert!(state.reveal_usage(&p, document, owner, &rows[1].target));
|
||||
assert!(
|
||||
state.selected_context.is_none(),
|
||||
"an arrow is not an occurrence authoring context"
|
||||
);
|
||||
let zoom = state.zoom.unwrap();
|
||||
assert!((rows[1].bounds.center_x() * zoom + state.pan.x - 400.).abs() < 0.01);
|
||||
assert!((rows[1].bounds.center_y() * zoom + state.pan.y - 230.).abs() < 0.01);
|
||||
assert!(state.expand(&p, owner, calls[0]));
|
||||
let expanded = state.diagram_usages(&p, owner, hidden_id);
|
||||
assert_eq!(expanded.len(), 1);
|
||||
assert!(
|
||||
matches!(&expanded[0].target, Target::ProcessPreviewLink { source, .. }
|
||||
if source.references == vec![calls[0]])
|
||||
);
|
||||
let mut stale = rows[1].target.clone();
|
||||
if let Target::ProcessPreviewLink { source, .. } = &mut stale {
|
||||
source.references = vec![Id::new_v4()];
|
||||
}
|
||||
let prior = (state.pan, state.zoom, state.fit);
|
||||
assert!(!state.reveal_usage(&p, document, owner, &stale));
|
||||
assert_eq!((state.pan, state.zoom, state.fit), prior);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_preview_segment_excludes_all_opaque_tiles_for_reverse_and_cross_row_arrows() {
|
||||
let tiles = [
|
||||
Rectangle::new(Point::new(12., 118.), Size::new(113., 32.)),
|
||||
Rectangle::new(Point::new(137., 118.), Size::new(113., 32.)),
|
||||
Rectangle::new(Point::new(12., 162.), Size::new(113., 32.)),
|
||||
Rectangle::new(Point::new(137., 162.), Size::new(113., 32.)),
|
||||
];
|
||||
for points in [
|
||||
[Point::new(250., 134.), Point::new(12., 134.)],
|
||||
[Point::new(125., 134.), Point::new(137., 178.)],
|
||||
[Point::new(250., 178.), Point::new(12., 134.)],
|
||||
] {
|
||||
let [a, b] = visible_preview_segment(points, tiles.into_iter()).unwrap();
|
||||
for fraction in [0.1, 0.5, 0.9] {
|
||||
let point = a + (b - a) * fraction;
|
||||
assert!(tiles.iter().all(|tile| !tile.contains(point)));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
visible_preview_segment(
|
||||
[Point::new(30., 134.), Point::new(100., 134.)],
|
||||
tiles.into_iter()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
visible_preview_segment([Point::new(1., 1.), Point::new(1., 1.)], tiles.into_iter())
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_nested_tiles_do_not_invent_links_the_preview_painter_does_not_emit() {
|
||||
let (mut p, owner, sequence, work, calls, _) = reused_fixture();
|
||||
let nested = p
|
||||
.make_element(Kind::Process, Layer::Oa, "Nested checks", None)
|
||||
.unwrap();
|
||||
let nested_id = nested.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(nested)]).unwrap();
|
||||
let leaves: Vec<_> = ["Nested one", "Nested two"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
add(
|
||||
&mut p,
|
||||
nested_id,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let Command::CreateRelation(relation) = link(&p, leaves[0], leaves[1], "next") else {
|
||||
unreachable!()
|
||||
};
|
||||
let relation_id = relation.id;
|
||||
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
|
||||
let reference = add(
|
||||
&mut p,
|
||||
sequence,
|
||||
"Nested invocation",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(nested_id),
|
||||
},
|
||||
);
|
||||
let mut state = State::default();
|
||||
state.enter(Id::new_v4(), owner);
|
||||
state.expanded.insert(calls[0]);
|
||||
state.contextual_expanded.insert(OccurrenceEndpoint {
|
||||
references: vec![calls[0]],
|
||||
occurrence: reference,
|
||||
});
|
||||
let shown = state.diagram_usages(&p, owner, leaves[0]);
|
||||
assert_eq!(shown.len(), 1);
|
||||
assert!(
|
||||
matches!(&shown[0].target, Target::ProcessOccurrence(endpoint)
|
||||
if endpoint.references == vec![calls[0], reference])
|
||||
);
|
||||
assert!(state.diagram_usages(&p, owner, relation_id).is_empty());
|
||||
assert_eq!(state.diagram_usages(&p, nested_id, relation_id).len(), 1);
|
||||
}
|
||||
@@ -183,6 +183,32 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
graph.selected_preview = state
|
||||
.qualified_selection(p, owner)
|
||||
.filter(|address| {
|
||||
address
|
||||
.endpoint
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(address.endpoint.occurrence)
|
||||
== app.selected
|
||||
})
|
||||
.map(|address| address.endpoint.clone());
|
||||
graph.selected_preview_link = app
|
||||
.diagram_focus(crate::diagram_usage::Surface::Process(owner))
|
||||
.and_then(|usage| match &usage.target {
|
||||
crate::diagram_usage::Target::ProcessPreviewLink {
|
||||
relation,
|
||||
source,
|
||||
target,
|
||||
} => Some(PreviewLink {
|
||||
relation: *relation,
|
||||
source: source.clone(),
|
||||
target: target.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
graph.camera_pan = state.pan;
|
||||
graph.pending_insertion = state
|
||||
|
||||
@@ -47,6 +47,8 @@ pub struct DiagramCanvas<'a> {
|
||||
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
|
||||
pub diagram: Id,
|
||||
pub selected: Id,
|
||||
/// Presentation-only navigation marker; never used by hit tests or edits.
|
||||
pub navigation_focus: Option<Rectangle>,
|
||||
pub zoom: f32,
|
||||
pub pan: Vector,
|
||||
pub context: Context,
|
||||
@@ -1323,6 +1325,14 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
f.fill(&Path::circle(center, 1.5), color);
|
||||
}
|
||||
}
|
||||
if let Some(bounds) = self.navigation_focus {
|
||||
f.stroke(
|
||||
&Path::rectangle(bounds.position(), bounds.size()),
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(2.5 / self.zoom),
|
||||
);
|
||||
}
|
||||
if let Some(edge) = scene.routed.edges.get(&self.selected)
|
||||
&& !edge.custom_suspended
|
||||
{
|
||||
@@ -2038,6 +2048,7 @@ mod tests {
|
||||
allocation: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::ZERO,
|
||||
};
|
||||
@@ -2113,6 +2124,7 @@ mod tests {
|
||||
allocation: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
zoom: 0.6,
|
||||
pan: Vector::new(10., 20.),
|
||||
};
|
||||
@@ -2229,6 +2241,7 @@ mod tests {
|
||||
diagram,
|
||||
context,
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
selected_bend: None,
|
||||
zoom,
|
||||
pan: Vector::new(20., 30.),
|
||||
@@ -2399,6 +2412,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
navigation_focus: None,
|
||||
zoom,
|
||||
pan: Vector::new(150., 300.),
|
||||
context: Context {
|
||||
@@ -2475,6 +2489,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
navigation_focus: None,
|
||||
zoom: 0.5,
|
||||
pan: Vector::new(150., 300.),
|
||||
context: Context {
|
||||
@@ -2572,6 +2587,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::new(150., 300.),
|
||||
context: Context {
|
||||
@@ -2700,6 +2716,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
navigation_focus: None,
|
||||
zoom: 0.7,
|
||||
pan: Vector::new(350., 350.),
|
||||
context: Context {
|
||||
@@ -2754,6 +2771,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::new(100., 400.),
|
||||
context: Context {
|
||||
@@ -2828,6 +2846,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::new(100., 400.),
|
||||
context: Context {
|
||||
@@ -2861,6 +2880,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::ZERO,
|
||||
context: Context {
|
||||
|
||||
@@ -57,6 +57,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
|
||||
@@ -65,6 +65,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
navigation_focus: None,
|
||||
zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
|
||||
@@ -6,6 +6,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: Id::nil(),
|
||||
navigation_focus: None,
|
||||
zoom,
|
||||
pan: Vector::new(17., 23.),
|
||||
context: Context {
|
||||
|
||||
@@ -181,6 +181,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::ZERO,
|
||||
context: app.route_context().unwrap(),
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Read-only identities for objects actually represented by a diagram renderer.
|
||||
use iced::Rectangle;
|
||||
use oa_core::{Id, OccurrenceEndpoint, Project, projection, routing, view_options::ViewIndex};
|
||||
pub(crate) mod view;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Context {
|
||||
pub document: Id,
|
||||
pub revision: u64,
|
||||
pub subject: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Open(Context),
|
||||
Close(Id),
|
||||
Search(Id, String),
|
||||
Page(Id, usize),
|
||||
Destination(Id, Option<Surface>),
|
||||
Choose(Id, Usage),
|
||||
Move(Id, i8),
|
||||
Activate(Id),
|
||||
}
|
||||
|
||||
/// One completed immutable accepted-model lookup. The token owns one worker slot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Completion {
|
||||
pub token: Id,
|
||||
pub context: Context,
|
||||
pub result: Result<Vec<Usage>, String>,
|
||||
}
|
||||
|
||||
pub(crate) struct Panel {
|
||||
pub token: Id,
|
||||
pub context: Context,
|
||||
pub usages: Vec<Usage>,
|
||||
pub loading: bool,
|
||||
pub search: String,
|
||||
pub page: usize,
|
||||
pub cursor: usize,
|
||||
pub destination: Option<Surface>,
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
pub fn new(context: Context, usages: Vec<Usage>) -> Self {
|
||||
Self {
|
||||
token: Id::new_v4(),
|
||||
context,
|
||||
usages,
|
||||
loading: false,
|
||||
search: String::new(),
|
||||
page: 0,
|
||||
cursor: 0,
|
||||
destination: None,
|
||||
notice: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches<'a>(&'a self, p: &Project) -> Vec<&'a Usage> {
|
||||
let query = self.search.to_lowercase();
|
||||
if query.is_empty() {
|
||||
return self.usages.iter().collect();
|
||||
}
|
||||
self.usages
|
||||
.iter()
|
||||
.filter(|usage| {
|
||||
let surface = usage.surface;
|
||||
let searchable = format!(
|
||||
"{} {} {} {} {}",
|
||||
surface.category(p),
|
||||
p.named(surface.id()),
|
||||
alias(p, surface.id()),
|
||||
target_name(p, usage),
|
||||
usage.role
|
||||
);
|
||||
searchable.to_lowercase().contains(&query)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn choices(&self, p: &Project) -> Vec<Event> {
|
||||
if self.loading {
|
||||
return Vec::new();
|
||||
}
|
||||
let matches = self.matches(p);
|
||||
if let Some(surface) = self.destination {
|
||||
matches
|
||||
.into_iter()
|
||||
.filter(|u| u.surface == surface)
|
||||
.map(|u| Event::Choose(self.token, u.clone()))
|
||||
.collect()
|
||||
} else {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
matches
|
||||
.into_iter()
|
||||
.filter(|u| seen.insert(u.surface))
|
||||
.map(|u| Event::Destination(self.token, Some(u.surface)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum Surface {
|
||||
Saved(Id),
|
||||
Process(Id),
|
||||
Scenario(Id),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum Target {
|
||||
Whole,
|
||||
Placement(Id),
|
||||
Port {
|
||||
port: Id,
|
||||
placement: Id,
|
||||
},
|
||||
Edge(Id),
|
||||
StateRow {
|
||||
placement: Id,
|
||||
relation: Id,
|
||||
},
|
||||
AllocationGrouping {
|
||||
relation: Id,
|
||||
work_placement: Id,
|
||||
performer_placement: Id,
|
||||
},
|
||||
ProcessOccurrence(OccurrenceEndpoint),
|
||||
ProcessLink(Id),
|
||||
ProcessPreviewLink {
|
||||
relation: Id,
|
||||
source: OccurrenceEndpoint,
|
||||
target: OccurrenceEndpoint,
|
||||
},
|
||||
ScenarioObject(Id),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct Usage {
|
||||
pub subject: Id,
|
||||
pub surface: Surface,
|
||||
pub target: Target,
|
||||
/// Concise, truthful explanation, e.g. "action occurrence" or "entry action".
|
||||
pub role: String,
|
||||
/// Actual current renderer coordinates. Recompute before activation.
|
||||
pub bounds: Rectangle,
|
||||
}
|
||||
|
||||
/// A navigation highlight is presentation state, never an edit/Delete target.
|
||||
pub(crate) struct Focus {
|
||||
pub context: Context,
|
||||
pub usage: Usage,
|
||||
pub selected: Id,
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
pub fn id(self) -> Id {
|
||||
match self {
|
||||
Self::Saved(id) | Self::Process(id) | Self::Scenario(id) => id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category(self, p: &Project) -> &'static str {
|
||||
match self {
|
||||
Self::Saved(id)
|
||||
if p.diagrams
|
||||
.get(&id)
|
||||
.is_some_and(|d| d.projection.state_machine().is_some()) =>
|
||||
{
|
||||
"State views"
|
||||
}
|
||||
Self::Saved(_) => "Architecture",
|
||||
Self::Process(_) => "Processes and chains",
|
||||
Self::Scenario(_) => "Scenarios",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect(
|
||||
p: &Project,
|
||||
subject: Id,
|
||||
process: &crate::behavior::topology::State,
|
||||
scenario: &crate::behavior::scenario::State,
|
||||
) -> Vec<Usage> {
|
||||
let mut usages = saved_usages(p, subject);
|
||||
for element in p.elements.values() {
|
||||
match element.kind() {
|
||||
oa_core::Kind::Process | oa_core::Kind::FunctionalChain => {
|
||||
usages.extend(process.diagram_usages(p, element.id, subject));
|
||||
}
|
||||
oa_core::Kind::Scenario => {
|
||||
usages.extend(scenario.diagram_usages(p, element.id, subject));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
usages.sort_by_cached_key(|u| {
|
||||
(
|
||||
u.surface.category(p),
|
||||
p.named(u.surface.id()).to_lowercase(),
|
||||
u.surface,
|
||||
target_name(p, u).to_lowercase(),
|
||||
)
|
||||
});
|
||||
usages
|
||||
.dedup_by(|a, b| a.subject == b.subject && a.surface == b.surface && a.target == b.target);
|
||||
usages
|
||||
}
|
||||
|
||||
pub(crate) fn alias(p: &Project, id: Id) -> &str {
|
||||
p.elements
|
||||
.get(&id)
|
||||
.map(|e| e.alias.as_str())
|
||||
.or_else(|| p.relations.get(&id).map(|r| r.alias.as_str()))
|
||||
.or_else(|| p.diagrams.get(&id).map(|d| d.alias.as_str()))
|
||||
.or_else(|| p.traces.get(&id).map(|t| t.alias.as_str()))
|
||||
.or_else(|| {
|
||||
p.diagrams
|
||||
.values()
|
||||
.find_map(|d| d.nodes.get(&id).map(|n| n.alias.as_str()))
|
||||
})
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
pub(crate) fn target_name(p: &Project, usage: &Usage) -> String {
|
||||
let id = match &usage.target {
|
||||
Target::Whole => usage.surface.id(),
|
||||
Target::Placement(id) => p
|
||||
.diagrams
|
||||
.get(&usage.surface.id())
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
.map_or(*id, |n| n.element),
|
||||
Target::Port { port, .. } => *port,
|
||||
Target::Edge(id)
|
||||
| Target::StateRow { relation: id, .. }
|
||||
| Target::AllocationGrouping { relation: id, .. }
|
||||
| Target::ProcessLink(id)
|
||||
| Target::ScenarioObject(id) => *id,
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
return endpoint
|
||||
.references
|
||||
.iter()
|
||||
.copied()
|
||||
.chain([endpoint.occurrence])
|
||||
.map(|id| format!("{} (@{})", p.named(id), alias(p, id)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
}
|
||||
Target::ProcessPreviewLink {
|
||||
relation, source, ..
|
||||
} => {
|
||||
return source
|
||||
.references
|
||||
.iter()
|
||||
.copied()
|
||||
.chain([*relation])
|
||||
.map(|id| format!("{} (@{})", p.named(id), alias(p, id)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
}
|
||||
};
|
||||
format!("{} (@{})", p.named(id), alias(p, id))
|
||||
}
|
||||
|
||||
/// Saved view membership is the renderer's final projection, including folds,
|
||||
/// projection-kind exclusions and explicit view filters. Stored route metadata
|
||||
/// and merely having both relation endpoints placed do not imply visibility.
|
||||
pub(crate) fn saved_usages(p: &Project, subject: Id) -> Vec<Usage> {
|
||||
saved_usages_for(p, subject, None)
|
||||
}
|
||||
|
||||
/// Revalidate only the requested saved surface before activation. The optional
|
||||
/// filter is applied before projection and routing, never after a whole lookup.
|
||||
pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id>) -> Vec<Usage> {
|
||||
if only_diagram.is_some_and(|id| !p.diagrams.contains_key(&id)) {
|
||||
return Vec::new();
|
||||
}
|
||||
let index = ViewIndex::new(p);
|
||||
let mut usages = Vec::new();
|
||||
for diagram in p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|diagram| only_diagram.is_none_or(|id| diagram.id == id))
|
||||
{
|
||||
let view = projection::project_diagram_with_index(p, diagram, &index);
|
||||
let surface = Surface::Saved(diagram.id);
|
||||
if subject == diagram.id || diagram.projection.state_machine() == Some(subject) {
|
||||
usages.push(Usage {
|
||||
subject,
|
||||
surface,
|
||||
target: Target::Whole,
|
||||
role: if subject == diagram.id {
|
||||
"diagram"
|
||||
} else {
|
||||
"state machine view"
|
||||
}
|
||||
.into(),
|
||||
bounds: routed_bounds(routing::route_diagram(p, diagram, &view).bounds),
|
||||
});
|
||||
}
|
||||
for node in &view.nodes {
|
||||
if subject == node.element || subject == node.id {
|
||||
usages.push(Usage {
|
||||
subject,
|
||||
surface,
|
||||
target: Target::Placement(node.id),
|
||||
role: "node".into(),
|
||||
bounds: Rectangle {
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
// Reveal the labelled header, not the empty interior of
|
||||
// a large expanded container at the minimum zoom.
|
||||
width: node.size.width.min(420.),
|
||||
height: node.size.height.min(64.),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
for port in &view.ports {
|
||||
if subject == port.element {
|
||||
usages.push(Usage {
|
||||
subject,
|
||||
surface,
|
||||
target: Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
},
|
||||
role: "port".into(),
|
||||
bounds: Rectangle {
|
||||
x: port.position.x - 12.,
|
||||
y: port.position.y - 12.,
|
||||
width: 24.,
|
||||
height: 24.,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Allocation arrows are suppressed in this projection. A direct
|
||||
// relation is represented only by its actual visible containment chain;
|
||||
// aggregate summaries never manufacture inherited relations.
|
||||
if diagram.projection == oa_core::ProjectionKind::Allocation
|
||||
&& let Some(relation) = p.relations.get(&subject)
|
||||
&& relation.kind() == oa_core::RelationKind::Allocation
|
||||
{
|
||||
let placements: std::collections::BTreeMap<_, _> =
|
||||
view.nodes.iter().map(|node| (node.id, node)).collect();
|
||||
for work in view.nodes.iter().filter(|n| n.element == relation.target()) {
|
||||
let mut parent = work.visual_parent;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
while let Some(id) = parent.filter(|id| seen.insert(*id)) {
|
||||
let Some(performer) = placements.get(&id) else {
|
||||
break;
|
||||
};
|
||||
if performer.element == relation.source() {
|
||||
usages.push(Usage {
|
||||
subject,
|
||||
surface,
|
||||
target: Target::AllocationGrouping {
|
||||
relation: subject,
|
||||
work_placement: work.id,
|
||||
performer_placement: performer.id,
|
||||
},
|
||||
role: "allocation grouping".into(),
|
||||
bounds: Rectangle {
|
||||
x: work.position.x,
|
||||
y: work.position.y,
|
||||
width: work.size.width.min(420.),
|
||||
height: work.size.height.min(64.),
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
parent = performer.visual_parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
if view.edges.iter().any(|edge| edge.id == subject)
|
||||
&& let Some(edge) = routing::route_diagram(p, diagram, &view)
|
||||
.edges
|
||||
.get(&subject)
|
||||
{
|
||||
usages.push(Usage {
|
||||
subject,
|
||||
surface,
|
||||
target: Target::Edge(subject),
|
||||
role: "relationship".into(),
|
||||
bounds: edge_focus_bounds(edge),
|
||||
});
|
||||
}
|
||||
if diagram.projection.state_machine().is_some() {
|
||||
usages.extend(crate::behavior::state_canvas::diagram_usages(
|
||||
p, diagram, &view, subject,
|
||||
));
|
||||
}
|
||||
}
|
||||
usages
|
||||
}
|
||||
|
||||
pub(crate) fn routed_bounds(bounds: routing::RoutingBounds) -> Rectangle {
|
||||
Rectangle {
|
||||
x: bounds.position.x,
|
||||
y: bounds.position.y,
|
||||
width: bounds.size.width,
|
||||
height: bounds.size.height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus a named edge at its label. For an unlabelled line, focus its middle
|
||||
/// segment instead of shrinking a long connector to fit the whole diagram.
|
||||
pub(crate) fn edge_focus_bounds(edge: &routing::RoutedEdge) -> Rectangle {
|
||||
if !edge.label.text.is_empty() {
|
||||
return routed_bounds(edge.label.bounds);
|
||||
}
|
||||
let point = edge
|
||||
.points
|
||||
.get(edge.points.len() / 2)
|
||||
.copied()
|
||||
.unwrap_or(edge.label.center);
|
||||
Rectangle {
|
||||
x: point.x - 40.,
|
||||
y: point.y - 24.,
|
||||
width: 80.,
|
||||
height: 48.,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole-canvas navigation fits its entire extent; an individual target keeps
|
||||
/// the readable minimum zoom of `camera` rather than shrinking unrelated space.
|
||||
pub(crate) fn camera_for(
|
||||
target: &Target,
|
||||
bounds: Rectangle,
|
||||
viewport: iced::Size,
|
||||
) -> (f32, iced::Vector) {
|
||||
if !matches!(target, Target::Whole) {
|
||||
return camera(bounds, viewport);
|
||||
}
|
||||
let available = iced::Size::new(
|
||||
(viewport.width - 64.).max(1.),
|
||||
(viewport.height - 64.).max(1.),
|
||||
);
|
||||
let zoom = (available.width / bounds.width.max(1.))
|
||||
.min(available.height / bounds.height.max(1.))
|
||||
.clamp(0.0001, 1.25);
|
||||
let pan = iced::Vector::new(
|
||||
viewport.width / 2. - bounds.center_x() * zoom,
|
||||
viewport.height / 2. - bounds.center_y() * zoom,
|
||||
);
|
||||
(zoom, pan)
|
||||
}
|
||||
|
||||
/// Return a readable, centered camera without modifying authored geometry.
|
||||
pub(crate) fn camera(bounds: Rectangle, viewport: iced::Size) -> (f32, iced::Vector) {
|
||||
let available = iced::Size::new(
|
||||
(viewport.width - 64.).max(1.),
|
||||
(viewport.height - 64.).max(1.),
|
||||
);
|
||||
let zoom = (available.width / bounds.width.max(1.))
|
||||
.min(available.height / bounds.height.max(1.))
|
||||
.clamp(0.5, 1.25);
|
||||
let pan = iced::Vector::new(
|
||||
viewport.width / 2. - bounds.center_x() * zoom,
|
||||
viewport.height / 2. - bounds.center_y() * zoom,
|
||||
);
|
||||
(zoom, pan)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod coverage_tests;
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Independent allocation membership oracles. Expected targets and coordinates
|
||||
//! come from handwritten fixtures, never from the usage predicate under test.
|
||||
use super::{Surface, Target, Usage, saved_usages, saved_usages_for};
|
||||
use iced::Rectangle;
|
||||
use oa_core::{
|
||||
Id, Kind, Layer, Position, Project, ProjectionKind, RelationKind, Size, projection, validation,
|
||||
};
|
||||
|
||||
struct AllocationFixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
performer: Id,
|
||||
work: Id,
|
||||
relation: Id,
|
||||
performer_node: Id,
|
||||
work_node: Id,
|
||||
}
|
||||
|
||||
fn assert_valid(project: &Project) {
|
||||
let errors: Vec<_> = validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"fixture must be structurally valid: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn fixture() -> AllocationFixture {
|
||||
let mut project = Project::empty();
|
||||
let performer = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Rescue team", None)
|
||||
.unwrap();
|
||||
let work = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Dispatch", None)
|
||||
.unwrap();
|
||||
let relation = project.relate(RelationKind::Allocation, "Performs", performer, work);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
let performer_node = project.place(diagram, performer, Position { x: 20., y: 40. });
|
||||
let work_node = project.place(diagram, work, Position { x: 240., y: 320. });
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&work_node)
|
||||
.unwrap()
|
||||
.size = Size {
|
||||
width: 320.,
|
||||
height: 110.,
|
||||
};
|
||||
assert_valid(&project);
|
||||
AllocationFixture {
|
||||
project,
|
||||
diagram,
|
||||
performer,
|
||||
work,
|
||||
relation,
|
||||
performer_node,
|
||||
work_node,
|
||||
}
|
||||
}
|
||||
|
||||
fn expected(f: &AllocationFixture) -> Usage {
|
||||
Usage {
|
||||
subject: f.relation,
|
||||
surface: Surface::Saved(f.diagram),
|
||||
target: Target::AllocationGrouping {
|
||||
relation: f.relation,
|
||||
work_placement: f.work_node,
|
||||
performer_placement: f.performer_node,
|
||||
},
|
||||
role: "allocation grouping".into(),
|
||||
bounds: Rectangle {
|
||||
x: 240.,
|
||||
y: 320.,
|
||||
width: 320.,
|
||||
height: 64.,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Two semantic decomposition levels, both summaries of the one allocated leaf.
|
||||
/// No Allocation relation targets either summary.
|
||||
fn nest_under_summaries(f: &mut AllocationFixture) -> (Id, Id, Id, Id) {
|
||||
let top = f
|
||||
.project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
|
||||
.unwrap();
|
||||
let middle = f
|
||||
.project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Coordinate", Some(top))
|
||||
.unwrap();
|
||||
f.project.elements.get_mut(&f.work).unwrap().parent = Some(middle);
|
||||
let top_node = f
|
||||
.project
|
||||
.place(f.diagram, top, Position { x: 70., y: 120. });
|
||||
let middle_node = f
|
||||
.project
|
||||
.place(f.diagram, middle, Position { x: 130., y: 210. });
|
||||
assert_valid(&f.project);
|
||||
(top, middle, top_node, middle_node)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_grouping_has_exact_relation_placements_and_readable_work_geometry() {
|
||||
let f = fixture();
|
||||
let before = f.project.clone();
|
||||
let view = projection::project_view(&f.project, f.diagram);
|
||||
let work = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == f.work_node)
|
||||
.unwrap();
|
||||
assert_eq!(work.visual_parent, Some(f.performer_node));
|
||||
assert_eq!(work.position, Position { x: 240., y: 320. });
|
||||
assert_eq!(
|
||||
work.size,
|
||||
Size {
|
||||
width: 320.,
|
||||
height: 110.
|
||||
}
|
||||
);
|
||||
assert!(!view.edges.iter().any(|edge| edge.id == f.relation));
|
||||
|
||||
assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]);
|
||||
assert_eq!(
|
||||
saved_usages_for(&f.project, f.relation, Some(f.diagram)),
|
||||
vec![expected(&f)]
|
||||
);
|
||||
assert!(saved_usages_for(&f.project, f.relation, Some(Id::new_v4())).is_empty());
|
||||
// The semantic work and its placement remain ordinary node selections.
|
||||
for subject in [f.work, f.work_node] {
|
||||
let usages = saved_usages(&f.project, subject);
|
||||
assert_eq!(usages.len(), 1);
|
||||
assert_eq!(usages[0].target, Target::Placement(f.work_node));
|
||||
}
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_grouping_walks_two_visible_summary_levels_without_inventing_allocations() {
|
||||
let mut f = fixture();
|
||||
let (top, middle, top_node, middle_node) = nest_under_summaries(&mut f);
|
||||
let before = f.project.clone();
|
||||
let view = projection::project_view(&f.project, f.diagram);
|
||||
for (node_id, parent, summary) in [
|
||||
(f.work_node, Some(middle_node), false),
|
||||
(middle_node, Some(top_node), true),
|
||||
(top_node, Some(f.performer_node), true),
|
||||
] {
|
||||
let node = view.nodes.iter().find(|node| node.id == node_id).unwrap();
|
||||
assert_eq!(node.visual_parent, parent);
|
||||
assert_eq!(node.allocation_summary, summary);
|
||||
}
|
||||
assert_eq!(f.project.relations.len(), 1);
|
||||
assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]);
|
||||
for (element, node) in [(top, top_node), (middle, middle_node)] {
|
||||
let usages = saved_usages(&f.project, element);
|
||||
assert_eq!(usages.len(), 1);
|
||||
assert_eq!(usages[0].target, Target::Placement(node));
|
||||
}
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folded_summary_or_performer_does_not_substitute_a_visible_ancestor_for_the_leaf() {
|
||||
let mut f = fixture();
|
||||
let (_, _, top_node, middle_node) = nest_under_summaries(&mut f);
|
||||
for folded in [f.performer_node, top_node, middle_node] {
|
||||
let mut project = f.project.clone();
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&folded)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
assert_valid(&project);
|
||||
let before = project.clone();
|
||||
let view = projection::project_view(&project, f.diagram);
|
||||
assert!(view.nodes.iter().any(|node| node.id == folded));
|
||||
assert!(!view.nodes.iter().any(|node| node.id == f.work_node));
|
||||
assert!(saved_usages(&project, f.relation).is_empty());
|
||||
assert_eq!(project, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_endpoint_placements_and_hidden_performer_do_not_create_grouping_targets() {
|
||||
let mut f = fixture();
|
||||
let (_, _, top_node, _) = nest_under_summaries(&mut f);
|
||||
for absent in [f.performer_node, f.work_node] {
|
||||
let mut project = f.project.clone();
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&absent);
|
||||
assert_valid(&project);
|
||||
let before = project.clone();
|
||||
let view = projection::project_view(&project, f.diagram);
|
||||
// The aggregate card remains visible even when the actual leaf or its
|
||||
// actual performer has no placement. It cannot stand in for that end.
|
||||
assert!(
|
||||
view.nodes
|
||||
.iter()
|
||||
.any(|node| node.id == top_node && node.allocation_summary)
|
||||
);
|
||||
assert!(saved_usages(&project, f.relation).is_empty());
|
||||
assert_eq!(project, before);
|
||||
}
|
||||
|
||||
let outer = f
|
||||
.project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Organization", None)
|
||||
.unwrap();
|
||||
f.project.elements.get_mut(&f.performer).unwrap().parent = Some(outer);
|
||||
let outer_node = f
|
||||
.project
|
||||
.place(f.diagram, outer, Position { x: -40., y: -60. });
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&outer_node)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
assert_valid(&f.project);
|
||||
let before = f.project.clone();
|
||||
let view = projection::project_view(&f.project, f.diagram);
|
||||
assert!(view.nodes.iter().any(|node| node.id == outer_node));
|
||||
assert!(!view.nodes.iter().any(|node| node.id == f.performer_node));
|
||||
assert!(saved_usages(&f.project, f.relation).is_empty());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_leaf_and_edge_filter_still_leave_the_actual_nesting_visible() {
|
||||
let mut f = fixture();
|
||||
let diagram = f.project.diagrams.get_mut(&f.diagram).unwrap();
|
||||
diagram.nodes.get_mut(&f.work_node).unwrap().collapsed = true;
|
||||
// hidden_edges controls arrows. Allocation is drawn as nesting, which is
|
||||
// still visible; do not mistake the filter for a hidden node/grouping.
|
||||
diagram.view_options.hidden_edges.push(f.relation);
|
||||
assert_valid(&f.project);
|
||||
let before = f.project.clone();
|
||||
let view = projection::project_view(&f.project, f.diagram);
|
||||
let work = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == f.work_node)
|
||||
.unwrap();
|
||||
assert!(work.collapsed);
|
||||
assert_eq!(work.visual_parent, Some(f.performer_node));
|
||||
assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]);
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_projections_do_not_report_allocation_nesting_when_only_an_arrow_or_nothing_is_drawn() {
|
||||
let mut f = fixture();
|
||||
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
assert_valid(&f.project);
|
||||
let before = f.project.clone();
|
||||
let view = projection::project_view(&f.project, f.diagram);
|
||||
assert!(view.edges.iter().any(|edge| edge.id == f.relation));
|
||||
assert!(view.nodes.iter().all(|node| node.visual_parent.is_none()));
|
||||
let usages = saved_usages(&f.project, f.relation);
|
||||
assert_eq!(usages.len(), 1);
|
||||
assert_eq!(usages[0].subject, f.relation);
|
||||
assert_eq!(usages[0].surface, Surface::Saved(f.diagram));
|
||||
assert_eq!(usages[0].target, Target::Edge(f.relation));
|
||||
assert_eq!(f.project, before);
|
||||
|
||||
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
assert_valid(&f.project);
|
||||
let before = f.project.clone();
|
||||
let view = projection::project_view(&f.project, f.diagram);
|
||||
assert!(view.nodes.iter().any(|node| node.id == f.work_node));
|
||||
assert!(!view.nodes.iter().any(|node| node.id == f.performer_node));
|
||||
assert!(saved_usages(&f.project, f.relation).is_empty());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_saved_views_keep_allocation_placement_identity_and_geometry_independent() {
|
||||
let mut f = fixture();
|
||||
// The first diagram is Allocation, so this creates a different Overview.
|
||||
let second = f.project.ensure_diagram(Layer::Oa);
|
||||
assert_ne!(second, f.diagram);
|
||||
f.project.diagrams.get_mut(&second).unwrap().projection = ProjectionKind::Allocation;
|
||||
let second_performer = f
|
||||
.project
|
||||
.place(second, f.performer, Position { x: 900., y: 600. });
|
||||
let second_work = f
|
||||
.project
|
||||
.place(second, f.work, Position { x: 1000., y: 700. });
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&second)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&second_work)
|
||||
.unwrap()
|
||||
.size = Size {
|
||||
width: 520.,
|
||||
height: 130.,
|
||||
};
|
||||
assert_ne!(second_performer, f.performer_node);
|
||||
assert_ne!(second_work, f.work_node);
|
||||
assert_valid(&f.project);
|
||||
let before = f.project.clone();
|
||||
let other = Usage {
|
||||
subject: f.relation,
|
||||
surface: Surface::Saved(second),
|
||||
target: Target::AllocationGrouping {
|
||||
relation: f.relation,
|
||||
work_placement: second_work,
|
||||
performer_placement: second_performer,
|
||||
},
|
||||
role: "allocation grouping".into(),
|
||||
bounds: Rectangle {
|
||||
x: 1000.,
|
||||
y: 700.,
|
||||
width: 420.,
|
||||
height: 64.,
|
||||
},
|
||||
};
|
||||
let usages = saved_usages(&f.project, f.relation);
|
||||
assert_eq!(usages.len(), 2);
|
||||
assert!(usages.contains(&expected(&f)));
|
||||
assert!(usages.contains(&other));
|
||||
assert_eq!(
|
||||
saved_usages_for(&f.project, f.relation, Some(second)),
|
||||
vec![other]
|
||||
);
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_derived_nesting_without_a_direct_allocation_does_not_invent_one() {
|
||||
let mut f = fixture();
|
||||
f.project.relations.remove(&f.relation);
|
||||
let role = f
|
||||
.project
|
||||
.add_element(Kind::Role, Layer::Oa, "Dispatcher", None)
|
||||
.unwrap();
|
||||
let realization = f.project.relate(
|
||||
RelationKind::RoleRealization,
|
||||
"Realizes dispatcher",
|
||||
f.performer,
|
||||
role,
|
||||
);
|
||||
let assignment = f
|
||||
.project
|
||||
.relate(RelationKind::RoleAssignment, "Dispatches", role, f.work);
|
||||
assert_valid(&f.project);
|
||||
let before = f.project.clone();
|
||||
let view = projection::project_view(&f.project, f.diagram);
|
||||
let work = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == f.work_node)
|
||||
.unwrap();
|
||||
assert_eq!(work.visual_parent, Some(f.performer_node));
|
||||
assert!(work.allocation_summary);
|
||||
assert!(
|
||||
f.project
|
||||
.relations
|
||||
.values()
|
||||
.all(|relation| relation.kind() != RelationKind::Allocation)
|
||||
);
|
||||
for subject in [f.relation, realization, assignment] {
|
||||
assert!(saved_usages(&f.project, subject).is_empty());
|
||||
}
|
||||
let usages = saved_usages(&f.project, f.work);
|
||||
assert_eq!(usages.len(), 1);
|
||||
assert_eq!(usages[0].target, Target::Placement(f.work_node));
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use super::*;
|
||||
use oa_core::{Kind, Layer, Position, RelationKind};
|
||||
|
||||
#[test]
|
||||
fn saved_membership_respects_filters_and_does_not_change_project() {
|
||||
let mut p = Project::empty();
|
||||
let first = p.add(Kind::Activity, "Same name", 20., 20.);
|
||||
let second = p.add(Kind::Activity, "Same name", 340., 20.);
|
||||
let relation = p.relate(RelationKind::Interaction, "Handoff", first, second);
|
||||
let shown = p.ensure_diagram(Layer::Oa);
|
||||
let mut hidden = p.diagrams[&shown].clone();
|
||||
hidden.id = Id::new_v4();
|
||||
hidden.alias = "filtered_copy".into();
|
||||
hidden.nodes = hidden
|
||||
.nodes
|
||||
.into_values()
|
||||
.map(|mut node| {
|
||||
node.id = Id::new_v4();
|
||||
node.alias = format!("filtered_{}", node.alias);
|
||||
(node.id, node)
|
||||
})
|
||||
.collect();
|
||||
hidden.view_options.hidden_edges.push(relation);
|
||||
let hidden_id = hidden.id;
|
||||
p.diagrams.insert(hidden_id, hidden);
|
||||
let before = p.clone();
|
||||
|
||||
let hits = saved_usages(&p, relation);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].surface, Surface::Saved(shown));
|
||||
assert_eq!(hits[0].target, Target::Edge(relation));
|
||||
let hits = saved_usages(&p, first);
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert!(hits.iter().all(|hit| match hit.target {
|
||||
Target::Placement(node) => p.diagrams[&hit.surface.id()].nodes[&node].element == first,
|
||||
_ => false,
|
||||
}));
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folded_child_is_not_a_visible_node_and_placement_identity_stays_exact() {
|
||||
let mut p = Project::empty();
|
||||
let parent = p.add(Kind::Activity, "Container", 20., 20.);
|
||||
let child = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Nested", Some(parent))
|
||||
.unwrap();
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let child_node = p.place(diagram, child, Position { x: 50., y: 100. });
|
||||
let parent_node = p.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == parent)
|
||||
.unwrap()
|
||||
.id;
|
||||
let hits = saved_usages(&p, child_node);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].target, Target::Placement(child_node));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent_node)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let before = p.clone();
|
||||
assert!(saved_usages(&p, child).is_empty());
|
||||
assert!(saved_usages(&p, child_node).is_empty());
|
||||
assert_eq!(saved_usages(&p, parent).len(), 1);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_are_found_at_the_visible_owner_and_unplaced_elements_do_not_match_names() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p.add(Kind::Function, "Compute", 20., 20.);
|
||||
let port = Id::new_v4();
|
||||
let element = oa_core::ports::make_port(
|
||||
&p,
|
||||
port,
|
||||
owner,
|
||||
"Input",
|
||||
oa_core::PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p = oa_core::execute_batch(&p, &[oa_core::Command::CreateElement(element)]).unwrap();
|
||||
let unrelated = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Compute", None)
|
||||
.unwrap();
|
||||
let hits = saved_usages(&p, port);
|
||||
assert_eq!(hits.len(), 1);
|
||||
let view = projection::project_view(&p, hits[0].surface.id());
|
||||
let painted = view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|shown| shown.element == port)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
hits[0].target,
|
||||
Target::Port {
|
||||
port,
|
||||
placement: painted.owner
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
hits[0].bounds.center(),
|
||||
iced::Point::new(painted.position.x, painted.position.y)
|
||||
);
|
||||
assert!(saved_usages(&p, unrelated).is_empty());
|
||||
assert!(saved_usages(&p, Id::new_v4()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_camera_centers_target_without_whole_view_shrink() {
|
||||
let bounds = Rectangle {
|
||||
x: 100_000.,
|
||||
y: -50_000.,
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
};
|
||||
let viewport = iced::Size::new(700., 500.);
|
||||
let (zoom, pan) = camera(bounds, viewport);
|
||||
assert_eq!(zoom, 1.25);
|
||||
assert!((bounds.center_x() * zoom + pan.x - 350.).abs() < 0.01);
|
||||
assert!((bounds.center_y() * zoom + pan.y - 250.).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_container_reveal_targets_its_labelled_header() {
|
||||
let mut p = Project::empty();
|
||||
let container = p.add(Kind::Activity, "Large enclosing activity", 40., 70.);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let node = p
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.next()
|
||||
.unwrap();
|
||||
node.size = oa_core::Size {
|
||||
width: 4000.,
|
||||
height: 4000.,
|
||||
};
|
||||
let before = p.clone();
|
||||
let hits = saved_usages(&p, container);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].bounds.x, 40.);
|
||||
assert_eq!(hits[0].bounds.y, 70.);
|
||||
assert!(hits[0].bounds.width <= 420.);
|
||||
assert!(hits[0].bounds.height <= 64.);
|
||||
let (zoom, pan) = camera(hits[0].bounds, iced::Size::new(500., 300.));
|
||||
assert!((hits[0].bounds.center_x() * zoom + pan.x - 250.).abs() < 0.01);
|
||||
assert!(70. * zoom + pan.y >= 0.);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine_owner_finds_its_view_without_a_machine_placement() {
|
||||
let mut p = Project::empty();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Monitor", None)
|
||||
.unwrap();
|
||||
let unrelated = p
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Other", None)
|
||||
.unwrap();
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection =
|
||||
oa_core::ProjectionKind::StateMachine { machine };
|
||||
let before = p.clone();
|
||||
let hits = saved_usages(&p, machine);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].surface, Surface::Saved(diagram));
|
||||
assert_eq!(hits[0].target, Target::Whole);
|
||||
assert_eq!(hits[0].role, "state machine view");
|
||||
assert!(saved_usages(&p, unrelated).is_empty());
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_diagram_camera_fits_large_bounds_instead_of_individual_object_scale() {
|
||||
let bounds = Rectangle {
|
||||
x: 2000.,
|
||||
y: -4000.,
|
||||
width: 100_000.,
|
||||
height: 80_000.,
|
||||
};
|
||||
let (zoom, pan) = camera_for(&Target::Whole, bounds, iced::Size::new(500., 300.));
|
||||
assert!(zoom < 0.01);
|
||||
assert!(bounds.x * zoom + pan.x >= 0.);
|
||||
assert!(bounds.y * zoom + pan.y >= 0.);
|
||||
assert!((bounds.x + bounds.width) * zoom + pan.x <= 500.);
|
||||
assert!((bounds.y + bounds.height) * zoom + pan.y <= 300.);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
style,
|
||||
views::{action, short},
|
||||
};
|
||||
use iced::{
|
||||
Element, Fill,
|
||||
widget::{self, column, container, row, text, text_input, tooltip},
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const PAGE_SIZE: usize = 6;
|
||||
|
||||
pub(crate) fn overlay<'a>(app: &'a App, base: Element<'a, Message>) -> Element<'a, Message> {
|
||||
let Some(panel) = &app.diagram_usages else {
|
||||
return base;
|
||||
};
|
||||
let p = app.project();
|
||||
let token = panel.token;
|
||||
let message = |event| Message::DiagramUsage(event);
|
||||
let matched = panel.matches(p);
|
||||
let mut surfaces = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for usage in &matched {
|
||||
if seen.insert(usage.surface) {
|
||||
surfaces.push(usage.surface);
|
||||
}
|
||||
}
|
||||
let targets: Vec<_> = matched
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|usage| Some(usage.surface) == panel.destination)
|
||||
.collect();
|
||||
let count = if panel.destination.is_some() {
|
||||
targets.len()
|
||||
} else {
|
||||
surfaces.len()
|
||||
};
|
||||
let last = count.saturating_sub(1) / PAGE_SIZE;
|
||||
let page = panel.page.min(last);
|
||||
let total_surfaces = panel
|
||||
.usages
|
||||
.iter()
|
||||
.map(|u| u.surface)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len();
|
||||
let mut content = column![
|
||||
row![
|
||||
text("Show in diagrams").size(22).width(Fill),
|
||||
action("Close", message(Event::Close(token)))
|
||||
]
|
||||
.spacing(12),
|
||||
tooltip(
|
||||
text(format!(
|
||||
"{} · @{}",
|
||||
short(p.named(panel.context.subject), 48),
|
||||
short(alias(p, panel.context.subject), 48)
|
||||
))
|
||||
.size(14),
|
||||
container(
|
||||
widget::scrollable(
|
||||
text(format!(
|
||||
"{}\n@{}",
|
||||
p.named(panel.context.subject),
|
||||
alias(p, panel.context.subject)
|
||||
))
|
||||
.size(13)
|
||||
)
|
||||
.height(220)
|
||||
)
|
||||
.padding(10)
|
||||
.width(620)
|
||||
.style(style::card),
|
||||
tooltip::Position::Bottom
|
||||
),
|
||||
text(if panel.loading {
|
||||
"Searching rendered objects…".into()
|
||||
} else {
|
||||
format!(
|
||||
"{} diagram destinations · {} representations",
|
||||
total_surfaces,
|
||||
panel.usages.len()
|
||||
)
|
||||
})
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
text_input("Filter diagram, occurrence, or alias", &panel.search)
|
||||
.id(format!("diagram-usage-filter-{}", panel.token))
|
||||
.padding(9)
|
||||
.on_input(move |value| Message::DiagramUsage(Event::Search(token, value)))
|
||||
.on_submit(message(Event::Activate(token))),
|
||||
]
|
||||
.spacing(10);
|
||||
if let Some(notice) = &panel.notice {
|
||||
content = content.push(text(notice).size(13));
|
||||
}
|
||||
if let Some(surface) = panel.destination {
|
||||
content = content.push(
|
||||
row![
|
||||
action("All diagrams", message(Event::Destination(token, None))),
|
||||
text(short(p.named(surface.id()), 55)).size(15),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
for (index, usage) in targets
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.skip(page * PAGE_SIZE)
|
||||
.take(PAGE_SIZE)
|
||||
{
|
||||
let name = target_name(p, usage);
|
||||
content = content.push(tooltip(
|
||||
action(
|
||||
&format!("{} · {}", usage.role, short(&name, 70)),
|
||||
message(Event::Choose(token, usage.clone())),
|
||||
)
|
||||
.width(Fill)
|
||||
.style(if panel.cursor == index {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
}),
|
||||
container(text(name).size(13))
|
||||
.padding(10)
|
||||
.max_width(680)
|
||||
.style(style::card),
|
||||
tooltip::Position::Top,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
for (index, surface) in surfaces
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.skip(page * PAGE_SIZE)
|
||||
.take(PAGE_SIZE)
|
||||
{
|
||||
let targets = matched.iter().filter(|u| u.surface == surface).count();
|
||||
let name = format!(
|
||||
"{} · {} (@{}) · {} representations",
|
||||
surface.category(p),
|
||||
p.named(surface.id()),
|
||||
alias(p, surface.id()),
|
||||
targets
|
||||
);
|
||||
let layer = p
|
||||
.diagrams
|
||||
.get(&surface.id())
|
||||
.map(|d| d.layer)
|
||||
.or_else(|| p.layer_of(surface.id()))
|
||||
.map(|l| l.to_string())
|
||||
.unwrap_or_default();
|
||||
content = content.push(tooltip(
|
||||
action(
|
||||
&format!(
|
||||
"{} · {} · {} · {}",
|
||||
surface.category(p),
|
||||
layer,
|
||||
short(p.named(surface.id()), 34),
|
||||
targets
|
||||
),
|
||||
message(Event::Destination(token, Some(surface))),
|
||||
)
|
||||
.width(Fill)
|
||||
.style(if panel.cursor == index {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
}),
|
||||
container(text(name).size(13))
|
||||
.padding(10)
|
||||
.max_width(680)
|
||||
.style(style::card),
|
||||
tooltip::Position::Top,
|
||||
));
|
||||
}
|
||||
}
|
||||
if panel.loading {
|
||||
content = content.push(text("Finding diagram representations…").size(13));
|
||||
} else if count == 0 {
|
||||
content = content.push(text(if panel.usages.is_empty() {
|
||||
"No currently rendered representation found. Folded or filtered objects and unexpanded reuse contents are excluded."
|
||||
} else { "No results match this filter." }).size(13));
|
||||
}
|
||||
let mut paging = row![
|
||||
text(format!(
|
||||
"Page {} of {} · {} matches",
|
||||
page + 1,
|
||||
last + 1,
|
||||
count
|
||||
))
|
||||
.size(12)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(8);
|
||||
if page > 0 {
|
||||
paging = paging.push(action("Previous", message(Event::Page(token, page - 1))));
|
||||
}
|
||||
if page < last {
|
||||
paging = paging.push(action("Next", message(Event::Page(token, page + 1))));
|
||||
}
|
||||
content = content.push(paging).push(text("↑ ↓ choose · Enter opens · Escape closes. Source and saved view settings are preserved.").size(12).color(style::MUTED));
|
||||
widget::stack![
|
||||
base,
|
||||
widget::opaque(
|
||||
container(
|
||||
container(content)
|
||||
.padding(22)
|
||||
.width((app.window_size.width - 80.).clamp(620., 820.))
|
||||
.style(style::card)
|
||||
)
|
||||
.center(Fill)
|
||||
.style(|_| container::Style {
|
||||
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
|
||||
..Default::default()
|
||||
})
|
||||
)
|
||||
]
|
||||
.into()
|
||||
}
|
||||
@@ -10,6 +10,10 @@ use std::{fmt, sync::Arc};
|
||||
const HISTORY_LIMIT: usize = 100;
|
||||
const HISTORY_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
#[path = "document_alias_rename.rs"]
|
||||
mod alias_rename;
|
||||
pub use alias_rename::PreparedAliasRename;
|
||||
|
||||
struct HistoryEntry {
|
||||
snapshot: Arc<CompiledSourceProject>,
|
||||
bytes: usize,
|
||||
@@ -91,6 +95,8 @@ impl From<Vec<SourceDiagnostic>> for DocumentError {
|
||||
}
|
||||
|
||||
pub struct Document {
|
||||
// Distinguishes equal accepted snapshots belonging to different documents.
|
||||
alias_owner: Arc<()>,
|
||||
current: Arc<CompiledSourceProject>,
|
||||
past: Vec<HistoryEntry>,
|
||||
future: Vec<HistoryEntry>,
|
||||
@@ -121,6 +127,7 @@ impl Document {
|
||||
let current = Arc::new(source_project::compile_bundle(&bundle, limits)?);
|
||||
let current_bytes = estimated_bytes(¤t);
|
||||
Ok(Self {
|
||||
alias_owner: Arc::new(()),
|
||||
current,
|
||||
current_bytes,
|
||||
past: Vec::new(),
|
||||
@@ -394,8 +401,17 @@ impl Document {
|
||||
return false;
|
||||
}
|
||||
let bytes = estimated_bytes(&compiled);
|
||||
self.commit_snapshot(Arc::new(compiled), bytes)
|
||||
}
|
||||
|
||||
// Only validated internal paths supply a changed snapshot and its exact
|
||||
// estimate. The ordinary commit wrapper checks bundle inequality; the alias
|
||||
// path checks its private changed flag and exact accepted-snapshot authority.
|
||||
// Alias preparation shares this algorithm while retaining the live save and
|
||||
// history state, and does not clone/recompile the candidate on adoption.
|
||||
fn commit_snapshot(&mut self, compiled: Arc<CompiledSourceProject>, bytes: usize) -> bool {
|
||||
self.past.push(HistoryEntry {
|
||||
snapshot: std::mem::replace(&mut self.current, Arc::new(compiled)),
|
||||
snapshot: std::mem::replace(&mut self.current, compiled),
|
||||
bytes: std::mem::replace(&mut self.current_bytes, bytes),
|
||||
});
|
||||
self.future.clear();
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Alias-only worker preparation and guarded adoption. Public compiled DTOs do
|
||||
//! not authorize adoption; only this module can mint a prepared rename.
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A pinned intent minted by its owning Document. Move it to a worker and call
|
||||
/// `prepare`; preparation has no access to the live Document or its histories.
|
||||
#[derive(Debug)]
|
||||
pub struct AliasRenameRequest {
|
||||
owner: Arc<()>,
|
||||
before: Arc<CompiledSourceProject>,
|
||||
revision: u64,
|
||||
limits: ProjectLimits,
|
||||
target: Id,
|
||||
old_alias: String,
|
||||
new_alias: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedData {
|
||||
request: AliasRenameRequest,
|
||||
after: Arc<CompiledSourceProject>,
|
||||
before_bytes: usize,
|
||||
after_bytes: usize,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
/// Immutable, Clone-compatible transport for preview and approval. Neither
|
||||
/// mutable candidate access nor deserialization can manufacture this authority.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreparedAliasRename(Arc<PreparedData>);
|
||||
|
||||
impl PreparedAliasRename {
|
||||
pub fn before(&self) -> &CompiledSourceProject {
|
||||
&self.0.request.before
|
||||
}
|
||||
|
||||
pub fn after(&self) -> &CompiledSourceProject {
|
||||
&self.0.after
|
||||
}
|
||||
|
||||
pub fn target(&self) -> Id {
|
||||
self.0.request.target
|
||||
}
|
||||
|
||||
pub fn old_alias(&self) -> &str {
|
||||
&self.0.request.old_alias
|
||||
}
|
||||
|
||||
pub fn new_alias(&self) -> &str {
|
||||
&self.0.request.new_alias
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.0.request.revision
|
||||
}
|
||||
|
||||
pub fn changed(&self) -> bool {
|
||||
self.0.changed
|
||||
}
|
||||
}
|
||||
|
||||
fn refusal(message: &str) -> DocumentError {
|
||||
ModelError(message.into()).into()
|
||||
}
|
||||
|
||||
fn check_history_capacity(before_bytes: usize, after_bytes: usize) -> Result<(), DocumentError> {
|
||||
if before_bytes.saturating_add(after_bytes) > HISTORY_BYTES {
|
||||
return Err(refusal(
|
||||
"This rename exceeds the model Undo budget. No rename was applied.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn alias_rename_request(
|
||||
&self,
|
||||
target: Id,
|
||||
replacement: String,
|
||||
) -> Result<AliasRenameRequest, DocumentError> {
|
||||
if self.revision == u64::MAX {
|
||||
return Err(refusal(
|
||||
"The document revision is exhausted. Reopen before renaming.",
|
||||
));
|
||||
}
|
||||
let old_alias = self
|
||||
.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find_map(|(id, alias)| (id == target).then(|| alias.to_owned()))
|
||||
.ok_or_else(|| refusal("The alias target is no longer present."))?;
|
||||
// A candidate has a nonzero estimate. Avoid expensive preparation when
|
||||
// even the current snapshot leaves no room for its immediate Undo.
|
||||
if old_alias != replacement && self.current_bytes >= HISTORY_BYTES {
|
||||
return Err(refusal(
|
||||
"This rename exceeds the model Undo budget. No rename was applied.",
|
||||
));
|
||||
}
|
||||
Ok(AliasRenameRequest {
|
||||
owner: self.alias_owner.clone(),
|
||||
before: self.current.clone(),
|
||||
revision: self.revision,
|
||||
limits: self.limits,
|
||||
target,
|
||||
old_alias,
|
||||
new_alias: replacement,
|
||||
})
|
||||
}
|
||||
|
||||
/// App must separately consume its current approval and check workspace,
|
||||
/// invocation and draft freshness. This checks accepted Document authority.
|
||||
pub fn commit_alias_rename(
|
||||
&mut self,
|
||||
prepared: &PreparedAliasRename,
|
||||
) -> Result<bool, DocumentError> {
|
||||
let data = &prepared.0;
|
||||
let request = &data.request;
|
||||
if self.revision == u64::MAX
|
||||
|| self.revision != request.revision
|
||||
|| !Arc::ptr_eq(&self.alias_owner, &request.owner)
|
||||
|| !Arc::ptr_eq(&self.current, &request.before)
|
||||
{
|
||||
return Err(refusal(
|
||||
"This rename preview is stale. Preview the current alias again.",
|
||||
));
|
||||
}
|
||||
if !data.changed {
|
||||
return Ok(false);
|
||||
}
|
||||
if self.current_bytes != data.before_bytes {
|
||||
return Err(refusal(
|
||||
"The rename history estimate is stale. Preview again.",
|
||||
));
|
||||
}
|
||||
check_history_capacity(self.current_bytes, data.after_bytes)?;
|
||||
Ok(self.commit_snapshot(data.after.clone(), data.after_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl AliasRenameRequest {
|
||||
/// Run on the preparation worker. The typed command and source updater are
|
||||
/// the only candidate construction path, followed by alias-only invariants.
|
||||
pub fn prepare(self) -> Result<PreparedAliasRename, DocumentError> {
|
||||
let before_project = &self.before.decoded.project;
|
||||
let candidate = prepare_command(
|
||||
before_project,
|
||||
&Command::SetAlias(self.target, self.new_alias.clone()),
|
||||
)?;
|
||||
let mut expected = before_project.clone();
|
||||
assign_expected_alias(&mut expected, self.target, &self.new_alias)?;
|
||||
let identities: BTreeMap<_, _> = before_project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| (id, id))
|
||||
.collect();
|
||||
if candidate.project != expected || candidate.identity_map != identities {
|
||||
return Err(refusal(
|
||||
"Alias preparation changed unrelated model data or identities.",
|
||||
));
|
||||
}
|
||||
let after = source_project::update_bundle_with_identity_map(
|
||||
&self.before,
|
||||
&candidate.project,
|
||||
&candidate.identity_map,
|
||||
self.limits,
|
||||
)?;
|
||||
let before = &self.before;
|
||||
if after.decoded.project != expected
|
||||
|| before.bundle.entry != after.bundle.entry
|
||||
|| !before.bundle.files.keys().eq(after.bundle.files.keys())
|
||||
|| before.index.lexical_owners != after.index.lexical_owners
|
||||
|| !before
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.map(|(id, loc)| (id, &loc.path))
|
||||
.eq(after
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.map(|(id, loc)| (id, &loc.path)))
|
||||
{
|
||||
return Err(refusal(
|
||||
"Alias preparation changed source membership or ownership.",
|
||||
));
|
||||
}
|
||||
let changed = before.bundle != after.bundle;
|
||||
let before_bytes = estimated_bytes(before);
|
||||
let after_bytes = estimated_bytes(&after);
|
||||
if changed {
|
||||
check_history_capacity(before_bytes, after_bytes)?;
|
||||
}
|
||||
Ok(PreparedAliasRename(Arc::new(PreparedData {
|
||||
request: self,
|
||||
after: Arc::new(after),
|
||||
before_bytes,
|
||||
after_bytes,
|
||||
changed,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
// Independent full-model expectation: SetAlias may change exactly one header
|
||||
// field, across every supported identity family. Do not reuse command execution
|
||||
// to derive the expected result, which would hide collateral command changes.
|
||||
fn assign_expected_alias(project: &mut Project, id: Id, alias: &str) -> Result<(), DocumentError> {
|
||||
if project.id == id {
|
||||
project.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.elements.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.relations.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.diagrams.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.artifact_links.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.traces.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else {
|
||||
project
|
||||
.diagrams
|
||||
.values_mut()
|
||||
.find_map(|diagram| diagram.nodes.get_mut(&id))
|
||||
.ok_or_else(|| refusal("The alias target is no longer present."))?
|
||||
.alias = alias.to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "document_alias_rename_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,459 @@
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> Document {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), r#"arcadia 7;
|
||||
project mission "Испытание" id "10000000-0000-4000-8000-000000000001" {
|
||||
include "work.arcadia"; include "views.arcadia";
|
||||
}
|
||||
"#.replace('\n', "\r\n")),
|
||||
("work.arcadia".into(), r#"// @assess and assess are explanatory prose.
|
||||
oa_activity assess "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000002" { description "Keep @assess and assess"; }
|
||||
oa_activity assist "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000003" { parent @ // owner evidence
|
||||
assess; }
|
||||
sa_function service "Service" id "10000000-0000-4000-8000-000000000004" {}
|
||||
interaction transfer "Transfer" id "10000000-0000-4000-8000-000000000005" { source @assess; target @assist; }
|
||||
trace review "Reviewed correspondence" id "10000000-0000-4000-8000-000000000006" { oa @assess; sa @service; kind Refinement; baseline { oa { name "Old assess"; description "@assess"; }; sa { name "Old service"; description "Kept"; }; }; }
|
||||
artifact evidence "Evidence" id "10000000-0000-4000-8000-000000000007" { owner @review; uri "docs/assess.md"; }
|
||||
"#.replace('\n', "\r\n")),
|
||||
("views.arcadia".into(), r#"diagram overview "Overview" id "10000000-0000-4000-8000-000000000008" { layer Oa; routes [{ edge @ // routed identity
|
||||
transfer; waypoints [{ x 4.20e2; y -16; }]; }]; }
|
||||
node placement id "10000000-0000-4000-8000-000000000009" { diagram @overview; element @assess; position { x -0.0; y 12.375; }; size { width 260; height 94; }; }
|
||||
"#.replace('\n', "\r\n")),
|
||||
]),
|
||||
};
|
||||
let document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
document.bundle(),
|
||||
&bundle,
|
||||
"fixture must need no migration or ID insertion"
|
||||
);
|
||||
document
|
||||
}
|
||||
|
||||
fn id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("10000000-0000-4000-8000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn prepare(document: &Document, target: Id, replacement: &str) -> PreparedAliasRename {
|
||||
document
|
||||
.alias_rename_request(target, replacement.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// Capture every live Document state relevant to refusal/no-op. Arc identity also
|
||||
// catches replacing an equal snapshot or saved marker behind the user's back.
|
||||
struct Unchanged {
|
||||
current: Arc<CompiledSourceProject>,
|
||||
saved: Option<Arc<SourceBundle>>,
|
||||
past: Vec<(Arc<CompiledSourceProject>, usize)>,
|
||||
future: Vec<(Arc<CompiledSourceProject>, usize)>,
|
||||
revision: u64,
|
||||
current_bytes: usize,
|
||||
recovered: bool,
|
||||
}
|
||||
|
||||
impl Unchanged {
|
||||
fn capture(document: &Document) -> Self {
|
||||
Self {
|
||||
current: document.snapshot(),
|
||||
saved: document.saved.clone(),
|
||||
past: document
|
||||
.past
|
||||
.iter()
|
||||
.map(|h| (h.snapshot.clone(), h.bytes))
|
||||
.collect(),
|
||||
future: document
|
||||
.future
|
||||
.iter()
|
||||
.map(|h| (h.snapshot.clone(), h.bytes))
|
||||
.collect(),
|
||||
revision: document.revision,
|
||||
current_bytes: document.current_bytes,
|
||||
recovered: document.recovered_unsaved,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert(&self, document: &Document) {
|
||||
assert!(Arc::ptr_eq(&self.current, &document.current));
|
||||
match (&self.saved, &document.saved) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
||||
(None, None) => {}
|
||||
_ => panic!("saved baseline changed"),
|
||||
}
|
||||
for (before, after) in [
|
||||
(&self.past, &document.past),
|
||||
(&self.future, &document.future),
|
||||
] {
|
||||
assert_eq!(before.len(), after.len());
|
||||
for ((snapshot, bytes), entry) in before.iter().zip(after) {
|
||||
assert!(Arc::ptr_eq(snapshot, &entry.snapshot));
|
||||
assert_eq!(*bytes, entry.bytes);
|
||||
}
|
||||
}
|
||||
assert_eq!(self.revision, document.revision);
|
||||
assert_eq!(self.current_bytes, document.current_bytes);
|
||||
assert_eq!(self.recovered, document.recovered_unsaved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_identity_family_changes_only_authored_alias_roles_and_one_history_entry() {
|
||||
// Literal role-specific replacements are independent of the updater/tokenizer.
|
||||
// They deliberately exclude prose, historical review text and URI spelling.
|
||||
type AliasCase<'a> = (u8, &'a str, &'a str, &'a [(&'a str, &'a str)]);
|
||||
let cases: &[AliasCase<'_>] = &[
|
||||
(
|
||||
1,
|
||||
"mission",
|
||||
"renamed",
|
||||
&[("project mission ", "project renamed ")],
|
||||
),
|
||||
(
|
||||
2,
|
||||
"assess",
|
||||
"renamed",
|
||||
&[
|
||||
("oa_activity assess ", "oa_activity renamed "),
|
||||
("\r\n assess;", "\r\n renamed;"),
|
||||
("@assess;", "@renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
5,
|
||||
"transfer",
|
||||
"renamed",
|
||||
&[
|
||||
("interaction transfer ", "interaction renamed "),
|
||||
("\r\n transfer;", "\r\n renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
6,
|
||||
"review",
|
||||
"renamed",
|
||||
&[
|
||||
("trace review ", "trace renamed "),
|
||||
("owner @review;", "owner @renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
7,
|
||||
"evidence",
|
||||
"renamed",
|
||||
&[("artifact evidence ", "artifact renamed ")],
|
||||
),
|
||||
(
|
||||
8,
|
||||
"overview",
|
||||
"renamed",
|
||||
&[
|
||||
("diagram overview ", "diagram renamed "),
|
||||
("diagram @overview;", "diagram @renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
9,
|
||||
"placement",
|
||||
"renamed",
|
||||
&[("node placement ", "node renamed ")],
|
||||
),
|
||||
];
|
||||
for &(number, old, new, replacements) in cases {
|
||||
let mut document = fixture();
|
||||
let original = document.snapshot();
|
||||
let mut expected_model = document.project().clone();
|
||||
// Explicit per-family oracle, separate from production expectation helper.
|
||||
match number {
|
||||
1 => expected_model.alias = new.into(),
|
||||
2 => expected_model.elements.get_mut(&id(2)).unwrap().alias = new.into(),
|
||||
5 => expected_model.relations.get_mut(&id(5)).unwrap().alias = new.into(),
|
||||
6 => expected_model.traces.get_mut(&id(6)).unwrap().alias = new.into(),
|
||||
7 => expected_model.artifact_links.get_mut(&id(7)).unwrap().alias = new.into(),
|
||||
8 => expected_model.diagrams.get_mut(&id(8)).unwrap().alias = new.into(),
|
||||
9 => {
|
||||
expected_model
|
||||
.diagrams
|
||||
.get_mut(&id(8))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(9))
|
||||
.unwrap()
|
||||
.alias = new.into()
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let mut expected_bundle = document.bundle().clone();
|
||||
for source in expected_bundle.files.values_mut() {
|
||||
for &(before, after) in replacements {
|
||||
*source = source.replace(before, after);
|
||||
}
|
||||
}
|
||||
let state = Unchanged::capture(&document);
|
||||
let prepared = prepare(&document, id(number), new);
|
||||
state.assert(&document);
|
||||
assert!(prepared.changed());
|
||||
assert_eq!(prepared.old_alias(), old);
|
||||
assert_eq!(prepared.new_alias(), new);
|
||||
assert_eq!(prepared.target(), id(number));
|
||||
assert_eq!(prepared.revision(), 0);
|
||||
assert_eq!(prepared.before().bundle, original.bundle);
|
||||
assert_eq!(prepared.after().bundle, expected_bundle);
|
||||
assert_eq!(prepared.after().decoded.project, expected_model);
|
||||
assert!(document.commit_alias_rename(&prepared).unwrap());
|
||||
assert!(
|
||||
Arc::ptr_eq(&document.current, &prepared.0.after),
|
||||
"adopt the exact prepared Arc"
|
||||
);
|
||||
assert_eq!(document.past.len(), 1);
|
||||
assert!(document.dirty());
|
||||
let after = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&prepared.clone()).is_err());
|
||||
after.assert(&document);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &original));
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &expected_bundle);
|
||||
assert_eq!(document.project(), &expected_model);
|
||||
document.mark_saved(document.snapshot());
|
||||
let reopened =
|
||||
Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected_model);
|
||||
assert_eq!(reopened.bundle(), &expected_bundle);
|
||||
assert!(!reopened.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_keyword_alias_is_valid_without_normalizing_input() {
|
||||
assert!(matches!(
|
||||
language::tokenize("oa_activity")[0].class,
|
||||
language::TokenClass::Keyword
|
||||
));
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "oa_activity");
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert_eq!(document.project().elements[&id(2)].alias, "oa_activity");
|
||||
assert_eq!(document.project().elements[&id(2)].name, "Оценка ущерба");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_identical_aliases_preserve_live_state_and_redo() {
|
||||
let mut document = fixture();
|
||||
let first = prepare(&document, id(2), "changed");
|
||||
document.commit_alias_rename(&first).unwrap();
|
||||
assert!(document.undo());
|
||||
document.invalidate_saved_baseline();
|
||||
document.mark_recovered();
|
||||
let state = Unchanged::capture(&document);
|
||||
for replacement in ["", "has space", "project", "assist", " assess", "assess "] {
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), replacement.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.is_err()
|
||||
);
|
||||
state.assert(&document);
|
||||
}
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(Id::nil(), "new".into())
|
||||
.is_err()
|
||||
);
|
||||
let no_op = prepare(&document, id(2), "assess");
|
||||
assert!(!no_op.changed());
|
||||
assert!(!document.commit_alias_rename(&no_op).unwrap());
|
||||
state.assert(&document);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project().elements[&id(2)].alias, "changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_bytes_undo_replay_cross_document_and_exhausted_revision_refuse() {
|
||||
let mut document = fixture();
|
||||
let old = prepare(&document, id(2), "renamed");
|
||||
let mut second = fixture();
|
||||
let second_state = Unchanged::capture(&second);
|
||||
assert!(second.commit_alias_rename(&old).is_err());
|
||||
second_state.assert(&second);
|
||||
// Even an identical shared accepted Arc cannot confer this Document's owner.
|
||||
second.current = document.current.clone();
|
||||
let second_state = Unchanged::capture(&second);
|
||||
assert!(second.commit_alias_rename(&old).is_err());
|
||||
second_state.assert(&second);
|
||||
document
|
||||
.execute(Command::Rename(id(2), "Later name".into()))
|
||||
.unwrap();
|
||||
let edited = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&old).is_err());
|
||||
edited.assert(&document);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &old.0.request.before));
|
||||
let undone = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&old).is_err());
|
||||
undone.assert(&document);
|
||||
document.revision = u64::MAX - 1;
|
||||
let last = prepare(&document, id(2), "renamed");
|
||||
document
|
||||
.execute(Command::Rename(id(2), "Exhaust revision".into()))
|
||||
.unwrap();
|
||||
document.undo();
|
||||
assert!(Arc::ptr_eq(&document.current, &last.0.request.before));
|
||||
let exhausted = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&last).is_err());
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "renamed".into())
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "assess".into())
|
||||
.is_err()
|
||||
);
|
||||
exhausted.assert(&document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_retains_latest_saved_baseline_and_recovery_state() {
|
||||
for baseline in ["saved_before", "unknown", "raw_repair", "recovered"] {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
let original = document.snapshot();
|
||||
match baseline {
|
||||
"saved_before" => document.mark_saved(original.clone()),
|
||||
"unknown" => document.invalidate_saved_baseline(),
|
||||
"raw_repair" => {
|
||||
let mut raw = document.bundle().clone();
|
||||
raw.files
|
||||
.insert("project.arcadia".into(), "invalid repair source".into());
|
||||
document.bind_repair_baseline(raw);
|
||||
}
|
||||
"recovered" => document.mark_recovered(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let saved = document.saved.clone();
|
||||
let recovered = document.recovered_unsaved;
|
||||
assert!(document.commit_alias_rename(&prepared).unwrap());
|
||||
assert_eq!(document.saved, saved);
|
||||
assert_eq!(document.recovered_unsaved, recovered);
|
||||
assert!(document.dirty());
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.saved, saved);
|
||||
assert_eq!(document.recovered_unsaved, recovered);
|
||||
assert_eq!(document.dirty(), baseline != "saved_before");
|
||||
}
|
||||
// A completed save after Preview remains authoritative, including when it
|
||||
// clears recovery; a worker must not restore its earlier state on Confirm.
|
||||
let mut document = fixture();
|
||||
document.mark_recovered();
|
||||
document.invalidate_saved_baseline();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
document.mark_saved(document.snapshot());
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert!(!document.recovered_unsaved);
|
||||
document.undo();
|
||||
assert!(!document.dirty());
|
||||
// Undo from saved C to B, then a new B->C rename can correctly finish clean.
|
||||
document.redo();
|
||||
document.mark_saved(document.snapshot());
|
||||
document.undo();
|
||||
let return_to_saved = prepare(&document, id(2), "renamed");
|
||||
document.commit_alias_rename(&return_to_saved).unwrap();
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutable_public_preview_copies_cannot_change_prepared_authority() {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
let exact = prepared.after().clone();
|
||||
let mut counterfeit = prepared.after().clone();
|
||||
counterfeit.decoded.project.name = "Injected unrelated edit".into();
|
||||
counterfeit.index.declarations.clear();
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(document.apply_compiled(counterfeit).is_err());
|
||||
unchanged.assert(&document);
|
||||
let mut unrelated = exact.decoded.project.clone();
|
||||
unrelated.name = "A self-consistent unrelated candidate".into();
|
||||
let forged_dto = source_project::compile_bundle(
|
||||
&source_project::format_bundle(&unrelated),
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(forged_dto.decoded.project, prepared.after().decoded.project);
|
||||
// No API converts either DTO to PreparedAliasRename. Mutating owned copies
|
||||
// leaves the opaque capability and its exact adopted model/source intact.
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert_eq!(document.project(), &exact.decoded.project);
|
||||
assert_eq!(document.bundle(), &exact.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_history_capacity_admits_immediate_undo_and_rejects_larger_candidate() {
|
||||
let mut document = fixture();
|
||||
let mut bundle = document.bundle().clone();
|
||||
let padding = HISTORY_BYTES / 2 - document.current_bytes;
|
||||
bundle
|
||||
.files
|
||||
.get_mut("views.arcadia")
|
||||
.unwrap()
|
||||
.push_str(&format!("//{}\r\n", "x".repeat(padding - 4)));
|
||||
document.apply_bundle(bundle).unwrap();
|
||||
assert_eq!(document.current_bytes, HISTORY_BYTES / 2);
|
||||
// A same-length alias retains the exact estimate; older history is evictable.
|
||||
let boundary = prepare(&document, id(2), "rating");
|
||||
assert_eq!(
|
||||
boundary.0.before_bytes + boundary.0.after_bytes,
|
||||
HISTORY_BYTES
|
||||
);
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "longer_alias".into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.is_err()
|
||||
);
|
||||
unchanged.assert(&document);
|
||||
document.commit_alias_rename(&boundary).unwrap();
|
||||
assert_eq!(document.past.len(), 1);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &boundary.0.request.before));
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert!(Arc::ptr_eq(&document.current, &boundary.0.after));
|
||||
assert!(check_history_capacity(usize::MAX, 1).is_err());
|
||||
assert!(check_history_capacity(HISTORY_BYTES, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_and_commit_refuse_exhausted_or_inconsistent_live_history_estimate() {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
// Inject cache corruption to falsify the commit-side check independently of
|
||||
// the preparation admission. Normal edits also change revision and Arc.
|
||||
document.current_bytes = HISTORY_BYTES;
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "renamed".into())
|
||||
.is_err()
|
||||
);
|
||||
assert!(document.commit_alias_rename(&prepared).is_err());
|
||||
// No-op intent needs no Undo reservation even at the admission boundary.
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "assess".into())
|
||||
.is_ok()
|
||||
);
|
||||
unchanged.assert(&document);
|
||||
}
|
||||
@@ -805,7 +805,7 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "The text editor and graphical forms edit the same model. Source projects preserve file organization and comments for human review, automation and version control.",
|
||||
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename diff merge restructuredtext spec canonical parity",
|
||||
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename F2 preview diff merge restructuredtext spec canonical parity definition references lexical navigation F12 back",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Edit and review source",
|
||||
@@ -818,10 +818,31 @@ fn article(topic: Topic) -> Article {
|
||||
"Save all writes the entry and its included UTF-8 files. Review the Git diff before committing, including relationships, traces and diagram changes that belong to the same engineering edit.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Follow definitions and lexical references",
|
||||
"Place the caret on a declaration alias or an @reference. Choose Definition (F12) to locate its declaration, or References (Shift+F12) to list lexical uses. Choose a row when a definition is ambiguous, draft or detached. Lexical uses exclude comments and strings; they do not establish typed relationships or resolve an ambiguous alias to one declaration. Back (Alt+Left) returns to the previous source location; if that draft changed, it restores the current cursor instead of an obsolete selection. These shortcuts work while the source editor has focus; the Text toolbar also offers the actions.",
|
||||
),
|
||||
explain(
|
||||
"Read the navigation scope",
|
||||
"Queries inspect bounded snapshots of currently loaded drafts, without applying them or loading missing included files. The panel identifies partial scope, draft evidence, detached or unverified files, and alias or ID collisions. Counts show total, displayed and omitted rows. A missing result in partial scope does not establish that the whole project has no match. If sources change, stale rows are disabled; run Definition or References again. Row locations use one-based lines and UTF-8 byte columns. Navigation preserves your source drafts and the accepted model.",
|
||||
),
|
||||
explain(
|
||||
"Find the record in diagrams",
|
||||
"Choose Show in diagrams from the Text toolbar on a unique verified alias in clean sources, or from the chosen model-tree record's context menu. Choose a diagram destination, then an exact representation. Results follow visible projection: ports, state rows, repeated caller tiles and their drawn sequence links remain distinct. Hidden tiles and nested links that are not drawn are excluded. A direct allocation can be represented by visible grouping; aggregate summaries do not invent allocation relations. A visual marker locates the port, row or grouped work while keeping the queried semantic record selected for model actions. Moving the camera preserves that marker. Finish pending edits first; navigation preserves accepted geometry, sources and history.",
|
||||
),
|
||||
explain(
|
||||
"Names, aliases and IDs serve different purposes",
|
||||
"The displayed name is engineering prose. A stable @alias is the readable source reference and need not change when the name changes. UUID identity distinguishes the persistent record. Preserve IDs when revising the same engineering object; changing identity is a deliberate operation that affects references.",
|
||||
),
|
||||
walkthrough(
|
||||
"Rename a source alias",
|
||||
&[
|
||||
"Place the caret on a declaration alias or live @reference, then choose Rename alias or press F2 in the source editor. First finish or revert pending source, property and behavior drafts; close or reattach any extra loaded detached files. Accepted edits do not need to be saved first.",
|
||||
"Edit Source alias and choose Preview (Enter also previews). Pasting line breaks, tabs or other control characters is rejected without changing the field; choose Preview again after correcting the paste. Review the changed files, hunks and lines; long lines have additional segments. The engineering name and UUID stay unchanged. Comments and quoted prose keep their original spelling. This preview covers the rename; a later Save can include earlier accepted edits too.",
|
||||
"Choose Rename alias to accept the complete preview, or Cancel/Escape to leave everything unchanged. An invalid, unchanged, stale or oversized preview cannot be confirmed. After editing the replacement, choose Preview again. If the source context changes, close the form and invoke Rename again on the current alias.",
|
||||
"Use Undo rename immediately, or the model Undo/Redo controls, to restore the complete source/model snapshot. Ctrl+Z in Text still undoes source drafting. Save all and reopen use the normal project workflow; an unconfirmed preview is not saved.",
|
||||
],
|
||||
),
|
||||
example(
|
||||
"Readable references",
|
||||
"For example, renaming an activity does not require renaming @assess_incident. A related record can still refer to that alias.",
|
||||
|
||||
@@ -9,6 +9,7 @@ mod context_menu;
|
||||
mod data_values;
|
||||
mod data_view;
|
||||
mod diagram;
|
||||
mod diagram_usage;
|
||||
mod document;
|
||||
mod explorer;
|
||||
mod explorer_view;
|
||||
@@ -26,6 +27,7 @@ mod recovery;
|
||||
mod route_edit;
|
||||
mod semantic_review;
|
||||
mod source_navigation;
|
||||
mod source_rename;
|
||||
mod source_tree;
|
||||
mod source_workspace;
|
||||
mod style;
|
||||
|
||||
@@ -281,6 +281,11 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
}
|
||||
entries.push(item("Properties", Message::EditProperties(id), true));
|
||||
entries.push(item("Open source", Message::SourceForSelection, true));
|
||||
entries.push(item(
|
||||
"Show in diagrams…",
|
||||
app.diagram_usage_message(id),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Connections & references",
|
||||
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
|
||||
|
||||
@@ -126,28 +126,8 @@ fn groups(p: &Project, selected: Id) -> Vec<Group> {
|
||||
]
|
||||
}
|
||||
|
||||
pub fn diagrams_containing(p: &Project, selected: Id) -> Vec<Id> {
|
||||
let endpoints = p.relations.get(&selected).map(|r| [r.source(), r.target()]);
|
||||
p.diagrams
|
||||
.values()
|
||||
.filter(|d| {
|
||||
let placed = |id| {
|
||||
let e = p.elements.get(&id);
|
||||
let owner = e
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.and_then(|e| e.parent)
|
||||
.unwrap_or(id);
|
||||
let kind = p.elements.get(&owner).map(|e| e.kind());
|
||||
kind.is_none_or(|kind| d.projection.includes(kind))
|
||||
&& d.nodes.values().any(|n| n.element == owner || n.id == id)
|
||||
};
|
||||
placed(selected) || endpoints.is_some_and(|ends| ends.into_iter().all(placed))
|
||||
})
|
||||
.map(|d| d.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn view(p: &Project, selected: Id) -> Element<'_, Message> {
|
||||
pub fn view(app: &crate::app::App, selected: Id) -> Element<'_, Message> {
|
||||
let p = app.project();
|
||||
let mut content = column![
|
||||
text(short(p.named(selected), 50)).size(18),
|
||||
text("Follow ownership, usage and engineering relationships.")
|
||||
@@ -203,25 +183,8 @@ pub fn view(p: &Project, selected: Id) -> Element<'_, Message> {
|
||||
content = content.push(container(entry).padding(8).style(style::subtle));
|
||||
}
|
||||
}
|
||||
let diagrams = diagrams_containing(p, selected);
|
||||
if !diagrams.is_empty() {
|
||||
content = content.push(label("SHOWN IN DIAGRAMS"));
|
||||
for id in diagrams {
|
||||
content = content.push(
|
||||
action(
|
||||
&short(p.named(id), 42),
|
||||
Message::RevealInDiagram(id, selected),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
} else if p.elements.contains_key(&selected) {
|
||||
content = content.push(
|
||||
text("This element has no diagram placement yet.")
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
content =
|
||||
content.push(action("Show in diagrams…", app.diagram_usage_message(selected)).width(Fill));
|
||||
if count == 0 {
|
||||
content = content.push(text("No semantic connections yet. Use Actions or right-click an element to add relationships.").size(12).color(style::MUTED));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
//! Literal source search and line navigation, independent of accepted model state.
|
||||
mod controller;
|
||||
pub mod interaction;
|
||||
pub mod query;
|
||||
pub mod symbols;
|
||||
pub use controller::*;
|
||||
use iced::widget::text_editor::{Cursor, Position};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Ephemeral editor navigation; no model commands or filesystem operations.
|
||||
use super::query;
|
||||
use iced::widget::text_editor::{Cursor, Position};
|
||||
use oa_core::Id;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SymbolEvent {
|
||||
DiagramUsages,
|
||||
Query(query::Action),
|
||||
Back,
|
||||
Close,
|
||||
Choose {
|
||||
stamp: query::QueryStamp,
|
||||
reference: bool,
|
||||
index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Identity of the mounted source view that emitted an action. Message queues
|
||||
/// may outlive that view; request-time stamping alone cannot detect replay.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SymbolContext {
|
||||
pub session: Id,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HistoryEntry {
|
||||
pub session: Id,
|
||||
pub path: String,
|
||||
pub generation: u64,
|
||||
pub cursor: Cursor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
pub job: Id,
|
||||
pub interaction: Id,
|
||||
pub raw_interaction: u64,
|
||||
pub stamp: query::QueryStamp,
|
||||
pub path: String,
|
||||
pub offset: usize,
|
||||
pub cursor: Cursor,
|
||||
pub action: query::Action,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Completion {
|
||||
pub request: Request,
|
||||
pub result: Result<(Arc<query::Snapshot>, Arc<query::Results>), String>,
|
||||
}
|
||||
|
||||
pub struct Reveal {
|
||||
pub ticket: Id,
|
||||
pub interaction: Id,
|
||||
pub raw_interaction: u64,
|
||||
pub snapshot: query::SnapshotKey,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub struct SymbolState {
|
||||
pub open: bool,
|
||||
pub interaction: Id,
|
||||
pub raw_interaction: Arc<AtomicU64>,
|
||||
pub serial: u64,
|
||||
pub in_flight: Option<Id>,
|
||||
pub request: Option<Request>,
|
||||
pub cache: Option<Arc<query::Snapshot>>,
|
||||
pub results: Option<Arc<query::Results>>,
|
||||
pub notice: Option<String>,
|
||||
pub back: Vec<HistoryEntry>,
|
||||
pub reveal: Option<Reveal>,
|
||||
}
|
||||
|
||||
impl Default for SymbolState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open: false,
|
||||
interaction: Id::new_v4(),
|
||||
raw_interaction: Arc::new(AtomicU64::new(0)),
|
||||
serial: 0,
|
||||
in_flight: None,
|
||||
request: None,
|
||||
cache: None,
|
||||
results: None,
|
||||
notice: None,
|
||||
back: Vec::new(),
|
||||
reveal: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Iced uses zero-based UTF-8 byte columns. Reject stale or split-codepoint
|
||||
/// coordinates; do not normalize CRLF or silently clamp query destinations.
|
||||
pub fn byte_offset(text: &str, position: Position) -> Option<usize> {
|
||||
let start = if position.line == 0 {
|
||||
0
|
||||
} else {
|
||||
text.match_indices('\n').nth(position.line - 1)?.0 + 1
|
||||
};
|
||||
let end = text[start..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |end| start + end);
|
||||
let offset = start.checked_add(position.column)?;
|
||||
(offset <= end && text.is_char_boundary(offset)).then_some(offset)
|
||||
}
|
||||
|
||||
pub fn cursor_offset(text: &str, cursor: Cursor) -> Option<usize> {
|
||||
let position = cursor.selection.map_or(cursor.position, |selection| {
|
||||
if (selection.line, selection.column) < (cursor.position.line, cursor.position.column) {
|
||||
selection
|
||||
} else {
|
||||
cursor.position
|
||||
}
|
||||
});
|
||||
byte_offset(text, position)
|
||||
}
|
||||
|
||||
pub fn exact_cursor(text: &str, cursor: Cursor) -> bool {
|
||||
byte_offset(text, cursor.position).is_some()
|
||||
&& cursor
|
||||
.selection
|
||||
.is_none_or(|position| byte_offset(text, position).is_some())
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Raw input invalidation and focus checks at widget-operation execution time.
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Task, Vector, keyboard, mouse, touch};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
/// Exhaustion fails closed: a saturated clock can never authorize navigation.
|
||||
/// Replace the clock with a new workspace/controller, rather than wrapping it.
|
||||
pub fn is_current(clock: &AtomicU64, expected: u64) -> bool {
|
||||
expected != u64::MAX && clock.load(Ordering::Acquire) == expected
|
||||
}
|
||||
|
||||
fn invalidate(clock: &AtomicU64, event: &Event) {
|
||||
if matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { .. })
|
||||
| Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. })
|
||||
| Event::Touch(touch::Event::FingerPressed { .. })
|
||||
| Event::Window(iced::window::Event::Unfocused)
|
||||
) {
|
||||
let _ = clock.fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
|
||||
Some(value.saturating_add(1))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparent, including overlays: invalidate before the child can capture input.
|
||||
pub fn observe<'a, Message: 'a, Theme: 'a, Renderer: renderer::Renderer + 'a>(
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
clock: Arc<AtomicU64>,
|
||||
) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(Observed { content, clock })
|
||||
}
|
||||
|
||||
struct Observed<'a, Message, Theme, Renderer> {
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
clock: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer: renderer::Renderer> Widget<Message, Theme, Renderer>
|
||||
for Observed<'_, Message, Theme, Renderer>
|
||||
{
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
self.content.as_widget().tag()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
self.content.as_widget().state()
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
self.content.as_widget().children()
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
self.content.as_widget().diff(tree);
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content.as_widget_mut().layout(tree, renderer, limits)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(tree, renderer, theme, style, layout, cursor, viewport);
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
invalidate(&self.clock, event);
|
||||
self.content.as_widget_mut().update(
|
||||
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(tree, layout, cursor, viewport, renderer)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
let clock = self.clock.clone();
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(tree, layout, renderer, viewport, translation)
|
||||
.map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock })))
|
||||
}
|
||||
}
|
||||
|
||||
struct ObservedOverlay<'a, Message, Theme, Renderer> {
|
||||
content: overlay::Element<'a, Message, Theme, Renderer>,
|
||||
clock: Arc<AtomicU64>,
|
||||
}
|
||||
impl<Message, Theme, Renderer: renderer::Renderer> overlay::Overlay<Message, Theme, Renderer>
|
||||
for ObservedOverlay<'_, Message, Theme, Renderer>
|
||||
{
|
||||
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
|
||||
self.content.as_overlay_mut().layout(renderer, bounds)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
self.content
|
||||
.as_overlay()
|
||||
.draw(renderer, theme, style, layout, cursor);
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_overlay_mut()
|
||||
.operate(layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
invalidate(&self.clock, event);
|
||||
self.content
|
||||
.as_overlay_mut()
|
||||
.update(event, layout, cursor, renderer, clipboard, shell);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_overlay()
|
||||
.mouse_interaction(layout, cursor, renderer)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
layout: Layout<'a>,
|
||||
renderer: &Renderer,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
let clock = self.clock.clone();
|
||||
self.content
|
||||
.as_overlay_mut()
|
||||
.overlay(layout, renderer)
|
||||
.map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock })))
|
||||
}
|
||||
fn index(&self) -> f32 {
|
||||
self.content.as_overlay().index()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect all focus owners, then focus only the mounted target. Iced executes
|
||||
/// Operation::Chain synchronously, without an intervening input event. Both
|
||||
/// traversals recheck the raw clock; no result message authorizes later focus.
|
||||
pub fn guarded_focus<Message: Send + 'static>(
|
||||
target: widget::Id,
|
||||
clock: Arc<AtomicU64>,
|
||||
expected: u64,
|
||||
) -> Task<Message> {
|
||||
widget::operate(GuardedFocus::new(target, clock, expected))
|
||||
}
|
||||
|
||||
struct GuardedFocus {
|
||||
target: widget::Id,
|
||||
clock: Arc<AtomicU64>,
|
||||
expected: u64,
|
||||
target_count: usize,
|
||||
foreign_focus: bool,
|
||||
apply: bool,
|
||||
}
|
||||
impl GuardedFocus {
|
||||
fn new(target: widget::Id, clock: Arc<AtomicU64>, expected: u64) -> Self {
|
||||
Self {
|
||||
target,
|
||||
clock,
|
||||
expected,
|
||||
target_count: 0,
|
||||
foreign_focus: false,
|
||||
apply: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Message: 'static> widget::Operation<Message> for GuardedFocus {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation<Message>)) {
|
||||
if is_current(&self.clock, self.expected) {
|
||||
visit(self);
|
||||
}
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
state: &mut dyn widget::operation::Focusable,
|
||||
) {
|
||||
if !is_current(&self.clock, self.expected) {
|
||||
return;
|
||||
}
|
||||
if id == Some(&self.target) {
|
||||
self.target_count += 1;
|
||||
if self.apply {
|
||||
state.focus();
|
||||
}
|
||||
} else if state.is_focused() {
|
||||
self.foreign_focus = true;
|
||||
}
|
||||
}
|
||||
fn finish(&self) -> widget::operation::Outcome<Message> {
|
||||
if !self.apply
|
||||
&& self.target_count == 1
|
||||
&& !self.foreign_focus
|
||||
&& is_current(&self.clock, self.expected)
|
||||
{
|
||||
widget::operation::Outcome::Chain(Box::new(Self {
|
||||
apply: true,
|
||||
..Self::new(self.target.clone(), self.clock.clone(), self.expected)
|
||||
}))
|
||||
} else {
|
||||
widget::operation::Outcome::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
@@ -0,0 +1,190 @@
|
||||
use super::*;
|
||||
use iced::advanced::widget::operation::{Focusable, Outcome};
|
||||
use iced::widget::{column, text_input};
|
||||
use iced::{Point, Theme};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FocusMap(Vec<(Option<widget::Id>, Rectangle, bool)>);
|
||||
impl widget::Operation for FocusMap {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&widget::Id>, bounds: Rectangle, state: &mut dyn Focusable) {
|
||||
self.0.push((id.cloned(), bounds, state.is_focused()));
|
||||
}
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
element: Element<'static, (), Theme, ()>,
|
||||
tree: widget::Tree,
|
||||
node: layout::Node,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(clock: Arc<AtomicU64>) -> Self {
|
||||
let content = column![
|
||||
text_input("Editor", "source")
|
||||
.id("editor")
|
||||
.on_input(|_| ())
|
||||
.padding(12),
|
||||
text_input("Find", "")
|
||||
.id("find")
|
||||
.on_input(|_| ())
|
||||
.padding(12),
|
||||
]
|
||||
.spacing(10)
|
||||
.into();
|
||||
let mut element = observe(content, clock);
|
||||
let mut tree = widget::Tree::new(element.as_widget());
|
||||
let node = element.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(400., 200.)),
|
||||
);
|
||||
Self {
|
||||
element,
|
||||
tree,
|
||||
node,
|
||||
}
|
||||
}
|
||||
fn pass(&mut self, operation: &mut dyn widget::Operation) {
|
||||
self.element.as_widget_mut().operate(
|
||||
&mut self.tree,
|
||||
Layout::new(&self.node),
|
||||
&(),
|
||||
operation,
|
||||
);
|
||||
}
|
||||
fn run(&mut self, operation: impl widget::Operation + 'static) {
|
||||
let mut next: Box<dyn widget::Operation> = Box::new(operation);
|
||||
loop {
|
||||
self.pass(next.as_mut());
|
||||
match next.finish() {
|
||||
Outcome::Chain(chained) => next = chained,
|
||||
Outcome::None => break,
|
||||
Outcome::Some(()) => panic!("focus must not publish an application message"),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn focus(&mut self) -> FocusMap {
|
||||
let mut focus = FocusMap::default();
|
||||
self.pass(&mut focus);
|
||||
focus
|
||||
}
|
||||
fn click(&mut self, id: &str) {
|
||||
let bounds = self
|
||||
.focus()
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
|
||||
.unwrap()
|
||||
.1;
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
self.element.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Layout::new(&self.node),
|
||||
mouse::Cursor::Available(bounds.center()),
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&Rectangle::with_size(Size::new(400., 200.)),
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"focus-only click must not rely on App messages"
|
||||
);
|
||||
}
|
||||
fn focused(&mut self, id: &str) -> bool {
|
||||
self.focus()
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
|
||||
.unwrap()
|
||||
.2
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_find_click_invalidates_without_app_message_and_preserves_focus() {
|
||||
let clock = Arc::new(AtomicU64::new(0));
|
||||
let mut ui = Mounted::new(clock.clone());
|
||||
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
|
||||
assert!(ui.focused("editor"));
|
||||
ui.click("find");
|
||||
assert!(!is_current(&clock, 0));
|
||||
assert!(ui.focused("find"));
|
||||
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
|
||||
assert!(ui.focused("find"));
|
||||
// Even a matching clock cannot authorize stealing another widget's focus.
|
||||
ui.run(GuardedFocus::new(
|
||||
"editor".into(),
|
||||
clock.clone(),
|
||||
clock.load(Ordering::Acquire),
|
||||
));
|
||||
assert!(ui.focused("find"));
|
||||
assert!(!ui.focused("editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_rechecks_clock_between_inspection_and_application_and_missing_target_is_inert() {
|
||||
let clock = Arc::new(AtomicU64::new(0));
|
||||
let mut ui = Mounted::new(clock.clone());
|
||||
let mut operation = GuardedFocus::new("editor".into(), clock.clone(), 0);
|
||||
ui.pass(&mut operation);
|
||||
let Outcome::Chain(mut apply) = <GuardedFocus as widget::Operation<()>>::finish(&operation)
|
||||
else {
|
||||
panic!("mounted, unopposed target must be eligible");
|
||||
};
|
||||
clock.store(1, Ordering::Release);
|
||||
ui.pass(apply.as_mut());
|
||||
assert!(!ui.focused("editor"));
|
||||
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 1));
|
||||
assert!(ui.focused("editor"));
|
||||
ui.run(GuardedFocus::new("removed-editor".into(), clock, 1));
|
||||
assert!(ui.focused("editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_navigation_inputs_advance_but_passive_geometry_does_not_and_exhaustion_refuses() {
|
||||
let clock = AtomicU64::new(0);
|
||||
let events = [
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::ArrowLeft),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
}),
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
||||
}),
|
||||
Event::Touch(touch::Event::FingerPressed {
|
||||
id: touch::Finger(1),
|
||||
position: Point::ORIGIN,
|
||||
}),
|
||||
Event::Window(iced::window::Event::Unfocused),
|
||||
];
|
||||
for (index, event) in events.iter().enumerate() {
|
||||
invalidate(&clock, event);
|
||||
assert_eq!(clock.load(Ordering::Acquire), index as u64 + 1);
|
||||
}
|
||||
invalidate(
|
||||
&clock,
|
||||
&Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: Point::ORIGIN,
|
||||
}),
|
||||
);
|
||||
invalidate(&clock, &Event::Window(iced::window::Event::Focused));
|
||||
assert!(is_current(&clock, 5));
|
||||
clock.store(u64::MAX - 1, Ordering::Release);
|
||||
invalidate(&clock, &events[0]);
|
||||
assert!(!is_current(&clock, u64::MAX));
|
||||
invalidate(&clock, &events[0]);
|
||||
assert_eq!(clock.load(Ordering::Acquire), u64::MAX);
|
||||
assert!(!is_current(&clock, 0));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
//! Pure preview and exact origin mapping for the guarded alias-rename controller.
|
||||
pub mod origin;
|
||||
pub mod preview;
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Map a role-proven alias occurrence through the exact prepared source change.
|
||||
//! This is cursor restoration, not target resolution or commit authority.
|
||||
use oa_core::language::{self, Outline, OutlineLimits, Span, TokenClass};
|
||||
use std::fmt;
|
||||
|
||||
const LIMITS: OutlineLimits = OutlineLimits {
|
||||
max_source_bytes: 64 * 1024 * 1024,
|
||||
max_tokens: 1_000_000,
|
||||
max_declarations: 250_000,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OriginError {
|
||||
Budget,
|
||||
InvalidSource,
|
||||
MissingOrigin,
|
||||
ChangedStructure,
|
||||
}
|
||||
|
||||
impl fmt::Display for OriginError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Budget => "The alias cursor preview exceeds its source scan budget. No rename was applied.",
|
||||
Self::InvalidSource => "The alias cursor could not be verified in this source. No rename was applied.",
|
||||
Self::MissingOrigin => "The original alias occurrence no longer matches. Preview again; no rename was applied.",
|
||||
Self::ChangedStructure => "The preview changed source outside the expected alias tokens. No rename was applied.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OriginError {}
|
||||
|
||||
fn word(class: TokenClass) -> bool {
|
||||
matches!(class, TokenClass::Identifier | TokenClass::Keyword)
|
||||
}
|
||||
|
||||
/// The controller passes the exact alias span returned by the admitted symbol
|
||||
/// hit. `marker` is Some only for a reference (its separate @ span), including
|
||||
/// invocation with the caret on @. A declaration passes None.
|
||||
pub fn map(
|
||||
before: &str,
|
||||
after: &str,
|
||||
old_alias: &str,
|
||||
new_alias: &str,
|
||||
origin: Span,
|
||||
marker: Option<Span>,
|
||||
) -> Result<Span, OriginError> {
|
||||
let old = language::outline(before, LIMITS).map_err(|_| OriginError::Budget)?;
|
||||
let new = language::outline(after, LIMITS).map_err(|_| OriginError::Budget)?;
|
||||
if old.has_lexical_errors || new.has_lexical_errors {
|
||||
return Err(OriginError::InvalidSource);
|
||||
}
|
||||
if old.tokens.len() != new.tokens.len() {
|
||||
return Err(OriginError::ChangedStructure);
|
||||
}
|
||||
let roles_before = roles(&old);
|
||||
let roles_after = roles(&new);
|
||||
let mut mapped = None;
|
||||
for (index, (a, b)) in old.tokens.iter().zip(&new.tokens).enumerate() {
|
||||
let left = &before[a.span.start..a.span.end];
|
||||
let right = &after[b.span.start..b.span.end];
|
||||
let alias_pair = word(a.class)
|
||||
&& word(b.class)
|
||||
&& left == old_alias
|
||||
&& right == new_alias
|
||||
&& roles_before[index].is_some()
|
||||
&& roles_after[index].is_some()
|
||||
&& roles_before[index].map(|role| role.is_some())
|
||||
== roles_after[index].map(|role| role.is_some());
|
||||
if a.span == origin {
|
||||
if !alias_pair || roles_before[index] != Some(marker) {
|
||||
return Err(OriginError::MissingOrigin);
|
||||
}
|
||||
mapped = Some(b.span);
|
||||
}
|
||||
if !alias_pair && (left != right || a.class != b.class) {
|
||||
return Err(OriginError::ChangedStructure);
|
||||
}
|
||||
}
|
||||
mapped.ok_or(OriginError::MissingOrigin)
|
||||
}
|
||||
|
||||
/// Outer None is not an alias role; Some(None) is a declaration alias;
|
||||
/// Some(Some(span)) is a live @ reference with its exact separate marker.
|
||||
/// Scan sorted declaration spans and tokens together, without repeated searches.
|
||||
fn roles(outline: &Outline<'_>) -> Vec<Option<Option<Span>>> {
|
||||
let mut result = vec![None; outline.tokens.len()];
|
||||
let mut declarations = outline.declarations.iter().peekable();
|
||||
let mut significant = None;
|
||||
for (index, token) in outline.tokens.iter().enumerate() {
|
||||
while declarations
|
||||
.peek()
|
||||
.is_some_and(|d| d.alias_span.start < token.span.start)
|
||||
{
|
||||
declarations.next();
|
||||
}
|
||||
if word(token.class) {
|
||||
if declarations
|
||||
.peek()
|
||||
.is_some_and(|d| d.alias_span == token.span)
|
||||
{
|
||||
result[index] = Some(None);
|
||||
} else if let Some(previous) = significant {
|
||||
let span: Span = previous;
|
||||
if &outline.source[span.start..span.end] == "@" {
|
||||
result[index] = Some(Some(span));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(token.class, TokenClass::Whitespace | TokenClass::Comment) {
|
||||
significant = Some(token.span);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn occurrence(source: &str, needle: &str, which: usize) -> Span {
|
||||
let start = source.match_indices(needle).nth(which).unwrap().0;
|
||||
Span {
|
||||
start,
|
||||
end: start + needle.len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_last_repeated_reference_with_unicode_crlf_and_comment_separator() {
|
||||
let before =
|
||||
"oa_activity a \"救援\" {}\r\n// @a is prose\r\nsource @a; target @ // a\r\n a;\r\n";
|
||||
let after = "oa_activity renamed \"救援\" {}\r\n// @a is prose\r\nsource @renamed; target @ // a\r\n renamed;\r\n";
|
||||
let start = before.rfind(" a;").unwrap() + 1;
|
||||
let marker = before.rfind("@ //").unwrap();
|
||||
let mapped = map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"renamed",
|
||||
Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
},
|
||||
Some(Span {
|
||||
start: marker,
|
||||
end: marker + 1,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&after[mapped.start..mapped.end], "renamed");
|
||||
assert_eq!(mapped.start, after.rfind(" renamed;").unwrap() + 1);
|
||||
assert_eq!(
|
||||
before[..start].bytes().filter(|b| *b == b'\n').count(),
|
||||
after[..mapped.start]
|
||||
.bytes()
|
||||
.filter(|b| *b == b'\n')
|
||||
.count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_final_same_line_occurrence_not_first_or_prose() {
|
||||
let before = "oa_activity a \"a @a\" {} source @a; target @a;";
|
||||
let after = "oa_activity longer \"a @a\" {} source @longer; target @longer;";
|
||||
let start = before.rfind("@a").unwrap();
|
||||
let result = map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"longer",
|
||||
Span {
|
||||
start: start + 1,
|
||||
end: start + 2,
|
||||
},
|
||||
Some(Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.start, after.rfind("@longer").unwrap() + 1);
|
||||
let prose = occurrence(before, "a @a", 0);
|
||||
assert!(
|
||||
map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"longer",
|
||||
Span {
|
||||
start: prose.start,
|
||||
end: prose.start + 1
|
||||
},
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permits_identifier_keyword_transitions_in_alias_roles() {
|
||||
let before = "oa_activity a \"Name\" {} source @a;";
|
||||
let alias = occurrence(before, "a \"Name", 0);
|
||||
let origin = Span {
|
||||
start: alias.start,
|
||||
end: alias.start + 1,
|
||||
};
|
||||
for replacement in ["none", "include", "oa_activity"] {
|
||||
assert!(oa_core::valid_alias(replacement));
|
||||
assert_eq!(
|
||||
language::tokenize(replacement)[0].class,
|
||||
TokenClass::Keyword
|
||||
);
|
||||
let after = format!("oa_activity {replacement} \"Name\" {{}} source @{replacement};");
|
||||
let mapped = map(before, &after, "a", replacement, origin, None).unwrap();
|
||||
assert_eq!(&after[mapped.start..mapped.end], replacement);
|
||||
assert_eq!(
|
||||
map(&after, before, replacement, "a", mapped, None).unwrap(),
|
||||
origin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_changed_trivia_non_alias_words_and_wrong_marker() {
|
||||
let before = "oa_activity a \"Name\" {} source @a;";
|
||||
let after = "oa_activity b \"Name\" {} source @b;";
|
||||
let start = before.rfind("@a").unwrap();
|
||||
let span = Span {
|
||||
start: start + 1,
|
||||
end: start + 2,
|
||||
};
|
||||
let marker = Some(Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
});
|
||||
assert_eq!(
|
||||
map(
|
||||
before,
|
||||
&after.replace("Name", "Other"),
|
||||
"a",
|
||||
"b",
|
||||
span,
|
||||
marker
|
||||
),
|
||||
Err(OriginError::ChangedStructure)
|
||||
);
|
||||
assert_eq!(
|
||||
map(
|
||||
before,
|
||||
&after.replace(" source", " source"),
|
||||
"a",
|
||||
"b",
|
||||
span,
|
||||
marker
|
||||
),
|
||||
Err(OriginError::ChangedStructure)
|
||||
);
|
||||
assert_eq!(
|
||||
map(before, after, "a", "b", span, None),
|
||||
Err(OriginError::MissingOrigin)
|
||||
);
|
||||
assert!(
|
||||
map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"b",
|
||||
Span {
|
||||
start: 0,
|
||||
end: usize::MAX
|
||||
},
|
||||
marker
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
//! Complete, bounded line-ordinal previews of an already prepared alias rename.
|
||||
//!
|
||||
//! Source text remains in the caller's immutable before/after snapshots. This
|
||||
//! module owns only paths and indexes; it is not rename or commit authority.
|
||||
use oa_core::source_project::SourceBundle;
|
||||
use std::{fmt, ops::Range};
|
||||
|
||||
const CONTEXT: usize = 3;
|
||||
// A byte-grid boundary can move back by at most three UTF-8 continuation bytes,
|
||||
// so a 252-byte grid yields fragments of at most 255 bytes, independent of input.
|
||||
const SEGMENT_GRID: usize = 252;
|
||||
|
||||
/// Count all bounded fragments of exact source text; an empty line has one
|
||||
/// empty fragment. Page these as well as lines, never mount an unbounded line.
|
||||
pub fn segment_count(text: &str) -> usize {
|
||||
text.len().div_ceil(SEGMENT_GRID).max(1)
|
||||
}
|
||||
|
||||
/// Constant-time UTF-8-safe page lookup. Concatenating every fragment gives the
|
||||
/// original bytes, including CRLF. No ellipsis or truncation replaces content.
|
||||
pub fn text_segment(text: &str, index: usize) -> Option<&str> {
|
||||
if index >= segment_count(text) {
|
||||
return None;
|
||||
}
|
||||
let mut start = index.checked_mul(SEGMENT_GRID)?;
|
||||
let mut end = start.saturating_add(SEGMENT_GRID).min(text.len());
|
||||
while !text.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text.get(start..end)
|
||||
}
|
||||
const LIMITS: Limits = Limits {
|
||||
files: 256,
|
||||
changed_lines: 50_000,
|
||||
display_bytes: 8 * 1024 * 1024,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Limits {
|
||||
files: usize,
|
||||
changed_lines: usize,
|
||||
display_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Preview {
|
||||
files: Vec<File>,
|
||||
changed_lines: usize,
|
||||
display_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct File {
|
||||
path: String,
|
||||
hunks: Vec<Hunk>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Hunk {
|
||||
lines: Vec<Line>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Line {
|
||||
number: usize,
|
||||
before: Range<usize>,
|
||||
after: Range<usize>,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PreviewError {
|
||||
MembershipChanged,
|
||||
LineCountChanged(String),
|
||||
LineEndingChanged { path: String, line: usize },
|
||||
TooManyFiles { limit: usize },
|
||||
TooManyChangedLines { limit: usize },
|
||||
TooManyDisplayBytes { limit: usize },
|
||||
}
|
||||
|
||||
impl fmt::Display for PreviewError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MembershipChanged => write!(
|
||||
f,
|
||||
"Rename preview refused: the entry or source file membership changed. No rename was applied."
|
||||
),
|
||||
Self::LineCountChanged(path) => write!(
|
||||
f,
|
||||
"Rename preview refused: the line count changed in {path}. No rename was applied."
|
||||
),
|
||||
Self::LineEndingChanged { path, line } => write!(
|
||||
f,
|
||||
"Rename preview refused: the line ending changed in {path}:{line}. No rename was applied."
|
||||
),
|
||||
Self::TooManyFiles { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} changed files. A complete preview is required; no rename was applied."
|
||||
),
|
||||
Self::TooManyChangedLines { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} changed source lines. A complete preview is required; no rename was applied."
|
||||
),
|
||||
Self::TooManyDisplayBytes { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} display bytes (paths and both versions of changed/context lines). A complete preview is required; no rename was applied."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PreviewError {}
|
||||
|
||||
impl Preview {
|
||||
pub fn build(before: &SourceBundle, after: &SourceBundle) -> Result<Self, PreviewError> {
|
||||
Self::with_limits(before, after, LIMITS)
|
||||
}
|
||||
|
||||
fn with_limits(
|
||||
before: &SourceBundle,
|
||||
after: &SourceBundle,
|
||||
limits: Limits,
|
||||
) -> Result<Self, PreviewError> {
|
||||
if before.entry != after.entry
|
||||
|| !before.files.contains_key(&before.entry)
|
||||
|| !before.files.keys().eq(after.files.keys())
|
||||
{
|
||||
return Err(PreviewError::MembershipChanged);
|
||||
}
|
||||
let mut preview = Self {
|
||||
files: Vec::new(),
|
||||
changed_lines: 0,
|
||||
display_bytes: 0,
|
||||
};
|
||||
for ((path, old), (_, new)) in before.files.iter().zip(&after.files) {
|
||||
if old == new {
|
||||
continue;
|
||||
}
|
||||
if preview.files.len() == limits.files {
|
||||
return Err(PreviewError::TooManyFiles {
|
||||
limit: limits.files,
|
||||
});
|
||||
}
|
||||
// A changed source line can contain arbitrarily many renamed tokens.
|
||||
// Count the line once; this count is never a semantic-reference count.
|
||||
let mut changed = Vec::new();
|
||||
let mut old_lines = Lines::new(old);
|
||||
let mut new_lines = Lines::new(new);
|
||||
let mut count = 0;
|
||||
loop {
|
||||
match (old_lines.next(), new_lines.next()) {
|
||||
(None, None) => break,
|
||||
(Some(a), Some(b)) => {
|
||||
count += 1;
|
||||
if old[a.content_end..a.end] != new[b.content_end..b.end] {
|
||||
return Err(PreviewError::LineEndingChanged {
|
||||
path: path.clone(),
|
||||
line: count,
|
||||
});
|
||||
}
|
||||
if old[a.start..a.end] != new[b.start..b.end] {
|
||||
if preview.changed_lines == limits.changed_lines {
|
||||
return Err(PreviewError::TooManyChangedLines {
|
||||
limit: limits.changed_lines,
|
||||
});
|
||||
}
|
||||
preview.changed_lines += 1;
|
||||
changed.push(count - 1);
|
||||
}
|
||||
}
|
||||
_ => return Err(PreviewError::LineCountChanged(path.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
// Merge touching context intervals in order, without pairwise diff.
|
||||
// At most 7 indexed lines per changed line are retained; unchanged
|
||||
// lines outside these intervals are scanned but never indexed.
|
||||
let mut ranges: Vec<Range<usize>> = Vec::new();
|
||||
for line in changed {
|
||||
let range =
|
||||
line.saturating_sub(CONTEXT)..line.saturating_add(CONTEXT + 1).min(count);
|
||||
if let Some(previous) = ranges.last_mut()
|
||||
&& range.start <= previous.end
|
||||
{
|
||||
previous.end = previous.end.max(range.end);
|
||||
continue;
|
||||
}
|
||||
ranges.push(range);
|
||||
}
|
||||
preview.charge(path.len(), limits)?;
|
||||
let mut file = File {
|
||||
path: path.clone(),
|
||||
hunks: Vec::new(),
|
||||
};
|
||||
let mut pairs = Lines::new(old).zip(Lines::new(new)).enumerate();
|
||||
for range in ranges {
|
||||
let mut hunk = Hunk { lines: Vec::new() };
|
||||
for (ordinal, (a, b)) in pairs.by_ref() {
|
||||
if ordinal < range.start {
|
||||
continue;
|
||||
}
|
||||
// Charge exact UTF-8 bytes, including preserved endings and
|
||||
// both copies of shared context, before adding the index.
|
||||
// No render-time omission or shortening grants admission.
|
||||
preview.charge(a.end - a.start, limits)?;
|
||||
preview.charge(b.end - b.start, limits)?;
|
||||
hunk.lines.push(Line {
|
||||
number: ordinal + 1,
|
||||
changed: old[a.start..a.end] != new[b.start..b.end],
|
||||
before: a.start..a.end,
|
||||
after: b.start..b.end,
|
||||
});
|
||||
if ordinal + 1 == range.end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
file.hunks.push(hunk);
|
||||
}
|
||||
preview.files.push(file);
|
||||
}
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
fn charge(&mut self, bytes: usize, limits: Limits) -> Result<(), PreviewError> {
|
||||
let Some(total) = self.display_bytes.checked_add(bytes) else {
|
||||
return Err(PreviewError::TooManyDisplayBytes {
|
||||
limit: limits.display_bytes,
|
||||
});
|
||||
};
|
||||
if total > limits.display_bytes {
|
||||
return Err(PreviewError::TooManyDisplayBytes {
|
||||
limit: limits.display_bytes,
|
||||
});
|
||||
}
|
||||
self.display_bytes = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All admitted files in source path order; suitable for a file picker/search.
|
||||
pub fn files(&self) -> &[File] {
|
||||
&self.files
|
||||
}
|
||||
|
||||
/// Changed source lines, not occurrences, declarations or semantic uses.
|
||||
pub fn changed_line_count(&self) -> usize {
|
||||
self.changed_lines
|
||||
}
|
||||
|
||||
/// Exact displayed source bytes for both sides plus each stored path once.
|
||||
/// Index metadata is separately bounded by 7 lines per changed line.
|
||||
pub fn display_bytes(&self) -> usize {
|
||||
self.display_bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl File {
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Every hunk is present; the caller may page these indexes, never truncate.
|
||||
pub fn hunks(&self) -> &[Hunk] {
|
||||
&self.hunks
|
||||
}
|
||||
}
|
||||
|
||||
impl Hunk {
|
||||
/// The complete ordered line slice supports paging even a large single hunk.
|
||||
pub fn lines(&self) -> &[Line] {
|
||||
&self.lines
|
||||
}
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// One-based source line number, identical on both sides.
|
||||
pub fn number(&self) -> usize {
|
||||
self.number
|
||||
}
|
||||
|
||||
pub fn changed(&self) -> bool {
|
||||
self.changed
|
||||
}
|
||||
|
||||
/// Borrow from the immutable original file; includes its exact line ending.
|
||||
pub fn before_text<'a>(&self, original: &'a str) -> Option<&'a str> {
|
||||
original.get(self.before.clone())
|
||||
}
|
||||
|
||||
/// Borrow from the immutable candidate file; includes its exact line ending.
|
||||
pub fn after_text<'a>(&self, candidate: &'a str) -> Option<&'a str> {
|
||||
candidate.get(self.after.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct RawLine {
|
||||
start: usize,
|
||||
content_end: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
struct Lines<'a> {
|
||||
bytes: &'a [u8],
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> Lines<'a> {
|
||||
fn new(text: &'a str) -> Self {
|
||||
Self {
|
||||
bytes: text.as_bytes(),
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Lines<'_> {
|
||||
type Item = RawLine;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.offset == self.bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let start = self.offset;
|
||||
// The compiler and source navigation count LF, not bare CR, as a line
|
||||
// boundary. Preserve CRLF as an ending and bare CR as source content.
|
||||
while self.offset < self.bytes.len() && self.bytes[self.offset] != b'\n' {
|
||||
self.offset += 1;
|
||||
}
|
||||
let mut content_end = self.offset;
|
||||
if self.offset < self.bytes.len() {
|
||||
if self.offset > start && self.bytes[self.offset - 1] == b'\r' {
|
||||
content_end -= 1;
|
||||
}
|
||||
self.offset += 1;
|
||||
}
|
||||
Some(RawLine {
|
||||
start,
|
||||
content_end,
|
||||
end: self.offset,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bundle(source: &str) -> SourceBundle {
|
||||
SourceBundle {
|
||||
entry: "救援.arcadia".into(),
|
||||
files: [("救援.arcadia".into(), source.into())].into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_unicode_lines_are_fully_reachable_as_bounded_fragments() {
|
||||
let text = format!("{}救援🙂{}\r\n", "a".repeat(251), "🙂".repeat(200));
|
||||
let pieces: Vec<_> = (0..segment_count(&text))
|
||||
.map(|i| text_segment(&text, i).unwrap())
|
||||
.collect();
|
||||
assert!(pieces.len() > 2);
|
||||
assert!(pieces.iter().all(|part| part.len() <= 255));
|
||||
assert_eq!(pieces.concat(), text);
|
||||
assert_eq!(text_segment(&text, segment_count(&text)), None);
|
||||
assert_eq!(text_segment(&text, usize::MAX), None);
|
||||
assert_eq!(segment_count(""), 1);
|
||||
assert_eq!(text_segment("", 0), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_unicode_mixed_endings_and_counts_lines_not_repeated_uses() {
|
||||
let old = bundle("// 救援\r\n@a @a\ncontext\r@a");
|
||||
let new = bundle("// 救援\r\n@long @long\ncontext\r@long");
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
assert_eq!(preview.changed_line_count(), 2);
|
||||
let file = &preview.files()[0];
|
||||
assert_eq!(file.path(), "救援.arcadia");
|
||||
assert_eq!(file.hunks().len(), 1);
|
||||
let lines = file.hunks()[0].lines();
|
||||
assert_eq!(
|
||||
lines.iter().map(Line::number).collect::<Vec<_>>(),
|
||||
[1, 2, 3]
|
||||
);
|
||||
assert_eq!(
|
||||
lines[0].before_text(&old.files[&old.entry]),
|
||||
Some("// 救援\r\n")
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1].after_text(&new.files[&new.entry]),
|
||||
Some("@long @long\n")
|
||||
);
|
||||
assert_eq!(
|
||||
lines[2].after_text(&new.files[&new.entry]),
|
||||
Some("context\r@long")
|
||||
);
|
||||
assert!(lines[1].changed() && lines[2].changed());
|
||||
assert!(!lines[0].changed());
|
||||
assert_eq!(
|
||||
preview.display_bytes(),
|
||||
old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_and_last_lines_form_complete_separate_hunks_and_pages() {
|
||||
let old = bundle("a\n1\n2\n3\n4\n5\n6\n7\n8\n9\na");
|
||||
let new = bundle("long\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlong");
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
let hunks = preview.files()[0].hunks();
|
||||
assert_eq!(hunks.len(), 2);
|
||||
let page_numbers: Vec<_> = hunks
|
||||
.iter()
|
||||
.flat_map(|h| h.lines().chunks(2))
|
||||
.flat_map(|page| page.iter().map(Line::number))
|
||||
.collect();
|
||||
assert_eq!(page_numbers, [1, 2, 3, 4, 8, 9, 10, 11]);
|
||||
assert_eq!(
|
||||
hunks[1].lines()[3].after_text(&new.files[&new.entry]),
|
||||
Some("long")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearby_changes_merge_and_all_changed_files_are_reachable() {
|
||||
let mut old = bundle("a\n1\n2\n3\n4\n5\na\n");
|
||||
let mut new = bundle("b\n1\n2\n3\n4\n5\nb\n");
|
||||
old.files.insert("other.arcadia".into(), "a a a".into());
|
||||
new.files.insert("other.arcadia".into(), "b b b".into());
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
assert_eq!(preview.changed_line_count(), 3);
|
||||
assert_eq!(preview.files().len(), 2);
|
||||
assert_eq!(preview.files()[0].path(), "other.arcadia");
|
||||
assert_eq!(preview.files()[0].hunks()[0].lines().len(), 1);
|
||||
assert_eq!(preview.files()[1].hunks().len(), 1);
|
||||
assert_eq!(preview.files()[1].hunks()[0].lines().len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_and_empty_sources_have_no_preview() {
|
||||
for source in ["", "a\r\nb\n", "a"] {
|
||||
let original = bundle(source);
|
||||
let preview = Preview::build(&original, &original).unwrap();
|
||||
assert!(preview.is_empty());
|
||||
assert_eq!(preview.changed_line_count(), 0);
|
||||
assert_eq!(preview.display_bytes(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_membership_entry_line_count_and_line_ending_changes() {
|
||||
let old = bundle("a\nb");
|
||||
let mut new = old.clone();
|
||||
new.files.insert("extra.arcadia".into(), "".into());
|
||||
assert_eq!(
|
||||
Preview::build(&old, &new).unwrap_err(),
|
||||
PreviewError::MembershipChanged
|
||||
);
|
||||
let mut new = old.clone();
|
||||
new.entry = "missing.arcadia".into();
|
||||
assert_eq!(
|
||||
Preview::build(&old, &new).unwrap_err(),
|
||||
PreviewError::MembershipChanged
|
||||
);
|
||||
assert!(matches!(
|
||||
Preview::build(&old, &bundle("a\nb\nc")),
|
||||
Err(PreviewError::LineEndingChanged { .. }) | Err(PreviewError::LineCountChanged(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::build(&bundle("a\nb\n"), &bundle("a\n")),
|
||||
Err(PreviewError::LineCountChanged(_))
|
||||
));
|
||||
for (a, b) in [("a\r\n", "a\n"), ("a\r", "a\n"), ("a", "a\n")] {
|
||||
assert_eq!(
|
||||
Preview::build(&bundle(a), &bundle(b)).unwrap_err(),
|
||||
PreviewError::LineEndingChanged {
|
||||
path: old.entry.clone(),
|
||||
line: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_bound_is_exact_and_overflow_never_returns_a_partial_preview() {
|
||||
let old = bundle("a\ncontext\na");
|
||||
let new = bundle("bbb\ncontext\nbbb");
|
||||
let exact = old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len();
|
||||
let limits = Limits {
|
||||
files: 1,
|
||||
changed_lines: 2,
|
||||
display_bytes: exact,
|
||||
};
|
||||
assert!(Preview::with_limits(&old, &new, limits).is_ok());
|
||||
assert_eq!(
|
||||
Preview::with_limits(&old, &new, Limits { files: 0, ..limits }).unwrap_err(),
|
||||
PreviewError::TooManyFiles { limit: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
changed_lines: 1,
|
||||
..limits
|
||||
}
|
||||
)
|
||||
.unwrap_err(),
|
||||
PreviewError::TooManyChangedLines { limit: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
display_bytes: exact - 1,
|
||||
..limits
|
||||
}
|
||||
)
|
||||
.unwrap_err(),
|
||||
PreviewError::TooManyDisplayBytes { limit: exact - 1 }
|
||||
);
|
||||
let long = bundle(&"a".repeat(LIMITS.display_bytes));
|
||||
assert!(matches!(
|
||||
Preview::build(&bundle("b"), &long),
|
||||
Err(PreviewError::TooManyDisplayBytes { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_apply_to_the_whole_preview_not_each_file() {
|
||||
let mut old = bundle("a");
|
||||
let mut new = bundle("b");
|
||||
old.files.insert("second.arcadia".into(), "a".into());
|
||||
new.files.insert("second.arcadia".into(), "b".into());
|
||||
let limits = Limits {
|
||||
files: 2,
|
||||
changed_lines: 2,
|
||||
display_bytes: old.entry.len() + "second.arcadia".len() + 4,
|
||||
};
|
||||
let complete = Preview::with_limits(&old, &new, limits).unwrap();
|
||||
assert_eq!(complete.files().len(), 2);
|
||||
assert_eq!(complete.changed_line_count(), 2);
|
||||
assert_eq!(complete.display_bytes(), limits.display_bytes);
|
||||
assert!(matches!(
|
||||
Preview::with_limits(&old, &new, Limits { files: 1, ..limits }),
|
||||
Err(PreviewError::TooManyFiles { limit: 1 })
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
changed_lines: 1,
|
||||
..limits
|
||||
}
|
||||
),
|
||||
Err(PreviewError::TooManyChangedLines { limit: 1 })
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
display_bytes: limits.display_bytes - 1,
|
||||
..limits
|
||||
}
|
||||
),
|
||||
Err(PreviewError::TooManyDisplayBytes { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Per-file drafts and navigation. Accepted model/source history lives in Document.
|
||||
use iced::widget::text_editor::Cursor;
|
||||
use oa_core::{
|
||||
language,
|
||||
Id, language,
|
||||
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic},
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -86,6 +86,7 @@ pub struct Buffer {
|
||||
pub cursor: Cursor,
|
||||
past: Vec<String>,
|
||||
future: Vec<String>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
@@ -99,14 +100,20 @@ impl Buffer {
|
||||
},
|
||||
past: Vec::new(),
|
||||
future: Vec::new(),
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.text != self.baseline
|
||||
}
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
session: Id,
|
||||
revision: u64,
|
||||
pub entry: String,
|
||||
pub active: String,
|
||||
pub files: BTreeMap<String, Buffer>,
|
||||
@@ -117,6 +124,35 @@ pub struct Workspace {
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// Ephemeral editor identity, never a model identity or recovery payload.
|
||||
pub fn session(&self) -> Id {
|
||||
self.session
|
||||
}
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
fn advance(&mut self) -> u64 {
|
||||
if let Some(revision) = self.revision.checked_add(1) {
|
||||
self.revision = revision;
|
||||
} else {
|
||||
self.reset_navigation_session();
|
||||
}
|
||||
self.revision
|
||||
}
|
||||
/// A changed filesystem root must not reuse queued navigation from Save As.
|
||||
pub fn reset_navigation_session(&mut self) {
|
||||
self.session = Id::new_v4();
|
||||
self.revision = 0;
|
||||
for buffer in self.files.values_mut() {
|
||||
buffer.generation = 0;
|
||||
}
|
||||
}
|
||||
pub fn set_reachable(&mut self, reachable: BTreeSet<String>) {
|
||||
if self.reachable != reachable {
|
||||
self.advance();
|
||||
self.reachable = reachable;
|
||||
}
|
||||
}
|
||||
pub fn recovery_snapshot(&self) -> RecoveryWorkspace {
|
||||
RecoveryWorkspace {
|
||||
entry: self.entry.clone(),
|
||||
@@ -136,6 +172,8 @@ impl Workspace {
|
||||
pub fn from_recovery(saved: RecoveryWorkspace) -> Result<Self, String> {
|
||||
saved.validate()?;
|
||||
Ok(Self {
|
||||
session: Id::new_v4(),
|
||||
revision: 0,
|
||||
entry: saved.entry,
|
||||
active: saved.active,
|
||||
files: saved
|
||||
@@ -154,6 +192,8 @@ impl Workspace {
|
||||
}
|
||||
pub fn new(bundle: &SourceBundle, applied: bool) -> Self {
|
||||
Self {
|
||||
session: Id::new_v4(),
|
||||
revision: 0,
|
||||
entry: bundle.entry.clone(),
|
||||
active: bundle.entry.clone(),
|
||||
files: bundle
|
||||
@@ -191,11 +231,20 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
pub fn edit(&mut self, text: String, cursor: Cursor) {
|
||||
let changed = self.files.get(&self.active).is_none_or(|b| b.text != text);
|
||||
let generation = if changed {
|
||||
self.advance()
|
||||
} else {
|
||||
self.revision
|
||||
};
|
||||
let buffer = self
|
||||
.files
|
||||
.entry(self.active.clone())
|
||||
.or_insert_with(|| Buffer::new(String::new()));
|
||||
buffer.cursor = cursor;
|
||||
if changed {
|
||||
buffer.generation = generation;
|
||||
}
|
||||
if text != buffer.text {
|
||||
buffer.past.push(std::mem::replace(&mut buffer.text, text));
|
||||
if buffer.past.len() > 100 {
|
||||
@@ -268,6 +317,8 @@ impl Workspace {
|
||||
} else {
|
||||
buffer.future.push(previous);
|
||||
}
|
||||
let generation = self.advance();
|
||||
self.files.get_mut(&self.active).unwrap().generation = generation;
|
||||
self.trim_history(DRAFT_HISTORY_BYTES);
|
||||
true
|
||||
}
|
||||
@@ -281,6 +332,7 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
pub fn accept(&mut self, bundle: &SourceBundle) {
|
||||
let generation = self.advance();
|
||||
if self.entry != bundle.entry
|
||||
&& !bundle.files.contains_key(&self.entry)
|
||||
&& self.files.get(&self.entry).is_some_and(|b| !b.dirty())
|
||||
@@ -291,7 +343,13 @@ impl Workspace {
|
||||
self.reachable = bundle.files.keys().cloned().collect();
|
||||
for (path, text) in &bundle.files {
|
||||
let cursor = self.files.get(path).map(|b| b.cursor);
|
||||
let previous_generation = self
|
||||
.files
|
||||
.get(path)
|
||||
.filter(|b| b.text == *text)
|
||||
.map(|b| b.generation);
|
||||
let mut buffer = Buffer::new(text.clone());
|
||||
buffer.generation = previous_generation.unwrap_or(generation);
|
||||
if let Some(cursor) = cursor {
|
||||
buffer.cursor = cursor;
|
||||
}
|
||||
@@ -303,10 +361,22 @@ impl Workspace {
|
||||
self.applied = true;
|
||||
}
|
||||
pub fn discovered(&mut self, bundle: &SourceBundle, reachable: BTreeSet<String>) {
|
||||
let changed = self.reachable != reachable
|
||||
|| bundle
|
||||
.files
|
||||
.keys()
|
||||
.any(|path| !self.files.contains_key(path));
|
||||
let generation = if changed {
|
||||
self.advance()
|
||||
} else {
|
||||
self.revision
|
||||
};
|
||||
for (path, text) in &bundle.files {
|
||||
self.files
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| Buffer::new(text.clone()));
|
||||
self.files.entry(path.clone()).or_insert_with(|| {
|
||||
let mut buffer = Buffer::new(text.clone());
|
||||
buffer.generation = generation;
|
||||
buffer
|
||||
});
|
||||
}
|
||||
self.reachable = reachable;
|
||||
}
|
||||
@@ -314,6 +384,7 @@ impl Workspace {
|
||||
if self.reachable.contains(path) || self.files.get(path).is_none_or(Buffer::dirty) {
|
||||
return false;
|
||||
}
|
||||
self.advance();
|
||||
self.files.remove(path);
|
||||
if self.active == path {
|
||||
self.active = self.entry.clone();
|
||||
@@ -376,6 +447,148 @@ pub fn lint(bundle: &SourceBundle) -> Vec<SourceDiagnostic> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oa_core::Project;
|
||||
|
||||
#[test]
|
||||
fn fresh_session_cannot_reuse_a_retained_buffer_generation() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "main.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("main.arcadia".into(), "first".into()),
|
||||
("other.arcadia".into(), "other".into()),
|
||||
]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
let cursor = workspace.files["main.arcadia"].cursor;
|
||||
workspace.edit("second".into(), cursor);
|
||||
workspace.reset_navigation_session();
|
||||
let before = workspace.files["main.arcadia"].generation();
|
||||
workspace.edit("third".into(), cursor);
|
||||
assert_ne!(workspace.files["main.arcadia"].generation(), before);
|
||||
workspace.switch("other.arcadia", cursor);
|
||||
workspace.revision = u64::MAX;
|
||||
workspace.edit("changed other".into(), cursor);
|
||||
let after_wrap = workspace.files["main.arcadia"].generation();
|
||||
workspace.switch("main.arcadia", cursor);
|
||||
workspace.edit("fourth".into(), cursor);
|
||||
assert_ne!(workspace.files["main.arcadia"].generation(), after_wrap);
|
||||
assert!(workspace.undo(false));
|
||||
assert_eq!(workspace.text(), "third");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_versions_distinguish_reopened_recovered_and_changed_drafts() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "entry\r\n".into()),
|
||||
("part.arcadia".into(), "雨 draft\r\n".into()),
|
||||
]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
let reopened = Workspace::new(&bundle, true);
|
||||
let recovered = Workspace::from_recovery(workspace.recovery_snapshot()).unwrap();
|
||||
assert_ne!(workspace.session(), reopened.session());
|
||||
assert_ne!(workspace.session(), recovered.session());
|
||||
assert_eq!(workspace.bundle(), recovered.bundle());
|
||||
let saved = serde_json::to_value(workspace.recovery_snapshot()).unwrap();
|
||||
assert!(saved.get("session").is_none() && saved.get("revision").is_none());
|
||||
|
||||
let cursor = Cursor {
|
||||
position: iced::widget::text_editor::Position { line: 0, column: 3 },
|
||||
selection: None,
|
||||
};
|
||||
let revision = workspace.revision();
|
||||
workspace.edit(workspace.text().into(), cursor);
|
||||
assert_eq!(
|
||||
workspace.revision(),
|
||||
revision,
|
||||
"selection is not a text change"
|
||||
);
|
||||
workspace.switch("part.arcadia", cursor);
|
||||
assert_eq!(
|
||||
workspace.revision(),
|
||||
revision,
|
||||
"switching retains the snapshot"
|
||||
);
|
||||
let untouched = workspace.files["project.arcadia"].generation();
|
||||
workspace.edit("雨 unfinished !\r\n".into(), cursor);
|
||||
let edited = workspace.files["part.arcadia"].generation();
|
||||
assert!(workspace.revision() > revision);
|
||||
assert_eq!(workspace.files["project.arcadia"].generation(), untouched);
|
||||
assert!(workspace.undo(false));
|
||||
assert_eq!(workspace.bundle(), bundle);
|
||||
assert!(workspace.files["part.arcadia"].generation() > edited);
|
||||
assert!(workspace.undo(true));
|
||||
assert_eq!(workspace.text(), "雨 unfinished !\r\n");
|
||||
let previous_session = workspace.session();
|
||||
let previous_text = workspace.bundle();
|
||||
workspace.reset_navigation_session();
|
||||
assert_ne!(workspace.session(), previous_session);
|
||||
assert_eq!(workspace.bundle(), previous_text);
|
||||
assert!(
|
||||
workspace.undo(false),
|
||||
"session reset preserves text history"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_scope_changes_invalidate_without_changing_unchanged_positions() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "entry".into()),
|
||||
("part.arcadia".into(), "part".into()),
|
||||
]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
let entry_generation = workspace.files[&bundle.entry].generation();
|
||||
let initial_revision = workspace.revision();
|
||||
workspace.set_reachable(BTreeSet::from([bundle.entry.clone()]));
|
||||
assert!(workspace.revision() > initial_revision);
|
||||
let membership_revision = workspace.revision();
|
||||
workspace.set_reachable(workspace.reachable.clone());
|
||||
assert_eq!(workspace.revision(), membership_revision);
|
||||
assert!(workspace.discard_detached("part.arcadia"));
|
||||
assert!(workspace.revision() > membership_revision);
|
||||
let closed_revision = workspace.revision();
|
||||
workspace.discovered(&bundle, BTreeSet::from([bundle.entry.clone()]));
|
||||
assert!(workspace.revision() > closed_revision);
|
||||
assert_eq!(
|
||||
workspace.files[&bundle.entry].generation(),
|
||||
entry_generation
|
||||
);
|
||||
assert!(workspace.files["part.arcadia"].generation() > closed_revision);
|
||||
let discovered_revision = workspace.revision();
|
||||
workspace.discovered(&bundle, workspace.reachable.clone());
|
||||
assert_eq!(workspace.revision(), discovered_revision);
|
||||
workspace.accept(&bundle);
|
||||
assert!(
|
||||
workspace.revision() > discovered_revision,
|
||||
"validation evidence changed"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace.files[&bundle.entry].generation(),
|
||||
entry_generation
|
||||
);
|
||||
assert_eq!(workspace.bundle(), bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_revision_wrap_cannot_reuse_an_old_workspace_key() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), "original".into())]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
workspace.revision = u64::MAX;
|
||||
let before = workspace.session();
|
||||
let cursor = workspace.files[&bundle.entry].cursor;
|
||||
workspace.edit("changed".into(), cursor);
|
||||
assert_ne!(workspace.session(), before);
|
||||
assert_eq!(workspace.revision(), 0);
|
||||
assert!(workspace.undo(false));
|
||||
assert_eq!(workspace.bundle(), bundle);
|
||||
}
|
||||
#[test]
|
||||
fn draft_history_budget_covers_inactive_files_without_discarding_drafts() {
|
||||
let bundle = SourceBundle {
|
||||
|
||||
@@ -377,7 +377,8 @@ fn source_navigation_waits_for_editor_layout_and_cancels_on_edit() {
|
||||
send(&mut app, Message::SourceForSelection);
|
||||
assert_eq!(app.page, Page::Text);
|
||||
assert!(app.source_jump.is_some());
|
||||
send(&mut app, Message::SourceViewport);
|
||||
let ticket = app.source_reveal_ticket();
|
||||
send(&mut app, Message::SourceViewport(ticket));
|
||||
assert!(app.source_jump.is_none());
|
||||
send(&mut app, Message::SourceForSelection);
|
||||
assert!(app.source_jump.is_some());
|
||||
@@ -409,7 +410,8 @@ fn source_find_navigates_an_invalid_draft_without_applying_or_losing_it() {
|
||||
cursor.selection,
|
||||
Some(text_editor::Position { line: 0, column: 9 })
|
||||
);
|
||||
send(&mut app, Message::SourceViewport);
|
||||
let ticket = app.source_reveal_ticket();
|
||||
send(&mut app, Message::SourceViewport(ticket));
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
send(
|
||||
&mut app,
|
||||
@@ -1269,7 +1271,26 @@ fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner()
|
||||
.get_mut(&ancestor)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
assert!(crate::navigation::diagrams_containing(&p, port).contains(&diagram));
|
||||
// The usage query reports rendered objects; this port's owner is folded.
|
||||
// The legacy authoring reveal below explicitly expands its saved ancestor.
|
||||
assert!(
|
||||
!oa_core::projection::project_view(&p, diagram)
|
||||
.ports
|
||||
.iter()
|
||||
.any(|shown| shown.element == port)
|
||||
);
|
||||
assert!(crate::diagram_usage::saved_usages_for(&p, port, Some(diagram)).is_empty());
|
||||
// Other saved views of this function may still paint the port.
|
||||
let elsewhere = crate::diagram_usage::saved_usages(&p, port);
|
||||
assert!(
|
||||
!elsewhere.is_empty(),
|
||||
"the demo still paints this port in another saved view"
|
||||
);
|
||||
assert!(
|
||||
elsewhere
|
||||
.iter()
|
||||
.all(|usage| usage.surface != crate::diagram_usage::Surface::Saved(diagram))
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, port));
|
||||
assert_eq!(app.selected, port);
|
||||
@@ -1281,6 +1302,12 @@ fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner()
|
||||
.iter()
|
||||
.any(|p| p.element == port)
|
||||
);
|
||||
assert!(
|
||||
crate::diagram_usage::saved_usages_for(app.project(), port, Some(diagram))
|
||||
.iter()
|
||||
.any(|usage| matches!(usage.target,
|
||||
crate::diagram_usage::Target::Port { port: found, .. } if found == port))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+596
-14
@@ -1,3 +1,4 @@
|
||||
mod source_symbols;
|
||||
pub(crate) mod traceability;
|
||||
use crate::{
|
||||
app::{App, Choice, FileOperation, Message, Page, Pending},
|
||||
@@ -12,6 +13,7 @@ use iced::{
|
||||
},
|
||||
};
|
||||
use oa_core::*;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
pub fn label<'a>(s: impl Into<String>) -> widget::Text<'a> {
|
||||
text(s.into()).size(11).color(style::MUTED)
|
||||
@@ -24,10 +26,12 @@ pub fn action<'a>(s: &str, m: Message) -> widget::Button<'a, Message> {
|
||||
}
|
||||
pub fn short(s: &str, limit: usize) -> String {
|
||||
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if s.chars().count() > limit {
|
||||
if s.graphemes(true).count() > limit {
|
||||
format!(
|
||||
"{}…",
|
||||
s.chars().take(limit.saturating_sub(1)).collect::<String>()
|
||||
s.graphemes(true)
|
||||
.take(limit.saturating_sub(1))
|
||||
.collect::<String>()
|
||||
)
|
||||
} else {
|
||||
s
|
||||
@@ -350,6 +354,13 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
if app.pending.is_some() {
|
||||
return modal(root,column![text("Keep your changes?").size(21),text("Save the model and pending edits before continuing. Invalid drafts stay available for correction.").size(13),row![action("Cancel",Message::Cancel),action("Discard changes",Message::Discard),button("Save & continue").padding(10).on_press(Message::SaveAndContinue)].spacing(8)].spacing(16).into());
|
||||
}
|
||||
if app.source_rename.open() {
|
||||
return modal_width(
|
||||
root,
|
||||
crate::app::source_rename::dialog(app),
|
||||
(app.window_size.width - 32.).clamp(120., 760.),
|
||||
);
|
||||
}
|
||||
root
|
||||
}
|
||||
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
|
||||
@@ -448,6 +459,9 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
view_index: Some(app.document.view_index()),
|
||||
diagram: id,
|
||||
selected: app.selected,
|
||||
navigation_focus: app
|
||||
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
|
||||
.map(|usage| usage.bounds),
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
@@ -477,6 +491,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) {
|
||||
scene = scene.push(editor);
|
||||
}
|
||||
let reveal = app.architecture_reveal_stamp(id);
|
||||
row![
|
||||
widget::sensor(
|
||||
container(scene)
|
||||
@@ -484,8 +499,15 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
)
|
||||
.on_show(Message::CanvasResized)
|
||||
.on_resize(Message::CanvasResized),
|
||||
.on_show(
|
||||
move |size| reveal.map_or(Message::CanvasResized(size), |(context, epoch)| {
|
||||
Message::DiagramRevealViewport(context, epoch, size)
|
||||
})
|
||||
)
|
||||
.on_resize(move |size| reveal
|
||||
.map_or(Message::CanvasResized(size), |(context, epoch)| {
|
||||
Message::DiagramRevealViewport(context, epoch, size)
|
||||
})),
|
||||
crate::app::architecture_authoring::palette(app)
|
||||
]
|
||||
.height(Fill)
|
||||
@@ -657,7 +679,7 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
return container(
|
||||
column![
|
||||
tabs,
|
||||
scrollable(crate::navigation::view(p, app.selected)).height(Fill)
|
||||
scrollable(crate::navigation::view(app, app.selected)).height(Fill)
|
||||
]
|
||||
.spacing(14),
|
||||
)
|
||||
@@ -1075,6 +1097,13 @@ fn source_files(app: &App) -> View<'_, Message> {
|
||||
|
||||
fn source(app: &App) -> View<'_, Message> {
|
||||
use crate::source_navigation::Event as SourceEvent;
|
||||
let (undo_label, undo_message) = match app.source_rename_undo_message() {
|
||||
Some(message) => ("Undo rename", Some(message)),
|
||||
None => (
|
||||
"Undo model",
|
||||
(app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo),
|
||||
),
|
||||
};
|
||||
let navigation: View<'_, Message> = if app.source_navigation.open {
|
||||
column![
|
||||
row![
|
||||
@@ -1091,7 +1120,7 @@ fn source(app: &App) -> View<'_, Message> {
|
||||
),
|
||||
action("Next", Message::SourceNavigation(SourceEvent::Next(false))),
|
||||
label(app.source_navigation.label()),
|
||||
action("Close", Message::SourceNavigation(SourceEvent::Close)),
|
||||
action("Close", app.source_find_close_message()),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
@@ -1185,9 +1214,14 @@ fn source(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
}
|
||||
let editor = widget::text_editor(&app.source_editor)
|
||||
.id("source-editor")
|
||||
.id(app.source_editor_id())
|
||||
.style(style::source_editor)
|
||||
.on_action(Message::SourceEdit)
|
||||
.key_binding({
|
||||
let context = app.source_symbol_context();
|
||||
let rename = app.source_rename_begin();
|
||||
move |press| source_binding(press, &context, Some(&rename))
|
||||
})
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
@@ -1196,8 +1230,7 @@ fn source(app: &App) -> View<'_, Message> {
|
||||
app.source_snapshot.clone(),
|
||||
crate::highlight::format,
|
||||
);
|
||||
column![
|
||||
container(
|
||||
let header = container(
|
||||
column![
|
||||
row![
|
||||
container(heading(
|
||||
@@ -1215,8 +1248,8 @@ fn source(app: &App) -> View<'_, Message> {
|
||||
row![
|
||||
action("Undo text", Message::SourceUndo),
|
||||
action("Redo text", Message::SourceRedo),
|
||||
button(text("Undo model").size(12)).padding([8,10]).style(style::nav)
|
||||
.on_press_maybe((app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo)),
|
||||
button(text(undo_label).size(12)).padding([8,10]).style(style::nav)
|
||||
.on_press_maybe(undo_message),
|
||||
button(text("Redo model").size(12)).padding([8,10]).style(style::nav)
|
||||
.on_press_maybe((app.document.can_redo() && !app.source_dirty()).then_some(Message::Redo)),
|
||||
action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))),
|
||||
@@ -1240,17 +1273,512 @@ fn source(app: &App) -> View<'_, Message> {
|
||||
"Undo text affects this file's draft. Model Undo restores accepted files together (100 changes / about 32 MiB)."
|
||||
}),
|
||||
navigation,
|
||||
row![
|
||||
action("Definition · F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::Definition))),
|
||||
action("References · Shift F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::References))),
|
||||
action("Rename alias · F2", app.source_rename_message()),
|
||||
action("Show in diagrams…", app.source_symbol_message(crate::source_navigation::SymbolEvent::DiagramUsages)),
|
||||
button(text("Back · Alt ←").size(12)).padding([8, 10]).style(style::nav)
|
||||
.on_press_maybe((app.source_symbols.back.iter().any(|entry| entry.session == app.source_workspace.session())).then(|| app.source_symbol_message(crate::source_navigation::SymbolEvent::Back))),
|
||||
].spacing(7),
|
||||
scrollable(diagnostics)
|
||||
.height((visible_diagnostics as f32 * 32.).min(132.))
|
||||
]
|
||||
.spacing(12)
|
||||
)
|
||||
.padding(18),
|
||||
widget::sensor(editor).key((app.source_workspace.active.clone(),app.source_jump))
|
||||
.on_show(|_| Message::SourceViewport).on_resize(|_| Message::SourceViewport)
|
||||
.padding(18);
|
||||
let editor = widget::sensor(editor)
|
||||
.key((
|
||||
app.source_workspace.active.clone(),
|
||||
app.source_reveal_ticket(),
|
||||
))
|
||||
.on_show({
|
||||
let ticket = app.source_reveal_ticket();
|
||||
move |_| Message::SourceViewport(ticket)
|
||||
})
|
||||
.on_resize({
|
||||
let ticket = app.source_reveal_ticket();
|
||||
move |_| Message::SourceViewport(ticket)
|
||||
});
|
||||
let workspace: View<'_, Message> = if app.source_symbols.open {
|
||||
row![
|
||||
container(editor).width(Fill).height(Fill),
|
||||
container(scrollable(source_symbols::panel(app)))
|
||||
.width(300)
|
||||
.height(Fill),
|
||||
]
|
||||
.height(Fill)
|
||||
.into()
|
||||
} else {
|
||||
container(editor).width(Fill).height(Fill).into()
|
||||
};
|
||||
source_layout(header.into(), workspace, app.window_size.height)
|
||||
}
|
||||
|
||||
fn source_layout<'a, Renderer: iced::advanced::text::Renderer + 'a>(
|
||||
header: View<'a, Message, iced::Theme, Renderer>,
|
||||
workspace: View<'a, Message, iced::Theme, Renderer>,
|
||||
window_height: f32,
|
||||
) -> View<'a, Message, iced::Theme, Renderer> {
|
||||
// Lint clears and repopulates diagnostics asynchronously. Reserve the same
|
||||
// outer slot so a pointer aimed at the editor cannot land in the header.
|
||||
column![
|
||||
container(scrollable(header).id("source-header")).height((window_height * 0.42).max(180.)),
|
||||
workspace,
|
||||
]
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
pub(crate) fn source_binding(
|
||||
press: widget::text_editor::KeyPress,
|
||||
context: &crate::source_navigation::SymbolContext,
|
||||
rename: Option<&crate::app::source_rename::Begin>,
|
||||
) -> Option<widget::text_editor::Binding<Message>> {
|
||||
use crate::source_navigation::{SymbolEvent, query::Action};
|
||||
use iced::keyboard::{Key, key::Named};
|
||||
use widget::text_editor::{Binding, Status};
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& press.modifiers.is_empty()
|
||||
&& press.key == Key::Named(Named::F2)
|
||||
&& let Some(begin) = rename
|
||||
{
|
||||
return Some(Binding::Custom(Message::SourceRename(
|
||||
crate::app::source_rename::Event::Begin(begin.clone()),
|
||||
)));
|
||||
}
|
||||
// Windows owns Alt+Space for its window menu. Iced's default binding would
|
||||
// otherwise insert a supplied space payload, even after that menu closes.
|
||||
// Keep Ctrl+Alt (AltGr), shifted input, and ordinary Space unchanged.
|
||||
#[cfg(target_os = "windows")]
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& press.modifiers == iced::keyboard::Modifiers::ALT
|
||||
&& matches!(
|
||||
press.key.as_ref(),
|
||||
Key::Named(Named::Space) | Key::Character(" ")
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& !press.modifiers.control()
|
||||
&& !press.modifiers.logo()
|
||||
{
|
||||
let event = match &press.key {
|
||||
Key::Named(Named::F12) if !press.modifiers.alt() => {
|
||||
Some(SymbolEvent::Query(if press.modifiers.shift() {
|
||||
Action::References
|
||||
} else {
|
||||
Action::Definition
|
||||
}))
|
||||
}
|
||||
Key::Named(Named::ArrowLeft) if press.modifiers.alt() && !press.modifiers.shift() => {
|
||||
Some(SymbolEvent::Back)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(event) = event {
|
||||
return Some(Binding::Custom(Message::SourceSymbols(
|
||||
context.clone(),
|
||||
event,
|
||||
)));
|
||||
}
|
||||
}
|
||||
Binding::from_key_press(press)
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
pub(crate) mod source_tests {
|
||||
use super::*;
|
||||
use iced::advanced::{
|
||||
Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget,
|
||||
};
|
||||
use iced::widget::column;
|
||||
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector};
|
||||
|
||||
// Measure real Iced paragraphs/editor text without constructing a window or GPU.
|
||||
pub(crate) struct FontLayout;
|
||||
impl renderer::Renderer for FontLayout {
|
||||
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) {}
|
||||
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 (), handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for FontLayout {
|
||||
type Font = Font;
|
||||
type Paragraph = graphics::text::Paragraph;
|
||||
type Editor = graphics::text::Editor;
|
||||
const ICON_FONT: Font = Font::DEFAULT;
|
||||
const CHECKMARK_ICON: char = '0';
|
||||
const ARROW_DOWN_ICON: char = '0';
|
||||
const SCROLL_UP_ICON: char = '0';
|
||||
const SCROLL_DOWN_ICON: char = '0';
|
||||
const SCROLL_LEFT_ICON: char = '0';
|
||||
const SCROLL_RIGHT_ICON: char = '0';
|
||||
const ICED_LOGO: char = '0';
|
||||
fn default_font(&self) -> Font {
|
||||
Font::DEFAULT
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
Pixels(16.)
|
||||
}
|
||||
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Geometry {
|
||||
header: Option<(Rectangle, Rectangle, Vector)>,
|
||||
editor: Option<Rectangle>,
|
||||
}
|
||||
impl core_widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&core_widget::Id>, bounds: Rectangle) {
|
||||
if id == Some(&core_widget::Id::new("test-source-editor")) {
|
||||
self.editor = Some(bounds);
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&core_widget::Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn core_widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&core_widget::Id::new("source-header")) {
|
||||
self.header = Some((bounds, content, offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mounted_source<'a>(
|
||||
content: &'a widget::text_editor::Content<FontLayout>,
|
||||
diagnostics: usize,
|
||||
find: bool,
|
||||
window_height: f32,
|
||||
) -> View<'a, Message, iced::Theme, FontLayout> {
|
||||
let mut notices = column![].spacing(5);
|
||||
for index in 0..diagnostics {
|
||||
notices = notices.push(button(text(format!(
|
||||
"救援/assess.arcadia:{}:5 · Warning: verify this participant's engineering allocation", index + 1,
|
||||
)).size(12)).padding([4, 6]).width(Fill));
|
||||
}
|
||||
let find: View<'_, Message, iced::Theme, FontLayout> = if find {
|
||||
column![
|
||||
text_input("Find literal text", "assess").on_input(|s| Message::SourceNavigation(
|
||||
crate::source_navigation::Event::Query(s)
|
||||
)),
|
||||
text_input("Line", "1").on_input(|s| Message::SourceNavigation(
|
||||
crate::source_navigation::Event::Line(s)
|
||||
)),
|
||||
]
|
||||
.spacing(8)
|
||||
.into()
|
||||
} else {
|
||||
Space::new().into()
|
||||
};
|
||||
let header = container(
|
||||
column![
|
||||
text("Textual model").size(24),
|
||||
text("Edit the current file. Apply text validates the complete included model.")
|
||||
.size(11),
|
||||
row![
|
||||
button(text("Apply text")),
|
||||
button(text("Undo text")),
|
||||
button(text("Model Undo"))
|
||||
]
|
||||
.spacing(8),
|
||||
text("救援/assess.arcadia · All files applied to the accepted model").size(11),
|
||||
text("Undo text affects this draft. Model Undo restores accepted files together.")
|
||||
.size(11),
|
||||
find,
|
||||
row![
|
||||
button(text("Definition · F12")),
|
||||
button(text("References · Shift F12")),
|
||||
button(text("Back"))
|
||||
]
|
||||
.spacing(7),
|
||||
scrollable(notices).height((diagnostics as f32 * 32.).min(132.)),
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(18);
|
||||
let editor = container(
|
||||
widget::text_editor(content)
|
||||
.on_action(Message::SourceEdit)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(Fill),
|
||||
)
|
||||
.id("test-source-editor")
|
||||
.height(Fill)
|
||||
.width(Fill);
|
||||
source_layout(header.into(), editor.into(), window_height)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_header_keeps_editor_bounds_stable_through_lint_and_find_rebuilds() {
|
||||
for (host, window_height) in [
|
||||
(Size::new(720., 540.), 700.),
|
||||
(Size::new(1180., 890.), 1080.),
|
||||
] {
|
||||
let content = widget::text_editor::Content::<FontLayout>::with_text(
|
||||
"// is excluded\r\n// 雨\r\n",
|
||||
);
|
||||
let mut tree = core_widget::Tree::empty();
|
||||
let mut baseline = None;
|
||||
for (diagnostics, find) in [
|
||||
(0, false),
|
||||
(6, false),
|
||||
(0, false),
|
||||
(6, true),
|
||||
(1, true),
|
||||
(0, false),
|
||||
] {
|
||||
let mut ui = mounted_source(&content, diagnostics, find, window_height);
|
||||
tree.diff(ui.as_widget());
|
||||
let node = ui.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, host),
|
||||
);
|
||||
let mut geometry = Geometry::default();
|
||||
ui.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&FontLayout,
|
||||
&mut geometry,
|
||||
);
|
||||
let editor = geometry.editor.unwrap();
|
||||
let (header, _, _) = geometry.header.unwrap();
|
||||
let slot = node.children()[0].bounds();
|
||||
assert_eq!(slot.height, (window_height * 0.42).max(180.));
|
||||
assert!(
|
||||
header.height <= slot.height,
|
||||
"short content may shrink inside the fixed slot"
|
||||
);
|
||||
assert!(
|
||||
editor.height >= 240.,
|
||||
"remaining editor must stay usable: {editor:?}"
|
||||
);
|
||||
assert_eq!(editor.y, slot.y + slot.height);
|
||||
if let Some(expected) = baseline {
|
||||
assert_eq!(editor, expected, "diagnostics={diagnostics}, find={find}");
|
||||
} else {
|
||||
baseline = Some(editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_header_remains_scrollable_and_preserves_offset_across_diagnostic_refresh() {
|
||||
let content = widget::text_editor::Content::<FontLayout>::with_text("// 雨\r\n");
|
||||
let host = Size::new(720., 540.);
|
||||
let mut tree = core_widget::Tree::empty();
|
||||
let mut saved = None;
|
||||
for diagnostics in [6, 7] {
|
||||
let mut ui = mounted_source(&content, diagnostics, true, 700.);
|
||||
tree.diff(ui.as_widget());
|
||||
let node = ui.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, host),
|
||||
);
|
||||
let mut geometry = Geometry::default();
|
||||
ui.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
|
||||
let (bounds, contents, _) = geometry.header.unwrap();
|
||||
assert!(
|
||||
contents.height > bounds.height,
|
||||
"fixture must actually overflow"
|
||||
);
|
||||
if saved.is_none() {
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
ui.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&iced::Event::Mouse(iced::mouse::Event::WheelScrolled {
|
||||
delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -45. },
|
||||
}),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Available(Point::new(bounds.x + 8., bounds.y + 8.)),
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&Rectangle::with_size(host),
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
}
|
||||
let mut geometry = Geometry::default();
|
||||
ui.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
|
||||
let offset = geometry.header.unwrap().2;
|
||||
assert!(offset.y > 0., "the actual mounted Scrollable must move");
|
||||
if let Some(before) = saved {
|
||||
assert_eq!(offset, before);
|
||||
} else {
|
||||
saved = Some(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() {
|
||||
use core_widget::operation::Focusable;
|
||||
use iced::keyboard::{
|
||||
self, Key, Modifiers,
|
||||
key::{Code, Named, Physical},
|
||||
};
|
||||
use widget::text_editor::{Binding, Content, KeyPress, State, Status};
|
||||
let baseline = "// is excluded\r\n// 雨\r\n";
|
||||
let context = crate::source_navigation::SymbolContext {
|
||||
session: Id::new_v4(),
|
||||
path: "救援/assess.arcadia".into(),
|
||||
};
|
||||
for (key, physical_key, modifiers, payload, inserted) in [
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::ALT,
|
||||
" ",
|
||||
false,
|
||||
),
|
||||
(
|
||||
Key::Character(" ".into()),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::ALT,
|
||||
" ",
|
||||
false,
|
||||
),
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::empty(),
|
||||
" ",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::CTRL | Modifiers::ALT,
|
||||
" ",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Key::Character("e".into()),
|
||||
Physical::Code(Code::KeyE),
|
||||
Modifiers::CTRL | Modifiers::ALT,
|
||||
"€",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::ALT | Modifiers::SHIFT,
|
||||
" ",
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let mut content = Content::<FontLayout>::with_text(baseline);
|
||||
content.move_to(widget::text_editor::Cursor {
|
||||
position: widget::text_editor::Position { line: 0, column: 5 },
|
||||
selection: None,
|
||||
});
|
||||
let press = KeyPress {
|
||||
key: key.clone(),
|
||||
modified_key: key.clone(),
|
||||
physical_key,
|
||||
modifiers,
|
||||
text: Some(payload.into()),
|
||||
status: Status::Focused { is_hovered: false },
|
||||
};
|
||||
assert!(
|
||||
matches!(
|
||||
Binding::<Message>::from_key_press(press),
|
||||
Some(Binding::Insert(_))
|
||||
),
|
||||
"the supplied event must expose the default insertion path"
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
{
|
||||
let mut editor: View<'_, Message, iced::Theme, FontLayout> =
|
||||
widget::text_editor(&content)
|
||||
.on_action(Message::SourceEdit)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(Fill)
|
||||
.key_binding(|press| source_binding(press, &context, None))
|
||||
.into();
|
||||
let mut tree = core_widget::Tree::new(editor.as_widget());
|
||||
let size = Size::new(600., 240.);
|
||||
let node = editor.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, size),
|
||||
);
|
||||
tree.state
|
||||
.downcast_mut::<State<shaped::highlighter::PlainText>>()
|
||||
.focus();
|
||||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: key.clone(),
|
||||
modified_key: key,
|
||||
physical_key,
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers,
|
||||
text: Some(payload.into()),
|
||||
repeat: false,
|
||||
});
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
editor.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&Rectangle::with_size(size),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
usize::from(inserted),
|
||||
"modifiers={modifiers:?}"
|
||||
);
|
||||
for message in messages {
|
||||
let Message::SourceEdit(action) = message else {
|
||||
panic!("unexpected navigation action");
|
||||
};
|
||||
content.perform(action);
|
||||
}
|
||||
let expected = if inserted {
|
||||
format!("// is{payload} excluded\r\n// 雨\r\n")
|
||||
} else {
|
||||
baseline.into()
|
||||
};
|
||||
assert_eq!(content.text(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn checks(app: &App) -> View<'_, Message> {
|
||||
let issues = &app.model_issues;
|
||||
let mut out = column![heading(
|
||||
@@ -1363,3 +1891,57 @@ fn checks(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
scrollable(out.padding(24)).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::short;
|
||||
|
||||
#[test]
|
||||
fn short_preserves_native_lifeline_clusters_at_cutoff() {
|
||||
let oa = "abcdefghijklmnope\u{0301}尾";
|
||||
assert_eq!(short(oa, 17), "abcdefghijklmnop…");
|
||||
assert_eq!(short(oa, 18), oa);
|
||||
assert_eq!(short(oa, 19), oa);
|
||||
|
||||
let sa = "abcdefghijklmnop👩\u{200d}🔬 tail";
|
||||
assert_eq!(short(sa, 17), "abcdefghijklmnop…");
|
||||
assert_eq!(short(sa, 18), "abcdefghijklmnop👩\u{200d}🔬…");
|
||||
assert_eq!(short(sa, 19), "abcdefghijklmnop👩\u{200d}🔬 …");
|
||||
assert_eq!(short(sa, 22), sa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_never_splits_retained_extended_graphemes() {
|
||||
for cluster in ["e\u{0301}", "👩\u{200d}🔬", "👍🏽", "🇫🇷"] {
|
||||
let value = format!("A{cluster}BC");
|
||||
assert_eq!(short(&value, 2), "A…");
|
||||
assert_eq!(short(&value, 3), format!("A{cluster}…"));
|
||||
assert_eq!(short(&value, 4), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_preserves_ascii_cjk_and_whitespace_policy() {
|
||||
assert_eq!(short("abcd", 3), "ab…");
|
||||
assert_eq!(short("abcd", 4), "abcd");
|
||||
assert_eq!(short("abcd", 5), "abcd");
|
||||
assert_eq!(short("甲乙丙丁", 3), "甲乙…");
|
||||
assert_eq!(short("甲乙丙丁", 4), "甲乙丙丁");
|
||||
assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 5), "A e\u{0301} B");
|
||||
assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 4), "A e\u{0301}…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_preserves_empty_and_small_limit_policy() {
|
||||
for limit in [0, 1, 2] {
|
||||
assert_eq!(short("", limit), "");
|
||||
assert_eq!(short(" \t\r\n", limit), "");
|
||||
}
|
||||
assert_eq!(short("A", 0), "…");
|
||||
assert_eq!(short("A", 1), "A");
|
||||
assert_eq!(short("AB", 1), "…");
|
||||
assert_eq!(short("e\u{0301}", 0), "…");
|
||||
assert_eq!(short("e\u{0301}", 1), "e\u{0301}");
|
||||
assert_eq!(short("e\u{0301}B", 1), "…");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
source_navigation::{
|
||||
SymbolEvent,
|
||||
query::{self, Declaration, Disposition, Evidence, Identity, Membership, Position},
|
||||
},
|
||||
style,
|
||||
};
|
||||
use iced::{
|
||||
Element, Fill,
|
||||
widget::{Space, button, column, container, row, scrollable, text, tooltip},
|
||||
};
|
||||
|
||||
pub(super) fn panel(app: &App) -> Element<'_, Message> {
|
||||
let state = &app.source_symbols;
|
||||
if !state.open
|
||||
|| (state.results.is_none() && state.in_flight.is_none() && state.notice.is_none())
|
||||
{
|
||||
return column![].into();
|
||||
}
|
||||
let title = state.results.as_ref().map_or_else(
|
||||
|| "Source symbols".to_owned(),
|
||||
|results| {
|
||||
let action = match results.action {
|
||||
query::Action::Definition => "Definitions",
|
||||
query::Action::References => "References",
|
||||
};
|
||||
results.alias.as_ref().map_or_else(
|
||||
|| action.to_owned(),
|
||||
|alias| format!("{action} · @{}", preview(alias, 40)),
|
||||
)
|
||||
},
|
||||
);
|
||||
let mut content = column![
|
||||
row![
|
||||
text(title).size(13),
|
||||
Space::new().width(Fill),
|
||||
button(text("Close").size(11))
|
||||
.padding([3, 8])
|
||||
.style(style::nav)
|
||||
.on_press(app.source_symbol_message(SymbolEvent::Close)),
|
||||
]
|
||||
.align_y(iced::Center)
|
||||
]
|
||||
.spacing(5);
|
||||
|
||||
if let Some(notice) = &state.notice {
|
||||
content = content.push(super::label(preview(notice, 240)));
|
||||
}
|
||||
if state.in_flight.is_some() {
|
||||
content = content.push(super::label("Finding source symbols…"));
|
||||
} else if let Some(results) = &state.results {
|
||||
let stale = results.stamp.snapshot != app.source_key();
|
||||
if stale {
|
||||
content = content.push(super::label(
|
||||
"Stale results — source changed. Run Definition or References again.",
|
||||
));
|
||||
}
|
||||
let scope = &results.scope;
|
||||
content = content.push(super::label(format!(
|
||||
"{} · {} · {} included / {} detached / {} unverified files · {} scope issues",
|
||||
if scope.partial() {
|
||||
"Partial scope"
|
||||
} else {
|
||||
"Complete include scope"
|
||||
},
|
||||
if !scope.applied {
|
||||
"Unapplied drafts"
|
||||
} else if scope.validated {
|
||||
"Validated sources"
|
||||
} else {
|
||||
"Draft sources"
|
||||
},
|
||||
scope.included_files,
|
||||
scope.detached_files,
|
||||
scope.unverified_files,
|
||||
scope.issues.len(),
|
||||
)));
|
||||
let explanation = match results.disposition {
|
||||
Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.",
|
||||
Disposition::NoDefinition => "No matching declaration in the loaded sources.",
|
||||
Disposition::Choices => {
|
||||
"Choose a declaration; draft and detached candidates need an explicit choice."
|
||||
}
|
||||
Disposition::Direct => "Definition in the current sources.",
|
||||
Disposition::LexicalReferences => {
|
||||
"Lexical uses of this alias; these do not establish a semantic target."
|
||||
}
|
||||
};
|
||||
content = content.push(super::label(explanation));
|
||||
if results.total_definitions > 1 {
|
||||
content = content.push(super::label(format!(
|
||||
"Alias collision: {} declarations. Uses are not attributed to a single declaration.",
|
||||
results.total_definitions,
|
||||
)));
|
||||
}
|
||||
let enabled = !stale && !app.busy;
|
||||
let mut rows = column![super::label(counts(
|
||||
"Declarations",
|
||||
results.definitions.len(),
|
||||
results.total_definitions,
|
||||
results.omitted_definitions,
|
||||
))]
|
||||
.spacing(4);
|
||||
for (index, definition) in results.definitions.iter().enumerate() {
|
||||
rows = rows.push(location_row(
|
||||
declaration_title(&definition.declaration),
|
||||
&definition.path,
|
||||
definition.position,
|
||||
provenance(
|
||||
definition.membership,
|
||||
definition.evidence,
|
||||
definition.declaration.identity,
|
||||
false,
|
||||
),
|
||||
enabled.then(|| {
|
||||
app.source_symbol_message(SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
reference: false,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
rows = rows.push(super::label(counts(
|
||||
"Lexical uses",
|
||||
results.references.len(),
|
||||
results.total_references,
|
||||
results.omitted_references,
|
||||
)));
|
||||
for (index, reference) in results.references.iter().enumerate() {
|
||||
let title = reference.context.as_ref().map_or_else(
|
||||
|| format!("@{} · lexical use", preview(&reference.alias, 48)),
|
||||
|context| {
|
||||
format!(
|
||||
"@{} · inside {}",
|
||||
preview(&reference.alias, 48),
|
||||
declaration_title(context)
|
||||
)
|
||||
},
|
||||
);
|
||||
rows = rows.push(location_row(
|
||||
title,
|
||||
&reference.path,
|
||||
reference.position,
|
||||
provenance(
|
||||
reference.membership,
|
||||
reference.evidence,
|
||||
reference
|
||||
.context
|
||||
.as_ref()
|
||||
.map_or(Identity::Missing, |context| context.identity),
|
||||
true,
|
||||
),
|
||||
enabled.then(|| {
|
||||
app.source_symbol_message(SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
reference: true,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
let row_count = results.definitions.len() + results.references.len();
|
||||
content =
|
||||
content.push(scrollable(rows).height((44.0 + row_count as f32 * 48.0).min(180.0)));
|
||||
}
|
||||
container(content)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::subtle)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String {
|
||||
format!("{label}: {total} total · {shown} shown · {omitted} omitted")
|
||||
}
|
||||
|
||||
fn declaration_title(declaration: &Declaration) -> String {
|
||||
let name = declaration
|
||||
.name
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |name| format!(" \"{}\"", preview(name, 48)));
|
||||
format!(
|
||||
"{}{} · @{}",
|
||||
preview(&declaration.kind, 24),
|
||||
name,
|
||||
preview(&declaration.alias, 48)
|
||||
)
|
||||
}
|
||||
|
||||
fn provenance(
|
||||
membership: Membership,
|
||||
evidence: Evidence,
|
||||
identity: Identity,
|
||||
context: bool,
|
||||
) -> String {
|
||||
let membership = match membership {
|
||||
Membership::Included => "Included",
|
||||
Membership::Detached => "Detached",
|
||||
Membership::Unverified => "Unverified",
|
||||
};
|
||||
let evidence = match evidence {
|
||||
Evidence::Validated => "Validated",
|
||||
Evidence::Draft => "Draft",
|
||||
};
|
||||
let collision = match identity {
|
||||
Identity::Collision { count } => format!(
|
||||
" · {}ID collision ({count})",
|
||||
if context { "Context " } else { "" },
|
||||
),
|
||||
Identity::Missing | Identity::Unique(_) => String::new(),
|
||||
};
|
||||
format!("{membership} · {evidence}{collision}")
|
||||
}
|
||||
|
||||
fn location_row(
|
||||
title: String,
|
||||
path: &str,
|
||||
position: Position,
|
||||
status: String,
|
||||
event: Option<Message>,
|
||||
) -> Element<'static, Message> {
|
||||
let line = position.line.saturating_add(1);
|
||||
let column = position.byte_column.saturating_add(1);
|
||||
let location = format!("{}:{line}:{column}", preview(path, 72));
|
||||
tooltip(
|
||||
button(
|
||||
column![
|
||||
text(title).size(12),
|
||||
super::label(format!("{location} · {status}"))
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.padding([4, 6])
|
||||
.width(Fill)
|
||||
.style(style::nav)
|
||||
.on_press_maybe(event),
|
||||
text(format!(
|
||||
"{path}:{line}:{column} (1-based line and UTF-8 byte column)"
|
||||
))
|
||||
.size(11),
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Keep headings compact without copying the remainder of a long admitted value.
|
||||
fn preview(value: &str, limit: usize) -> String {
|
||||
let mut chars = value.chars();
|
||||
let mut result: String = chars
|
||||
.by_ref()
|
||||
.take(limit)
|
||||
.map(|ch| if ch.is_whitespace() { ' ' } else { ch })
|
||||
.collect();
|
||||
if chars.next().is_some() {
|
||||
result.push('…');
|
||||
}
|
||||
result
|
||||
}
|
||||
Reference in New Issue
Block a user