Compose compact Text workspace with guarded root navigation
This commit is contained in:
+120
-48
@@ -20,6 +20,7 @@ mod reference_authoring;
|
||||
mod review_navigation_tests;
|
||||
pub(crate) mod source_rename;
|
||||
mod source_symbols;
|
||||
pub(crate) mod text_workspace;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
#[cfg(test)]
|
||||
@@ -440,8 +441,7 @@ pub enum Message {
|
||||
IncludesLoaded(u64, Result<DiskProject, String>),
|
||||
Lint(u64),
|
||||
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
|
||||
SourceDiagnostic(usize),
|
||||
SourceRelated(SourceLocation),
|
||||
TextWorkspace(text_workspace::Event),
|
||||
SourceViewport(Option<Id>),
|
||||
SourceSymbols(
|
||||
crate::source_navigation::SymbolContext,
|
||||
@@ -537,6 +537,7 @@ pub struct App {
|
||||
pub source_navigation: crate::source_navigation::State,
|
||||
source_find_instance: Id,
|
||||
pub source_symbols: crate::source_navigation::SymbolState,
|
||||
pub text_workspace: text_workspace::State,
|
||||
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>,
|
||||
@@ -659,6 +660,7 @@ impl App {
|
||||
source_navigation: crate::source_navigation::State::default(),
|
||||
source_find_instance: Id::new_v4(),
|
||||
source_symbols: crate::source_navigation::SymbolState::default(),
|
||||
text_workspace: text_workspace::State::default(),
|
||||
source_rename: source_rename::State::default(),
|
||||
diagram_usages: None,
|
||||
diagram_navigation_focus: None,
|
||||
@@ -713,10 +715,35 @@ impl App {
|
||||
fn sync_source(&mut self) {
|
||||
self.sync_source_with_alias_editor(None);
|
||||
}
|
||||
fn publish_source_diagnostics(&mut self, diagnostics: Vec<SourceDiagnostic>) {
|
||||
self.source_diagnostics = diagnostics;
|
||||
self.text_workspace.publish_diagnostics(self.source_key());
|
||||
}
|
||||
/// A different source destination owns a new navigation/presentation session.
|
||||
/// Only diagnostics proven current before this identity-only change may be
|
||||
/// republished for it; stale source locations must not become fresh again.
|
||||
fn reset_source_navigation_session(&mut self) {
|
||||
let diagnostics_current = self
|
||||
.text_workspace
|
||||
.diagnostic_publication()
|
||||
.is_some_and(|publication| publication.source() == self.source_key());
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.clear_source_presentation();
|
||||
if diagnostics_current {
|
||||
self.publish_source_diagnostics(self.source_diagnostics.clone());
|
||||
}
|
||||
}
|
||||
fn clear_source_presentation(&mut self) {
|
||||
let in_flight = self.source_symbols.in_flight;
|
||||
self.source_symbols = crate::source_navigation::SymbolState::default();
|
||||
self.source_symbols.in_flight = in_flight;
|
||||
self.source_jump = None;
|
||||
self.text_workspace.reset();
|
||||
}
|
||||
fn sync_source_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
|
||||
self.source_workspace.accept(self.document.bundle());
|
||||
self.show_source_file_with_alias_editor(false, hint);
|
||||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||||
self.publish_source_diagnostics(self.document.compiled().diagnostics.clone());
|
||||
self.source_version += 1;
|
||||
self.lint_requested = None;
|
||||
}
|
||||
@@ -857,7 +884,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
Err(issues) => {
|
||||
self.source_diagnostics = issues;
|
||||
self.publish_source_diagnostics(issues);
|
||||
self.status = "Source contains errors. Your draft is preserved.".into();
|
||||
self.page = Page::Text;
|
||||
false
|
||||
@@ -1013,6 +1040,7 @@ impl App {
|
||||
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 menu_sequence = self.menu_sequence;
|
||||
let guide = std::mem::take(&mut self.guide);
|
||||
let mut hosted = std::mem::take(&mut self.hosted);
|
||||
hosted.leave_document();
|
||||
@@ -1027,6 +1055,7 @@ impl App {
|
||||
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.menu_sequence = menu_sequence;
|
||||
self.guide = guide;
|
||||
self.path = path;
|
||||
self.status = "Project ready".into();
|
||||
@@ -1124,7 +1153,7 @@ impl App {
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
let base = crate::context_menu::Popup::new(
|
||||
crate::views::view(self),
|
||||
crate::model_menu::popup(self),
|
||||
crate::model_menu::popup(self).or_else(|| self.text_workspace_menu()),
|
||||
)
|
||||
.into();
|
||||
crate::source_navigation::interaction::observe(
|
||||
@@ -1192,14 +1221,15 @@ impl App {
|
||||
// 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.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()),
|
||||
self.text_workspace.menu().is_none()
|
||||
&& (self.behavior.creation.has_pending()
|
||||
|| self.diagram_usages.is_some()
|
||||
|| self.behavior.source_active()
|
||||
|| (self.page == Page::Text
|
||||
&& (self.text_workspace.drawer().is_some()
|
||||
|| self.source_navigation.open
|
||||
|| self.source_rename.open())
|
||||
&& self.architecture.is_none())),
|
||||
)
|
||||
.map(|()| Message::Escape),
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
@@ -1249,6 +1279,11 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// Captured Text commands validate their origin before they can
|
||||
// invalidate that same origin or pass into existing command handlers.
|
||||
if let Message::TextWorkspace(event) = message {
|
||||
return self.text_workspace_event(event);
|
||||
}
|
||||
// These handlers reject old owners/tokens without changing state. Apply
|
||||
// the same admission before shared camera and allocation boundaries.
|
||||
if !self.source_input_envelope(&message)
|
||||
@@ -1411,6 +1446,7 @@ impl App {
|
||||
}
|
||||
message => message,
|
||||
};
|
||||
|
||||
// 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.
|
||||
@@ -1423,6 +1459,38 @@ impl App {
|
||||
if let Message::DiagramUsagesReady(completion) = message {
|
||||
return self.diagram_usages_ready(completion);
|
||||
}
|
||||
if self.text_workspace.menu().is_some() {
|
||||
if matches!(
|
||||
message,
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
) {
|
||||
self.text_workspace.dismiss_menu();
|
||||
self.source_interaction();
|
||||
return Task::none();
|
||||
}
|
||||
// Global subscriptions can receive captured keyboard input.
|
||||
// A Text menu owns those commands while passive work can finish.
|
||||
if !matches!(
|
||||
message,
|
||||
Message::CheckpointWritten(..)
|
||||
| Message::CheckpointLoaded(..)
|
||||
| Message::CheckpointTick
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::Saved(_)
|
||||
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||
| Message::Loaded(_)
|
||||
| Message::IncludesLoaded(..)
|
||||
| Message::SavePath(_)
|
||||
| Message::SourceRecovered(_)
|
||||
| Message::Exported(_)
|
||||
) {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if !matches!(
|
||||
&message,
|
||||
Message::CheckpointTick
|
||||
@@ -2190,11 +2258,11 @@ impl App {
|
||||
// Validate the originating canvas before advancing its input
|
||||
// epoch. Actions are then generated with the refreshed context,
|
||||
// while pointer history hidden behind the menu becomes stale.
|
||||
let Some(opened) = crate::model_menu::Opened::new(
|
||||
self,
|
||||
request,
|
||||
self.menu_sequence.wrapping_add(1),
|
||||
) else {
|
||||
let Some(token) = self.menu_sequence.checked_add(1) else {
|
||||
self.status = "Reopen the application to start a new menu session.".into();
|
||||
return Task::none();
|
||||
};
|
||||
let Some(opened) = crate::model_menu::Opened::new(self, request, token) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.connection = None;
|
||||
@@ -2248,7 +2316,8 @@ impl App {
|
||||
}
|
||||
Message::Escape => {
|
||||
let restore_source_focus = self.source_navigation_available()
|
||||
&& (self.source_navigation.open || self.source_symbols.open);
|
||||
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
|
||||
let dismiss_find_only = restore_source_focus && self.source_navigation.open;
|
||||
if self.behavior.source_back() {
|
||||
self.status =
|
||||
"Returned to the unchanged parent form; no source was created.".into();
|
||||
@@ -2264,15 +2333,11 @@ impl App {
|
||||
if self.connection.take().is_some() {
|
||||
self.status = "Drawing cancelled · Select / move is active".into();
|
||||
}
|
||||
if restore_source_focus && self.source_navigation.open {
|
||||
// Dismiss the active Find surface first. Its underlying
|
||||
// references remain available until a separate Escape.
|
||||
self.source_navigation.open = false;
|
||||
} else {
|
||||
self.source_navigation.open = false;
|
||||
self.source_symbols.open = false;
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
self.source_navigation.open = false;
|
||||
// Popup/modal priority was handled above. In the available Text
|
||||
// workspace, one Escape dismisses Find before the drawer beneath it.
|
||||
if !dismiss_find_only {
|
||||
self.text_workspace.show_drawer(None);
|
||||
}
|
||||
if restore_source_focus {
|
||||
self.restore_source_editor_focus();
|
||||
@@ -3260,7 +3325,7 @@ impl App {
|
||||
.edit(after.clone(), self.source_editor.cursor());
|
||||
self.source_snapshot = Arc::from(after);
|
||||
self.source_version += 1;
|
||||
self.source_diagnostics.clear();
|
||||
self.publish_source_diagnostics(Vec::new());
|
||||
let version = self.source_version;
|
||||
return Task::perform(
|
||||
async move {
|
||||
@@ -3325,8 +3390,13 @@ impl App {
|
||||
}
|
||||
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");
|
||||
let diagnostics = self
|
||||
.source_diagnostics
|
||||
.iter()
|
||||
.filter(|issue| issue.diagnostic.code != "pending_save")
|
||||
.cloned()
|
||||
.collect();
|
||||
self.publish_source_diagnostics(diagnostics);
|
||||
match receipt.disk {
|
||||
Ok(disk) if !self.source_workspace.applied => {
|
||||
self.open_source_project(disk)
|
||||
@@ -3338,7 +3408,7 @@ impl App {
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
if self.path.as_ref() != Some(&receipt.target) {
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.path = Some(receipt.target);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
@@ -3372,7 +3442,7 @@ impl App {
|
||||
self.source_workspace
|
||||
.discovered(&disk.bundle, disk.reachable.clone());
|
||||
self.source_version += 1;
|
||||
self.source_diagnostics = disk.diagnostics.clone();
|
||||
self.publish_source_diagnostics(disk.diagnostics.clone());
|
||||
self.disk_project = Some(disk);
|
||||
self.status = "Included files loaded; existing drafts retained.".into();
|
||||
return self.request_lint();
|
||||
@@ -3393,21 +3463,13 @@ impl App {
|
||||
self.lint_running = None;
|
||||
if version == self.source_version {
|
||||
match result {
|
||||
Ok(diagnostics) => self.source_diagnostics = diagnostics,
|
||||
Ok(diagnostics) => self.publish_source_diagnostics(diagnostics),
|
||||
Err(error) => self.status = format!("Source checks failed: {error}"),
|
||||
}
|
||||
}
|
||||
return self.start_pending_lint();
|
||||
}
|
||||
Message::SourceDiagnostic(index) => {
|
||||
if let Some(issue) = self.source_diagnostics.get(index) {
|
||||
self.jump_to_source(SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
});
|
||||
}
|
||||
}
|
||||
Message::SourceRelated(location) => self.jump_to_source(location),
|
||||
Message::TextWorkspace(_) => return Task::none(), // checked before generic input
|
||||
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),
|
||||
@@ -3449,7 +3511,7 @@ impl App {
|
||||
if self.source_workspace.undo(redo) {
|
||||
self.show_source_file(true);
|
||||
self.source_version += 1;
|
||||
self.source_diagnostics.clear();
|
||||
self.publish_source_diagnostics(Vec::new());
|
||||
return self.request_lint();
|
||||
}
|
||||
}
|
||||
@@ -3597,7 +3659,7 @@ impl App {
|
||||
self.sync_source();
|
||||
}
|
||||
if self.path.as_ref() != Some(&receipt.path) {
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.disk_project = Some(disk);
|
||||
self.path = Some(receipt.path);
|
||||
@@ -4139,7 +4201,7 @@ impl App {
|
||||
self.source_workspace
|
||||
.discovered(&disk.bundle, disk.reachable.clone());
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||||
self.publish_source_diagnostics(self.document.compiled().diagnostics.clone());
|
||||
self.migration = self.document.compiled().decoded.migration.clone();
|
||||
if self.migration.is_some() {
|
||||
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
|
||||
@@ -4159,9 +4221,10 @@ impl App {
|
||||
self.path = Some(path);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.source_workspace = Workspace::new(&disk.bundle, false);
|
||||
self.clear_source_presentation();
|
||||
self.source_workspace.set_reachable(disk.reachable.clone());
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = error.diagnostics().to_vec();
|
||||
self.publish_source_diagnostics(error.diagnostics().to_vec());
|
||||
self.source_version += 1;
|
||||
self.lint_requested = None;
|
||||
self.page = Page::Text;
|
||||
@@ -5226,7 +5289,16 @@ mod source_project_tests {
|
||||
assert_eq!(app.project(), &project);
|
||||
assert!(!app.apply_source());
|
||||
assert_eq!(app.source_diagnostics[0].path, fragment);
|
||||
send(&mut app, Message::SourceDiagnostic(0));
|
||||
send(&mut app, Message::Page(Page::Text));
|
||||
let _ = app.show_text_drawer(Some(text_workspace::DrawerTab::Problems));
|
||||
let issue = &app.source_diagnostics[0];
|
||||
let message = app
|
||||
.text_diagnostic_message(SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
})
|
||||
.unwrap();
|
||||
send(&mut app, message);
|
||||
assert_eq!(app.source_workspace.active, fragment);
|
||||
assert_eq!(app.source_editor.text(), "? invalid source");
|
||||
send(&mut app, Message::RevertSource);
|
||||
|
||||
@@ -219,7 +219,7 @@ impl App {
|
||||
|| session.connection.base() != request.connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection.clone(),
|
||||
@@ -359,7 +359,7 @@ impl App {
|
||||
|| session.connection.base() != connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection,
|
||||
|
||||
@@ -215,7 +215,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_admission(&self) -> Result<(), String> {
|
||||
pub(super) fn rename_admission(&self) -> Result<(), String> {
|
||||
if self.page != Page::Text
|
||||
|| self.busy
|
||||
|| self.pending.is_some()
|
||||
|
||||
@@ -359,7 +359,7 @@ fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arriv
|
||||
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);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
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);
|
||||
|
||||
@@ -164,3 +164,112 @@ fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() {
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_compact_drawer_controls_preserve_current_navigation_and_evidence() {
|
||||
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
|
||||
for changed in 0..3 {
|
||||
let mut app = navigation_fixture();
|
||||
app.text_workspace.show_drawer(Some(DrawerTab::Status));
|
||||
let origin = app.text_drawer_context();
|
||||
let captured = [
|
||||
Message::TextWorkspace(TextEvent::ShowDrawer(origin, DrawerTab::Problems)),
|
||||
Message::TextWorkspace(TextEvent::HideDrawer(origin)),
|
||||
crate::views::captured_text_drawer_resize(&app),
|
||||
];
|
||||
match changed {
|
||||
0 => app.canvas_document = Id::new_v4(),
|
||||
1 => app.source_workspace.reset_navigation_session(),
|
||||
_ => app.text_workspace.show_drawer(Some(DrawerTab::Status)),
|
||||
}
|
||||
let found = navigate(&mut app);
|
||||
// Keep command availability true to isolate the stale owner check from
|
||||
// the page guard, while a newer measured navigation is still pending.
|
||||
app.page = Page::Text;
|
||||
assert!(app.source_navigation_available());
|
||||
let destination = navigation(&app);
|
||||
let allocation = app.allocation_boundary();
|
||||
let camera = app.camera_boundary();
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let drawer = app.text_workspace.drawer();
|
||||
let height = app.text_workspace.drawer_height();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let status = app.status.clone();
|
||||
for message in captured {
|
||||
send(&mut app, message);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert!(app.allocation_boundary() == allocation);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.text_workspace.drawer(), drawer);
|
||||
assert_eq!(app.text_workspace.drawer_height(), height);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_eq!(app.status, status);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
app.page = Page::Diagram;
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_compact_drawer_controls_and_mounted_resize_remain_live() {
|
||||
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
|
||||
let mut app = navigation_fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let show = Message::TextWorkspace(TextEvent::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Status,
|
||||
));
|
||||
send(&mut app, show);
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status));
|
||||
let height = app.text_workspace.drawer_height();
|
||||
let resize = crate::views::captured_text_drawer_resize(&app);
|
||||
send(&mut app, resize);
|
||||
assert_eq!(app.text_workspace.drawer_height(), height + 60.);
|
||||
let hide = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
|
||||
send(&mut app, hide);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_diagram_usages_command_uses_captured_query_and_existing_chooser() {
|
||||
let mut app = navigation_fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let command = app.text_command_message(crate::app::text_workspace::Command::ShowDiagramUsages);
|
||||
send(&mut app, command);
|
||||
let request = app
|
||||
.source_symbols
|
||||
.request
|
||||
.clone()
|
||||
.expect("actual source query started");
|
||||
assert_eq!(app.source_diagram_job, Some(request.job));
|
||||
let completion = source_completion(&app, request);
|
||||
send(&mut app, Message::SourceSymbolsReady(completion));
|
||||
let panel = app
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.expect("existing guarded chooser opened");
|
||||
assert_eq!(panel.context.subject, model_id(2));
|
||||
assert_eq!(panel.context.document, app.canvas_document);
|
||||
assert_eq!(panel.context.revision, app.document.revision());
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(panel.token));
|
||||
assert!(!app.source_navigation_available());
|
||||
let (_, found) = finish_lookup(&mut app);
|
||||
assert_eq!(found.surface, usage::Surface::Saved(model_id(8)));
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ fn alias_editor_sync_fallback_restores_accepted_display_and_preserves_model_hist
|
||||
assert_ne!(app.source_navigation.label(), "0 matches");
|
||||
let mut invalid = original_source.clone();
|
||||
invalid.files.insert(path.clone(), "?".into());
|
||||
app.source_diagnostics = source_workspace::lint(&invalid);
|
||||
app.publish_source_diagnostics(source_workspace::lint(&invalid));
|
||||
assert!(!app.source_diagnostics.is_empty());
|
||||
let version = app.source_version;
|
||||
app.lint_requested = Some(version);
|
||||
@@ -839,7 +839,8 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
send(&mut app, Message::SourceRenameReady(ready.clone()));
|
||||
}
|
||||
app.source_navigation.open = true;
|
||||
app.source_symbols.open = true;
|
||||
app.text_workspace
|
||||
.show_drawer(Some(crate::app::text_workspace::DrawerTab::References));
|
||||
match other_modal {
|
||||
1 => app.hosted.visible = true,
|
||||
2 => {
|
||||
@@ -866,7 +867,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
}
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(
|
||||
app.source_symbols.open && app.source_navigation.open,
|
||||
app.text_workspace.drawer().is_some() && app.source_navigation.open,
|
||||
"rename Escape does not fall through"
|
||||
);
|
||||
if pending {
|
||||
@@ -875,7 +876,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
assert!(!app.source_rename.open());
|
||||
}
|
||||
assert_eq!(evidence(&app), before);
|
||||
app.source_symbols.open = false;
|
||||
app.text_workspace.show_drawer(None);
|
||||
app.source_navigation.open = false;
|
||||
assert!(captured_escape(&app).is_empty());
|
||||
}
|
||||
|
||||
@@ -33,9 +33,29 @@ fn admission_notice(error: query::AdmissionError) -> String {
|
||||
impl App {
|
||||
pub(super) fn source_input_envelope(&self, message: &Message) -> bool {
|
||||
match message {
|
||||
Message::SourceSymbols(context, _) => {
|
||||
Message::SourceSymbols(context, event) => {
|
||||
context.session == self.source_workspace.session()
|
||||
&& context.path == self.source_workspace.active
|
||||
&& match event {
|
||||
nav::SymbolEvent::Choose { stamp, visit, .. } => {
|
||||
self.text_workspace.drawer()
|
||||
== Some(text_workspace::DrawerTab::References)
|
||||
&& self.source_navigation_available()
|
||||
&& stamp.snapshot == self.source_key()
|
||||
&& *visit == self.text_workspace.drawer_visit()
|
||||
&& self
|
||||
.source_symbols
|
||||
.results
|
||||
.as_ref()
|
||||
.is_some_and(|results| results.stamp == *stamp)
|
||||
&& self
|
||||
.source_symbols
|
||||
.cache
|
||||
.as_ref()
|
||||
.is_some_and(|cache| self.source_snapshot_matches(cache))
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
Message::SourceFindClose(context, instance) => {
|
||||
context == &self.source_symbol_context()
|
||||
@@ -118,6 +138,7 @@ impl App {
|
||||
&& self.model_menu.is_none()
|
||||
&& self.diagram_usages.is_none()
|
||||
&& !self.reference_input_active()
|
||||
&& self.text_workspace.menu().is_none()
|
||||
&& self.architecture.is_none()
|
||||
&& !self.behavior.has_pending()
|
||||
}
|
||||
@@ -133,6 +154,12 @@ impl App {
|
||||
))
|
||||
}
|
||||
|
||||
/// Capture the same source-owned query used by the keyboard/menu adapter.
|
||||
pub(super) fn text_diagram_usages_message(&self) -> Option<Message> {
|
||||
self.source_navigation_available()
|
||||
.then(|| self.source_symbol_message(nav::SymbolEvent::DiagramUsages))
|
||||
}
|
||||
|
||||
pub fn source_reveal_ticket(&self) -> Option<Id> {
|
||||
self.source_symbols
|
||||
.reveal
|
||||
@@ -215,7 +242,8 @@ impl App {
|
||||
match event {
|
||||
nav::SymbolEvent::DiagramUsages => {
|
||||
if self.source_dirty() || !self.source_workspace.applied {
|
||||
self.source_symbols.open = true;
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into());
|
||||
return Task::none();
|
||||
}
|
||||
@@ -228,20 +256,30 @@ impl App {
|
||||
return task;
|
||||
}
|
||||
nav::SymbolEvent::Close => {
|
||||
self.source_symbols.open = false;
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
return self.show_text_drawer(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();
|
||||
if self.source_symbols.notice.is_some() {
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
let refs = self.text_workspace.references();
|
||||
return iced::widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
iced::widget::scrollable::AbsoluteOffset {
|
||||
x: 0.,
|
||||
y: refs.scroll_y(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
nav::SymbolEvent::Choose {
|
||||
stamp,
|
||||
reference,
|
||||
index,
|
||||
..
|
||||
} => {
|
||||
let Some(results) = self.source_symbols.results.clone() else {
|
||||
return Task::none();
|
||||
@@ -279,7 +317,8 @@ impl App {
|
||||
}
|
||||
|
||||
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
|
||||
self.source_symbols.open = true;
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
if !nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
@@ -300,7 +339,7 @@ impl App {
|
||||
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
|
||||
Some(serial) => serial,
|
||||
None => {
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
self.source_symbols.back.clear();
|
||||
self.source_symbols.cache = None;
|
||||
0
|
||||
@@ -340,9 +379,12 @@ impl App {
|
||||
};
|
||||
self.source_symbols.in_flight = Some(request.job);
|
||||
self.source_symbols.request = Some(request.clone());
|
||||
self.source_symbols.display_request = Some(request.clone());
|
||||
self.text_workspace
|
||||
.begin_references(request.stamp, request.path.clone());
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
Task::perform(
|
||||
let worker = Task::perform(
|
||||
async move {
|
||||
let worker_request = request.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
@@ -373,7 +415,14 @@ impl App {
|
||||
nav::Completion { request, result }
|
||||
},
|
||||
Message::SourceSymbolsReady,
|
||||
)
|
||||
);
|
||||
Task::batch([
|
||||
worker,
|
||||
iced::widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
iced::widget::scrollable::AbsoluteOffset { x: 0., y: 0. },
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
|
||||
@@ -386,11 +435,11 @@ impl App {
|
||||
if diagram_query {
|
||||
self.source_diagram_job = None;
|
||||
}
|
||||
let wanted = self
|
||||
let wanted_navigation = self
|
||||
.source_symbols
|
||||
.request
|
||||
.take()
|
||||
.is_some_and(|current| current.job == request.job)
|
||||
.is_some_and(|current| current == request)
|
||||
&& request.interaction == self.source_symbols.interaction
|
||||
&& request.stamp.snapshot == self.source_key()
|
||||
&& nav::interaction::is_current(
|
||||
@@ -400,15 +449,34 @@ impl App {
|
||||
&& request.path == self.source_workspace.active
|
||||
&& request.cursor == self.source_editor.cursor()
|
||||
&& self.source_navigation_available();
|
||||
if !wanted {
|
||||
let wanted_display = self
|
||||
.source_symbols
|
||||
.display_request
|
||||
.as_ref()
|
||||
.is_some_and(|current| current == &request)
|
||||
&& request.stamp.snapshot == self.source_key();
|
||||
if !wanted_display {
|
||||
return Task::none();
|
||||
}
|
||||
match completion.result {
|
||||
Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => {
|
||||
Ok((snapshot, results))
|
||||
if self.source_snapshot_matches(&snapshot)
|
||||
&& results.stamp == request.stamp
|
||||
&& results.action == request.action =>
|
||||
{
|
||||
self.source_symbols.cache = Some(snapshot);
|
||||
self.source_symbols.results = Some(results.clone());
|
||||
if diagram_query {
|
||||
if results.disposition == query::Disposition::Direct
|
||||
self.text_workspace.install_reference_files(
|
||||
results.stamp,
|
||||
results
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|row| row.path.clone())
|
||||
.chain(results.references.iter().map(|row| row.path.clone())),
|
||||
);
|
||||
if diagram_query && wanted_navigation {
|
||||
if wanted_navigation
|
||||
&& results.disposition == query::Disposition::Direct
|
||||
&& results.scope.validated
|
||||
&& results.omitted_definitions == 0
|
||||
&& let [row] = results.definitions.as_slice()
|
||||
@@ -419,7 +487,7 @@ impl App {
|
||||
&& crate::diagram_usage::alias(self.project(), identity.id())
|
||||
== row.declaration.alias
|
||||
{
|
||||
self.source_symbols.open = false;
|
||||
self.text_workspace.show_drawer(None);
|
||||
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
|
||||
crate::diagram_usage::Context {
|
||||
document: self.canvas_document,
|
||||
@@ -431,7 +499,8 @@ impl App {
|
||||
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
|
||||
if wanted_navigation
|
||||
&& results.disposition == query::Disposition::Direct
|
||||
&& let Some(row) = results.definitions.first()
|
||||
{
|
||||
self.source_symbol_jump(&row.path, row.position, row.end_position);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ fn scoped_escape_retains_references_then_closes_them_without_losing_effective_se
|
||||
send(&mut app, messages.remove(0));
|
||||
assert!(!app.source_navigation.open);
|
||||
assert_eq!(app.source_navigation.query, "retain 雪");
|
||||
assert!(app.source_symbols.open);
|
||||
assert!(references_visible(&app));
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
@@ -40,7 +40,7 @@ fn scoped_escape_retains_references_then_closes_them_without_losing_effective_se
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
assert!(!app.source_symbols.open);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert!(app.source_symbols.results.is_none() && app.source_symbols.notice.is_none());
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
//! Text-only presentation and captured command envelopes. Source/model authority
|
||||
//! remains in the existing App controllers; dispatch this envelope before the
|
||||
//! generic source_interaction invalidation in App::update.
|
||||
use super::{App, Message};
|
||||
use crate::{context_menu, source_navigation as nav};
|
||||
use iced::{
|
||||
Point, Task,
|
||||
widget::{self, text_editor::Cursor},
|
||||
};
|
||||
use nav::query::{QueryStamp, SnapshotKey};
|
||||
use oa_core::{Id, source_project::SourceLocation};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DrawerTab {
|
||||
References,
|
||||
Problems,
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Find,
|
||||
GoToLine,
|
||||
Definition,
|
||||
References,
|
||||
RenameAlias,
|
||||
Back,
|
||||
UndoText,
|
||||
RedoText,
|
||||
UndoModel,
|
||||
RedoModel,
|
||||
UndoRename,
|
||||
RevertFile,
|
||||
ShowProblems,
|
||||
ShowStatus,
|
||||
ShowDiagramUsages,
|
||||
}
|
||||
impl Command {
|
||||
fn shortcut(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Find => Some("Ctrl F"),
|
||||
Self::GoToLine => Some("Ctrl G"),
|
||||
Self::Definition => Some("F12"),
|
||||
Self::References => Some("Shift F12"),
|
||||
Self::RenameAlias => Some("F2"),
|
||||
Self::Back => Some("Alt ←"),
|
||||
Self::UndoText => Some("Ctrl Z"),
|
||||
Self::RedoText => Some("Ctrl Y"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Find => "Find in file",
|
||||
Self::GoToLine => "Go to line",
|
||||
Self::Definition => "Go to definition",
|
||||
Self::References => "Find references",
|
||||
Self::RenameAlias => "Rename source alias",
|
||||
Self::Back => "Back in source",
|
||||
Self::UndoText => "Undo text edit",
|
||||
Self::RedoText => "Redo text edit",
|
||||
Self::UndoModel => "Undo model change",
|
||||
Self::RedoModel => "Redo model change",
|
||||
Self::UndoRename => "Undo source rename",
|
||||
Self::RevertFile => "Revert current file",
|
||||
Self::ShowProblems => "Source problems",
|
||||
Self::ShowStatus => "Source status",
|
||||
Self::ShowDiagramUsages => "Show diagram usages",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CommandContext {
|
||||
document: Id,
|
||||
document_revision: u64,
|
||||
source: SnapshotKey,
|
||||
path: String,
|
||||
cursor: Cursor,
|
||||
interaction: Id,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandRequest {
|
||||
context: CommandContext,
|
||||
command: Command,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MenuRequest {
|
||||
context: CommandContext,
|
||||
anchor: Point,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DiagnosticPublication {
|
||||
generation: Id,
|
||||
source: SnapshotKey,
|
||||
}
|
||||
impl DiagnosticPublication {
|
||||
pub fn generation(self) -> Id {
|
||||
self.generation
|
||||
}
|
||||
pub fn source(self) -> SnapshotKey {
|
||||
self.source
|
||||
}
|
||||
pub fn target(self, visit: Id, location: SourceLocation) -> DiagnosticTarget {
|
||||
DiagnosticTarget {
|
||||
publication: self,
|
||||
visit,
|
||||
location,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiagnosticTarget {
|
||||
publication: DiagnosticPublication,
|
||||
visit: Id,
|
||||
location: SourceLocation,
|
||||
}
|
||||
|
||||
/// Authority captured by the rendered drawer, independent of its source rows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DrawerContext {
|
||||
pub document: Id,
|
||||
pub session: Id,
|
||||
pub visit: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
ShowDrawer(DrawerContext, DrawerTab),
|
||||
HideDrawer(DrawerContext),
|
||||
ResizeDrawer(DrawerContext, f32),
|
||||
ToggleFile {
|
||||
stamp: QueryStamp,
|
||||
visit: Id,
|
||||
path: String,
|
||||
},
|
||||
ReferenceScrolled {
|
||||
stamp: QueryStamp,
|
||||
visit: Id,
|
||||
y: f32,
|
||||
},
|
||||
Command(CommandRequest),
|
||||
OpenMenu(MenuRequest),
|
||||
DismissMenu(Id),
|
||||
Invoke {
|
||||
token: Id,
|
||||
command: Command,
|
||||
},
|
||||
Diagnostic(DiagnosticTarget),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FrozenEntry {
|
||||
command: Command,
|
||||
action: Option<Action>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
enum Action {
|
||||
Dispatch(Box<Message>),
|
||||
Drawer(DrawerTab),
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Menu {
|
||||
token: Id,
|
||||
popup_token: u64,
|
||||
anchor: Point,
|
||||
context: CommandContext,
|
||||
entries: Vec<FrozenEntry>,
|
||||
}
|
||||
impl Menu {
|
||||
pub fn token(&self) -> Id {
|
||||
self.token
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReferencePresentation {
|
||||
stamp: Option<QueryStamp>,
|
||||
origin: String,
|
||||
files: BTreeSet<String>,
|
||||
installed: bool,
|
||||
collapsed_files: BTreeSet<String>,
|
||||
scroll_y: f32,
|
||||
}
|
||||
impl Default for ReferencePresentation {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stamp: None,
|
||||
origin: String::new(),
|
||||
files: BTreeSet::new(),
|
||||
installed: false,
|
||||
collapsed_files: BTreeSet::new(),
|
||||
scroll_y: 0.,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ReferencePresentation {
|
||||
pub fn stamp(&self) -> Option<QueryStamp> {
|
||||
self.stamp
|
||||
}
|
||||
pub fn scroll_y(&self) -> f32 {
|
||||
self.scroll_y
|
||||
}
|
||||
pub fn collapsed(&self, path: &str) -> bool {
|
||||
self.collapsed_files.contains(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
drawer: Option<DrawerTab>,
|
||||
drawer_visit: Id,
|
||||
drawer_height: f32,
|
||||
references: ReferencePresentation,
|
||||
menu: Option<Menu>,
|
||||
diagnostic_publication: Option<DiagnosticPublication>,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
drawer: None,
|
||||
drawer_visit: Id::new_v4(),
|
||||
drawer_height: 180.,
|
||||
references: ReferencePresentation::default(),
|
||||
menu: None,
|
||||
diagnostic_publication: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub fn drawer_visit(&self) -> Id {
|
||||
self.drawer_visit
|
||||
}
|
||||
pub fn reference_scroll_id(&self) -> widget::Id {
|
||||
widget::Id::from(format!("text-references-{}", self.drawer_visit))
|
||||
}
|
||||
pub fn drawer(&self) -> Option<DrawerTab> {
|
||||
self.drawer
|
||||
}
|
||||
pub fn drawer_height(&self) -> f32 {
|
||||
self.drawer_height
|
||||
}
|
||||
pub fn references(&self) -> &ReferencePresentation {
|
||||
&self.references
|
||||
}
|
||||
pub fn menu(&self) -> Option<&Menu> {
|
||||
self.menu.as_ref()
|
||||
}
|
||||
pub fn diagnostic_publication(&self) -> Option<DiagnosticPublication> {
|
||||
self.diagnostic_publication
|
||||
}
|
||||
/// Call for EVERY diagnostics replacement or clear, including equal bytes.
|
||||
pub fn publish_diagnostics(&mut self, source: SnapshotKey) -> DiagnosticPublication {
|
||||
let publication = DiagnosticPublication {
|
||||
generation: Id::new_v4(),
|
||||
source,
|
||||
};
|
||||
self.diagnostic_publication = Some(publication);
|
||||
publication
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
pub fn dismiss_menu(&mut self) {
|
||||
self.menu = None;
|
||||
}
|
||||
pub fn begin_references(&mut self, stamp: QueryStamp, origin: String) {
|
||||
self.references = ReferencePresentation {
|
||||
stamp: Some(stamp),
|
||||
origin,
|
||||
..ReferencePresentation::default()
|
||||
};
|
||||
self.show_drawer(Some(DrawerTab::References));
|
||||
}
|
||||
/// Install presentation groups only for the current completed query. Repeated
|
||||
/// delivery of the same completion cannot undo the user's group choices.
|
||||
pub fn install_reference_files(
|
||||
&mut self,
|
||||
stamp: QueryStamp,
|
||||
paths: impl IntoIterator<Item = String>,
|
||||
) {
|
||||
let refs = &mut self.references;
|
||||
if refs.stamp != Some(stamp) || refs.installed {
|
||||
return;
|
||||
}
|
||||
refs.files = paths.into_iter().collect();
|
||||
refs.collapsed_files = refs.files.clone();
|
||||
let expanded = if refs.files.contains(&refs.origin) {
|
||||
Some(refs.origin.clone())
|
||||
} else {
|
||||
refs.files.first().cloned()
|
||||
};
|
||||
if let Some(path) = expanded {
|
||||
refs.collapsed_files.remove(&path);
|
||||
}
|
||||
refs.installed = true;
|
||||
}
|
||||
pub(super) fn show_drawer(&mut self, drawer: Option<DrawerTab>) {
|
||||
self.drawer = drawer;
|
||||
self.drawer_visit = Id::new_v4();
|
||||
}
|
||||
fn toggle_file(&mut self, stamp: QueryStamp, visit: Id, path: String) -> bool {
|
||||
let refs = &mut self.references;
|
||||
if self.drawer != Some(DrawerTab::References)
|
||||
|| self.drawer_visit != visit
|
||||
|| refs.stamp != Some(stamp)
|
||||
|| !refs.files.contains(&path)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !refs.collapsed_files.remove(&path) {
|
||||
refs.collapsed_files.insert(path);
|
||||
}
|
||||
// Collapsing a group can replace a pressed row at the same position.
|
||||
self.drawer_visit = Id::new_v4();
|
||||
true
|
||||
}
|
||||
fn reference_scrolled(&mut self, stamp: QueryStamp, visit: Id, y: f32) {
|
||||
let refs = &mut self.references;
|
||||
if self.drawer == Some(DrawerTab::References)
|
||||
&& refs.stamp == Some(stamp)
|
||||
&& self.drawer_visit == visit
|
||||
&& y.is_finite()
|
||||
&& y >= 0.
|
||||
{
|
||||
refs.scroll_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn text_drawer_context(&self) -> DrawerContext {
|
||||
DrawerContext {
|
||||
document: self.canvas_document,
|
||||
session: self.source_workspace.session(),
|
||||
visit: self.text_workspace.drawer_visit(),
|
||||
}
|
||||
}
|
||||
fn text_command_context(&self) -> CommandContext {
|
||||
CommandContext {
|
||||
document: self.canvas_document,
|
||||
document_revision: self.document.revision(),
|
||||
source: self.source_key(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
cursor: self.source_editor.cursor(),
|
||||
interaction: self.source_symbols.interaction,
|
||||
}
|
||||
}
|
||||
pub fn text_command_message(&self, command: Command) -> Message {
|
||||
Message::TextWorkspace(Event::Command(CommandRequest {
|
||||
context: self.text_command_context(),
|
||||
command,
|
||||
}))
|
||||
}
|
||||
pub fn text_menu_message(&self, anchor: Point) -> Message {
|
||||
Message::TextWorkspace(Event::OpenMenu(MenuRequest {
|
||||
context: self.text_command_context(),
|
||||
anchor,
|
||||
}))
|
||||
}
|
||||
pub fn text_workspace_menu(&self) -> Option<context_menu::Menu<Message>> {
|
||||
let menu = self.text_workspace.menu.as_ref()?;
|
||||
Some(context_menu::Menu {
|
||||
token: menu.popup_token,
|
||||
anchor: menu.anchor,
|
||||
entries: menu
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|entry| {
|
||||
let mut rows = Vec::with_capacity(2);
|
||||
if matches!(entry.command, Command::UndoText | Command::ShowProblems) {
|
||||
rows.push(context_menu::Entry::separator());
|
||||
}
|
||||
rows.push(context_menu::Entry::Action {
|
||||
label: entry.command.label().into(),
|
||||
shortcut: entry.command.shortcut().map(str::to_owned),
|
||||
message: entry.action.as_ref().map(|_| {
|
||||
Message::TextWorkspace(Event::Invoke {
|
||||
token: menu.token(),
|
||||
command: entry.command,
|
||||
})
|
||||
}),
|
||||
});
|
||||
rows
|
||||
})
|
||||
.collect(),
|
||||
on_close: Message::TextWorkspace(Event::DismissMenu(menu.token())),
|
||||
})
|
||||
}
|
||||
pub fn text_diagnostic_message(&self, location: SourceLocation) -> Option<Message> {
|
||||
let publication = self.text_workspace.diagnostic_publication?;
|
||||
(publication.source == self.source_key()
|
||||
&& self.text_workspace.drawer() == Some(DrawerTab::Problems))
|
||||
.then(|| {
|
||||
Message::TextWorkspace(Event::Diagnostic(
|
||||
publication.target(self.text_workspace.drawer_visit(), location),
|
||||
))
|
||||
})
|
||||
}
|
||||
fn text_command_action(&self, command: Command) -> Option<Action> {
|
||||
// Menu has been taken before dispatch-time admission. This intentionally
|
||||
// uses navigation admission, not rename admission, for Find/text history.
|
||||
if !self.source_navigation_available() {
|
||||
return None;
|
||||
}
|
||||
let message =
|
||||
match command {
|
||||
Command::Find => Message::SourceNavigation(nav::Event::Open(false)),
|
||||
Command::GoToLine => Message::SourceNavigation(nav::Event::Open(true)),
|
||||
Command::Definition => self
|
||||
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::Definition)),
|
||||
Command::References => self
|
||||
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::References)),
|
||||
Command::RenameAlias => {
|
||||
self.rename_admission().ok()?;
|
||||
self.source_rename_message()
|
||||
}
|
||||
Command::Back => {
|
||||
if !self
|
||||
.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.any(|entry| entry.session == self.source_workspace.session())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.source_symbol_message(nav::SymbolEvent::Back)
|
||||
}
|
||||
Command::UndoText => Message::SourceUndo,
|
||||
Command::RedoText => Message::SourceRedo,
|
||||
Command::UndoModel | Command::RedoModel => {
|
||||
if self.source_dirty() || self.draft_dirty() {
|
||||
return None;
|
||||
}
|
||||
if command == Command::UndoModel {
|
||||
if !self.document.can_undo() {
|
||||
return None;
|
||||
}
|
||||
Message::Undo
|
||||
} else {
|
||||
if !self.document.can_redo() {
|
||||
return None;
|
||||
}
|
||||
Message::Redo
|
||||
}
|
||||
}
|
||||
Command::UndoRename => self.source_rename_undo_message()?,
|
||||
Command::RevertFile => Message::RevertSource,
|
||||
Command::ShowProblems => return Some(Action::Drawer(DrawerTab::Problems)),
|
||||
Command::ShowStatus => return Some(Action::Drawer(DrawerTab::Status)),
|
||||
Command::ShowDiagramUsages => self.text_diagram_usages_message()?,
|
||||
};
|
||||
Some(Action::Dispatch(Box::new(message)))
|
||||
}
|
||||
pub(super) fn text_workspace_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::OpenMenu(request) => {
|
||||
if request.context != self.text_command_context()
|
||||
|| !self.source_navigation_available()
|
||||
|| self.text_workspace.menu.is_some()
|
||||
|| !request.anchor.x.is_finite()
|
||||
|| !request.anchor.y.is_finite()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(popup_token) = self.menu_sequence.checked_add(1) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.source_interaction();
|
||||
let commands = [
|
||||
Command::Find,
|
||||
Command::GoToLine,
|
||||
Command::Definition,
|
||||
Command::References,
|
||||
Command::RenameAlias,
|
||||
Command::Back,
|
||||
Command::UndoText,
|
||||
Command::RedoText,
|
||||
Command::UndoRename,
|
||||
Command::UndoModel,
|
||||
Command::RedoModel,
|
||||
Command::RevertFile,
|
||||
Command::ShowProblems,
|
||||
Command::ShowStatus,
|
||||
Command::ShowDiagramUsages,
|
||||
];
|
||||
let entries = commands
|
||||
.into_iter()
|
||||
.filter_map(|command| {
|
||||
let action = self.text_command_action(command);
|
||||
if command == Command::ShowDiagramUsages && action.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(FrozenEntry { command, action })
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.menu_sequence = popup_token;
|
||||
self.text_workspace.menu = Some(Menu {
|
||||
token: Id::new_v4(),
|
||||
popup_token,
|
||||
anchor: request.anchor,
|
||||
context: self.text_command_context(),
|
||||
entries,
|
||||
});
|
||||
}
|
||||
Event::DismissMenu(token) => {
|
||||
if self
|
||||
.text_workspace
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.token == token)
|
||||
{
|
||||
self.text_workspace.menu = None;
|
||||
self.source_interaction();
|
||||
}
|
||||
}
|
||||
Event::Invoke { token, command } => {
|
||||
if !self.text_workspace.menu.as_ref().is_some_and(|menu| {
|
||||
menu.token == token && menu.context == self.text_command_context()
|
||||
}) {
|
||||
return Task::none();
|
||||
}
|
||||
// Validate before closing; consume this invocation only once.
|
||||
// Never resolve a frozen row against a newly generated menu.
|
||||
let menu = self.text_workspace.menu.take().unwrap();
|
||||
let Some(action) = menu
|
||||
.entries
|
||||
.into_iter()
|
||||
.find(|entry| entry.command == command)
|
||||
.and_then(|entry| entry.action)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
if self.text_command_action(command).is_none() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.dispatch_text_action(action);
|
||||
}
|
||||
Event::Command(request) => {
|
||||
if request.context != self.text_command_context() {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(action) = self.text_command_action(request.command) {
|
||||
return self.dispatch_text_action(action);
|
||||
}
|
||||
}
|
||||
Event::ShowDrawer(context, tab) => {
|
||||
if context != self.text_drawer_context() || !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.show_text_drawer(Some(tab));
|
||||
}
|
||||
Event::HideDrawer(context) => {
|
||||
if context != self.text_drawer_context() || !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.show_text_drawer(None);
|
||||
}
|
||||
Event::ResizeDrawer(context, height) => {
|
||||
if context == self.text_drawer_context()
|
||||
&& self.source_navigation_available()
|
||||
&& height.is_finite()
|
||||
{
|
||||
// The view applies the measured editor-allocation bound.
|
||||
let height = height.clamp(180., 1200.);
|
||||
if height != self.text_workspace.drawer_height {
|
||||
self.source_interaction();
|
||||
self.text_workspace.drawer_height = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ToggleFile { stamp, visit, path } => {
|
||||
if self.source_navigation_available()
|
||||
&& self.text_workspace.toggle_file(stamp, visit, path)
|
||||
{
|
||||
self.source_interaction();
|
||||
}
|
||||
}
|
||||
Event::ReferenceScrolled { stamp, visit, y } => {
|
||||
// Passive geometry must neither invalidate pending navigation
|
||||
// nor reset the editor identity, even while commands are blocked.
|
||||
self.text_workspace.reference_scrolled(stamp, visit, y);
|
||||
}
|
||||
Event::Diagnostic(target) => {
|
||||
if !self.source_navigation_available()
|
||||
|| self.text_workspace.drawer() != Some(DrawerTab::Problems)
|
||||
|| self.text_workspace.drawer_visit() != target.visit
|
||||
|| self.text_workspace.diagnostic_publication != Some(target.publication)
|
||||
|| target.publication.source != self.source_key()
|
||||
|| !self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(&target.location.path)
|
||||
.is_some_and(|file| valid_span(&file.text, &target.location))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.source_interaction();
|
||||
self.jump_to_source(target.location);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn dispatch_text_action(&mut self, action: Action) -> Task<Message> {
|
||||
match action {
|
||||
Action::Dispatch(message) => self.update(*message),
|
||||
Action::Drawer(tab) => self.show_text_drawer(Some(tab)),
|
||||
}
|
||||
}
|
||||
pub(super) fn show_text_drawer(&mut self, drawer: Option<DrawerTab>) -> Task<Message> {
|
||||
self.source_interaction();
|
||||
self.text_workspace.show_drawer(drawer);
|
||||
if drawer == Some(DrawerTab::References) {
|
||||
let refs = self.text_workspace.references();
|
||||
widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
widget::scrollable::AbsoluteOffset {
|
||||
x: 0.,
|
||||
y: refs.scroll_y(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_span(text: &str, location: &SourceLocation) -> bool {
|
||||
let span = location.span;
|
||||
span.start <= span.end
|
||||
&& span.end <= text.len()
|
||||
&& text.is_char_boundary(span.start)
|
||||
&& text.is_char_boundary(span.end)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifecycle_tests;
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Real lifecycle receipts, with all disk writes confined to unique temp roots.
|
||||
//! Returned App worker tasks are not polled; completions use the normal captured
|
||||
//! query input and are delivered deterministically through App::update.
|
||||
use super::*;
|
||||
use crate::app::{Page, read_snapshot, write_snapshot};
|
||||
use crate::document::Document;
|
||||
use crate::source_workspace::{LIMITS, Workspace};
|
||||
use oa_core::{
|
||||
Project,
|
||||
language::{Diagnostic, Span},
|
||||
source_project::{SourceBundle, SourceDiagnostic},
|
||||
validation::Severity,
|
||||
};
|
||||
use std::{collections::BTreeMap, path::Path, sync::Arc};
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project model \"Lifecycle 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn fixture() -> App {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), VALID.into())]),
|
||||
};
|
||||
let mut app = App::with_project(Project::empty());
|
||||
// with_project uses in-memory Home metadata and no recovery directory.
|
||||
assert!(app.recovery.directory.is_none());
|
||||
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.page = Page::Text;
|
||||
app.show_source_file(false);
|
||||
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
fn save(app: &mut App, path: &Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
send(app, Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
fn reveal(app: &mut App) {
|
||||
if let Some(ticket) = app.source_reveal_ticket() {
|
||||
send(app, Message::SourceViewport(Some(ticket)));
|
||||
}
|
||||
}
|
||||
fn query(app: &mut App, action: nav::query::Action) -> nav::Completion {
|
||||
let message = app.source_symbol_message(nav::SymbolEvent::Query(action));
|
||||
send(app, message);
|
||||
let request = app.source_symbols.request.clone().expect("admitted query");
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
let snapshot = Arc::new(app.with_source_input(|input| {
|
||||
nav::query::admit_owned(input, nav::query::Limits::default())
|
||||
.unwrap()
|
||||
.capture()
|
||||
.unwrap()
|
||||
}));
|
||||
let results = Arc::new(
|
||||
snapshot
|
||||
.query(nav::query::Query {
|
||||
stamp: request.stamp,
|
||||
path: &request.path,
|
||||
byte_offset: request.offset,
|
||||
action,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
nav::Completion {
|
||||
request,
|
||||
result: Ok((snapshot, results)),
|
||||
}
|
||||
}
|
||||
fn seed_navigation(app: &mut App, pending: bool) -> Option<nav::Completion> {
|
||||
let text = app.source_workspace.text();
|
||||
let start = text.find("oa_activity assess").unwrap() + "oa_activity ".len();
|
||||
let prefix = &text[..start];
|
||||
let position = widget::text_editor::Position {
|
||||
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
|
||||
column: prefix.rsplit_once('\n').unwrap().1.len(),
|
||||
};
|
||||
app.source_editor.move_to(Cursor {
|
||||
position,
|
||||
selection: None,
|
||||
});
|
||||
let ready = query(app, nav::query::Action::Definition);
|
||||
send(app, Message::SourceSymbolsReady(ready));
|
||||
reveal(app);
|
||||
assert_eq!(app.source_symbols.back.len(), 1);
|
||||
let ready = query(app, nav::query::Action::References);
|
||||
send(app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.results.is_some() && app.source_symbols.cache.is_some());
|
||||
assert!(app.source_symbols.display_request.is_some());
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
|
||||
if pending {
|
||||
Some(query(app, nav::query::Action::Definition))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn warning(app: &App) -> SourceDiagnostic {
|
||||
let source = app.source_workspace.text();
|
||||
let primary = source.find("model").unwrap();
|
||||
let related = source.find("assess").unwrap();
|
||||
SourceDiagnostic {
|
||||
path: app.source_workspace.active.clone(),
|
||||
diagnostic: Diagnostic {
|
||||
severity: Severity::Warning,
|
||||
code: "lifecycle_warning".into(),
|
||||
object_id: None,
|
||||
field: None,
|
||||
line: 2,
|
||||
column: 9,
|
||||
message: "Retained warning with a related source location".into(),
|
||||
span: Span {
|
||||
start: primary,
|
||||
end: primary + 5,
|
||||
},
|
||||
},
|
||||
related: vec![SourceLocation {
|
||||
path: app.source_workspace.active.clone(),
|
||||
span: Span {
|
||||
start: related,
|
||||
end: related + 6,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
fn warning_messages(app: &App) -> [Message; 2] {
|
||||
let warning = &app.source_diagnostics[0];
|
||||
[
|
||||
app.text_diagnostic_message(SourceLocation {
|
||||
path: warning.path.clone(),
|
||||
span: warning.diagnostic.span,
|
||||
})
|
||||
.unwrap(),
|
||||
app.text_diagnostic_message(warning.related[0].clone())
|
||||
.unwrap(),
|
||||
]
|
||||
}
|
||||
fn assert_cleared(app: &App, job: Option<Id>) {
|
||||
assert_eq!(app.source_symbols.in_flight, job);
|
||||
assert!(app.source_symbols.request.is_none() && app.source_symbols.display_request.is_none());
|
||||
assert!(app.source_symbols.results.is_none() && app.source_symbols.cache.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.text_workspace.drawer().is_none() && app.text_workspace.menu().is_none());
|
||||
assert!(app.text_workspace.references().stamp().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_new_destination_republishes_current_warning_and_refuses_old_primary_and_related() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let mut app = fixture();
|
||||
save(&mut app, &first_path);
|
||||
let ready = seed_navigation(&mut app, true).unwrap();
|
||||
let job = ready.request.job;
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old = warning_messages(&app);
|
||||
let warnings = app.source_diagnostics.clone();
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let source = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let raw = app.source_workspace.bundle();
|
||||
save(&mut app, &second_path);
|
||||
assert_eq!(app.path.as_deref(), Some(second_path.as_path()));
|
||||
assert_ne!(app.source_key().workspace, source.workspace);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(std::fs::read_to_string(&first_path).unwrap(), VALID);
|
||||
assert_eq!(std::fs::read_to_string(&second_path).unwrap(), VALID);
|
||||
assert_eq!(app.source_diagnostics, warnings);
|
||||
let current = app
|
||||
.text_workspace
|
||||
.diagnostic_publication()
|
||||
.expect("identity-only save retained current warnings");
|
||||
assert_eq!(current.source(), app.source_key());
|
||||
assert_ne!(current.generation(), publication.generation());
|
||||
assert_cleared(&app, Some(job));
|
||||
for message in old {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
send(&mut app, message);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
}
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
for message in warning_messages(&app) {
|
||||
send(&mut app, message);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"fresh primary and related warnings remain actionable"
|
||||
);
|
||||
reveal(&mut app);
|
||||
}
|
||||
let cursor = app.source_editor.cursor();
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_repair_open_clears_prior_presentation_and_publishes_current_problems_preserving_worker()
|
||||
{
|
||||
for pending in [false, true] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("broken.arcadia");
|
||||
let invalid = format!(
|
||||
"{} include \"missing.arcadia\";\r\n}}\r\n",
|
||||
VALID.strip_suffix("}\r\n").unwrap()
|
||||
);
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
let mut app = fixture();
|
||||
let ready = seed_navigation(&mut app, pending);
|
||||
let job = ready.as_ref().map(|ready| ready.request.job);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old = warning_messages(&app);
|
||||
let old_publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let old_source = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let open_menu = app.text_menu_message(Point::new(20., 20.));
|
||||
send(&mut app, open_menu);
|
||||
assert!(app.text_workspace.menu().is_some());
|
||||
let loaded = read_snapshot(path.clone()).unwrap();
|
||||
send(&mut app, Message::Loaded(Ok(Some(loaded))));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert_eq!(app.path.as_deref(), Some(path.as_path()));
|
||||
assert_ne!(app.source_key().workspace, old_source.workspace);
|
||||
assert_eq!(app.source_workspace.text(), invalid);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_cleared(&app, job);
|
||||
assert!(
|
||||
app.source_diagnostics
|
||||
.iter()
|
||||
.any(|issue| issue.diagnostic.severity == Severity::Error)
|
||||
);
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_eq!(publication.source(), app.source_key());
|
||||
assert_ne!(publication.generation(), old_publication.generation());
|
||||
for message in old {
|
||||
send(&mut app, message);
|
||||
}
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let issue = app
|
||||
.source_diagnostics
|
||||
.iter()
|
||||
.find(|issue| {
|
||||
issue.path == app.source_workspace.active
|
||||
&& issue.diagnostic.span.end <= invalid.len()
|
||||
})
|
||||
.unwrap();
|
||||
let current = app
|
||||
.text_diagnostic_message(SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
})
|
||||
.unwrap();
|
||||
send(&mut app, current);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
reveal(&mut app);
|
||||
if let Some(ready) = ready {
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
}
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.text(), invalid);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), invalid);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceled_or_failed_open_that_installs_nothing_retains_current_presentation_and_diagnostics() {
|
||||
for failed in [false, true] {
|
||||
let mut app = fixture();
|
||||
seed_navigation(&mut app, false);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
let results = app.source_symbols.results.clone().unwrap();
|
||||
let cache = app.source_symbols.cache.clone().unwrap();
|
||||
let display = app.source_symbols.display_request.clone();
|
||||
let back = app
|
||||
.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.session,
|
||||
entry.path.clone(),
|
||||
entry.generation,
|
||||
entry.cursor,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let key = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let publication = app.text_workspace.diagnostic_publication();
|
||||
let diagnostics = app.source_diagnostics.clone();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
let result = if failed {
|
||||
Err("Fixture read failed before installing a workspace".into())
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
send(&mut app, Message::Loaded(result));
|
||||
assert_eq!(app.source_key(), key);
|
||||
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
|
||||
assert_eq!(app.source_diagnostics, diagnostics);
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.cache.as_ref().unwrap(),
|
||||
&cache
|
||||
));
|
||||
assert_eq!(app.source_symbols.display_request, display);
|
||||
assert_eq!(
|
||||
app.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.map(|entry| (
|
||||
entry.session,
|
||||
entry.path.clone(),
|
||||
entry.generation,
|
||||
entry.cursor
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
back
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let mut app = fixture();
|
||||
save(&mut app, &first_path);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old = warning_messages(&app);
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let draft = format!("{VALID}// unsaved current draft\r\n");
|
||||
app.source_workspace
|
||||
.edit(draft.clone(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
assert_ne!(publication.source(), app.source_key());
|
||||
let accepted = app.document.snapshot();
|
||||
save(&mut app, &second_path);
|
||||
assert!(
|
||||
app.text_workspace.diagnostic_publication().is_none(),
|
||||
"identity-only reset cannot turn an old source span into a current warning"
|
||||
);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert!(
|
||||
app.text_diagnostic_message(SourceLocation {
|
||||
path: app.source_diagnostics[0].path.clone(),
|
||||
span: app.source_diagnostics[0].diagnostic.span,
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
for message in old {
|
||||
send(&mut app, message);
|
||||
}
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.text(), draft);
|
||||
assert!(app.source_dirty());
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&second_path).unwrap(),
|
||||
VALID,
|
||||
"receipt records accepted bytes while the newer draft remains unsaved"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
use super::*;
|
||||
use crate::app::Page;
|
||||
use oa_core::{Project, language::Span};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fixture() -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.page = Page::Text;
|
||||
let diagnostics = app.source_diagnostics.clone();
|
||||
app.publish_source_diagnostics(diagnostics);
|
||||
assert!(app.source_navigation_available());
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
// Exercise the real envelope path; worker/lint futures are deliberately not
|
||||
// polled here. The parent owns serialized compile/runtime verification.
|
||||
let _ = app.update(Message::TextWorkspace(event));
|
||||
}
|
||||
fn toggle(app: &App, stamp: QueryStamp, path: String) -> Event {
|
||||
Event::ToggleFile {
|
||||
stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
path,
|
||||
}
|
||||
}
|
||||
fn open(app: &mut App) -> Id {
|
||||
let message = app.text_menu_message(Point::new(10., 20.));
|
||||
let _ = app.update(message);
|
||||
app.text_workspace.menu().unwrap().token()
|
||||
}
|
||||
fn invoke(app: &mut App, token: Id, command: Command) {
|
||||
send(app, Event::Invoke { token, command });
|
||||
}
|
||||
fn enabled(app: &App, command: Command) -> bool {
|
||||
app.text_workspace
|
||||
.menu
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.command == command)
|
||||
.is_some_and(|entry| entry.action.is_some())
|
||||
}
|
||||
fn back_entry(app: &App) -> nav::HistoryEntry {
|
||||
nav::HistoryEntry {
|
||||
session: app.source_workspace.session(),
|
||||
path: app.source_workspace.active.clone(),
|
||||
generation: app.source_workspace.files[&app.source_workspace.active].generation(),
|
||||
cursor: app.source_editor.cursor(),
|
||||
}
|
||||
}
|
||||
fn stamp(app: &App, serial: u64) -> QueryStamp {
|
||||
QueryStamp {
|
||||
snapshot: app.source_key(),
|
||||
serial,
|
||||
}
|
||||
}
|
||||
fn location(app: &App, start: usize, end: usize) -> SourceLocation {
|
||||
SourceLocation {
|
||||
path: app.source_workspace.active.clone(),
|
||||
span: Span { start, end },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_own_open_invalidation_does_not_stale_find_and_invoke_is_once() {
|
||||
let mut app = fixture();
|
||||
let old_interaction = app.source_symbols.interaction;
|
||||
let token = open(&mut app);
|
||||
assert_ne!(old_interaction, app.source_symbols.interaction);
|
||||
assert_eq!(
|
||||
app.text_workspace.menu.as_ref().unwrap().context,
|
||||
app.text_command_context()
|
||||
);
|
||||
assert!(enabled(&app, Command::Find));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(app.source_navigation.open);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
let instance = app.source_find_instance;
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert_eq!(
|
||||
app.source_find_instance, instance,
|
||||
"replay must not reopen Find"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_entry_cannot_become_a_new_action_under_the_same_menu_token() {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
assert!(!enabled(&app, Command::Back));
|
||||
app.source_symbols.back.push(back_entry(&app));
|
||||
assert!(
|
||||
!enabled(&app, Command::Back),
|
||||
"frozen presentation must remain disabled"
|
||||
);
|
||||
invoke(&mut app, token, Command::Back);
|
||||
assert_eq!(
|
||||
app.source_symbols.back.len(),
|
||||
1,
|
||||
"a newly enabled action cannot be substituted"
|
||||
);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_entry_is_readmitted_at_dispatch_and_blocked_by_busy() {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
assert!(enabled(&app, Command::Find));
|
||||
app.busy = true;
|
||||
assert!(enabled(&app, Command::Find));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(!app.source_navigation.open);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_dismiss_and_invoke_do_not_consume_a_newer_menu() {
|
||||
let mut app = fixture();
|
||||
let old = open(&mut app);
|
||||
send(&mut app, Event::DismissMenu(old));
|
||||
let new = open(&mut app);
|
||||
send(&mut app, Event::DismissMenu(old));
|
||||
invoke(&mut app, old, Command::Find);
|
||||
assert_eq!(app.text_workspace.menu().unwrap().token(), new);
|
||||
assert!(!app.source_navigation.open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_source_caret_document_path_and_interaction_changes_refuse_stale_find() {
|
||||
for change in 0..6 {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
match change {
|
||||
0 => {
|
||||
let value = format!("{}\n// changed", app.source_workspace.text());
|
||||
app.source_workspace.edit(value, app.source_editor.cursor());
|
||||
}
|
||||
1 => {
|
||||
let mut cursor = app.source_editor.cursor();
|
||||
cursor.position.column += 1;
|
||||
app.source_editor.move_to(cursor);
|
||||
}
|
||||
2 => app.canvas_document = Id::new_v4(),
|
||||
3 => app.source_workspace.active = "other.arcadia".into(),
|
||||
4 => app.source_symbols.interaction = Id::new_v4(),
|
||||
_ => {
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.push_str("\n// accepted revision");
|
||||
assert!(app.document.apply_bundle(bundle).unwrap());
|
||||
}
|
||||
}
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(!app.source_navigation.open, "stale context case {change}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_and_property_drafts_keep_find_and_text_undo_without_model_application() {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let pending_name = "Pending property draft";
|
||||
// Use the actual property input route before source edits coexist. Changing
|
||||
// Draft::value alone is overwritten by its authoritative raw input buffer.
|
||||
let _ = app.update(Message::Field("/name".into(), pending_name.into()));
|
||||
let assert_pending_property = |app: &App| {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert!(draft.dirty());
|
||||
assert_eq!(draft.inputs["/name"].raw, pending_name);
|
||||
assert_eq!(draft.inputs["/name"].editor.text(), pending_name);
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap()["name"].as_str(),
|
||||
Some(pending_name)
|
||||
);
|
||||
};
|
||||
assert_pending_property(&app);
|
||||
let initial = app.source_workspace.text().to_owned();
|
||||
app.source_workspace
|
||||
.edit(format!("{initial}\n// draft"), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
assert!(app.source_dirty() && app.draft_dirty());
|
||||
let token = open(&mut app);
|
||||
assert!(enabled(&app, Command::Find) && enabled(&app, Command::UndoText));
|
||||
assert!(!enabled(&app, Command::RenameAlias) && !enabled(&app, Command::UndoModel));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(app.source_navigation.open);
|
||||
assert_pending_property(&app);
|
||||
let token = open(&mut app);
|
||||
invoke(&mut app, token, Command::UndoText);
|
||||
assert_eq!(app.source_workspace.text(), initial);
|
||||
assert_pending_property(&app);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_toolbar_request_cannot_retarget_after_source_change() {
|
||||
let mut app = fixture();
|
||||
let request = app.text_command_message(Command::Find);
|
||||
app.source_workspace
|
||||
.edit("// changed".into(), app.source_editor.cursor());
|
||||
let _ = app.update(request);
|
||||
assert!(!app.source_navigation.open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_diagnostic_republication_invalidates_primary_and_related_targets() {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let primary = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
let related = app.text_diagnostic_message(location(&app, 1, 2)).unwrap();
|
||||
let previous = app.text_workspace.diagnostic_publication().unwrap();
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
let current = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_eq!(previous.source(), current.source());
|
||||
assert_ne!(previous.generation(), current.generation());
|
||||
for message in [primary, related] {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_diagnostic_target_navigates_but_changed_source_or_clear_refuses_it() {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
app.source_interaction();
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
app.source_workspace
|
||||
.edit("// newer".into(), app.source_editor.cursor());
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
assert!(app.text_diagnostic_message(location(&app, 0, 1)).is_none());
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_span_must_be_loaded_ordered_and_utf8_exact() {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
app.source_workspace
|
||||
.edit("a雨b".into(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let mut missing = location(&app, 0, 1);
|
||||
missing.path = "missing.arcadia".into();
|
||||
for target in [
|
||||
location(&app, 3, 2),
|
||||
location(&app, 0, 6),
|
||||
location(&app, 2, 4),
|
||||
location(&app, 1, 3),
|
||||
missing,
|
||||
] {
|
||||
let message = app.text_diagnostic_message(target).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
let message = app.text_diagnostic_message(location(&app, 1, 4)).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_groups_and_scroll_survive_tabs_and_reject_old_visits_and_queries() {
|
||||
let mut app = fixture();
|
||||
let first = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(first, "b/use.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(first, ["a/use.arcadia".into(), "b/use.arcadia".into()]);
|
||||
assert!(app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
assert!(!app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
let event = toggle(&app, first, "a/use.arcadia".into());
|
||||
send(&mut app, event);
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
let old_scroll_id = app.text_workspace.reference_scroll_id();
|
||||
let interaction = app.source_symbols.interaction;
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 123.,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_symbols.interaction, interaction,
|
||||
"scroll is passive"
|
||||
);
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Status);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 0.,
|
||||
},
|
||||
);
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
|
||||
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 0.,
|
||||
},
|
||||
);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
|
||||
assert_ne!(app.text_workspace.reference_scroll_id(), old_scroll_id);
|
||||
let second = stamp(&app, 2);
|
||||
app.text_workspace
|
||||
.begin_references(second, "absent.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(first, ["old.arcadia".into()]);
|
||||
app.text_workspace
|
||||
.install_reference_files(second, ["b/use.arcadia".into(), "a/use.arcadia".into()]);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 0.);
|
||||
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
let event = toggle(&app, first, "b/use.arcadia".into());
|
||||
send(&mut app, event);
|
||||
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_removes_ui_and_publication_authority_and_resize_rejects_nan() {
|
||||
let mut app = fixture();
|
||||
open(&mut app);
|
||||
app.text_workspace.reset();
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
assert!(app.text_workspace.diagnostic_publication().is_none());
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.text_workspace.drawer_height(), 180.);
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::MAX);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.text_workspace.drawer_height(), 1200.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_valid_explicit_group_and_resize_intent_cancel_pending_navigation() {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(current, "uses.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, ["uses.arcadia".into()]);
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: app.source_symbols.interaction,
|
||||
raw_interaction: 0,
|
||||
stamp: current,
|
||||
path: app.source_workspace.active.clone(),
|
||||
offset: 0,
|
||||
cursor: app.source_editor.cursor(),
|
||||
action: nav::query::Action::References,
|
||||
};
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
app.source_symbols.in_flight = Some(request.job);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let event = toggle(&app, current, "not-a-group.arcadia".into());
|
||||
send(&mut app, event);
|
||||
let old = QueryStamp {
|
||||
serial: 0,
|
||||
..current
|
||||
};
|
||||
let event = toggle(&app, old, "uses.arcadia".into());
|
||||
send(&mut app, event);
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), 180.);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.request.is_some());
|
||||
let event = toggle(&app, current, "uses.arcadia".into());
|
||||
send(&mut app, event);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_ne!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(
|
||||
app.source_symbols.in_flight,
|
||||
Some(request.job),
|
||||
"intent does not release an occupied worker"
|
||||
);
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), 240.);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
{
|
||||
let captured = Event::HideDrawer(app.text_drawer_context());
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(current));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_retained_group_can_expand_without_navigating_or_applying_drafts() {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
let path = app.source_workspace.active.clone();
|
||||
app.text_workspace.begin_references(current, path.clone());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, [path.clone()]);
|
||||
let event = toggle(&app, current, path.clone());
|
||||
send(&mut app, event);
|
||||
assert!(app.text_workspace.references().collapsed(&path));
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::SourceEdit(widget::text_editor::Action::Edit(
|
||||
widget::text_editor::Edit::Insert(' '),
|
||||
)));
|
||||
assert_ne!(current.snapshot, app.source_key());
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let event = toggle(&app, current, path.clone());
|
||||
send(&mut app, event);
|
||||
assert!(!app.text_workspace.references().collapsed(&path));
|
||||
assert_eq!(app.source_workspace.active, path);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_symbols.back.is_empty());
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_reset_preserves_popup_token_sequence_and_drops_old_menu_authority() {
|
||||
let mut app = fixture();
|
||||
let old = open(&mut app);
|
||||
let sequence = app.text_workspace_menu().unwrap().token;
|
||||
app.reset(Project::empty(), None);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
app.page = Page::Text;
|
||||
let fresh = open(&mut app);
|
||||
assert_ne!(old, fresh);
|
||||
assert!(app.text_workspace_menu().unwrap().token > sequence);
|
||||
invoke(&mut app, old, Command::Find);
|
||||
assert!(!app.source_navigation.open);
|
||||
assert_eq!(app.text_workspace.menu().unwrap().token(), fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_primary_and_related_targets_cannot_replay_after_hide_or_problems_references_problems() {
|
||||
for hide in [false, true] {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
let publication = app.text_workspace.diagnostic_publication();
|
||||
let targets = [location(&app, 0, 1), location(&app, 1, 2)];
|
||||
let old = targets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|target| app.text_diagnostic_message(target).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let history = (
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty(),
|
||||
);
|
||||
let event = if hide {
|
||||
Event::HideDrawer(app.text_drawer_context())
|
||||
} else {
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References)
|
||||
};
|
||||
send(&mut app, event);
|
||||
for reopened in [false, true] {
|
||||
if reopened {
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
} else {
|
||||
assert!(app.text_diagnostic_message(targets[0].clone()).is_none());
|
||||
}
|
||||
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
|
||||
assert_eq!(
|
||||
app.text_workspace.diagnostic_publication(),
|
||||
publication,
|
||||
"same list and source isolate the visit guard from publication freshness"
|
||||
);
|
||||
let context = app.text_command_context();
|
||||
let drawer = app.text_workspace.drawer();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
for message in old.iter().cloned() {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
app.text_command_context(),
|
||||
context,
|
||||
"stale diagnostic must fail before interaction invalidation"
|
||||
);
|
||||
assert_eq!(app.text_workspace.drawer(), drawer);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(
|
||||
(
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty()
|
||||
),
|
||||
history
|
||||
);
|
||||
}
|
||||
}
|
||||
for target in targets {
|
||||
let fresh = app.text_diagnostic_message(target).unwrap();
|
||||
let _ = app.update(fresh);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"fresh primary and related targets remain usable"
|
||||
);
|
||||
app.source_interaction();
|
||||
}
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() {
|
||||
for replacement in 0..3 {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(current, "a.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, ["a.arcadia".into(), "b.arcadia".into()]);
|
||||
let old = toggle(&app, current, "a.arcadia".into());
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
match replacement {
|
||||
0 => {
|
||||
{
|
||||
let captured = Event::HideDrawer(app.text_drawer_context());
|
||||
send(&mut app, captured);
|
||||
};
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
let event = toggle(&app, current, "b.arcadia".into());
|
||||
send(&mut app, event);
|
||||
}
|
||||
}
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
assert_ne!(visit, old_visit);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: current,
|
||||
visit,
|
||||
y: 84.,
|
||||
},
|
||||
);
|
||||
let scroll_id = app.text_workspace.reference_scroll_id();
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: app.source_symbols.interaction,
|
||||
raw_interaction: 0,
|
||||
stamp: current,
|
||||
path: app.source_workspace.active.clone(),
|
||||
offset: 0,
|
||||
cursor: app.source_editor.cursor(),
|
||||
action: nav::query::Action::References,
|
||||
};
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
app.source_symbols.in_flight = Some(request.job);
|
||||
let groups =
|
||||
["a.arcadia", "b.arcadia"].map(|path| app.text_workspace.references().collapsed(path));
|
||||
let context = app.text_command_context();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
for _ in 0..2 {
|
||||
send(&mut app, old.clone());
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 84.);
|
||||
assert_eq!(app.text_workspace.reference_scroll_id(), scroll_id);
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(current));
|
||||
assert_eq!(
|
||||
["a.arcadia", "b.arcadia"]
|
||||
.map(|path| app.text_workspace.references().collapsed(path)),
|
||||
groups,
|
||||
);
|
||||
assert_eq!(app.text_command_context(), context);
|
||||
assert_eq!(app.source_symbols.request.as_ref(), Some(&request));
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
}
|
||||
let fresh = toggle(&app, current, "a.arcadia".into());
|
||||
send(&mut app, fresh);
|
||||
assert_eq!(
|
||||
app.text_workspace.references().collapsed("a.arcadia"),
|
||||
!groups[0]
|
||||
);
|
||||
assert_ne!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_ne!(app.source_symbols.interaction, context.interaction);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Publication-and-visit-bound input for a mounted diagnostic list.
|
||||
//!
|
||||
//! Keep this wrapper at the same widget-tree position, inside the list's outer
|
||||
//! scroller. Pass `None` with an empty child while hidden/removed; unmounting the
|
||||
//! wrapper loses its ability to consume an outstanding release. Every change to
|
||||
//! row identity/order/action must have a fresh publication Id. Pass the pair
|
||||
//! (publication generation, drawer visit): a new visit resets armed controls even
|
||||
//! if Hide and Show coalesce without an intermediate hidden Tree. The child tree
|
||||
//! resets when either component changes, clearing button presses and row focus;
|
||||
//! the outer scroller and editor trees are not reset.
|
||||
//!
|
||||
//! Capturing stops Iced event bubbling and later consumers that respect capture.
|
||||
//! It cannot undo an earlier sibling's event handling. This is a row-list guard,
|
||||
//! not a root event filter or a guard for actions in child overlays. Diagnostic
|
||||
//! messages still need publication/snapshot and drawer-visit validation at dispatch.
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Vector, mouse, touch};
|
||||
use oa_core::Id;
|
||||
|
||||
pub struct Guard<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> {
|
||||
identity: Option<(Id, Id)>,
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> Guard<'a, Message, Theme, Renderer> {
|
||||
pub fn new(
|
||||
identity: Option<(Id, Id)>,
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
identity,
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Pointer {
|
||||
Mouse,
|
||||
Touch(touch::Finger),
|
||||
}
|
||||
impl Pointer {
|
||||
fn press(event: &Event) -> Option<Self> {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => Some(Self::Mouse),
|
||||
Event::Touch(touch::Event::FingerPressed { id, .. }) => Some(Self::Touch(*id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn release(event: &Event) -> Option<Self> {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => Some(Self::Mouse),
|
||||
Event::Touch(
|
||||
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
|
||||
) => Some(Self::Touch(*id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct State {
|
||||
identity: Option<(Id, Id)>,
|
||||
pressed: Option<Pointer>,
|
||||
swallowed: Vec<Pointer>,
|
||||
}
|
||||
impl State {
|
||||
fn quarantine(&mut self, pointer: Pointer) {
|
||||
if !self.swallowed.contains(&pointer) {
|
||||
self.swallowed.push(pointer);
|
||||
}
|
||||
}
|
||||
fn forget(&mut self, pointer: Pointer) -> bool {
|
||||
let found = self.swallowed.contains(&pointer);
|
||||
self.swallowed.retain(|item| *item != pointer);
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer: renderer::Renderer> Widget<Message, Theme, Renderer>
|
||||
for Guard<'_, Message, Theme, Renderer>
|
||||
{
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(State {
|
||||
identity: self.identity,
|
||||
pressed: None,
|
||||
swallowed: Vec::new(),
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(&self.content)]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
if state.identity != self.identity {
|
||||
state.identity = self.identity;
|
||||
if let Some(pointer) = state.pressed.take() {
|
||||
state.quarantine(pointer);
|
||||
}
|
||||
tree.children = self.children();
|
||||
} else {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.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: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
let release = Pointer::release(event);
|
||||
if let Some(pointer) = release {
|
||||
// Bounds may have become empty or moved. Consume the physical
|
||||
// gesture's terminal event regardless of its current position.
|
||||
if state.forget(pointer) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
// Iced Button has a single is_pressed bit, not a pointer identity.
|
||||
// A different finger/mouse release must not activate that button.
|
||||
if state.pressed.is_some_and(|pressed| pressed != pointer) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
let press = Pointer::press(event);
|
||||
if let Some(pointer) = state.pressed.filter(|pointer| {
|
||||
press == Some(*pointer) || (release == Some(*pointer) && shell.is_event_captured())
|
||||
}) {
|
||||
// Recover an omitted terminal event, or cancel one intercepted by
|
||||
// an earlier consumer. An off-target mouse release / lost touch
|
||||
// clears Iced Button's pressed state without rebuilding this
|
||||
// publication's tree (which would also discard row focus).
|
||||
state.pressed = None;
|
||||
let cancel = match pointer {
|
||||
Pointer::Mouse => Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Pointer::Touch(id) => Event::Touch(touch::Event::FingerLost {
|
||||
id,
|
||||
position: iced::Point::ORIGIN,
|
||||
}),
|
||||
};
|
||||
let mut discarded = Vec::new();
|
||||
let mut cancellation = Shell::new(&mut discarded);
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&cancel,
|
||||
layout,
|
||||
mouse::Cursor::Unavailable,
|
||||
renderer,
|
||||
clipboard,
|
||||
&mut cancellation,
|
||||
viewport,
|
||||
);
|
||||
// Cancellation must not publish an action or capture the fresh
|
||||
// down. Preserve rendering requests from the state change only.
|
||||
shell.request_redraw();
|
||||
if cancellation.is_layout_invalid() {
|
||||
shell.invalidate_layout();
|
||||
}
|
||||
if cancellation.are_widgets_invalid() {
|
||||
shell.invalidate_widgets();
|
||||
}
|
||||
}
|
||||
if shell.is_event_captured() || self.identity.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(pointer) = press {
|
||||
// A fresh down starts a new gesture even if the platform omitted a
|
||||
// previous release (for example while the window lacked focus).
|
||||
state.forget(pointer);
|
||||
if let Some(pressed) = state.pressed {
|
||||
if pressed != pointer {
|
||||
state.quarantine(pointer);
|
||||
}
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
let was_pressed = release.is_some() && release == state.pressed;
|
||||
if was_pressed {
|
||||
state.pressed = None;
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
if let Some(pointer) = press
|
||||
&& shell.is_event_captured()
|
||||
{
|
||||
state.pressed = Some(pointer);
|
||||
}
|
||||
if was_pressed {
|
||||
// FingerLost cancels Iced Button state but does not itself capture.
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
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.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
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.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut widget::Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message: 'a, Theme: 'a, Renderer: renderer::Renderer + 'a>
|
||||
From<Guard<'a, Message, Theme, Renderer>> for Element<'a, Message, Theme, Renderer>
|
||||
{
|
||||
fn from(guard: Guard<'a, Message, Theme, Renderer>) -> Self {
|
||||
Element::new(guard)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,626 @@
|
||||
use super::*;
|
||||
use iced::advanced::widget::operation::{self, Focusable, Scrollable};
|
||||
use iced::widget::{Space, button, column, container, mouse_area, scrollable, text_input};
|
||||
use iced::{Point, Theme, event, keyboard};
|
||||
|
||||
type View = Element<'static, Message, Theme, ()>;
|
||||
const BOUNDS: Rectangle = Rectangle {
|
||||
x: 0.,
|
||||
y: 0.,
|
||||
width: 400.,
|
||||
height: 300.,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Message {
|
||||
Primary(u8),
|
||||
Related(u8),
|
||||
BackgroundButton,
|
||||
BackgroundRelease,
|
||||
Submit(u8),
|
||||
Edit,
|
||||
}
|
||||
|
||||
fn rows(version: u8) -> View {
|
||||
column![
|
||||
button(Space::new())
|
||||
.width(200.)
|
||||
.height(40.)
|
||||
.padding(0)
|
||||
.on_press(Message::Primary(version)),
|
||||
button(Space::new())
|
||||
.width(200.)
|
||||
.height(40.)
|
||||
.padding(0)
|
||||
.on_press(Message::Related(version)),
|
||||
]
|
||||
.into()
|
||||
}
|
||||
fn guarded(generation: Option<Id>, version: u8) -> View {
|
||||
let child: View = if generation.is_some() {
|
||||
rows(version)
|
||||
} else {
|
||||
Space::new().into()
|
||||
};
|
||||
Guard::new(
|
||||
generation.map(|publication| (publication, Id::nil())),
|
||||
child,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
fn press() -> Event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
}
|
||||
fn release() -> Event {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
}
|
||||
fn enter() -> Event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Enter),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::Enter),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Enter),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn touch_down(id: u64, position: Point) -> Event {
|
||||
Event::Touch(touch::Event::FingerPressed {
|
||||
id: touch::Finger(id),
|
||||
position,
|
||||
})
|
||||
}
|
||||
fn touch_up(id: u64, position: Point) -> Event {
|
||||
Event::Touch(touch::Event::FingerLifted {
|
||||
id: touch::Finger(id),
|
||||
position,
|
||||
})
|
||||
}
|
||||
fn touch_lost(id: u64, position: Point) -> Event {
|
||||
Event::Touch(touch::Event::FingerLost {
|
||||
id: touch::Finger(id),
|
||||
position,
|
||||
})
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
view: View,
|
||||
tree: widget::Tree,
|
||||
node: layout::Node,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(mut view: View) -> Self {
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, BOUNDS.size()),
|
||||
);
|
||||
Self { view, tree, node }
|
||||
}
|
||||
fn replace(&mut self, mut view: View) {
|
||||
self.tree.diff(view.as_widget());
|
||||
self.node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, BOUNDS.size()),
|
||||
);
|
||||
self.view = view;
|
||||
}
|
||||
fn event(&mut self, event: Event, point: Point) -> (Vec<Message>, event::Status) {
|
||||
self.event_with_capture(event, point, false)
|
||||
}
|
||||
fn event_with_capture(
|
||||
&mut self,
|
||||
event: Event,
|
||||
point: Point,
|
||||
captured: bool,
|
||||
) -> (Vec<Message>, event::Status) {
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
if captured {
|
||||
shell.capture_event();
|
||||
}
|
||||
self.view.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&event,
|
||||
Layout::new(&self.node),
|
||||
mouse::Cursor::Available(point),
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&BOUNDS,
|
||||
);
|
||||
let status = shell.event_status();
|
||||
(messages, status)
|
||||
}
|
||||
fn operate(&mut self, operation: &mut dyn widget::Operation) {
|
||||
self.view
|
||||
.as_widget_mut()
|
||||
.operate(&mut self.tree, Layout::new(&self.node), &(), operation);
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.operate(&mut operation::focusable::focus::<()>("row".into()));
|
||||
}
|
||||
fn focused(&mut self) -> bool {
|
||||
#[derive(Default)]
|
||||
struct Focus(bool);
|
||||
impl widget::Operation for Focus {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
state: &mut dyn Focusable,
|
||||
) {
|
||||
self.0 |= state.is_focused();
|
||||
}
|
||||
}
|
||||
let mut focus = Focus::default();
|
||||
self.operate(&mut focus);
|
||||
focus.0
|
||||
}
|
||||
fn scroll(&mut self) -> Vector {
|
||||
#[derive(Default)]
|
||||
struct Offset(Option<Vector>);
|
||||
impl widget::Operation for Offset {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn Scrollable,
|
||||
) {
|
||||
self.0 = Some(translation);
|
||||
}
|
||||
}
|
||||
let mut offset = Offset::default();
|
||||
self.operate(&mut offset);
|
||||
offset.0.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_primary_and_related_mouse_clicks_survive_same_publication_diff() {
|
||||
for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] {
|
||||
let generation = Id::new_v4();
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(generation), 1));
|
||||
assert_eq!(ui.event(press(), point), (vec![], event::Status::Captured));
|
||||
ui.replace(guarded(Some(generation), 1));
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_publication_new_visit_cancels_armed_rows_without_a_hidden_tree_frame() {
|
||||
for touch in [false, true] {
|
||||
for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] {
|
||||
let publication = Id::new_v4();
|
||||
let old_visit = Id::new_v4();
|
||||
let new_visit = Id::new_v4();
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(Guard::new(Some((publication, old_visit)), rows(1)).into());
|
||||
let down = || if touch { touch_down(7, point) } else { press() };
|
||||
let up = || if touch { touch_up(7, point) } else { release() };
|
||||
assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured));
|
||||
// Hide→Show has already happened in application state. No None
|
||||
// publication or empty child is ever diffed into this retained Tree.
|
||||
ui.replace(Guard::new(Some((publication, new_visit)), rows(2)).into());
|
||||
assert_eq!(ui.event(up(), point), (vec![], event::Status::Captured));
|
||||
assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured));
|
||||
assert_eq!(
|
||||
ui.event(up(), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_control_iced_button_retargets_a_press_after_plain_tree_diff() {
|
||||
for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(rows(1));
|
||||
assert!(ui.event(press(), point).0.is_empty());
|
||||
ui.replace(rows(2));
|
||||
assert_eq!(ui.event(release(), point).0, vec![expected]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_primary_and_related_rows_consume_stale_release_then_accept_new_click() {
|
||||
for y in [20., 60.] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(press(), point);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
ui.replace(guarded(Some(Id::new_v4()), 3));
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
ui.event(press(), point);
|
||||
let expected = if y < 40. {
|
||||
Message::Primary(3)
|
||||
} else {
|
||||
Message::Related(3)
|
||||
};
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_zero_size_list_consumes_release_outside_old_bounds_once() {
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(press(), Point::new(20., 20.));
|
||||
ui.replace(guarded(None, 0));
|
||||
ui.replace(guarded(None, 0));
|
||||
assert_eq!(ui.node.size(), Size::ZERO);
|
||||
let outside = Point::new(350., 250.);
|
||||
assert_eq!(
|
||||
ui.event(release(), outside),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(release(), outside),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
assert_eq!(ui.event(release(), Point::new(20., 20.)).0, vec![]);
|
||||
}
|
||||
|
||||
fn with_background(content: View) -> View {
|
||||
mouse_area(
|
||||
button(container(content).width(250.).height(100.))
|
||||
.padding(0)
|
||||
.on_press(Message::BackgroundButton),
|
||||
)
|
||||
.on_release(Message::BackgroundRelease)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_release_does_not_bubble_to_real_background_controls() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(with_background(guarded(Some(Id::new_v4()), 1)));
|
||||
ui.event(press(), point);
|
||||
ui.replace(with_background(guarded(None, 0)));
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
// The enclosing real button still supports a fresh ordinary click.
|
||||
ui.event(press(), point);
|
||||
assert_eq!(
|
||||
ui.event(release(), point).0,
|
||||
vec![Message::BackgroundButton]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_control_removing_unguarded_rows_exposes_background_release() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(with_background(rows(1)));
|
||||
ui.event(press(), point);
|
||||
ui.replace(with_background(Space::new().into()));
|
||||
assert_eq!(
|
||||
ui.event(release(), point).0,
|
||||
vec![Message::BackgroundRelease]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_touch_activates_primary_and_related_and_cancel_does_not_activate() {
|
||||
for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
assert_eq!(
|
||||
ui.event(touch_down(1, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(1, point), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
ui.event(touch_down(2, point), point);
|
||||
assert_eq!(
|
||||
ui.event(touch_lost(2, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert!(ui.event(touch_up(2, point), point).0.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_touch_consumes_lift_or_cancel_even_when_hidden() {
|
||||
for hide in [false, true] {
|
||||
for lost in [false, true] {
|
||||
for y in [20., 60.] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(touch_down(1, point), point);
|
||||
ui.replace(guarded(if hide { None } else { Some(Id::new_v4()) }, 2));
|
||||
let end = if lost {
|
||||
touch_lost(1, point)
|
||||
} else {
|
||||
touch_up(1, point)
|
||||
};
|
||||
assert_eq!(ui.event(end, point), (vec![], event::Status::Captured));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_finger_or_mouse_release_cannot_complete_another_fingers_press() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(touch_down(1, point), point);
|
||||
ui.event(touch_down(2, point), point);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(2, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(1, point), point).0,
|
||||
vec![Message::Primary(1)]
|
||||
);
|
||||
}
|
||||
|
||||
fn keyboard_row(version: u8) -> View {
|
||||
// Iced 0.14.2 Button has no focus/keyboard activation. A real focusable
|
||||
// submit control tests the wrapper's focus lifecycle without inventing it.
|
||||
text_input("Diagnostic action", "row")
|
||||
.id("row")
|
||||
.on_input(|_| Message::Edit)
|
||||
.on_submit(Message::Submit(version))
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_publication_preserves_focus_but_replacement_never_inherits_activation() {
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into());
|
||||
ui.focus();
|
||||
assert!(ui.focused());
|
||||
ui.replace(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into());
|
||||
assert!(ui.focused());
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]);
|
||||
ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(2)).into());
|
||||
assert!(!ui.focused());
|
||||
assert!(ui.event(enter(), Point::ORIGIN).0.is_empty());
|
||||
ui.focus();
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]);
|
||||
ui.replace(Guard::new(None, Space::new()).into());
|
||||
ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(3)).into());
|
||||
assert!(!ui.focused());
|
||||
assert!(ui.event(enter(), Point::ORIGIN).0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_control_plain_focusable_row_migrates_activation_to_replacement() {
|
||||
let mut ui = Mounted::new(keyboard_row(1));
|
||||
ui.focus();
|
||||
ui.replace(keyboard_row(2));
|
||||
assert!(ui.focused());
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_scroll_offset_survives_same_and_changed_publications() {
|
||||
fn pane(generation: Id) -> View {
|
||||
scrollable(Guard::new(
|
||||
Some((generation, Id::nil())),
|
||||
column![rows(1), Space::new().height(600.)],
|
||||
))
|
||||
.width(240.)
|
||||
.height(100.)
|
||||
.into()
|
||||
}
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(pane(generation));
|
||||
ui.event(
|
||||
Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Pixels { x: 0., y: -120. },
|
||||
}),
|
||||
Point::new(20., 20.),
|
||||
);
|
||||
let offset = ui.scroll();
|
||||
assert_eq!(offset, Vector::new(0., 120.));
|
||||
ui.replace(pane(generation));
|
||||
assert_eq!(ui.scroll(), offset);
|
||||
ui.replace(pane(Id::new_v4()));
|
||||
assert_eq!(ui.scroll(), offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_touch_release_does_not_disarm_a_new_current_gesture() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(touch_down(1, point), point);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
ui.event(touch_down(2, point), point);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(1, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(2, point), point).0,
|
||||
vec![Message::Primary(2)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_mouse_press_after_missing_stale_release_can_activate_current_row() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(press(), point);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
// The platform delivers another down without the previous up.
|
||||
ui.event(press(), point);
|
||||
assert_eq!(ui.event(release(), point).0, vec![Message::Primary(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missed_press_is_ignored_and_dragging_release_outside_does_not_activate() {
|
||||
let inside = Point::new(20., 20.);
|
||||
let outside = Point::new(350., 250.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
assert_eq!(ui.event(press(), outside), (vec![], event::Status::Ignored));
|
||||
assert_eq!(
|
||||
ui.event(release(), inside),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
ui.event(press(), inside);
|
||||
assert_eq!(
|
||||
ui.event(release(), outside),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(release(), inside),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_publication_missing_terminal_recovers_on_fresh_mouse_or_touch_down() {
|
||||
let primary = Point::new(20., 20.);
|
||||
let related = Point::new(20., 60.);
|
||||
for is_touch in [false, true] {
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(guarded(Some(generation), 1));
|
||||
let down = |point| {
|
||||
if is_touch {
|
||||
touch_down(1, point)
|
||||
} else {
|
||||
press()
|
||||
}
|
||||
};
|
||||
let up = |point| {
|
||||
if is_touch {
|
||||
touch_up(1, point)
|
||||
} else {
|
||||
release()
|
||||
}
|
||||
};
|
||||
ui.event(down(primary), primary);
|
||||
ui.replace(guarded(Some(generation), 1));
|
||||
// The first gesture never receives its terminal event; same Id means
|
||||
// no publication reset can accidentally make this regression pass.
|
||||
assert_eq!(
|
||||
ui.event(down(related), related),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]);
|
||||
assert!(ui.event(up(primary), primary).0.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_captured_terminal_cancels_child_without_activation_or_lingering_press() {
|
||||
let primary = Point::new(20., 20.);
|
||||
let related = Point::new(20., 60.);
|
||||
for is_touch in [false, true] {
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
let down = |point| {
|
||||
if is_touch {
|
||||
touch_down(1, point)
|
||||
} else {
|
||||
press()
|
||||
}
|
||||
};
|
||||
let up = |point| {
|
||||
if is_touch {
|
||||
touch_up(1, point)
|
||||
} else {
|
||||
release()
|
||||
}
|
||||
};
|
||||
ui.event(down(primary), primary);
|
||||
assert_eq!(
|
||||
ui.event_with_capture(up(primary), primary, true),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
// An unpaired second release must not expose a lingering button press.
|
||||
assert_eq!(
|
||||
ui.event(up(primary), primary),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
ui.event(down(related), related);
|
||||
assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_terminal_cancellation_preserves_same_publication_focusable_child_state() {
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(
|
||||
Guard::new(
|
||||
Some((generation, Id::nil())),
|
||||
column![rows(1), keyboard_row(1)],
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
ui.event(press(), Point::new(20., 20.));
|
||||
// Focus through an operation avoids making the old pointer gesture whole.
|
||||
ui.focus();
|
||||
assert!(ui.focused());
|
||||
assert!(
|
||||
ui.event_with_capture(release(), Point::new(20., 20.), true)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
ui.focused(),
|
||||
"gesture cancellation must not remount the row tree"
|
||||
);
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_zero_height_scroller_delivers_cancelled_mouse_and_touch_terminals_to_guard() {
|
||||
fn pane(generation: Option<Id>) -> View {
|
||||
scrollable(guarded(generation, 1))
|
||||
.width(240.)
|
||||
.height(if generation.is_some() { 100. } else { 0. })
|
||||
.into()
|
||||
}
|
||||
let point = Point::new(20., 20.);
|
||||
let outside = Point::new(350., 250.);
|
||||
for is_touch in [false, true] {
|
||||
let mut ui = Mounted::new(pane(Some(Id::new_v4())));
|
||||
let down = if is_touch {
|
||||
touch_down(1, point)
|
||||
} else {
|
||||
press()
|
||||
};
|
||||
assert_eq!(ui.event(down, point), (vec![], event::Status::Captured));
|
||||
ui.replace(pane(None));
|
||||
ui.replace(pane(None));
|
||||
assert_eq!(ui.node.size().height, 0.);
|
||||
let up = if is_touch {
|
||||
touch_up(1, outside)
|
||||
} else {
|
||||
release()
|
||||
};
|
||||
assert_eq!(ui.event(up, outside), (vec![], event::Status::Captured));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod connection_tool;
|
||||
mod context_menu;
|
||||
mod data_values;
|
||||
mod data_view;
|
||||
mod diagnostic_input;
|
||||
mod diagram;
|
||||
mod diagram_usage;
|
||||
mod document;
|
||||
|
||||
@@ -13,6 +13,7 @@ pub enum SymbolEvent {
|
||||
Close,
|
||||
Choose {
|
||||
stamp: query::QueryStamp,
|
||||
visit: Id,
|
||||
reference: bool,
|
||||
index: usize,
|
||||
},
|
||||
@@ -34,7 +35,7 @@ pub struct HistoryEntry {
|
||||
pub cursor: Cursor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Request {
|
||||
pub job: Id,
|
||||
pub interaction: Id,
|
||||
@@ -61,12 +62,15 @@ pub struct Reveal {
|
||||
}
|
||||
|
||||
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>,
|
||||
/// Latest admitted query owns displayed data independently of whether its
|
||||
/// original navigation intent is still current. Hiding a drawer cancels
|
||||
/// navigation, but a matching completion can still populate retained data.
|
||||
pub display_request: Option<Request>,
|
||||
pub cache: Option<Arc<query::Snapshot>>,
|
||||
pub results: Option<Arc<query::Results>>,
|
||||
pub notice: Option<String>,
|
||||
@@ -77,12 +81,12 @@ pub struct SymbolState {
|
||||
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,
|
||||
display_request: None,
|
||||
cache: None,
|
||||
results: None,
|
||||
notice: None,
|
||||
|
||||
+67
-432
@@ -1,4 +1,5 @@
|
||||
mod source_symbols;
|
||||
mod text_workspace;
|
||||
pub(crate) mod traceability;
|
||||
use crate::{
|
||||
app::{App, Choice, FileOperation, Message, Page, Pending},
|
||||
@@ -256,7 +257,7 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
p.relations.len()
|
||||
)),
|
||||
action(
|
||||
&format!("{} checks", app.issue_count()),
|
||||
&format!("{} model checks", app.issue_count()),
|
||||
Message::Page(Page::Validation)
|
||||
)
|
||||
]
|
||||
@@ -1096,239 +1097,70 @@ 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![
|
||||
text_input("Find literal text…", &app.source_navigation.query)
|
||||
.id("source-find")
|
||||
.on_input(|query| Message::SourceNavigation(SourceEvent::Query(query)))
|
||||
.on_submit(Message::SourceNavigation(SourceEvent::Next(false)))
|
||||
.padding(8)
|
||||
.size(12)
|
||||
.width(Fill),
|
||||
action(
|
||||
"Previous",
|
||||
Message::SourceNavigation(SourceEvent::Next(true))
|
||||
),
|
||||
action("Next", Message::SourceNavigation(SourceEvent::Next(false))),
|
||||
label(app.source_navigation.label()),
|
||||
action("Close", app.source_find_close_message()),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
text_input("Line", &app.source_navigation.line)
|
||||
.id("source-line")
|
||||
.on_input(|line| Message::SourceNavigation(SourceEvent::Line(line)))
|
||||
.on_submit(Message::SourceNavigation(SourceEvent::Go))
|
||||
.padding(8)
|
||||
.size(12)
|
||||
.width(80),
|
||||
action("Go to line", Message::SourceNavigation(SourceEvent::Go)),
|
||||
label("Enter or F3: next match · Shift F3: previous · Esc: close"),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
]
|
||||
.spacing(6)
|
||||
.into()
|
||||
} else {
|
||||
column![].into()
|
||||
};
|
||||
let recovery: View<'_, Message> = if let Some(path) = &app.pending_recovery {
|
||||
let edits_block_recovery =
|
||||
!app.source_workspace.applied && app.source_workspace.has_edits();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
container(
|
||||
column![
|
||||
tooltip(
|
||||
text(format!("Interrupted save · {filename}")).size(15),
|
||||
text(path.display().to_string()).size(12),
|
||||
tooltip::Position::Bottom,
|
||||
),
|
||||
text(if edits_block_recovery {
|
||||
"Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them."
|
||||
} else if app.source_workspace.applied {
|
||||
"Restore files to their state before the interrupted save. Your current model and editor drafts are kept."
|
||||
} else {
|
||||
"Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save."
|
||||
})
|
||||
.size(13),
|
||||
button(text("Recover interrupted save").size(12))
|
||||
.padding(10)
|
||||
.on_press_maybe(
|
||||
(!edits_block_recovery && !app.busy).then_some(Message::SourceRecover),
|
||||
),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(12)
|
||||
.width(Fill)
|
||||
.style(style::card)
|
||||
.into()
|
||||
} else {
|
||||
column![].into()
|
||||
};
|
||||
let mut diagnostics = column![].spacing(5);
|
||||
let mut visible_diagnostics = 0;
|
||||
for (index, issue) in app.source_diagnostics.iter().enumerate() {
|
||||
// The persistent recovery banner supplies the action and explanation;
|
||||
// repeating the internal journal path obscures ordinary diagnostics.
|
||||
if app.pending_recovery.is_some() && issue.diagnostic.code == "pending_save" {
|
||||
continue;
|
||||
use crate::app::text_workspace::DrawerTab;
|
||||
use oa_core::source_project::SourceLocation;
|
||||
let visible = app.text_workspace.drawer() == Some(DrawerTab::Problems);
|
||||
let publication = app
|
||||
.text_workspace
|
||||
.diagnostic_publication()
|
||||
.filter(|publication| visible && publication.source() == app.source_key());
|
||||
let mut diagnostics = column![].spacing(3).width(Fill);
|
||||
if visible {
|
||||
if publication.is_none() && !app.source_diagnostics.is_empty() {
|
||||
diagnostics = diagnostics
|
||||
.push(text("Source changed. Waiting for current source checks…").size(12));
|
||||
}
|
||||
visible_diagnostics += 1;
|
||||
diagnostics = diagnostics.push(
|
||||
button(
|
||||
text(format!(
|
||||
"{}:{}:{} {}",
|
||||
issue.path,
|
||||
issue.diagnostic.line,
|
||||
issue.diagnostic.column,
|
||||
issue.diagnostic.message
|
||||
))
|
||||
.size(12)
|
||||
.color(match issue.diagnostic.severity {
|
||||
validation::Severity::Error => iced::Color::from_rgb8(174, 46, 64),
|
||||
validation::Severity::Warning => iced::Color::from_rgb8(145, 93, 23),
|
||||
}),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding([4, 6])
|
||||
.style(style::nav)
|
||||
.on_press(Message::SourceDiagnostic(index)),
|
||||
);
|
||||
for related in &issue.related {
|
||||
diagnostics = diagnostics.push(action(
|
||||
&format!("Related declaration · {}", related.path),
|
||||
Message::SourceRelated(related.clone()),
|
||||
));
|
||||
if app.source_diagnostics.is_empty() {
|
||||
diagnostics = diagnostics.push(text("No source problems reported.").size(12));
|
||||
}
|
||||
for issue in &app.source_diagnostics {
|
||||
let location = SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
};
|
||||
let severity = match issue.diagnostic.severity {
|
||||
validation::Severity::Error => "Error",
|
||||
validation::Severity::Warning => "Warning",
|
||||
};
|
||||
diagnostics = diagnostics.push(
|
||||
button(
|
||||
text(format!(
|
||||
"{severity} · {}:{}:{} · {}",
|
||||
issue.path,
|
||||
issue.diagnostic.line,
|
||||
issue.diagnostic.column,
|
||||
issue.diagnostic.message,
|
||||
))
|
||||
.size(12)
|
||||
.wrapping(widget::text::Wrapping::WordOrGlyph),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding([4, 6])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(app.text_diagnostic_message(location)),
|
||||
);
|
||||
for related in &issue.related {
|
||||
diagnostics = diagnostics.push(
|
||||
button(
|
||||
text(format!("Related declaration · {}", related.path))
|
||||
.size(12)
|
||||
.wrapping(widget::text::Wrapping::WordOrGlyph),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding([4, 12])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(app.text_diagnostic_message(related.clone())),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let editor = widget::text_editor(&app.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)
|
||||
.height(Fill)
|
||||
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
|
||||
app.source_snapshot.clone(),
|
||||
crate::highlight::format,
|
||||
);
|
||||
let header = container(
|
||||
column![
|
||||
row![
|
||||
container(heading(
|
||||
"Textual model",
|
||||
"One model across files · comments and identities preserved"
|
||||
))
|
||||
.width(Fill),
|
||||
button(text("Apply all").size(12))
|
||||
.padding(10)
|
||||
.on_press(Message::ApplySource),
|
||||
action("Revert file", Message::RevertSource)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
action("Undo text", Message::SourceUndo),
|
||||
action("Redo text", Message::SourceRedo),
|
||||
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))),
|
||||
label(if app.source_dirty() {
|
||||
"Drafts pending"
|
||||
} else {
|
||||
"Model in sync"
|
||||
})
|
||||
]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center),
|
||||
recovery,
|
||||
text(app.source_workspace.active.clone()).font(Font::MONOSPACE).size(12).color(style::INK),
|
||||
label(if app.pending_recovery.is_some() && !app.source_workspace.applied {
|
||||
"The previous graphical model remains available while these files are recovered."
|
||||
} else if !app.source_workspace.applied {
|
||||
"This project needs repair. Apply all adopts it; the previous graphical model remains available."
|
||||
} else if app.source_workspace.detached_dirty() {
|
||||
"A detached file has an unsaved draft. Reattach it or revert that file before saving."
|
||||
} else {
|
||||
"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);
|
||||
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)
|
||||
let guard = crate::diagnostic_input::Guard::new(
|
||||
publication.map(|p| (p.generation(), app.text_workspace.drawer_visit())),
|
||||
diagnostics,
|
||||
);
|
||||
text_workspace::view(app, source_symbols::panel(app), guard.into())
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -1392,8 +1224,7 @@ pub(crate) mod source_tests {
|
||||
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};
|
||||
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation};
|
||||
|
||||
// Measure real Iced paragraphs/editor text without constructing a window or GPU.
|
||||
pub(crate) struct FontLayout;
|
||||
@@ -1438,207 +1269,6 @@ pub(crate) mod source_tests {
|
||||
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() {
|
||||
@@ -1945,3 +1575,8 @@ mod tests {
|
||||
assert_eq!(short("e\u{0301}B", 1), "…");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
pub(crate) fn captured_text_drawer_resize(app: &App) -> Message {
|
||||
text_workspace::capture_drawer_resize(app)
|
||||
}
|
||||
|
||||
@@ -2,80 +2,151 @@ use crate::{
|
||||
app::{App, Message},
|
||||
source_navigation::{
|
||||
SymbolEvent,
|
||||
query::{self, Declaration, Disposition, Evidence, Identity, Membership, Position},
|
||||
query::{Declaration, Disposition, Evidence, Identity, Membership, Position},
|
||||
},
|
||||
style,
|
||||
};
|
||||
use iced::{
|
||||
Element, Fill,
|
||||
widget::{Space, button, column, container, row, scrollable, text, tooltip},
|
||||
widget::{button, column, container, scrollable, text, tooltip},
|
||||
};
|
||||
|
||||
pub(super) fn panel(app: &App) -> Element<'_, Message> {
|
||||
use crate::app::text_workspace::{DrawerTab, Event};
|
||||
use std::collections::BTreeSet;
|
||||
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 visible = app.text_workspace.drawer() == Some(DrawerTab::References);
|
||||
let presentation = app.text_workspace.references();
|
||||
let mut content = column![].spacing(0).width(Fill);
|
||||
if visible && let Some(notice) = &state.notice {
|
||||
content = content.push(text(notice.clone()).size(12));
|
||||
}
|
||||
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 {
|
||||
if visible && 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 enabled = !stale && app.source_navigation_available();
|
||||
let files: BTreeSet<_> = results
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|row| row.path.as_str())
|
||||
.chain(results.references.iter().map(|row| row.path.as_str()))
|
||||
.collect();
|
||||
let mut ordered: Vec<_> = files.iter().copied().collect();
|
||||
// Keep the query's captured origin first even after navigating elsewhere.
|
||||
if let Some(origin) = state
|
||||
.display_request
|
||||
.as_ref()
|
||||
.map(|request| request.path.as_str())
|
||||
{
|
||||
ordered.sort_by_key(|path| (*path != origin, *path));
|
||||
}
|
||||
for path in ordered {
|
||||
let collapsed = presentation.collapsed(path);
|
||||
let count = results
|
||||
.definitions
|
||||
.iter()
|
||||
.filter(|row| row.path == path)
|
||||
.count()
|
||||
+ results
|
||||
.references
|
||||
.iter()
|
||||
.filter(|row| row.path == path)
|
||||
.count();
|
||||
let suffix = distinguishing_path(path, &files);
|
||||
content = content.push(
|
||||
button(
|
||||
text(format!(
|
||||
"{} {suffix} · {count}",
|
||||
if collapsed { "▸" } else { "▾" }
|
||||
))
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
)
|
||||
.height(24)
|
||||
.width(Fill)
|
||||
.padding([2, 6])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(app.source_navigation_available().then(|| {
|
||||
Message::TextWorkspace(Event::ToggleFile {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
path: path.to_owned(),
|
||||
})
|
||||
})),
|
||||
);
|
||||
if collapsed {
|
||||
continue;
|
||||
}
|
||||
for (index, definition) in results
|
||||
.definitions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.path == path)
|
||||
{
|
||||
content = content.push(location_row(
|
||||
declaration_title(&definition.declaration),
|
||||
path,
|
||||
definition.position,
|
||||
provenance(
|
||||
definition.membership,
|
||||
definition.evidence,
|
||||
definition.declaration.identity,
|
||||
false,
|
||||
),
|
||||
enabled.then(|| {
|
||||
app.source_symbol_message(SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
reference: false,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
for (index, reference) in results
|
||||
.references
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.path == path)
|
||||
{
|
||||
let title = reference.context.as_ref().map_or_else(
|
||||
|| format!("@{} · lexical use", preview(&reference.alias, 48)),
|
||||
|context| format!("use · {}", declaration_title(context)),
|
||||
);
|
||||
content = content.push(location_row(
|
||||
title,
|
||||
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,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
reference: true,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
// Full path is readable by ordinary scrolling, independently of hover.
|
||||
content = content.push(
|
||||
container(
|
||||
text(path.to_owned())
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
)
|
||||
.padding([3, 8]),
|
||||
);
|
||||
}
|
||||
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.",
|
||||
@@ -87,89 +158,113 @@ pub(super) fn panel(app: &App) -> Element<'_, Message> {
|
||||
"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)
|
||||
)
|
||||
},
|
||||
content = content.push(text(explanation).size(12));
|
||||
if stale {
|
||||
content = content.push(
|
||||
text("Source changed. Run Definition or References again for current locations.")
|
||||
.size(12),
|
||||
);
|
||||
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)));
|
||||
if results.total_definitions > 1 {
|
||||
content = content.push(
|
||||
text("Alias collision: uses are not attributed to a single declaration.").size(12),
|
||||
);
|
||||
}
|
||||
content = content
|
||||
.push(
|
||||
text(format!(
|
||||
"{} · {} included / {} detached / {} unverified files · {} scope issues",
|
||||
if scope.partial() {
|
||||
"Partial include scope"
|
||||
} else {
|
||||
"Complete include scope"
|
||||
},
|
||||
scope.included_files,
|
||||
scope.detached_files,
|
||||
scope.unverified_files,
|
||||
scope.issues.len()
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(if !scope.applied {
|
||||
"Unapplied drafts"
|
||||
} else if scope.validated {
|
||||
"Validated sources"
|
||||
} else {
|
||||
"Draft sources"
|
||||
})
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(counts(
|
||||
"Declarations",
|
||||
results.definitions.len(),
|
||||
results.total_definitions,
|
||||
results.omitted_definitions,
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(counts(
|
||||
"Lexical uses",
|
||||
results.references.len(),
|
||||
results.total_references,
|
||||
results.omitted_references,
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
container(content)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::subtle)
|
||||
.into()
|
||||
let identity = if visible {
|
||||
state
|
||||
.display_request
|
||||
.as_ref()
|
||||
.map(|request| (request.job, app.text_workspace.drawer_visit()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let guarded = crate::diagnostic_input::Guard::new(identity, content);
|
||||
let mut body = scrollable(
|
||||
container(guarded)
|
||||
.width(Fill)
|
||||
.padding(iced::Padding::ZERO.right(14)),
|
||||
)
|
||||
.id(app.text_workspace.reference_scroll_id())
|
||||
.height(Fill)
|
||||
.width(Fill);
|
||||
if let Some(stamp) = presentation.stamp() {
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
body = body.on_scroll(move |viewport| {
|
||||
Message::TextWorkspace(Event::ReferenceScrolled {
|
||||
stamp,
|
||||
visit,
|
||||
y: viewport.absolute_offset().y,
|
||||
})
|
||||
});
|
||||
}
|
||||
body.into()
|
||||
}
|
||||
|
||||
/// Shortest suffix that still distinguishes duplicate basenames and suffix paths.
|
||||
fn distinguishing_path<'a>(path: &'a str, files: &std::collections::BTreeSet<&str>) -> &'a str {
|
||||
let starts = std::iter::once(0).chain(path.match_indices('/').map(|(index, _)| index + 1));
|
||||
for start in starts.collect::<Vec<_>>().into_iter().rev() {
|
||||
let candidate = &path[start..];
|
||||
if files
|
||||
.iter()
|
||||
.filter(|other| {
|
||||
**other == candidate
|
||||
|| other
|
||||
.strip_suffix(candidate)
|
||||
.is_some_and(|prefix| prefix.ends_with('/'))
|
||||
})
|
||||
.count()
|
||||
== 1
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String {
|
||||
@@ -223,23 +318,24 @@ fn location_row(
|
||||
) -> 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));
|
||||
let full =
|
||||
format!("{path}:{line}:{column} · {title} · {status} (1-based line and UTF-8 byte column)");
|
||||
tooltip(
|
||||
button(
|
||||
column![
|
||||
text(title).size(12),
|
||||
super::label(format!("{location} · {status}"))
|
||||
]
|
||||
.spacing(2),
|
||||
container(
|
||||
text(format!("{line}:{column} {title} · {status}"))
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
)
|
||||
.clip(true)
|
||||
.width(Fill),
|
||||
)
|
||||
.padding([4, 6])
|
||||
.padding([3, 12])
|
||||
.height(28)
|
||||
.width(Fill)
|
||||
.style(style::nav)
|
||||
.on_press_maybe(event),
|
||||
text(format!(
|
||||
"{path}:{line}:{column} (1-based line and UTF-8 byte column)"
|
||||
))
|
||||
.size(11),
|
||||
text(full).size(12),
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
.into()
|
||||
@@ -258,3 +354,32 @@ fn preview(value: &str, limit: usize) -> String {
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn suffixes_distinguish_nested_duplicate_basenames_without_changing_unicode() {
|
||||
let files = [
|
||||
"a/core.arcadia",
|
||||
"b/core.arcadia",
|
||||
"core.arcadia",
|
||||
"救援/actors.arcadia",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
distinguishing_path("a/core.arcadia", &files),
|
||||
"a/core.arcadia"
|
||||
);
|
||||
assert_eq!(
|
||||
distinguishing_path("b/core.arcadia", &files),
|
||||
"b/core.arcadia"
|
||||
);
|
||||
assert_eq!(distinguishing_path("core.arcadia", &files), "core.arcadia");
|
||||
assert_eq!(
|
||||
distinguishing_path("救援/actors.arcadia", &files),
|
||||
"actors.arcadia"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
//! Shapes and draws the production summary, checking actual paragraph advances.
|
||||
//! Native validation remains necessary for platform raster ink/fallback fonts.
|
||||
use super::*;
|
||||
use crate::source_navigation::query;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, renderer, text as shaped, widget::Tree};
|
||||
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Cluster {
|
||||
paragraph: String,
|
||||
bytes: std::ops::Range<usize>,
|
||||
bounds: Rectangle,
|
||||
clip: Rectangle,
|
||||
}
|
||||
struct Recorder {
|
||||
clips: Vec<Rectangle>,
|
||||
transforms: Vec<Transformation>,
|
||||
clusters: Vec<Cluster>,
|
||||
}
|
||||
impl Recorder {
|
||||
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 Recorder {
|
||||
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.clips = vec![bounds];
|
||||
self.transforms = vec![Transformation::IDENTITY];
|
||||
self.clusters.clear();
|
||||
}
|
||||
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 Recorder {
|
||||
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 content = buffer
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let clip = self.clip(clip);
|
||||
for run in buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
self.clusters.push(Cluster {
|
||||
paragraph: content.clone(),
|
||||
bytes: glyph.start..glyph.end,
|
||||
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(),
|
||||
clip,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
|
||||
const FLAGS: [&str; 5] = ["Stale", "Partial", "Collision", "Detached", "Unverified"];
|
||||
|
||||
fn draw_and_check_flags(
|
||||
content: Element<'_, Message, iced::Theme, Recorder>,
|
||||
width: f32,
|
||||
) -> Vec<String> {
|
||||
let viewport = Rectangle::with_size(Size::new(width, 24.));
|
||||
let mut renderer = Recorder {
|
||||
clips: vec![viewport],
|
||||
transforms: vec![Transformation::IDENTITY],
|
||||
clusters: Vec::new(),
|
||||
};
|
||||
let mut view: Element<'_, Message, iced::Theme, Recorder> =
|
||||
container(content).width(Fill).height(24).clip(true).into();
|
||||
let mut tree = Tree::new(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
assert_eq!(node.size(), viewport.size());
|
||||
view.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&iced::Theme::Light,
|
||||
&renderer::Style::default(),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
for flag in FLAGS {
|
||||
for byte in 0..flag.len() {
|
||||
let matching: Vec<_> = renderer
|
||||
.clusters
|
||||
.iter()
|
||||
.filter(|cluster| {
|
||||
cluster
|
||||
.paragraph
|
||||
.find(flag)
|
||||
.is_some_and(|start| cluster.bytes.contains(&(start + byte)))
|
||||
})
|
||||
.collect();
|
||||
if matching.is_empty() {
|
||||
failures.push(format!("missing shaped flag byte: {flag}[{byte}]"));
|
||||
}
|
||||
for cluster in matching {
|
||||
let b = cluster.bounds;
|
||||
let c = cluster.clip;
|
||||
if !(b.x >= c.x - 0.5
|
||||
&& b.y >= c.y - 0.5
|
||||
&& b.x + b.width <= c.x + c.width + 0.5
|
||||
&& b.y + b.height <= c.y + c.height + 0.5)
|
||||
{
|
||||
failures.push(format!("flag {flag} is clipped: glyph {b:?}, clip {c:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
fn old_summary(value: String) -> Element<'static, Message, iced::Theme, Recorder> {
|
||||
text(value)
|
||||
.size(12)
|
||||
.wrapping(widget::text::Wrapping::None)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_reference_exceptions_fit_allocated_width_with_wide_alias_and_large_counts() {
|
||||
let mut app = App::with_project(oa_core::Project::empty());
|
||||
let old_key = App::with_project(oa_core::Project::empty()).source_key();
|
||||
assert_ne!(old_key, app.source_key());
|
||||
for alias in ["雨".repeat(32), "W".repeat(32)] {
|
||||
// Unicode aliases are presentation stress: the current lexer admits
|
||||
// ASCII identifiers only. Six-digit totals are within the query's
|
||||
// 250,000-record cap; this does not build that many source records.
|
||||
let old_value = format!(
|
||||
"@{alias} · 100000 declarations · 100000 uses · Stale · Partial · Collision · Detached · Unverified"
|
||||
);
|
||||
app.source_symbols.results = Some(Arc::new(query::Results {
|
||||
stamp: query::QueryStamp {
|
||||
snapshot: old_key,
|
||||
serial: 1,
|
||||
},
|
||||
action: query::Action::References,
|
||||
origin_path: app.source_workspace.active.clone(),
|
||||
origin_offset: 0,
|
||||
origin: None,
|
||||
alias: Some(alias.clone()),
|
||||
scope: query::Scope {
|
||||
closure_complete: false,
|
||||
applied: false,
|
||||
validated: false,
|
||||
included_files: 28,
|
||||
detached_files: 1,
|
||||
unverified_files: 1,
|
||||
issues: Vec::new(),
|
||||
},
|
||||
disposition: query::Disposition::LexicalReferences,
|
||||
definitions: Vec::new(),
|
||||
references: Vec::new(),
|
||||
total_definitions: 100_000,
|
||||
total_references: 100_000,
|
||||
omitted_definitions: 100_000,
|
||||
omitted_references: 100_000,
|
||||
}));
|
||||
for width in [840., 720.] {
|
||||
let positive = draw_and_check_flags(reference_summary(&app), width);
|
||||
assert!(
|
||||
positive.is_empty(),
|
||||
"reserved flags, width {width}, alias {alias:?}: {positive:?}"
|
||||
);
|
||||
// Observe the exact old presentation without predicting font widths.
|
||||
// This is emitted under --nocapture, whether it clips or fits.
|
||||
let old = draw_and_check_flags(old_summary(old_value.clone()), width);
|
||||
println!(
|
||||
"old alias-first summary width={width} alias={alias:?}: {} missing/clipped flag-byte checks; first={:?}",
|
||||
old.len(),
|
||||
old.first()
|
||||
);
|
||||
}
|
||||
}
|
||||
// Deliberate evaluator stress, beyond the production 32-character subject
|
||||
// bound: require the same evaluator to reject a clipped old-style paragraph.
|
||||
// This is not evidence of a production/native overflow at 840 pixels.
|
||||
let stress = format!("@{} · {}", "W".repeat(256), FLAGS.join(" · "));
|
||||
let rejected = draw_and_check_flags(old_summary(stress), 720.);
|
||||
assert!(
|
||||
!rejected.is_empty(),
|
||||
"negative control must detect missing/clipped warning text"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user