Compose compact Text workspace with guarded root navigation
This commit is contained in:
@@ -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