Compose compact Text workspace with guarded root navigation

This commit is contained in:
2026-09-13 04:10:27 -07:00
parent feab2020d8
commit 74542ae2c1
20 changed files with 5482 additions and 738 deletions
+277 -152
View File
@@ -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"
);
}