Compose compact Text workspace with guarded root navigation
This commit is contained in:
@@ -2,80 +2,151 @@ use crate::{
|
||||
app::{App, Message},
|
||||
source_navigation::{
|
||||
SymbolEvent,
|
||||
query::{self, Declaration, Disposition, Evidence, Identity, Membership, Position},
|
||||
query::{Declaration, Disposition, Evidence, Identity, Membership, Position},
|
||||
},
|
||||
style,
|
||||
};
|
||||
use iced::{
|
||||
Element, Fill,
|
||||
widget::{Space, button, column, container, row, scrollable, text, tooltip},
|
||||
widget::{button, column, container, scrollable, text, tooltip},
|
||||
};
|
||||
|
||||
pub(super) fn panel(app: &App) -> Element<'_, Message> {
|
||||
use crate::app::text_workspace::{DrawerTab, Event};
|
||||
use std::collections::BTreeSet;
|
||||
let state = &app.source_symbols;
|
||||
if !state.open
|
||||
|| (state.results.is_none() && state.in_flight.is_none() && state.notice.is_none())
|
||||
{
|
||||
return column![].into();
|
||||
let visible = app.text_workspace.drawer() == Some(DrawerTab::References);
|
||||
let presentation = app.text_workspace.references();
|
||||
let mut content = column![].spacing(0).width(Fill);
|
||||
if visible && let Some(notice) = &state.notice {
|
||||
content = content.push(text(notice.clone()).size(12));
|
||||
}
|
||||
let title = state.results.as_ref().map_or_else(
|
||||
|| "Source symbols".to_owned(),
|
||||
|results| {
|
||||
let action = match results.action {
|
||||
query::Action::Definition => "Definitions",
|
||||
query::Action::References => "References",
|
||||
};
|
||||
results.alias.as_ref().map_or_else(
|
||||
|| action.to_owned(),
|
||||
|alias| format!("{action} · @{}", preview(alias, 40)),
|
||||
)
|
||||
},
|
||||
);
|
||||
let mut content = column![
|
||||
row![
|
||||
text(title).size(13),
|
||||
Space::new().width(Fill),
|
||||
button(text("Close").size(11))
|
||||
.padding([3, 8])
|
||||
.style(style::nav)
|
||||
.on_press(app.source_symbol_message(SymbolEvent::Close)),
|
||||
]
|
||||
.align_y(iced::Center)
|
||||
]
|
||||
.spacing(5);
|
||||
|
||||
if let Some(notice) = &state.notice {
|
||||
content = content.push(super::label(preview(notice, 240)));
|
||||
}
|
||||
if state.in_flight.is_some() {
|
||||
content = content.push(super::label("Finding source symbols…"));
|
||||
} else if let Some(results) = &state.results {
|
||||
if visible && let Some(results) = &state.results {
|
||||
let stale = results.stamp.snapshot != app.source_key();
|
||||
if stale {
|
||||
content = content.push(super::label(
|
||||
"Stale results — source changed. Run Definition or References again.",
|
||||
));
|
||||
let enabled = !stale && app.source_navigation_available();
|
||||
let files: BTreeSet<_> = results
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|row| row.path.as_str())
|
||||
.chain(results.references.iter().map(|row| row.path.as_str()))
|
||||
.collect();
|
||||
let mut ordered: Vec<_> = files.iter().copied().collect();
|
||||
// Keep the query's captured origin first even after navigating elsewhere.
|
||||
if let Some(origin) = state
|
||||
.display_request
|
||||
.as_ref()
|
||||
.map(|request| request.path.as_str())
|
||||
{
|
||||
ordered.sort_by_key(|path| (*path != origin, *path));
|
||||
}
|
||||
for path in ordered {
|
||||
let collapsed = presentation.collapsed(path);
|
||||
let count = results
|
||||
.definitions
|
||||
.iter()
|
||||
.filter(|row| row.path == path)
|
||||
.count()
|
||||
+ results
|
||||
.references
|
||||
.iter()
|
||||
.filter(|row| row.path == path)
|
||||
.count();
|
||||
let suffix = distinguishing_path(path, &files);
|
||||
content = content.push(
|
||||
button(
|
||||
text(format!(
|
||||
"{} {suffix} · {count}",
|
||||
if collapsed { "▸" } else { "▾" }
|
||||
))
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
)
|
||||
.height(24)
|
||||
.width(Fill)
|
||||
.padding([2, 6])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(app.source_navigation_available().then(|| {
|
||||
Message::TextWorkspace(Event::ToggleFile {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
path: path.to_owned(),
|
||||
})
|
||||
})),
|
||||
);
|
||||
if collapsed {
|
||||
continue;
|
||||
}
|
||||
for (index, definition) in results
|
||||
.definitions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.path == path)
|
||||
{
|
||||
content = content.push(location_row(
|
||||
declaration_title(&definition.declaration),
|
||||
path,
|
||||
definition.position,
|
||||
provenance(
|
||||
definition.membership,
|
||||
definition.evidence,
|
||||
definition.declaration.identity,
|
||||
false,
|
||||
),
|
||||
enabled.then(|| {
|
||||
app.source_symbol_message(SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
reference: false,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
for (index, reference) in results
|
||||
.references
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.path == path)
|
||||
{
|
||||
let title = reference.context.as_ref().map_or_else(
|
||||
|| format!("@{} · lexical use", preview(&reference.alias, 48)),
|
||||
|context| format!("use · {}", declaration_title(context)),
|
||||
);
|
||||
content = content.push(location_row(
|
||||
title,
|
||||
path,
|
||||
reference.position,
|
||||
provenance(
|
||||
reference.membership,
|
||||
reference.evidence,
|
||||
reference
|
||||
.context
|
||||
.as_ref()
|
||||
.map_or(Identity::Missing, |context| context.identity),
|
||||
true,
|
||||
),
|
||||
enabled.then(|| {
|
||||
app.source_symbol_message(SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
reference: true,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
// Full path is readable by ordinary scrolling, independently of hover.
|
||||
content = content.push(
|
||||
container(
|
||||
text(path.to_owned())
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
)
|
||||
.padding([3, 8]),
|
||||
);
|
||||
}
|
||||
let scope = &results.scope;
|
||||
content = content.push(super::label(format!(
|
||||
"{} · {} · {} included / {} detached / {} unverified files · {} scope issues",
|
||||
if scope.partial() {
|
||||
"Partial scope"
|
||||
} else {
|
||||
"Complete include scope"
|
||||
},
|
||||
if !scope.applied {
|
||||
"Unapplied drafts"
|
||||
} else if scope.validated {
|
||||
"Validated sources"
|
||||
} else {
|
||||
"Draft sources"
|
||||
},
|
||||
scope.included_files,
|
||||
scope.detached_files,
|
||||
scope.unverified_files,
|
||||
scope.issues.len(),
|
||||
)));
|
||||
let explanation = match results.disposition {
|
||||
Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.",
|
||||
Disposition::NoDefinition => "No matching declaration in the loaded sources.",
|
||||
@@ -87,89 +158,113 @@ pub(super) fn panel(app: &App) -> Element<'_, Message> {
|
||||
"Lexical uses of this alias; these do not establish a semantic target."
|
||||
}
|
||||
};
|
||||
content = content.push(super::label(explanation));
|
||||
if results.total_definitions > 1 {
|
||||
content = content.push(super::label(format!(
|
||||
"Alias collision: {} declarations. Uses are not attributed to a single declaration.",
|
||||
results.total_definitions,
|
||||
)));
|
||||
}
|
||||
let enabled = !stale && !app.busy;
|
||||
let mut rows = column![super::label(counts(
|
||||
"Declarations",
|
||||
results.definitions.len(),
|
||||
results.total_definitions,
|
||||
results.omitted_definitions,
|
||||
))]
|
||||
.spacing(4);
|
||||
for (index, definition) in results.definitions.iter().enumerate() {
|
||||
rows = rows.push(location_row(
|
||||
declaration_title(&definition.declaration),
|
||||
&definition.path,
|
||||
definition.position,
|
||||
provenance(
|
||||
definition.membership,
|
||||
definition.evidence,
|
||||
definition.declaration.identity,
|
||||
false,
|
||||
),
|
||||
enabled.then(|| {
|
||||
app.source_symbol_message(SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
reference: false,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
rows = rows.push(super::label(counts(
|
||||
"Lexical uses",
|
||||
results.references.len(),
|
||||
results.total_references,
|
||||
results.omitted_references,
|
||||
)));
|
||||
for (index, reference) in results.references.iter().enumerate() {
|
||||
let title = reference.context.as_ref().map_or_else(
|
||||
|| format!("@{} · lexical use", preview(&reference.alias, 48)),
|
||||
|context| {
|
||||
format!(
|
||||
"@{} · inside {}",
|
||||
preview(&reference.alias, 48),
|
||||
declaration_title(context)
|
||||
)
|
||||
},
|
||||
content = content.push(text(explanation).size(12));
|
||||
if stale {
|
||||
content = content.push(
|
||||
text("Source changed. Run Definition or References again for current locations.")
|
||||
.size(12),
|
||||
);
|
||||
rows = rows.push(location_row(
|
||||
title,
|
||||
&reference.path,
|
||||
reference.position,
|
||||
provenance(
|
||||
reference.membership,
|
||||
reference.evidence,
|
||||
reference
|
||||
.context
|
||||
.as_ref()
|
||||
.map_or(Identity::Missing, |context| context.identity),
|
||||
true,
|
||||
),
|
||||
enabled.then(|| {
|
||||
app.source_symbol_message(SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
reference: true,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
let row_count = results.definitions.len() + results.references.len();
|
||||
content =
|
||||
content.push(scrollable(rows).height((44.0 + row_count as f32 * 48.0).min(180.0)));
|
||||
if results.total_definitions > 1 {
|
||||
content = content.push(
|
||||
text("Alias collision: uses are not attributed to a single declaration.").size(12),
|
||||
);
|
||||
}
|
||||
content = content
|
||||
.push(
|
||||
text(format!(
|
||||
"{} · {} included / {} detached / {} unverified files · {} scope issues",
|
||||
if scope.partial() {
|
||||
"Partial include scope"
|
||||
} else {
|
||||
"Complete include scope"
|
||||
},
|
||||
scope.included_files,
|
||||
scope.detached_files,
|
||||
scope.unverified_files,
|
||||
scope.issues.len()
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(if !scope.applied {
|
||||
"Unapplied drafts"
|
||||
} else if scope.validated {
|
||||
"Validated sources"
|
||||
} else {
|
||||
"Draft sources"
|
||||
})
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(counts(
|
||||
"Declarations",
|
||||
results.definitions.len(),
|
||||
results.total_definitions,
|
||||
results.omitted_definitions,
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(counts(
|
||||
"Lexical uses",
|
||||
results.references.len(),
|
||||
results.total_references,
|
||||
results.omitted_references,
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
container(content)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::subtle)
|
||||
.into()
|
||||
let identity = if visible {
|
||||
state
|
||||
.display_request
|
||||
.as_ref()
|
||||
.map(|request| (request.job, app.text_workspace.drawer_visit()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let guarded = crate::diagnostic_input::Guard::new(identity, content);
|
||||
let mut body = scrollable(
|
||||
container(guarded)
|
||||
.width(Fill)
|
||||
.padding(iced::Padding::ZERO.right(14)),
|
||||
)
|
||||
.id(app.text_workspace.reference_scroll_id())
|
||||
.height(Fill)
|
||||
.width(Fill);
|
||||
if let Some(stamp) = presentation.stamp() {
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
body = body.on_scroll(move |viewport| {
|
||||
Message::TextWorkspace(Event::ReferenceScrolled {
|
||||
stamp,
|
||||
visit,
|
||||
y: viewport.absolute_offset().y,
|
||||
})
|
||||
});
|
||||
}
|
||||
body.into()
|
||||
}
|
||||
|
||||
/// Shortest suffix that still distinguishes duplicate basenames and suffix paths.
|
||||
fn distinguishing_path<'a>(path: &'a str, files: &std::collections::BTreeSet<&str>) -> &'a str {
|
||||
let starts = std::iter::once(0).chain(path.match_indices('/').map(|(index, _)| index + 1));
|
||||
for start in starts.collect::<Vec<_>>().into_iter().rev() {
|
||||
let candidate = &path[start..];
|
||||
if files
|
||||
.iter()
|
||||
.filter(|other| {
|
||||
**other == candidate
|
||||
|| other
|
||||
.strip_suffix(candidate)
|
||||
.is_some_and(|prefix| prefix.ends_with('/'))
|
||||
})
|
||||
.count()
|
||||
== 1
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String {
|
||||
@@ -223,23 +318,24 @@ fn location_row(
|
||||
) -> Element<'static, Message> {
|
||||
let line = position.line.saturating_add(1);
|
||||
let column = position.byte_column.saturating_add(1);
|
||||
let location = format!("{}:{line}:{column}", preview(path, 72));
|
||||
let full =
|
||||
format!("{path}:{line}:{column} · {title} · {status} (1-based line and UTF-8 byte column)");
|
||||
tooltip(
|
||||
button(
|
||||
column![
|
||||
text(title).size(12),
|
||||
super::label(format!("{location} · {status}"))
|
||||
]
|
||||
.spacing(2),
|
||||
container(
|
||||
text(format!("{line}:{column} {title} · {status}"))
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
)
|
||||
.clip(true)
|
||||
.width(Fill),
|
||||
)
|
||||
.padding([4, 6])
|
||||
.padding([3, 12])
|
||||
.height(28)
|
||||
.width(Fill)
|
||||
.style(style::nav)
|
||||
.on_press_maybe(event),
|
||||
text(format!(
|
||||
"{path}:{line}:{column} (1-based line and UTF-8 byte column)"
|
||||
))
|
||||
.size(11),
|
||||
text(full).size(12),
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
.into()
|
||||
@@ -258,3 +354,32 @@ fn preview(value: &str, limit: usize) -> String {
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn suffixes_distinguish_nested_duplicate_basenames_without_changing_unicode() {
|
||||
let files = [
|
||||
"a/core.arcadia",
|
||||
"b/core.arcadia",
|
||||
"core.arcadia",
|
||||
"救援/actors.arcadia",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
distinguishing_path("a/core.arcadia", &files),
|
||||
"a/core.arcadia"
|
||||
);
|
||||
assert_eq!(
|
||||
distinguishing_path("b/core.arcadia", &files),
|
||||
"b/core.arcadia"
|
||||
);
|
||||
assert_eq!(distinguishing_path("core.arcadia", &files), "core.arcadia");
|
||||
assert_eq!(
|
||||
distinguishing_path("救援/actors.arcadia", &files),
|
||||
"actors.arcadia"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
//! Shapes and draws the production summary, checking actual paragraph advances.
|
||||
//! Native validation remains necessary for platform raster ink/fallback fonts.
|
||||
use super::*;
|
||||
use crate::source_navigation::query;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, renderer, text as shaped, widget::Tree};
|
||||
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Cluster {
|
||||
paragraph: String,
|
||||
bytes: std::ops::Range<usize>,
|
||||
bounds: Rectangle,
|
||||
clip: Rectangle,
|
||||
}
|
||||
struct Recorder {
|
||||
clips: Vec<Rectangle>,
|
||||
transforms: Vec<Transformation>,
|
||||
clusters: Vec<Cluster>,
|
||||
}
|
||||
impl Recorder {
|
||||
fn transform(&self) -> Transformation {
|
||||
*self.transforms.last().unwrap()
|
||||
}
|
||||
fn clip(&self, bounds: Rectangle) -> Rectangle {
|
||||
self.clips
|
||||
.last()
|
||||
.unwrap()
|
||||
.intersection(&(bounds * self.transform()))
|
||||
.unwrap_or(Rectangle::with_size(Size::ZERO))
|
||||
}
|
||||
}
|
||||
impl renderer::Renderer for Recorder {
|
||||
fn start_layer(&mut self, bounds: Rectangle) {
|
||||
self.clips.push(self.clip(bounds));
|
||||
}
|
||||
fn end_layer(&mut self) {
|
||||
self.clips.pop().unwrap();
|
||||
}
|
||||
fn start_transformation(&mut self, transform: Transformation) {
|
||||
self.transforms.push(self.transform() * transform);
|
||||
}
|
||||
fn end_transformation(&mut self) {
|
||||
self.transforms.pop().unwrap();
|
||||
}
|
||||
fn reset(&mut self, bounds: Rectangle) {
|
||||
self.clips = vec![bounds];
|
||||
self.transforms = vec![Transformation::IDENTITY];
|
||||
self.clusters.clear();
|
||||
}
|
||||
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for Recorder {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(
|
||||
&mut self,
|
||||
paragraph: &Self::Paragraph,
|
||||
position: Point,
|
||||
_: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
let buffer = paragraph.buffer();
|
||||
let content = buffer
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let clip = self.clip(clip);
|
||||
for run in buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
self.clusters.push(Cluster {
|
||||
paragraph: content.clone(),
|
||||
bytes: glyph.start..glyph.end,
|
||||
bounds: Rectangle {
|
||||
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
|
||||
y: position.y + run.line_top,
|
||||
width: glyph.w,
|
||||
height: run.line_height,
|
||||
} * self.transform(),
|
||||
clip,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
|
||||
const FLAGS: [&str; 5] = ["Stale", "Partial", "Collision", "Detached", "Unverified"];
|
||||
|
||||
fn draw_and_check_flags(
|
||||
content: Element<'_, Message, iced::Theme, Recorder>,
|
||||
width: f32,
|
||||
) -> Vec<String> {
|
||||
let viewport = Rectangle::with_size(Size::new(width, 24.));
|
||||
let mut renderer = Recorder {
|
||||
clips: vec![viewport],
|
||||
transforms: vec![Transformation::IDENTITY],
|
||||
clusters: Vec::new(),
|
||||
};
|
||||
let mut view: Element<'_, Message, iced::Theme, Recorder> =
|
||||
container(content).width(Fill).height(24).clip(true).into();
|
||||
let mut tree = Tree::new(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
assert_eq!(node.size(), viewport.size());
|
||||
view.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&iced::Theme::Light,
|
||||
&renderer::Style::default(),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
for flag in FLAGS {
|
||||
for byte in 0..flag.len() {
|
||||
let matching: Vec<_> = renderer
|
||||
.clusters
|
||||
.iter()
|
||||
.filter(|cluster| {
|
||||
cluster
|
||||
.paragraph
|
||||
.find(flag)
|
||||
.is_some_and(|start| cluster.bytes.contains(&(start + byte)))
|
||||
})
|
||||
.collect();
|
||||
if matching.is_empty() {
|
||||
failures.push(format!("missing shaped flag byte: {flag}[{byte}]"));
|
||||
}
|
||||
for cluster in matching {
|
||||
let b = cluster.bounds;
|
||||
let c = cluster.clip;
|
||||
if !(b.x >= c.x - 0.5
|
||||
&& b.y >= c.y - 0.5
|
||||
&& b.x + b.width <= c.x + c.width + 0.5
|
||||
&& b.y + b.height <= c.y + c.height + 0.5)
|
||||
{
|
||||
failures.push(format!("flag {flag} is clipped: glyph {b:?}, clip {c:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
fn old_summary(value: String) -> Element<'static, Message, iced::Theme, Recorder> {
|
||||
text(value)
|
||||
.size(12)
|
||||
.wrapping(widget::text::Wrapping::None)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_reference_exceptions_fit_allocated_width_with_wide_alias_and_large_counts() {
|
||||
let mut app = App::with_project(oa_core::Project::empty());
|
||||
let old_key = App::with_project(oa_core::Project::empty()).source_key();
|
||||
assert_ne!(old_key, app.source_key());
|
||||
for alias in ["雨".repeat(32), "W".repeat(32)] {
|
||||
// Unicode aliases are presentation stress: the current lexer admits
|
||||
// ASCII identifiers only. Six-digit totals are within the query's
|
||||
// 250,000-record cap; this does not build that many source records.
|
||||
let old_value = format!(
|
||||
"@{alias} · 100000 declarations · 100000 uses · Stale · Partial · Collision · Detached · Unverified"
|
||||
);
|
||||
app.source_symbols.results = Some(Arc::new(query::Results {
|
||||
stamp: query::QueryStamp {
|
||||
snapshot: old_key,
|
||||
serial: 1,
|
||||
},
|
||||
action: query::Action::References,
|
||||
origin_path: app.source_workspace.active.clone(),
|
||||
origin_offset: 0,
|
||||
origin: None,
|
||||
alias: Some(alias.clone()),
|
||||
scope: query::Scope {
|
||||
closure_complete: false,
|
||||
applied: false,
|
||||
validated: false,
|
||||
included_files: 28,
|
||||
detached_files: 1,
|
||||
unverified_files: 1,
|
||||
issues: Vec::new(),
|
||||
},
|
||||
disposition: query::Disposition::LexicalReferences,
|
||||
definitions: Vec::new(),
|
||||
references: Vec::new(),
|
||||
total_definitions: 100_000,
|
||||
total_references: 100_000,
|
||||
omitted_definitions: 100_000,
|
||||
omitted_references: 100_000,
|
||||
}));
|
||||
for width in [840., 720.] {
|
||||
let positive = draw_and_check_flags(reference_summary(&app), width);
|
||||
assert!(
|
||||
positive.is_empty(),
|
||||
"reserved flags, width {width}, alias {alias:?}: {positive:?}"
|
||||
);
|
||||
// Observe the exact old presentation without predicting font widths.
|
||||
// This is emitted under --nocapture, whether it clips or fits.
|
||||
let old = draw_and_check_flags(old_summary(old_value.clone()), width);
|
||||
println!(
|
||||
"old alias-first summary width={width} alias={alias:?}: {} missing/clipped flag-byte checks; first={:?}",
|
||||
old.len(),
|
||||
old.first()
|
||||
);
|
||||
}
|
||||
}
|
||||
// Deliberate evaluator stress, beyond the production 32-character subject
|
||||
// bound: require the same evaluator to reject a clipped old-style paragraph.
|
||||
// This is not evidence of a production/native overflow at 840 pixels.
|
||||
let stress = format!("@{} · {}", "W".repeat(256), FLAGS.join(" · "));
|
||||
let rejected = draw_and_check_flags(old_summary(stress), 720.);
|
||||
assert!(
|
||||
!rejected.is_empty(),
|
||||
"negative control must detect missing/clipped warning text"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user