Integrate diagnostic decorations and current-source Text input boundaries

This commit is contained in:
2026-09-13 04:47:30 -07:00
parent 16904398db
commit 92ff99eb54
98 changed files with 35959 additions and 208 deletions
+280
View File
@@ -3436,6 +3436,17 @@ impl App {
if version == self.source_version {
match result {
Ok(disk) => {
// Discovery must include the exact overlays retained
// by this workspace. A current request version alone
// does not prove arbitrary reply spans match them.
if disk.bundle.entry != self.source_workspace.entry
|| self.source_workspace.files.iter().any(|(path, buffer)| {
disk.bundle.files.get(path) != Some(&buffer.text)
})
{
self.status = "Included-file results no longer match the retained drafts. Load includes again.".into();
return self.request_lint();
}
if let Some(target) = recovery_target(&disk) {
self.pending_recovery = Some(target);
}
@@ -5340,6 +5351,275 @@ mod source_project_tests {
);
assert!(!app.dirty());
}
fn open_missing_function_file(directory: &Directory) -> App {
let entry = format!(
"arcadia {}; project p \"P\" {{ include \"work.arcadia\"; }}\n",
oa_core::SCHEMA_VERSION
);
let path = directory.0.join("project.arcadia");
std::fs::write(&path, entry).unwrap();
let mut app = App::with_project(Project::demo());
send(
&mut app,
Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))),
);
assert!(!app.source_workspace.applied);
assert!(!app.source_workspace.files.contains_key("work.arcadia"));
app
}
fn assert_raw_second_warning(app: &App, raw: &str) {
let issue = app
.source_diagnostics
.iter()
.find(|issue| {
issue.path == "work.arcadia"
&& issue.diagnostic.code == "unallocated_leaf"
&& issue.diagnostic.message.contains("Second")
})
.unwrap();
let span = issue.diagnostic.span;
assert_eq!(span.start, raw.find("sa_function second").unwrap());
assert_eq!(
&raw[span.start..span.end],
"sa_function second \"Second\" {}"
);
assert_eq!(
app.text_workspace
.diagnostic_publication()
.unwrap()
.source(),
app.source_key()
);
}
#[test]
fn includes_loaded_diagnostics_use_raw_newly_discovered_bytes() {
let directory = Directory::new();
let mut app = open_missing_function_file(&directory);
let model = app.document.snapshot();
let publication = app.text_workspace.diagnostic_publication();
let raw = "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n";
std::fs::write(directory.0.join("work.arcadia"), raw).unwrap();
let disk = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
// Real producer control: compiled diagnostics would refer to inserted
// IDs, while IncludesLoaded must leave the raw draft unapplied.
let compiled = source_project::compile_bundle(&disk.reachable_bundle(), LIMITS).unwrap();
assert_ne!(compiled.bundle, disk.bundle);
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(disk)));
assert_raw_second_warning(&app, raw);
assert_eq!(app.source_workspace.files["work.arcadia"].text, raw);
assert!(!app.source_workspace.applied);
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
assert_ne!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(
std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(),
raw
);
}
#[test]
fn includes_loaded_diagnostics_use_retained_overlay_and_ignore_old_version() {
let directory = Directory::new();
let mut app = open_missing_function_file(&directory);
let raw = "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n";
std::fs::write(directory.0.join("work.arcadia"), raw).unwrap();
let first = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(first)));
let model = app.document.snapshot();
let previous = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
let previous_version = app.source_version;
send(&mut app, Message::SourceFile("work.arcadia".into()));
let edited = format!("// Retained Unicode draft: 航海\n{raw}");
edit(&mut app, &edited);
let current = source_workspace::lint(&app.source_workspace.bundle());
app.publish_source_diagnostics(current);
let publication = app.text_workspace.diagnostic_publication();
let key = app.source_key();
send(
&mut app,
Message::IncludesLoaded(previous_version, Ok(previous)),
);
assert_eq!(app.source_key(), key);
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
assert_raw_second_warning(&app, &edited);
std::fs::write(directory.0.join("work.arcadia"), "// external disk edit\n").unwrap();
let discovered = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
assert_eq!(discovered.bundle.files["work.arcadia"], edited);
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(discovered)));
assert_raw_second_warning(&app, &edited);
assert_ne!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(app.source_editor.text(), edited);
assert_eq!(app.source_workspace.files["work.arcadia"].text, edited);
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
assert_eq!(
std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(),
"// external disk edit\n"
);
}
#[test]
fn includes_loaded_rejects_mismatched_retained_bytes_before_publication() {
let directory = Directory::new();
let mut app = open_missing_function_file(&directory);
std::fs::write(
directory.0.join("work.arcadia"),
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
)
.unwrap();
// Discovery can also report a pending recovery target. A rejected
// reply must not install that metadata either.
std::fs::create_dir(directory.0.join(".arcadia-save-project.arcadia.journal")).unwrap();
let reply = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
assert!(recovery_target(&reply).is_some());
let edited = format!("// retained entry draft\n{}", app.source_editor.text());
edit(&mut app, &edited);
let current = source_workspace::lint(&app.source_workspace.bundle());
app.publish_source_diagnostics(current);
assert!(!app.source_diagnostics.is_empty());
let publication = app.text_workspace.diagnostic_publication();
let diagnostics = app.source_diagnostics.clone();
let before = app.source_workspace.bundle();
let key = app.source_key();
let reachable = app.source_workspace.reachable.clone();
let disk = app.disk_project.clone().unwrap();
let model = app.document.snapshot();
let recovery = app.pending_recovery.clone();
// Deliberately stamp an older real producer reply with the current
// version: this tests byte admission independently of version refusal.
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(reply)));
assert_eq!(app.source_version, version);
assert_eq!(app.source_key(), key);
assert_eq!(app.source_workspace.bundle(), before);
assert_eq!(app.source_workspace.reachable, reachable);
assert!(!app.source_workspace.files.contains_key("work.arcadia"));
assert_eq!(app.source_editor.text(), edited);
assert_eq!(app.pending_recovery, recovery);
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(app.source_diagnostics, diagnostics);
let after = app.disk_project.as_ref().unwrap();
assert_eq!(after.root, disk.root);
assert_eq!(after.bundle, disk.bundle);
assert_eq!(after.expectations, disk.expectations);
assert_eq!(after.reachable, disk.reachable);
assert_eq!(after.diagnostics, disk.diagnostics);
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
}
#[test]
fn includes_loaded_requires_each_inactive_detached_overlay() {
let directory = Directory::new();
let mut app = open_missing_function_file(&directory);
let original = "sa_function first \"First\" {}\n";
std::fs::write(directory.0.join("work.arcadia"), original).unwrap();
let discovered = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(discovered)));
send(&mut app, Message::SourceFile("work.arcadia".into()));
let retained = format!("// retained detached draft\n{original}");
edit(&mut app, &retained);
send(&mut app, Message::SourceFile("project.arcadia".into()));
let entry = format!(
"arcadia {}; project p \"P\" {{}}\n",
oa_core::SCHEMA_VERSION
);
edit(&mut app, &entry);
assert!(app.apply_source(), "{}", app.status);
assert_eq!(app.source_workspace.active, app.source_workspace.entry);
assert!(!app.source_workspace.reachable.contains("work.arcadia"));
assert_eq!(app.source_workspace.files["work.arcadia"].text, retained);
let valid = source_files::discover_missing(
app.disk_project.as_ref().unwrap(),
&app.source_workspace.bundle().files,
LIMITS,
)
.unwrap();
let before = app.source_workspace.bundle();
let key = app.source_key();
let publication = app.text_workspace.diagnostic_publication();
let diagnostics = app.source_diagnostics.clone();
let disk = app.disk_project.clone().unwrap();
let model = app.document.snapshot();
for missing in [false, true] {
let mut reply = valid.clone();
if missing {
reply.bundle.files.remove("work.arcadia");
} else {
reply
.bundle
.files
.insert("work.arcadia".into(), original.into());
}
// Entry and active text match exactly. Only an inactive, detached
// overlay is wrong or absent; checking active/entry alone would pass.
assert_eq!(
reply.bundle.files[&app.source_workspace.entry],
app.source_editor.text()
);
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(reply)));
assert_eq!(app.source_version, version);
assert_eq!(app.source_key(), key);
assert_eq!(app.source_workspace.bundle(), before);
assert!(!app.source_workspace.reachable.contains("work.arcadia"));
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(app.source_diagnostics, diagnostics);
let after = app.disk_project.as_ref().unwrap();
assert_eq!(after.bundle, disk.bundle);
assert_eq!(after.expectations, disk.expectations);
assert_eq!(after.reachable, disk.reachable);
assert_eq!(after.diagnostics, disk.diagnostics);
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
}
// Exact overlays remain admissible even when the retained draft is
// detached and its external file still contains the original bytes.
let version = app.source_version;
send(&mut app, Message::IncludesLoaded(version, Ok(valid)));
assert_eq!(app.source_version, version + 1);
assert_ne!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(app.source_workspace.bundle(), before);
assert_eq!(app.source_workspace.files["work.arcadia"].text, retained);
assert_eq!(
std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(),
original
);
}
#[test]
fn closed_detached_file_keeps_first_save_conflict_then_can_be_reloaded_fresh() {
let directory = Directory::new();
@@ -165,6 +165,7 @@ fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() {
first_measurement(&mut app, &found);
}
#[cfg(debug_assertions)]
#[test]
fn stale_compact_drawer_controls_preserve_current_navigation_and_evidence() {
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
@@ -218,25 +219,37 @@ fn stale_compact_drawer_controls_preserve_current_navigation_and_evidence() {
}
}
#[cfg(debug_assertions)]
#[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();
navigate(&mut app);
app.page = Page::Text;
assert!(app.pending_diagram_reveal.is_some());
let epoch = app.allocation_drag.epoch;
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));
assert!(app.pending_diagram_reveal.is_none());
assert!(app.diagram_navigation_focus.is_none());
assert!(app.allocation_drag.epoch > epoch);
let epoch = app.allocation_drag.epoch;
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.);
assert!(app.allocation_drag.epoch > epoch);
let epoch = app.allocation_drag.epoch;
let hide = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
send(&mut app, hide);
assert!(app.text_workspace.drawer().is_none());
assert!(app.allocation_drag.epoch > epoch);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
@@ -512,3 +512,45 @@ async fn details_remount_restores_wheel_offset_before_any_rescroll() {
assert_eq!(evidence(&app), before);
}
}
#[tokio::test]
async fn diagnostic_publication_during_rename_keeps_actual_preview_report_passive() {
let mut app = paged_fixture();
app.publish_source_diagnostics(app.source_diagnostics.clone());
let publication = app.text_workspace.diagnostic_publication().unwrap();
let before = evidence(&app);
let interaction = app.source_symbols.interaction;
let camera = app.camera_boundary();
let allocation = app.allocation_boundary();
let cursor = app.source_editor.cursor();
let mut mounted = Mounted::new(&app);
let reports = mounted.wheel(&app);
assert_eq!(reports.len(), 1);
let Message::SourceRename(Event::PreviewScrolled(_, _, _, offset)) = &reports[0] else {
panic!("actual wheel must publish the retained preview measurement");
};
assert!(offset.is_finite() && *offset > 0.);
app.publish_source_diagnostics(app.source_diagnostics.clone());
let current = app.text_workspace.diagnostic_publication().unwrap();
assert_ne!(current.generation(), publication.generation());
assert_eq!(current.source(), publication.source());
assert!(app.source_annotation_snapshot().is_some());
assert!(
!app.source_navigation_available(),
"rename still owns modal input"
);
for message in reports.iter().cloned() {
let task = app.update(message);
assert_eq!(mounted.execute(&app, task).await, 0);
}
assert_eq!(
app.source_rename.form.as_ref().unwrap().preview_offset,
*offset
);
assert_eq!(mounted.offset(&app), offset.round());
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.camera_boundary() == camera);
assert!(app.allocation_boundary() == allocation);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(evidence(&app), before);
}
@@ -126,6 +126,20 @@ impl App {
&& self.with_source_input(|input| snapshot.matches(input))
}
/// Retained rows remain readable after departure, but are current only while
/// their captured source bytes still match the complete workspace.
pub fn source_results_current(&self) -> bool {
self.source_symbols
.results
.as_ref()
.is_some_and(|results| results.stamp.snapshot == self.source_key())
&& self
.source_symbols
.cache
.as_ref()
.is_some_and(|cache| self.source_snapshot_matches(cache))
}
pub fn source_navigation_available(&self) -> bool {
self.page == Page::Text
&& !self.source_rename.open()
+28 -184
View File
@@ -74,190 +74,6 @@ fn select_effective_pump(app: &mut App, line: bool) -> String {
selected
}
#[test]
fn effective_selection_survives_file_roundtrip_and_definition_back() {
for line in [false, true] {
for definition in [false, true] {
let mut app = fixture();
let selected = select_effective_pump(&mut app, line);
let accepted = app.document.snapshot();
let bundle = app.source_workspace.bundle();
let revision = app.source_workspace.revision();
if definition {
let results = finish(&mut app, query::Action::Definition);
assert_eq!(results.disposition, query::Disposition::Direct);
reveal(&mut app);
assert_eq!(app.source_workspace.active, DEFINITIONS);
symbol(&mut app, nav::SymbolEvent::Back);
reveal(&mut app);
assert!(app.source_symbols.back.is_empty());
} else {
show(&mut app, DEFINITIONS);
show(&mut app, USES);
}
assert_eq!(app.source_workspace.active, USES);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
assert_eq!(app.source_workspace.bundle(), bundle);
assert_eq!(app.source_workspace.revision(), revision);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.source_workspace.undo(false) && !app.source_workspace.undo(true));
assert!(!app.document.can_undo() && !app.document.can_redo());
let expected = if line {
bundle.files[USES].replacen(&selected, "x", 1)
} else {
bundle.files[USES].replacen("source @pump", "source @x", 1)
};
send(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))),
);
assert_eq!(app.source_workspace.text(), expected);
assert_eq!(app.source_editor.text(), expected);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
}
#[test]
fn effective_selection_is_untouched_by_noop_missing_invalid_or_cancelled_navigation() {
for variant in 0..4 {
let mut app = fixture();
let selected = select_effective_pump(&mut app, false);
let raw_cursor = app.source_editor.cursor();
let bundle = app.source_workspace.bundle();
let revision = app.source_workspace.revision();
let accepted = app.document.snapshot();
match variant {
0 => send(&mut app, Message::SourceFile(USES.into())),
1 => send(&mut app, Message::SourceFile("missing.arcadia".into())),
2 => app.source_symbol_jump(
DEFINITIONS,
query::Position {
line: 999,
byte_column: 0,
},
query::Position {
line: 999,
byte_column: 1,
},
),
_ => {
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
send(&mut app, Message::SourceFile(USES.into()));
send(&mut app, Message::SourceSymbolsReady(ready));
reveal(&mut app);
}
}
assert_eq!(app.source_workspace.active, USES);
assert_eq!(
app.source_editor.cursor(),
raw_cursor,
"live selection mode must not normalize"
);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
assert_eq!(app.source_workspace.bundle(), bundle);
assert_eq!(app.source_workspace.revision(), revision);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn changed_active_origin_back_keeps_current_effective_selection() {
let mut app = fixture();
let old_generation = app.source_workspace.files[USES].generation();
let old_cursor = app.source_editor.cursor();
let original = app.source_workspace.text().to_owned();
app.source_editor
.perform(text_editor::Action::Move(text_editor::Motion::Left));
app.source_editor.move_to(text_editor::Cursor {
position: text_editor::Position { line: 0, column: 3 },
selection: None,
});
send(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))),
);
let mut expected_edit = original;
expected_edit.insert(3, 'x');
assert_eq!(app.source_workspace.text(), expected_edit);
let selected = select_effective_pump(&mut app, true);
let bundle = app.source_workspace.bundle();
let revision = app.source_workspace.revision();
app.source_symbols.back.push(nav::HistoryEntry {
session: app.source_workspace.session(),
path: USES.into(),
generation: old_generation,
cursor: old_cursor,
});
symbol(&mut app, nav::SymbolEvent::Back);
reveal(&mut app);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
assert!(
app.source_symbols
.notice
.as_deref()
.unwrap()
.contains("draft changed")
);
assert_eq!(app.source_workspace.bundle(), bundle);
assert_eq!(app.source_workspace.revision(), revision);
}
#[test]
fn source_creation_preserves_outgoing_selection_and_undisplayed_entry_cursor() {
let mut app = fixture();
let selected = select_effective_pump(&mut app, true);
let bundle = app.source_workspace.bundle();
let entry_cursor = app.source_workspace.files[&bundle.entry].cursor;
let accepted = app.document.snapshot();
send(&mut app, Message::SourceNewPath("new.arcadia".into()));
send(&mut app, Message::SourceCreateFile);
assert_eq!(app.source_workspace.active, "new.arcadia");
assert_eq!(
app.source_workspace.files[&bundle.entry].cursor,
entry_cursor
);
assert_eq!(app.source_workspace.files[USES].text, bundle.files[USES]);
assert!(
app.source_workspace.files[&bundle.entry]
.text
.contains("include \"new.arcadia\";")
);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
show(&mut app, USES);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
select_effective_pump(&mut app, true);
let raw = app.source_editor.cursor();
send(
&mut app,
Message::SourceNewPath("../invalid.arcadia".into()),
);
send(&mut app, Message::SourceCreateFile);
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), raw);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
}
fn send(app: &mut App, message: Message) {
// Dropping the returned Task does not poll its worker/lint future. Completion
// below is deterministic and still traverses the real App message handler.
@@ -2272,3 +2088,31 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn same_key_changed_bytes_mark_retained_rows_stale_before_choice_boundaries() {
for path in [USES, DEFINITIONS] {
let mut app = fixture();
let results = finish(&mut app, query::Action::References);
assert!(app.source_results_current());
#[cfg(debug_assertions)]
assert!(!crate::views::captured_source_reference_stale_summary(&app));
let captured = choice(&app, results.stamp, true, 0);
let key = app.source_key();
// Isolate byte authority from revision authority in both the displayed
// and an inactive buffer. Views share this exact currentness decision.
app.source_workspace
.files
.get_mut(path)
.unwrap()
.text
.push_str("\r\n// exact bytes changed");
assert_eq!(app.source_key(), key);
assert!(!app.source_results_current());
#[cfg(debug_assertions)]
assert!(crate::views::captured_source_reference_stale_summary(&app));
app.queue_source_reveal(app.source_editor.cursor(), false);
assert!(app.source_reveal_ticket().is_some());
assert_stale_envelopes_preserve_current(&mut app, &[captured]);
}
}
+44 -1
View File
@@ -399,6 +399,24 @@ impl App {
))
})
}
pub(crate) fn source_annotation_snapshot(
&self,
) -> Option<crate::source_annotations::Snapshot<'_>> {
let publication = self.text_workspace.diagnostic_publication?;
let buffer = self
.source_workspace
.files
.get(&self.source_workspace.active)?;
if publication.source != self.source_key() || buffer.text != self.source_snapshot.as_ref() {
return None;
}
Some(crate::source_annotations::Snapshot {
publication: publication.generation,
source: self.source_snapshot.clone(),
path: &self.source_workspace.active,
diagnostics: &self.source_diagnostics,
})
}
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.
@@ -468,6 +486,7 @@ impl App {
let Some(popup_token) = self.menu_sequence.checked_add(1) else {
return Task::none();
};
self.text_presentation_boundary(Event::OpenMenu(request.clone()));
self.source_interaction();
let commands = [
Command::Find,
@@ -513,6 +532,7 @@ impl App {
.as_ref()
.is_some_and(|menu| menu.token == token)
{
self.text_presentation_boundary(Event::DismissMenu(token));
self.text_workspace.menu = None;
self.source_interaction();
}
@@ -551,12 +571,14 @@ impl App {
if context != self.text_drawer_context() || !self.source_navigation_available() {
return Task::none();
}
self.text_presentation_boundary(Event::ShowDrawer(context, tab));
return self.show_text_drawer(Some(tab));
}
Event::HideDrawer(context) => {
if context != self.text_drawer_context() || !self.source_navigation_available() {
return Task::none();
}
self.text_presentation_boundary(Event::HideDrawer(context));
return self.show_text_drawer(None);
}
Event::ResizeDrawer(context, height) => {
@@ -567,15 +589,22 @@ impl App {
// The view applies the measured editor-allocation bound.
let height = height.clamp(180., 1200.);
if height != self.text_workspace.drawer_height {
self.text_presentation_boundary(Event::ResizeDrawer(context, height));
self.source_interaction();
self.text_workspace.drawer_height = height;
}
}
}
Event::ToggleFile { stamp, visit, path } => {
let captured = Event::ToggleFile {
stamp,
visit,
path: path.clone(),
};
if self.source_navigation_available()
&& self.text_workspace.toggle_file(stamp, visit, path)
{
self.text_presentation_boundary(captured);
self.source_interaction();
}
}
@@ -598,6 +627,7 @@ impl App {
{
return Task::none();
}
self.text_presentation_boundary(Event::Diagnostic(target.clone()));
self.source_interaction();
self.jump_to_source(target.location);
}
@@ -607,9 +637,22 @@ impl App {
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)),
Action::Drawer(tab) => {
self.text_presentation_boundary(Event::ShowDrawer(self.text_drawer_context(), tab));
self.show_text_drawer(Some(tab))
}
}
}
/// Local Text presentation changes do not mutate a camera, document or route.
/// After their own authority check, they still supersede pending navigation
/// and allocation through the root's existing input boundary hooks. Source
/// commands use update instead; passive measurements never enter this hook.
fn text_presentation_boundary(&mut self, event: Event) {
let message = Message::TextWorkspace(event);
self.reconcile_pending_diagram_reveal(&message);
self.allocation_before(&message);
}
pub(super) fn show_text_drawer(&mut self, drawer: Option<DrawerTab>) -> Task<Message> {
self.source_interaction();
self.text_workspace.show_drawer(drawer);
@@ -670,3 +670,208 @@ fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() {
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn annotation_snapshot_requires_exact_current_bytes_and_publication() {
let mut app = fixture();
let original_model = app.document.snapshot();
let original_source = app.source_snapshot.clone();
let publication = app.source_annotation_snapshot().unwrap().publication;
let initial = app.source_annotation_snapshot().unwrap();
assert!(Arc::ptr_eq(&initial.source, &original_source));
assert_eq!(initial.path, app.source_workspace.active);
assert!(std::ptr::eq(
initial.diagnostics,
app.source_diagnostics.as_slice()
));
// Republish identical diagnostics: old cached geometry must get a new identity.
app.publish_source_diagnostics(app.source_diagnostics.clone());
assert_ne!(
app.source_annotation_snapshot().unwrap().publication,
publication
);
app.source_workspace
.edit("// newer draft\n".into(), app.source_editor.cursor());
assert!(
app.source_annotation_snapshot().is_none(),
"old publication after a real draft edit"
);
// A current source key alone must not bind new ranges to the old displayed text.
app.publish_source_diagnostics(Vec::new());
assert!(
app.source_annotation_snapshot().is_none(),
"displayed snapshot still contains old bytes"
);
app.show_source_file(false);
let current = app.source_annotation_snapshot().unwrap();
assert_eq!(current.source.as_ref(), "// newer draft\n");
assert!(current.diagnostics.is_empty());
assert!(Arc::ptr_eq(&original_model, &app.document.snapshot()));
app.text_workspace.reset();
assert!(app.source_annotation_snapshot().is_none());
}
#[test]
fn annotation_snapshot_tracks_real_file_round_trips_without_changing_model() {
let mut project = Project::empty();
let element = project
.add_element(
oa_core::Kind::Activity,
oa_core::Layer::Oa,
"Assess incident",
None,
)
.unwrap();
let mut app = App::with_project(project);
app.page = Page::Text;
app.publish_source_diagnostics(app.source_diagnostics.clone());
let model = app.document.snapshot();
let entry = app.source_workspace.entry.clone();
let fragment = app.document.compiled().index.declarations[&element]
.path
.clone();
assert_ne!(entry, fragment);
for path in [&entry, &fragment, &entry] {
let _ = app.update(Message::SourceFile(path.clone()));
let current = app.source_annotation_snapshot().unwrap();
assert_eq!(current.path, path);
assert_eq!(
current.source.as_ref(),
app.source_workspace.files[path].text
);
assert!(Arc::ptr_eq(&current.source, &app.source_snapshot));
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
}
}
#[test]
fn drawer_document_session_and_visit_guards_are_independently_required() {
for component in 0..3 {
for action in 0..3 {
let mut app = fixture();
app.text_workspace.show_drawer(Some(DrawerTab::Status));
let mut captured = app.text_drawer_context();
match component {
0 => captured.document = Id::new_v4(),
1 => captured.session = Id::new_v4(),
_ => captured.visit = Id::new_v4(),
}
app.queue_source_reveal(app.source_editor.cursor(), false);
let reveal = app.source_reveal_ticket();
assert!(reveal.is_some());
let interaction = app.source_symbols.interaction;
let cursor = app.source_editor.cursor();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let current = app.text_drawer_context();
let height = app.text_workspace.drawer_height();
let event = match action {
0 => Event::ShowDrawer(captured, DrawerTab::Problems),
1 => Event::HideDrawer(captured),
_ => Event::ResizeDrawer(captured, height + 60.),
};
send(&mut app, event);
assert_eq!(app.text_drawer_context(), current);
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status));
assert_eq!(app.text_workspace.drawer_height(), height);
assert_eq!(app.source_reveal_ticket(), reveal);
assert_eq!(app.source_symbols.interaction, interaction);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}
#[cfg(debug_assertions)]
#[test]
fn actual_chooser_blocks_diagnostic_actions_and_mounted_hover_until_closed() {
use oa_core::{language::Diagnostic, source_project::SourceDiagnostic, validation::Severity};
for republish in [false, true] {
let mut app = fixture();
assert!(app.source_workspace.text().starts_with("arcadia"));
app.publish_source_diagnostics(vec![SourceDiagnostic {
path: app.source_workspace.active.clone(),
diagnostic: Diagnostic {
severity: Severity::Warning,
code: "union".into(),
message: "Union diagnostic details".into(),
object_id: None,
field: None,
line: 1,
column: 1,
span: Span { start: 0, end: 7 },
},
related: Vec::new(),
}]);
let show = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, show);
let old_target = app.text_diagnostic_message(location(&app, 0, 7)).unwrap();
let mut mounted = crate::source_annotations::AppMounted::new(
app.source_annotation_snapshot().unwrap(),
app.source_navigation_available(),
app.source_symbols.raw_interaction.clone(),
);
mounted.assert_hover(true);
let open = app.diagram_usage_message(app.project().id);
let _ = app.update(open);
let token = app
.diagram_usages
.as_ref()
.expect("actual chooser opened")
.token;
assert!(!app.source_navigation_available());
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let interaction = app.source_symbols.interaction;
let reveal = app.source_reveal_ticket();
if republish {
app.publish_source_diagnostics(app.source_diagnostics.clone());
}
mounted.refresh(
app.source_annotation_snapshot().unwrap(),
app.source_navigation_available(),
);
mounted.assert_hover(false);
let _ = app.update(old_target.clone());
assert_eq!(app.source_symbols.interaction, interaction);
assert_eq!(app.source_reveal_ticket(), reveal);
assert_eq!(app.diagram_usages.as_ref().unwrap().token, token);
assert_eq!(app.diagram_lookup_in_flight, Some(token));
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(Message::DiagramUsage(crate::diagram_usage::Event::Close(
token,
)));
assert!(app.source_navigation_available());
mounted.refresh(
app.source_annotation_snapshot().unwrap(),
app.source_navigation_available(),
);
mounted.assert_hover(true);
if republish {
let interaction = app.source_symbols.interaction;
let reveal = app.source_reveal_ticket();
let _ = app.update(old_target);
assert_eq!(
app.source_symbols.interaction, interaction,
"old publication stays stale after modal closes"
);
assert_eq!(app.source_reveal_ticket(), reveal);
}
let target = app.text_diagnostic_message(location(&app, 0, 7)).unwrap();
let _ = app.update(target);
assert!(
app.source_reveal_ticket().is_some(),
"fresh admitted location still navigates"
);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
+1
View File
@@ -27,6 +27,7 @@ mod port_workflow;
mod recovery;
mod route_edit;
mod semantic_review;
mod source_annotations;
mod source_navigation;
mod source_rename;
mod source_tree;
+312
View File
@@ -0,0 +1,312 @@
//! Read-only source-range admission and projection onto the editor's real layout.
//! Byte ranges retain their diagnostic meaning; visual bands expand to clusters.
mod widget;
use iced::{Point, Rectangle, Size, Vector, advanced::graphics::text::cosmic_text};
use oa_core::source_project::SourceDiagnostic;
use std::{collections::BTreeMap, ops::Range, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
pub(crate) use widget::{Decorations, Snapshot};
#[derive(Debug)]
struct Part {
diagnostic: usize,
bytes: Range<usize>,
}
/// Source-only index, reusable across pointer movement, scroll and reflow.
/// A caller must separately establish publication/path/snapshot authority.
#[derive(Debug)]
pub(crate) struct Index {
source: Arc<str>,
lines: Vec<Range<usize>>,
parts: BTreeMap<usize, Vec<Part>>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Band {
pub diagnostic: usize,
pub bounds: Rectangle,
pub underline: Rectangle,
}
impl Index {
pub fn new(source: Arc<str>, path: &str, diagnostics: &[SourceDiagnostic]) -> Self {
// This is the same paragraph splitter used by the actual editor engine,
// including standalone CR and LFCR, not the diagnostic's LF-only label.
let mut lines: Vec<_> = cosmic_text::LineIter::new(&source)
.map(|(bytes, _)| bytes)
.collect();
if lines.last().is_none_or(|line| line.end < source.len()) {
lines.push(source.len()..source.len());
}
let mut parts: BTreeMap<usize, Vec<Part>> = BTreeMap::new();
for (ordinal, diagnostic) in diagnostics.iter().enumerate() {
let span = diagnostic.diagnostic.span;
if diagnostic.path != path
|| span.start > span.end
|| span.end > source.len()
|| !source.is_char_boundary(span.start)
|| !source.is_char_boundary(span.end)
{
continue;
}
let first = lines.partition_point(|line| line.start <= span.start) - 1;
let last_byte = if span.start == span.end {
span.end
} else {
span.end - 1
};
let last = lines.partition_point(|line| line.start <= last_byte) - 1;
for (line_index, line) in lines.iter().enumerate().take(last + 1).skip(first) {
let start = span.start.max(line.start).min(line.end) - line.start;
let end = span.end.min(line.end) - line.start;
parts.entry(line_index).or_default().push(Part {
diagnostic: ordinal,
bytes: start..end,
});
}
}
Self {
source,
lines,
parts,
}
}
/// Only actual visible runs are projected. The engine's iterator already
/// applies scroll; the caller supplies the actual padded origin and clip.
pub fn bands(&self, buffer: &cosmic_text::Buffer, origin: Point, clip: Rectangle) -> Vec<Band> {
let mut bands = Vec::new();
if self.parts.is_empty() || clip.width <= 0. || clip.height <= 0. {
return bands;
}
let translation = Vector::new(origin.x, origin.y);
let mut checked_line = None;
let mut source_matches = false;
for run in buffer.layout_runs() {
let Some(parts) = self.parts.get(&run.line_i) else {
continue;
};
let underline_y = origin.y + run.line_top + run.line_height - 2.;
if underline_y >= clip.y + clip.height || underline_y + 1.5 <= clip.y {
continue;
}
// Several visible wraps can share a very large paragraph. Validate
// its exact bytes once in this projection, never in a stale cache.
if checked_line != Some(run.line_i) {
checked_line = Some(run.line_i);
source_matches = self
.lines
.get(run.line_i)
.and_then(|line| self.source.get(line.clone()))
== Some(run.text);
}
if !source_matches {
// A mismatched editor must never remap a diagnostic by text search.
continue;
}
let (first_byte, last_byte) = run
.glyphs
.iter()
.fold((usize::MAX, 0), |(first, last), glyph| {
(first.min(glyph.start), last.max(glyph.end))
});
for part in parts {
// Bidirectional glyphs need not be ordered by source byte. Their
// envelope safely rejects disjoint ranges before a glyph scan.
// Empty ranges retain the separate logical gap/EOF rules.
if !part.bytes.is_empty()
&& (part.bytes.end <= first_byte || part.bytes.start >= last_byte)
{
continue;
}
let mut intervals: Vec<(f32, f32)> = if part.bytes.is_empty() {
anchor(buffer, &run, part.bytes.start)
.into_iter()
.map(|x| (x, x + 4.))
.collect()
} else {
run.glyphs
.iter()
.filter(|glyph| {
glyph.start < part.bytes.end && part.bytes.start < glyph.end
})
.map(|glyph| {
let left = glyph.x.min(glyph.x + glyph.w);
let right = glyph.x.max(glyph.x + glyph.w);
(left, right.max(left + 1.))
})
.collect()
};
intervals.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut merged: Vec<(f32, f32)> = Vec::new();
for (left, right) in intervals {
if let Some(previous) = merged.last_mut()
&& left <= previous.1 + 0.01
{
previous.1 = previous.1.max(right);
} else {
merged.push((left, right));
}
}
for (mut left, mut right) in merged {
if part.bytes.is_empty() {
// An insertion at the right edge remains visible while
// still containing its true boundary, without borrowing a token.
let max_x = clip.x + clip.width - origin.x;
if left <= max_x && right > max_x {
right = max_x;
left = (right - 4.).max(0.);
}
}
let bounds = Rectangle::new(
Point::new(left, run.line_top),
Size::new(right - left, run.line_height),
) + translation;
let underline = Rectangle {
y: bounds.y + bounds.height - 2.,
height: 1.5,
..bounds
};
if let (Some(bounds), Some(underline)) =
(bounds.intersection(&clip), underline.intersection(&clip))
&& bounds.width > 0.
&& bounds.height > 0.
&& underline.width > 0.
&& underline.height > 0.
{
bands.push(Band {
diagnostic: part.diagnostic,
bounds,
underline,
});
}
}
}
}
bands
}
}
/// Prefer the following cluster at a wrap boundary. EOF uses the preceding
/// cluster's trailing edge; an empty paragraph has a real origin insertion.
fn anchor(
buffer: &cosmic_text::Buffer,
run: &cosmic_text::LayoutRun<'_>,
byte: usize,
) -> Option<f32> {
if run.glyphs.is_empty() {
return (byte == 0 && run.text.is_empty()).then_some(0.);
}
if let Some(glyph) = run
.glyphs
.iter()
.find(|glyph| glyph.start <= byte && byte < glyph.end)
{
// Match cosmic-text's cursor heuristic inside a shaped cluster: a
// proportional grapheme advance, including ligatures. This describes
// a caret affordance, not an exact font-provided ligature caret or ink.
let cluster = run.text.get(glyph.start..glyph.end)?;
let mut before = 0;
let mut total = 0;
for (offset, _) in cluster.grapheme_indices(true) {
before += usize::from(glyph.start + offset < byte);
total += 1;
}
let advance = glyph.w * before as f32 / total.max(1) as f32;
return Some(if glyph.level.is_rtl() {
glyph.x + glyph.w - advance
} else {
glyph.x + advance
});
}
// Visual order need not be logical order, even between wrapped runs. First
// find this run's best logical predecessor, not its largest end overall.
let previous = run
.glyphs
.iter()
.filter(|glyph| glyph.end <= byte)
.max_by_key(|glyph| (glyph.end, glyph.start))?;
// Most unrelated runs fail here without scanning the paragraph's layout.
// Only a genuinely omitted separator or EOF may use a predecessor edge.
if !run
.text
.get(previous.end..byte)?
.chars()
.all(char::is_whitespace)
|| (byte != run.text.len() && !run.text.get(byte..)?.chars().next()?.is_whitespace())
{
return None;
}
let layout = buffer.lines.get(run.line_i)?.layout_opt()?;
let key = (previous.end, previous.start);
let mut current_found = false;
for line in layout {
let current = std::ptr::eq(line.glyphs.as_slice(), run.glyphs);
current_found |= current;
for glyph in &line.glyphs {
// A painted source position, including an exact next-run start,
// belongs to that containing run, never to a gap fallback here.
if glyph.start <= byte && byte < glyph.end {
return None;
}
let candidate = (glyph.end, glyph.start);
if glyph.end <= byte && candidate > key {
return None;
}
// A cluster split across visual runs has no single run-local edge.
// Suppress unavailable geometry instead of moving it to a neighbor.
if !current && candidate == key {
return None;
}
}
}
if !current_found {
return None;
}
cluster_trailing_edge(run.glyphs, previous)
}
fn cluster_trailing_edge(
glyphs: &[cosmic_text::LayoutGlyph],
cluster: &cosmic_text::LayoutGlyph,
) -> Option<f32> {
// Multiple glyphs can share a source cluster. Their iterator order follows
// paragraph layout, so its last entry need not be the logical trailing edge.
let rtl = cluster.level.is_rtl();
let mut edge = None;
for glyph in glyphs
.iter()
.filter(|glyph| glyph.start == cluster.start && glyph.end == cluster.end)
{
if glyph.level.is_rtl() != rtl {
return None;
}
let next = if rtl {
glyph.x.min(glyph.x + glyph.w)
} else {
glyph.x.max(glyph.x + glyph.w)
};
edge = Some(edge.map_or(next, |value: f32| {
if rtl {
value.min(next)
} else {
value.max(next)
}
}));
}
edge
}
#[cfg(test)]
mod layout_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod anchor_tests;
#[cfg(test)]
mod performance_tests;
#[cfg(all(test, debug_assertions))]
pub(crate) use widget::tests::AppMounted;
@@ -0,0 +1,260 @@
//! Manual actual-font witnesses; missing supported glyphs fail fixture admission.
use super::*;
use iced::advanced::{
graphics::text::Editor,
text::{
Editor as _, Highlighter as _, LineHeight, Wrapping,
highlighter::{Format, PlainText},
},
};
use iced::{Font, Pixels};
fn shaped(source: &str, width: f32) -> Editor {
let mut editor = Editor::with_text(source);
let mut highlighter = PlainText::new(&());
editor.update(
Size::new(width, 1200.),
Font::MONOSPACE,
Pixels(16.),
LineHeight::Absolute(Pixels(24.)),
Wrapping::WordOrGlyph,
&mut highlighter,
);
editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default());
editor
}
fn trailing(cluster: &[&cosmic_text::LayoutGlyph]) -> f32 {
let rtl = cluster[0].level.is_rtl();
assert!(cluster.iter().all(|g| g.level.is_rtl() == rtl));
if rtl {
cluster
.iter()
.map(|g| g.x.min(g.x + g.w))
.fold(f32::INFINITY, f32::min)
} else {
cluster
.iter()
.map(|g| g.x.max(g.x + g.w))
.fold(f32::NEG_INFINITY, f32::max)
}
}
#[test]
#[ignore = "manual supported-font cluster and bidi witnesses"]
fn final_multi_glyph_cluster_anchor_uses_logical_trailing_advance_edge() {
// Marks without precomposed forms exercise real multi-glyph shaping. Test
// both paragraph directions and both final-cluster directions. No font is
// assumed to support a given specimen; glyph_id=0 cannot admit a witness.
let sources = [
"abc q\u{301}\u{323}",
"אבג q\u{301}\u{323}",
"abc א\u{5b0}\u{5bc}",
"אבג א\u{5b0}\u{5bc}",
"abc ب\u{64e}\u{651}",
"אבג ب\u{64e}\u{651}",
"abc x\u{20dd}",
"אבג x\u{20dd}",
];
let mut admitted = 0;
let mut opposite_direction = 0;
for source in sources {
let editor = shaped(source, 700.);
let runs: Vec<_> = editor.buffer().layout_runs().collect();
assert_eq!(runs.len(), 1, "wide specimen unexpectedly wrapped");
let run = &runs[0];
let Some(start) = run
.glyphs
.iter()
.filter(|g| g.end == source.len())
.map(|g| g.start)
.max()
else {
continue;
};
let cluster: Vec<_> = run
.glyphs
.iter()
.filter(|g| g.start == start && g.end == source.len())
.collect();
if cluster.len() < 2 || cluster.iter().any(|g| g.glyph_id == 0) {
continue;
}
admitted += 1;
opposite_direction += usize::from(cluster[0].level.is_rtl() != run.rtl);
let expected = trailing(&cluster);
let actual = anchor(editor.buffer(), run, source.len());
eprintln!(
"EOF witness {source:?}: paragraph_rtl={} cluster={cluster:?} expected={expected} actual={actual:?}",
run.rtl
);
assert!(
actual.is_some_and(|x| (x - expected).abs() < 0.01),
"logical EOF must follow the whole cluster, including shared-range glyphs; source={source:?}"
);
}
assert!(
admitted > 0,
"fixture admission: current fonts produced no supported multi-glyph final cluster"
);
assert!(
opposite_direction > 0,
"fixture admission: no supported final cluster opposed its paragraph direction"
);
}
#[test]
#[ignore = "manual supported-font cluster and bidi witnesses"]
fn mixed_direction_omitted_separator_has_one_logical_predecessor_anchor() {
let sources = [
"one אבג דהו two זחט three יכל four מנס five עפצ six קרש seven",
"אבג one two דהו three four זחט five six יכל seven eight מנס nine",
];
let mut admitted = 0;
let mut rtl_predecessors = 0;
let mut failures = Vec::new();
let mut old_mismatches = 0;
let mut old_inside_mismatches = 0;
for source in sources {
for width in [72., 96., 120., 144., 180.] {
let editor = shaped(source, width);
let runs: Vec<_> = editor.buffer().layout_runs().collect();
assert!(runs.len() > 1);
assert_eq!(
runs.len(),
editor.buffer().lines[0].layout_opt().unwrap().len(),
"negative control requires the complete unscrolled paragraph"
);
assert!(
runs.iter()
.any(|r| r.glyphs.iter().any(|g| g.level.is_rtl()))
);
assert!(
runs.iter()
.any(|r| r.glyphs.iter().any(|g| !g.level.is_rtl()))
);
for (byte, character) in source.char_indices().filter(|(_, c)| *c == ' ') {
// Require a genuinely omitted separator, not a rendered space.
if runs
.iter()
.any(|r| r.glyphs.iter().any(|g| g.start <= byte && byte < g.end))
{
continue;
}
let Some(end) = runs
.iter()
.flat_map(|r| r.glyphs)
.filter(|g| g.end <= byte)
.map(|g| g.end)
.max()
else {
continue;
};
let Some(next) = runs
.iter()
.flat_map(|r| r.glyphs)
.filter(|g| g.start >= byte + character.len_utf8())
.map(|g| g.start)
.min()
else {
continue;
};
if !source[end..next].chars().all(char::is_whitespace) {
continue;
}
let previous: Vec<_> = runs
.iter()
.enumerate()
.filter(|(_, r)| r.glyphs.iter().any(|g| g.end == end))
.collect();
// A cluster broken across runs is a separate boundary problem;
// it is not admitted as this omitted-separator witness.
if previous.len() != 1 {
continue;
}
let (owner, run) = previous[0];
let start = run
.glyphs
.iter()
.filter(|g| g.end == end)
.map(|g| g.start)
.max()
.unwrap();
let cluster: Vec<_> = run
.glyphs
.iter()
.filter(|g| g.start == start && g.end == end)
.collect();
if cluster.iter().any(|g| g.glyph_id == 0) {
continue;
}
admitted += 1;
rtl_predecessors += usize::from(cluster[0].level.is_rtl());
let expected = trailing(&cluster);
let actual: Vec<_> = runs
.iter()
.enumerate()
.filter_map(|(i, r)| anchor(editor.buffer(), r, byte).map(|x| (i, x)))
.collect();
let matches_expected = |positions: &[(usize, f32)]| {
positions.len() == 1
&& positions[0].0 == owner
&& (positions[0].1 - expected).abs() < 0.01
};
// Exact rejected fallback, for already-proven omitted bytes:
// per-run maximum end and the next visual run's minimum start.
let old: Vec<_> = runs
.iter()
.enumerate()
.filter_map(|(i, r)| {
let last = r.glyphs.iter().max_by_key(|g| g.end)?;
let next_start = runs
.get(i + 1)
.and_then(|next| next.glyphs.iter().map(|g| g.start).min());
(byte >= last.end
&& (next_start.is_some_and(|start| byte < start)
|| (next_start.is_none() && byte == source.len())))
.then_some((
i,
if last.level.is_rtl() {
last.x
} else {
last.x + last.w
},
))
})
.collect();
let inside_clip = expected >= 0. && expected <= width;
if !matches_expected(&old) {
old_mismatches += 1;
old_inside_mismatches += usize::from(inside_clip);
}
eprintln!(
"gap witness width={width} source={source:?} byte={byte} owner={owner} expected={expected} inside_horizontal_clip={inside_clip} actual={actual:?} rejected_old={old:?} cluster={cluster:?}"
);
if !matches_expected(&actual) {
failures.push(format!("width={width} source={source:?} byte={byte} expected=({owner},{expected}) inside={inside_clip} actual={actual:?}"));
}
}
}
}
assert!(
admitted > 0,
"fixture admission: no omitted mixed-direction separator found"
);
assert!(
rtl_predecessors > 0,
"fixture admission: no omitted separator after a supported RTL cluster"
);
eprintln!(
"gap summary admitted={admitted} rejected_old_mismatches={old_mismatches} rejected_old_inside_horizontal_clip={old_inside_mismatches}"
);
assert!(
old_mismatches > 0,
"actual-layout negative control must discriminate the old fallback"
);
assert!(
failures.is_empty(),
"mixed-bidi logical anchor failures: {failures:#?}"
);
}
@@ -0,0 +1,321 @@
//! Real shaped-layout projection checks. No window/GPU or hover authority claim.
use super::*;
use iced::advanced::{
graphics::text::Editor,
text::{
Editor as _, Highlighter as _, LineHeight, Wrapping,
highlighter::{Format, PlainText},
},
};
use iced::{Font, Pixels};
use oa_core::{
language::{Diagnostic, Span},
validation::Severity,
};
const ORIGIN: Point = Point::new(37., 53.);
fn diagnostic(start: usize, end: usize) -> SourceDiagnostic {
SourceDiagnostic {
path: "a".into(),
diagnostic: Diagnostic {
severity: Severity::Error,
code: "layout".into(),
message: "fixture".into(),
object_id: None,
field: None,
line: 999,
column: 999,
span: Span { start, end },
},
related: Vec::new(),
}
}
fn editor(source: &str, width: f32, height: f32) -> Editor {
let mut editor = Editor::with_text(source);
let mut highlighter = PlainText::new(&());
editor.update(
Size::new(width, height),
Font::MONOSPACE,
Pixels(16.),
LineHeight::Absolute(Pixels(24.)),
Wrapping::WordOrGlyph,
&mut highlighter,
);
editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default());
editor
}
fn index(source: &str, ranges: &[Range<usize>]) -> Index {
Index::new(
Arc::from(source),
"a",
&ranges
.iter()
.map(|r| diagnostic(r.start, r.end))
.collect::<Vec<_>>(),
)
}
fn clip(width: f32, height: f32) -> Rectangle {
Rectangle::new(ORIGIN, Size::new(width, height))
}
fn covered(bands: &[Band], point: Point, ordinal: usize) -> bool {
bands
.iter()
.any(|b| b.diagnostic == ordinal && b.bounds.contains(point))
}
fn center(run: &cosmic_text::LayoutRun<'_>, glyph: &cosmic_text::LayoutGlyph) -> Point {
Point::new(
ORIGIN.x + glyph.x + glyph.w / 2.,
ORIGIN.y + run.line_top + run.line_height / 2.,
)
}
#[test]
fn repeated_alias_marks_only_second_actual_glyph_sequence_and_blank_space_misses() {
let source = "alias alias";
let editor = editor(source, 500., 120.);
let bands = index(source, std::slice::from_ref(&(6..11))).bands(
editor.buffer(),
ORIGIN,
clip(500., 120.),
);
let run = editor.buffer().layout_runs().next().unwrap();
let mut selected = 0;
let mut rejected = 0;
for glyph in run.glyphs {
let at = center(&run, glyph);
if glyph.start >= 6 {
assert!(covered(&bands, at, 0));
selected += 1;
}
if glyph.end <= 5 {
assert!(!covered(&bands, at, 0));
rejected += 1;
}
}
assert!(selected > 0 && rejected > 0);
assert!(!covered(
&bands,
Point::new(ORIGIN.x + 450., ORIGIN.y + 12.),
0
));
assert!(!covered(
&bands,
Point::new(ORIGIN.x + 12., ORIGIN.y + 100.),
0
));
// Wrong-origin negative control: expected source glyphs are not at raw local positions.
assert!(!covered(&bands, Point::new(0., 0.), 0));
}
#[test]
fn wrapped_scrolled_multiline_range_uses_actual_visible_runs_and_effective_clip() {
let source = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu\nsecond source line has more words\nthird source line\n";
let mut editor = editor(source, 150., 110.);
assert!(
editor
.buffer()
.layout_runs()
.filter(|r| r.line_i == 0)
.count()
>= 2
);
editor.perform(iced::widget::text_editor::Action::Scroll { lines: 2 });
let scroll = editor.buffer().scroll();
assert!(scroll.line > 0 || scroll.vertical > 0.);
let effective = Rectangle::new(
Point::new(ORIGIN.x + 9., ORIGIN.y + 5.),
Size::new(126., 91.),
);
let bands = index(source, std::slice::from_ref(&(0..source.len()))).bands(
editor.buffer(),
ORIGIN,
effective,
);
assert!(!bands.is_empty());
for b in &bands {
assert_eq!(b.bounds.intersection(&effective), Some(b.bounds));
assert_eq!(b.underline.intersection(&effective), Some(b.underline));
}
let mut checked = 0;
for run in editor.buffer().layout_runs() {
// Only rows whose actual underline is inside the clip are hoverable by policy.
let underline_y = ORIGIN.y + run.line_top + run.line_height - 1.;
if underline_y < effective.y || underline_y >= effective.y + effective.height {
continue;
}
for glyph in run.glyphs {
let at = center(&run, glyph);
if effective.contains(at) {
assert!(covered(&bands, at, 0), "visible glyph {glyph:?}");
checked += 1;
}
}
}
assert!(checked > 0);
assert_eq!(editor.buffer().scroll(), scroll);
assert!(
index(source, std::slice::from_ref(&(0..source.len())))
.bands(
editor.buffer(),
ORIGIN,
Rectangle::new(Point::new(-500., -500.), Size::new(10., 10.))
)
.is_empty()
);
}
#[test]
fn combining_cjk_and_bidi_ranges_cover_actual_clusters_without_neighbor_aliases() {
let source = "left e\u{301} 救援 abc אבג xyz right";
let editor = editor(source, 700., 120.);
let combining = source.find("e\u{301}").unwrap();
let cjk = source.find('救').unwrap();
let rtl = source.find('א').unwrap();
let ranges = [
combining + 1..combining + 3,
cjk..cjk + "救援".len(),
rtl..rtl + "אבג".len(),
];
let bands = index(source, &ranges).bands(editor.buffer(), ORIGIN, clip(700., 120.));
let run = editor.buffer().layout_runs().next().unwrap();
assert!(
run.glyphs.iter().any(|g| g.level.is_rtl()),
"fixture must shape RTL"
);
assert!(
run.glyphs
.iter()
.any(|g| g.start <= combining && g.end >= combining + 3),
"fixture must shape combining cluster"
);
for (ordinal, range) in ranges.iter().enumerate() {
let targets: Vec<_> = run
.glyphs
.iter()
.filter(|g| g.start < range.end && range.start < g.end)
.collect();
assert!(!targets.is_empty());
for g in targets {
assert!(covered(&bands, center(&run, g), ordinal));
}
}
for g in run.glyphs.iter().filter(|g| {
g.end <= 4
|| g.start >= source.find("right").unwrap()
|| (g.start >= source.find("abc").unwrap() && g.end <= source.find("abc").unwrap() + 3)
|| (g.start >= source.find("xyz").unwrap() && g.end <= source.find("xyz").unwrap() + 3)
}) {
assert!(!(0..ranges.len()).any(|n| covered(&bands, center(&run, g), n)));
}
}
#[test]
fn omitted_wrap_space_has_one_preceding_run_anchor_and_none_when_that_run_is_clipped_out() {
let source = "one two three four five six seven eight nine ten";
let mut found = None;
for width in [60., 72., 84., 96., 108., 120., 132., 144.] {
let e = editor(source, width, 600.);
let gap = {
let runs: Vec<_> = e.buffer().layout_runs().collect();
runs.windows(2).find_map(|pair| {
if pair[0].line_i != pair[1].line_i {
return None;
}
let before = pair[0].glyphs.iter().max_by_key(|g| g.end)?;
let after = pair[1].glyphs.iter().map(|g| g.start).min()?;
(before.end < after && source[before.end..after].chars().all(char::is_whitespace))
.then_some((
before.end,
before.x + before.w,
pair[0].line_top,
pair[0].line_height,
pair[1].line_top,
))
})
};
if let Some(gap) = gap {
found = Some((e, width, gap));
break;
}
}
let (editor, width, (byte, edge, top, height, next_top)) =
found.expect("actual wrapping must omit a separator; do not silently skip fixture");
let idx = index(source, std::slice::from_ref(&(byte..byte)));
let bands = idx.bands(editor.buffer(), ORIGIN, clip(width, 600.));
assert_eq!(
bands.len(),
1,
"exactly the preceding actual run owns the gap"
);
assert!((bands[0].bounds.y - (ORIGIN.y + top)).abs() < 0.01);
assert!(
bands[0].bounds.x <= ORIGIN.x + edge
&& bands[0].bounds.x + bands[0].bounds.width >= ORIGIN.x + edge
);
assert!((bands[0].bounds.height - height).abs() < 0.01);
let later_only = Rectangle::new(
Point::new(ORIGIN.x, ORIGIN.y + next_top),
Size::new(width, 100.),
);
assert!(
idx.bands(editor.buffer(), ORIGIN, later_only).is_empty(),
"must not relocate hidden prior-run anchor into next visible run"
);
}
#[test]
fn empty_eof_and_separator_ranges_follow_real_paragraph_ownership() {
for source in ["", "a", "a\n"] {
let editor = editor(source, 200., 200.);
let byte = source.len();
let bands = index(source, std::slice::from_ref(&(byte..byte))).bands(
editor.buffer(),
ORIGIN,
clip(200., 200.),
);
assert_eq!(bands.len(), 1, "EOF {source:?}");
let run = editor.buffer().layout_runs().last().unwrap();
assert!((bands[0].bounds.y - (ORIGIN.y + run.line_top)).abs() < 0.01);
let edge = run.glyphs.last().map_or(0., |g| g.x + g.w);
assert!(
bands[0].bounds.x <= ORIGIN.x + edge
&& bands[0].bounds.x + bands[0].bounds.width >= ORIGIN.x + edge
);
}
let source = "a\r\n\r\n雨\nz";
let editor = editor(source, 200., 200.);
let bands =
index(source, &[0..source.len(), 1..3]).bands(editor.buffer(), ORIGIN, clip(200., 200.));
let runs: Vec<_> = editor.buffer().layout_runs().collect();
assert_eq!(runs.len(), 4);
for run in &runs {
let x = run.glyphs.first().map_or(2., |g| g.x + g.w / 2.);
assert!(covered(
&bands,
Point::new(ORIGIN.x + x, ORIGIN.y + run.line_top + run.line_height / 2.),
0
));
}
let separator: Vec<_> = bands.iter().filter(|b| b.diagnostic == 1).collect();
assert_eq!(separator.len(), 1);
assert_eq!(separator[0].bounds.y, ORIGIN.y + runs[0].line_top);
}
#[test]
fn mismatched_affected_source_and_bottom_line_without_visible_underline_have_no_bands() {
let actual = editor("other", 300., 100.);
assert!(
index("alias", std::slice::from_ref(&(0..5)))
.bands(actual.buffer(), ORIGIN, clip(300., 100.))
.is_empty()
);
let actual = editor("alias", 300., 100.);
let run = actual.buffer().layout_runs().next().unwrap();
let top_only = Rectangle::new(ORIGIN, Size::new(300., run.line_height / 2.));
assert!(
index("alias", std::slice::from_ref(&(0..5)))
.bands(actual.buffer(), ORIGIN, top_only)
.is_empty(),
"partially visible text with clipped underline is not hoverable by declared policy"
);
}
@@ -0,0 +1,216 @@
//! Manual actual-layout timing distributions; no fixed machine threshold.
use super::*;
use iced::advanced::{
graphics::text::Editor,
text::{
Editor as _, Highlighter as _, LineHeight, Wrapping,
highlighter::{Format, PlainText},
},
};
use iced::{Font, Pixels};
use oa_core::{
language::{Diagnostic, Span},
validation::Severity,
};
use std::{hint::black_box, time::Instant};
fn diagnostic(start: usize, end: usize) -> SourceDiagnostic {
SourceDiagnostic {
path: "a".into(),
diagnostic: Diagnostic {
severity: Severity::Warning,
code: "perf".into(),
message: "prepared geometry specimen".into(),
object_id: None,
field: None,
line: 0,
column: 0,
span: Span { start, end },
},
related: Vec::new(),
}
}
fn shaped(source: &str) -> Editor {
let mut e = Editor::with_text(source);
settle_layout(&mut e);
e
}
// Stock TextEditor calls Editor::update during the next layout after input.
// Scroll initially changes pixel offset only; update normalizes logical scroll
// and shapes newly visible paragraphs before draw/highlight consumes them.
fn settle_layout(e: &mut Editor) {
let mut h = PlainText::new(&());
e.update(
Size::new(640., 240.),
Font::MONOSPACE,
Pixels(16.),
LineHeight::Absolute(Pixels(24.)),
Wrapping::WordOrGlyph,
&mut h,
);
e.highlight(Font::MONOSPACE, &mut h, |_| Format::default());
}
fn distribution<T>(label: &str, batch: usize, mut operation: impl FnMut() -> T) {
drop(black_box(operation())); // one excluded warmup
let mut samples = Vec::new();
for _ in 0..5 {
let mut elapsed = 0;
for _ in 0..batch {
let start = Instant::now();
let value = operation();
black_box(&value);
elapsed += start.elapsed().as_nanos();
drop(value); // allocation/destruction separated: operation includes allocation, excludes drop
}
samples.push(elapsed / batch as u128);
}
let mut sorted = samples.clone();
sorted.sort_unstable();
println!(
"TIMING {label} batch={batch} warmup=1 measured=5 ns_per_call={samples:?} min={} median={} max={} drop_excluded=true",
sorted[0], sorted[2], sorted[4]
);
}
fn run_view(label: &str, source: Arc<str>, editor: &Editor, dense: &[SourceDiagnostic]) {
let buffer = editor.buffer();
let runs: Vec<_> = buffer.layout_runs().collect();
assert!(!runs.is_empty());
let visible_glyphs: usize = runs.iter().map(|r| r.glyphs.len()).sum();
let first = runs
.iter()
.find_map(|r| {
r.glyphs
.iter()
.find(|g| g.w > 0.)
.map(|g| (r.line_i, g.start, g.end))
})
.unwrap();
let line_starts: Vec<_> = cosmic_text::LineIter::new(&source)
.map(|(r, _)| r.start)
.collect();
let sparse = [diagnostic(
line_starts[first.0] + first.1,
line_starts[first.0] + first.2,
)];
let sparse_index = Index::new(source.clone(), "a", &sparse);
let dense_index = Index::new(source.clone(), "a", dense);
let empty_index = Index::new(source.clone(), "a", &[]);
let origin = Point::new(17., 29.);
let clip = Rectangle::new(origin, Size::new(640., 240.));
let sparse_bands = sparse_index.bands(buffer, origin, clip);
assert!(!sparse_bands.is_empty(), "positive visible glyph fixture");
assert!(empty_index.bands(buffer, origin, clip).is_empty());
let rejected = Rectangle::new(Point::new(-1000., -1000.), Size::new(1., 1.));
assert!(
dense_index.bands(buffer, origin, rejected).is_empty(),
"offscreen-output negative control"
);
let dense_count = dense_index.bands(buffer, origin, clip).len();
println!(
"CASE {label} bytes={} paragraphs={} diagnostics={} visible_runs={} visible_glyphs={visible_glyphs} dense_bands={dense_count} scroll={:?}",
source.len(),
buffer.lines.len(),
dense.len(),
runs.len(),
buffer.scroll()
);
distribution(&format!("{label}/index-build-dense"), 1, || {
Index::new(black_box(source.clone()), "a", black_box(dense))
});
distribution(&format!("{label}/layout-runs-only"), 8, || {
black_box(buffer)
.layout_runs()
.map(|r| r.glyphs.len())
.sum::<usize>()
});
distribution(&format!("{label}/bands-no-diagnostics"), 8, || {
empty_index.bands(black_box(buffer), origin, clip)
});
distribution(&format!("{label}/bands-one-visible"), 8, || {
sparse_index.bands(black_box(buffer), origin, clip)
});
distribution(&format!("{label}/bands-dense"), 8, || {
dense_index.bands(black_box(buffer), origin, clip)
});
distribution(&format!("{label}/bands-dense-rejected-clip"), 8, || {
dense_index.bands(black_box(buffer), origin, rejected)
});
}
#[test]
#[ignore = "manual diagnostic projection distributions; no fixed machine threshold"]
fn annotation_projection_cost_distributions() {
// Ordinary file: many logical paragraphs; deep scroll can skip by paragraph.
let line = "alpha beta gamma delta epsilon\n";
let ordinary: Arc<str> = Arc::from(line.repeat(4000));
let ordinary_diagnostics: Vec<_> = (0..4000)
.map(|i| diagnostic(i * line.len(), i * line.len() + 5))
.collect();
let mut ordinary_editor = shaped(&ordinary);
run_view(
"ordinary/top",
ordinary.clone(),
&ordinary_editor,
&ordinary_diagnostics,
);
ordinary_editor.perform(iced::widget::text_editor::Action::Scroll { lines: 2000 });
settle_layout(&mut ordinary_editor);
assert!(
ordinary_editor.buffer().scroll().line > 1000,
"must really reach a deep logical paragraph"
);
assert!(
ordinary_editor
.buffer()
.layout_runs()
.next()
.unwrap()
.line_i
> 1000,
"newly visible deep paragraph must actually be shaped"
);
run_view(
"ordinary/deep",
ordinary,
&ordinary_editor,
&ordinary_diagnostics,
);
// Single paragraph: diagnostics near beginning remain indexed on the same
// logical line even when the visible runs are far below all of them.
let token = "alpha beta gamma delta ";
let single: Arc<str> = Arc::from(token.repeat(6000));
let dense: Vec<_> = (0..1500)
.map(|i| diagnostic(i * token.len(), i * token.len() + 5))
.collect();
let mut single_editor = shaped(&single);
let wrap_count = single_editor.buffer().lines[0].layout_opt().unwrap().len();
assert!(wrap_count > 1000, "fixture must shape thousands of wraps");
run_view("single/top", single.clone(), &single_editor, &dense);
single_editor.perform(iced::widget::text_editor::Action::Scroll {
lines: (wrap_count / 2) as i32,
});
settle_layout(&mut single_editor);
let buffer = single_editor.buffer();
assert_eq!(buffer.scroll().line, 0);
assert!(
buffer.scroll().vertical > 1000.,
"must really scroll within same paragraph"
);
let first_visible = buffer.layout_runs().next().unwrap();
assert!(
first_visible.glyphs.iter().map(|g| g.start).min().unwrap()
> dense.last().unwrap().diagnostic.span.end,
"dense diagnostics must all be above the deep viewport"
);
run_view(
"single/deep-offscreen-diagnostics",
single,
&single_editor,
&dense,
);
println!(
"DONE manual cost distributions; compare controls and top/deep ratios, not a platform-independent latency PASS"
);
}
@@ -0,0 +1,83 @@
use super::*;
use oa_core::{
language::{Diagnostic, Span},
validation::Severity,
};
fn diagnostic(path: &str, start: usize, end: usize) -> SourceDiagnostic {
SourceDiagnostic {
path: path.into(),
diagnostic: Diagnostic {
severity: Severity::Error,
code: "test".into(),
message: "synthetic admission fixture".into(),
object_id: None,
field: None,
line: 999,
column: 999,
span: Span { start, end },
},
related: Vec::new(),
}
}
#[test]
fn exact_path_and_utf8_range_admission_keep_valid_neighbor() {
let index = Index::new(
Arc::from("雨 x"),
"a.arcadia",
&[
diagnostic("b.arcadia", 0, 3),
diagnostic("a.arcadia", 1, 3),
diagnostic("a.arcadia", 4, 2),
diagnostic("a.arcadia", 0, 99),
diagnostic("a.arcadia", 4, 5),
],
);
assert_eq!(index.parts.len(), 1);
let parts = &index.parts[&0];
assert_eq!(parts.len(), 1);
assert_eq!((parts[0].diagnostic, parts[0].bytes.clone()), (4, 4..5));
}
#[test]
fn mixed_endings_use_actual_paragraphs_and_preserve_eof() {
let index = Index::new(
Arc::from("a\r\n雨\rb\n\rc\n"),
"a",
&[
diagnostic("a", 3, 6),
diagnostic("a", 7, 8),
diagnostic("a", 12, 12),
],
);
assert_eq!(index.lines, vec![0..1, 3..6, 7..8, 10..11, 12..12]);
assert_eq!(index.parts[&1][0].bytes, 0..3);
assert_eq!(index.parts[&2][0].bytes, 0..1);
assert_eq!(index.parts[&4][0].bytes, 0..0);
}
#[test]
fn half_open_next_line_excluded_separator_only_and_empty_line_anchored() {
let index = Index::new(
Arc::from("ab\r\n\r\ncd"),
"a",
&[
diagnostic("a", 0, 4),
diagnostic("a", 2, 4),
diagnostic("a", 4, 6),
],
);
assert_eq!(index.parts.len(), 2);
assert_eq!(index.parts[&0][0].bytes, 0..2);
assert_eq!(index.parts[&0][1].bytes, 2..2);
assert_eq!(index.parts[&1][0].bytes, 0..0);
assert!(!index.parts.contains_key(&2));
}
#[test]
fn empty_source_has_real_zero_width_marker() {
let index = Index::new(Arc::from(""), "a", &[diagnostic("a", 0, 0)]);
assert_eq!(index.lines, vec![0..0]);
assert_eq!(index.parts[&0][0].bytes, 0..0);
}
@@ -0,0 +1,425 @@
//! Permanent passive decoration around the existing editor. Draw uses current
//! geometry; only the source index is cached, never pointer hitboxes or messages.
//! Current-frame tooltip paragraphs stay owned until deferred renderer preparation.
use super::Index;
use iced::advanced::text::Paragraph as _;
use iced::{
Element, Event, Font, Length, Padding, Point, Rectangle, Size, Theme, Vector,
advanced::{
Clipboard, Layout, Shell, Widget, graphics, layout, overlay, renderer, text, widget,
},
alignment, mouse,
widget::text_editor,
};
use oa_core::{Id, source_project::SourceDiagnostic, validation::Severity};
use std::{cell::RefCell, sync::Arc};
pub(crate) struct Decorations<'a, Message, R: text::Renderer> {
child: Element<'a, Message, Theme, R>,
content: &'a text_editor::Content<R>,
snapshot: Option<Snapshot<'a>>,
padding: Padding,
hover_enabled: bool,
}
/// Supplied only after the App admits a current diagnostic publication for the
/// displayed draft. Absence keeps the editor mounted with no annotations.
pub(crate) struct Snapshot<'a> {
pub publication: Id,
pub source: Arc<str>,
pub path: &'a str,
pub diagnostics: &'a [SourceDiagnostic],
}
impl<'a, Message, R: text::Renderer> Decorations<'a, Message, R> {
pub fn new(
child: impl Into<Element<'a, Message, Theme, R>>,
content: &'a text_editor::Content<R>,
snapshot: Option<Snapshot<'a>>,
padding: Padding,
hover_enabled: bool,
) -> Self {
Self {
child: child.into(),
content,
snapshot,
padding,
hover_enabled,
}
}
}
struct Cached {
publication: Id,
path: String,
source: Arc<str>,
index: Index,
}
#[derive(Default)]
struct State {
index: RefCell<Option<Cached>>,
details: RefCell<Option<DetailsParagraphs>>,
}
// WGPU queues weak paragraph references during draw and upgrades them later.
// This is render-resource ownership, not a cache authorizing future hover hits.
struct DetailsParagraphs {
_body: graphics::text::Paragraph,
_footer: graphics::text::Paragraph,
}
impl<Message, R> Widget<Message, Theme, R> for Decorations<'_, Message, R>
where
R: text::Renderer<
Font = Font,
Editor = graphics::text::Editor,
Paragraph = graphics::text::Paragraph,
>,
{
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.child)]
}
fn diff(&self, tree: &mut widget::Tree) {
// A publication change only replaces the read-only index. The editor's
// existing Tree and hidden selection/IME/scroll state remain mounted.
tree.diff_children(std::slice::from_ref(&self.child));
}
fn size(&self) -> Size<Length> {
self.child.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.child.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.child
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.child
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if matches!(
event,
Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorLeft)
) {
// Movement within a focused editor need not change stock status.
// Request painting, but do not capture or publish any input action.
shell.request_redraw();
}
self.child.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.child.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
let state = tree.state.downcast_ref::<State>();
// Revoke the preceding frame's text even on every early/hidden path.
// Current source admission and current geometry alone decide new text.
let mut details = state.details.borrow_mut();
*details = None;
let mut cache = state.index.borrow_mut();
let Some(snapshot) = &self.snapshot else {
*cache = None;
return;
};
let inner = layout.bounds().shrink(self.padding);
let Some(clip) = inner
.intersection(viewport)
.filter(|clip| clip.width > 0. && clip.height > 0.)
else {
return;
};
if cache.as_ref().is_none_or(|cached| {
cached.publication != snapshot.publication
|| cached.path != snapshot.path
|| !Arc::ptr_eq(&cached.source, &snapshot.source)
}) {
*cache = Some(Cached {
publication: snapshot.publication,
path: snapshot.path.into(),
source: snapshot.source.clone(),
index: Index::new(snapshot.source.clone(), snapshot.path, snapshot.diagnostics),
});
}
// Child draw has now applied the actual syntax highlighter. Release the
// Content borrow before further renderer work; no callback reentrancy.
let mut bands = self.content.with_editor(|editor| {
cache
.as_ref()
.unwrap()
.index
.bands(editor.buffer(), inner.position(), clip)
});
bands.sort_by_key(
|band| match snapshot.diagnostics[band.diagnostic].diagnostic.severity {
Severity::Warning => 0,
Severity::Error => 1,
},
);
let palette = theme.palette();
renderer.start_layer(clip);
for band in &bands {
let color = match snapshot.diagnostics[band.diagnostic].diagnostic.severity {
Severity::Warning => palette.warning,
Severity::Error => palette.danger,
};
renderer.fill_quad(
renderer::Quad {
bounds: band.underline,
..Default::default()
},
color,
);
}
if self.hover_enabled
&& let Some(point) = cursor.position_over(clip)
{
let mut hits: Vec<_> = bands
.iter()
.filter(|band| band.bounds.contains(point))
.map(|band| band.diagnostic)
.collect();
hits.sort_unstable();
hits.dedup();
hits.sort_by_key(|ordinal| {
(
match snapshot.diagnostics[*ordinal].diagnostic.severity {
Severity::Error => 0,
Severity::Warning => 1,
},
*ordinal,
)
});
if !hits.is_empty() {
*details = draw_details(renderer, theme, snapshot.diagnostics, &hits, point, clip);
}
}
renderer.end_layer();
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.child.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: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.child.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: 'a, R> From<Decorations<'a, Message, R>> for Element<'a, Message, Theme, R>
where
R: text::Renderer<
Font = Font,
Editor = graphics::text::Editor,
Paragraph = graphics::text::Paragraph,
> + 'a,
{
fn from(value: Decorations<'a, Message, R>) -> Self {
Element::new(value)
}
}
fn draw_details<R: text::Renderer<Font = Font, Paragraph = graphics::text::Paragraph>>(
renderer: &mut R,
theme: &Theme,
diagnostics: &[SourceDiagnostic],
hits: &[usize],
point: Point,
clip: Rectangle,
) -> Option<DetailsParagraphs> {
const PAD: f32 = 8.;
if clip.width < 100. || clip.height < 80. {
return None;
}
let width = 520_f32.min(clip.width);
let mut details = Vec::new();
for ordinal in hits.iter().take(4) {
let issue = &diagnostics[*ordinal];
let d = &issue.diagnostic;
let severity = match d.severity {
Severity::Error => "Error",
Severity::Warning => "Warning",
};
let raw = format!(
"{severity} [{}] · {}:{}\n{}",
d.code, d.line, d.column, d.message
);
let mut value: String = raw.chars().take(1000).collect();
if value.len() < raw.len() {
value.push('…');
}
if !issue.related.is_empty() {
value.push_str(&format!(
"\n{} related location(s) in Problems",
issue.related.len()
));
}
details.push(value);
}
let content = details.join("\n\n");
let paragraph = graphics::text::Paragraph::with_text(text::Text {
content: content.as_str(),
bounds: Size::new(width - PAD * 2., f32::INFINITY),
size: 12.into(),
line_height: text::LineHeight::Relative(1.3),
font: renderer.default_font(),
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Top,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::WordOrGlyph,
});
let remaining = hits.len().saturating_sub(4);
let footer = if remaining > 0 {
format!(
"4 of {} shown · {remaining} more\nFull text: Actions → Source problems",
hits.len()
)
} else {
"Full text and locations: Actions → Source problems".into()
};
let footer_paragraph = graphics::text::Paragraph::with_text(text::Text {
content: footer.as_str(),
bounds: Size::new(width - PAD * 2., f32::INFINITY),
size: 11.into(),
line_height: text::LineHeight::Relative(1.2),
font: renderer.default_font(),
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Top,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::WordOrGlyph,
});
let footer_height = footer_paragraph.min_bounds().height;
let available_body = clip.height - PAD * 2. - footer_height - 4.;
if available_body < 16. {
return None;
}
let max_body_height =
(clip.height * 0.6 - PAD * 2. - footer_height - 4.).clamp(16., available_body);
let body_height = paragraph.min_bounds().height.min(max_body_height);
let height = PAD * 2. + body_height + 4. + footer_height;
let x = point.x.min(clip.x + clip.width - width).max(clip.x);
let y = if point.y + 18. + height <= clip.y + clip.height {
point.y + 18.
} else {
(point.y - 18. - height).max(clip.y)
};
let bounds = Rectangle::new(Point::new(x, y), Size::new(width, height));
let palette = theme.extended_palette();
renderer.fill_quad(
renderer::Quad {
bounds,
border: iced::Border {
color: palette.background.strong.color,
width: 1.,
radius: 4.into(),
},
..Default::default()
},
palette.background.weak.color,
);
let body = Rectangle::new(
Point::new(x + PAD, y + PAD),
Size::new(width - PAD * 2., body_height),
);
renderer.fill_paragraph(
&paragraph,
body.position(),
palette.background.weak.text,
body,
);
let footer_bounds = Rectangle::new(
Point::new(x + PAD, body.y + body.height + 4.),
Size::new(width - PAD * 2., footer_height),
);
renderer.fill_paragraph(
&footer_paragraph,
footer_bounds.position(),
palette.background.weak.text,
footer_bounds,
);
Some(DetailsParagraphs {
_body: paragraph,
_footer: footer_paragraph,
})
}
#[cfg(all(test, debug_assertions))]
pub(crate) mod tests;
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -1158,7 +1158,16 @@ fn source(app: &App) -> View<'_, Message> {
publication.map(|p| (p.generation(), app.text_workspace.drawer_visit())),
diagnostics,
);
text_workspace::view(app, source_symbols::panel(app), guard.into())
// The complete byte/evidence comparison is shared by the summary and rows,
// once per visible References rebuild; no parser or second query is started.
let references_current =
app.text_workspace.drawer() == Some(DrawerTab::References) && app.source_results_current();
text_workspace::view(
app,
source_symbols::panel(app, references_current),
guard.into(),
references_current,
)
}
pub(crate) fn source_binding(
@@ -1580,3 +1589,8 @@ mod tests {
pub(crate) fn captured_text_drawer_resize(app: &App) -> Message {
text_workspace::capture_drawer_resize(app)
}
#[cfg(all(test, debug_assertions))]
pub(crate) fn captured_source_reference_stale_summary(app: &App) -> bool {
text_workspace::summary_visibility_tests::rendered_stale(app)
}
@@ -11,7 +11,7 @@ use iced::{
widget::{button, column, container, scrollable, text, tooltip},
};
pub(super) fn panel(app: &App) -> Element<'_, Message> {
pub(super) fn panel(app: &App, current: bool) -> Element<'_, Message> {
use crate::app::text_workspace::{DrawerTab, Event};
use std::collections::BTreeSet;
let state = &app.source_symbols;
@@ -22,7 +22,7 @@ pub(super) fn panel(app: &App) -> Element<'_, Message> {
content = content.push(text(notice.clone()).size(12));
}
if visible && let Some(results) = &state.results {
let stale = results.stamp.snapshot != app.source_key();
let stale = !current;
let enabled = !stale && app.source_navigation_available();
let files: BTreeSet<_> = results
.definitions
+14 -4
View File
@@ -15,6 +15,7 @@ pub(crate) const STRIP_HEIGHT: f32 = 42.;
pub(crate) const FIND_HEIGHT: f32 = 36.;
pub(crate) const DRAWER_HEIGHT: f32 = 180.;
pub(crate) const STATUS_HEIGHT: f32 = 22.;
const EDITOR_PADDING: f32 = 16.;
/// The caller supplies one persistent diagnostic Guard, including when hidden
/// (None publication and empty child). References is empty while hidden; its
@@ -23,6 +24,7 @@ pub(super) fn view<'a>(
app: &'a App,
references: Element<'a, Message>,
problems: Element<'a, Message>,
references_current: bool,
) -> Element<'a, Message> {
let tab = app.text_workspace.drawer();
let measured_drawer = Rc::new(Cell::new(DRAWER_HEIGHT));
@@ -63,12 +65,19 @@ pub(super) fn view<'a>(
})
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.padding(EDITOR_PADDING)
.height(Fill)
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
app.source_snapshot.clone(),
crate::highlight::format,
);
let editor = crate::source_annotations::Decorations::new(
editor,
&app.source_editor,
app.source_annotation_snapshot(),
iced::Padding::new(EDITOR_PADDING),
app.source_navigation_available(),
);
let editor = widget::sensor(editor)
.key((
app.source_workspace.active.clone(),
@@ -84,7 +93,7 @@ pub(super) fn view<'a>(
});
let (errors, warnings) = diagnostic_counts(app);
let summary: Element<'a, Message> = match tab {
Some(DrawerTab::References) => reference_summary(app),
Some(DrawerTab::References) => reference_summary(app, references_current),
Some(DrawerTab::Problems) => text(format!(
"{errors} source errors · {warnings} warnings · choose a location for details"
))
@@ -306,6 +315,7 @@ fn find_bar(app: &App) -> Element<'_, Message> {
fn reference_summary<'a, Renderer: iced::advanced::text::Renderer + 'a>(
app: &'a App,
current: bool,
) -> Element<'a, Message, iced::Theme, Renderer> {
let Some(results) = &app.source_symbols.results else {
return text(if app.source_symbols.in_flight.is_some() {
@@ -326,7 +336,7 @@ fn reference_summary<'a, Renderer: iced::advanced::text::Renderer + 'a>(
results.total_definitions, results.total_references
);
let mut flags = Vec::new();
if results.stamp.snapshot != app.source_key() {
if !current {
flags.push("Stale");
}
if results.scope.partial() {
@@ -1104,7 +1114,7 @@ mod tests {
}
#[cfg(all(test, debug_assertions))]
mod summary_visibility_tests;
pub(crate) mod summary_visibility_tests;
/// The same retained layout and Alt+PageDown publisher as the production view;
/// only its five content slots are inert for this CPU integration witness.
@@ -117,6 +117,7 @@ const FLAGS: [&str; 5] = ["Stale", "Partial", "Collision", "Detached", "Unverifi
fn draw_and_check_flags(
content: Element<'_, Message, iced::Theme, Recorder>,
width: f32,
flags: &[&str],
) -> Vec<String> {
let viewport = Rectangle::with_size(Size::new(width, 24.));
let mut renderer = Recorder {
@@ -143,7 +144,7 @@ fn draw_and_check_flags(
&viewport,
);
let mut failures = Vec::new();
for flag in FLAGS {
for &flag in flags {
for byte in 0..flag.len() {
let matching: Vec<_> = renderer
.clusters
@@ -221,14 +222,18 @@ fn all_reference_exceptions_fit_allocated_width_with_wide_alias_and_large_counts
omitted_references: 100_000,
}));
for width in [840., 720.] {
let positive = draw_and_check_flags(reference_summary(&app), width);
let positive = draw_and_check_flags(
reference_summary(&app, app.source_results_current()),
width,
&FLAGS,
);
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);
let old = draw_and_check_flags(old_summary(old_value.clone()), width, &FLAGS);
println!(
"old alias-first summary width={width} alias={alias:?}: {} missing/clipped flag-byte checks; first={:?}",
old.len(),
@@ -240,9 +245,18 @@ fn all_reference_exceptions_fit_allocated_width_with_wide_alias_and_large_counts
// 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.);
let rejected = draw_and_check_flags(old_summary(stress), 720., &FLAGS);
assert!(
!rejected.is_empty(),
"negative control must detect missing/clipped warning text"
);
}
pub(crate) fn rendered_stale(app: &App) -> bool {
draw_and_check_flags(
reference_summary(app, app.source_results_current()),
720.,
&["Stale"],
)
.is_empty()
}