Integrate diagnostic decorations and current-source Text input boundaries
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t.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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
¶graph,
|
||||
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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user