Files
ARCADIA-Studio/crates/oa-desktop/src/views/text_workspace.rs
T
sorokin.oleg.edu 9fd76aef3a Add current-draft diagnostic underlines and passive hover
Preserve raw include diagnostic provenance and derive bands from the existing shaped editor. Retain deferred tooltip paragraphs through rendering. Include source, regression, failure and native-trial evidence; corrected native acceptance remains pending.
2026-09-13 01:43:22 -07:00

1079 lines
39 KiB
Rust

//! Compact Text workspace presentation. Visibility is explicit controller state;
//! rebuilding diagnostics or query results must never change editor geometry.
use crate::app::text_workspace::{Command, DrawerTab, Event};
use crate::{
app::{App, Message},
style,
};
use iced::{
Element, Fill, Font, Length,
widget::{self, button, column, container, row, scrollable, text, text_input},
};
use std::{cell::Cell, rc::Rc};
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
/// grouping and scroll restoration are controller-owned.
pub(super) fn view<'a>(
app: &'a App,
references: Element<'a, Message>,
problems: Element<'a, Message>,
) -> Element<'a, Message> {
let tab = app.text_workspace.drawer();
let measured_drawer = Rc::new(Cell::new(DRAWER_HEIGHT));
let strip = row![
container(
text(app.source_workspace.active.clone())
.font(Font::MONOSPACE)
.size(12)
.wrapping(widget::text::Wrapping::None)
)
.width(Fill)
.clip(true),
compact_action("Find", app.text_command_message(Command::Find)),
crate::context_menu::Area::left(
button(text("Actions ▾").size(12))
.padding([4, 8])
.style(style::nav)
.on_press(app.text_menu_message(iced::Point::ORIGIN)),
|anchor| app.text_menu_message(anchor),
),
compact_action("Apply all", Message::ApplySource),
]
.spacing(6)
.align_y(iced::Center);
let find: Element<'a, Message> = if app.source_navigation.open {
find_bar(app)
} else {
widget::Space::new().into()
};
let editor = widget::text_editor(&app.source_editor)
.id(app.source_editor_id())
.on_action(Message::SourceEdit)
.key_binding({
let context = app.source_symbol_context();
let rename = app.source_rename_begin();
move |press| super::source_binding(press, &context, Some(&rename))
})
.font(Font::MONOSPACE)
.size(14)
.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(),
app.source_reveal_ticket(),
))
.on_show({
let ticket = app.source_reveal_ticket();
move |_| Message::SourceViewport(ticket)
})
.on_resize({
let ticket = app.source_reveal_ticket();
move |_| Message::SourceViewport(ticket)
});
let (errors, warnings) = diagnostic_counts(app);
let summary: Element<'a, Message> = match tab {
Some(DrawerTab::References) => reference_summary(app),
Some(DrawerTab::Problems) => text(format!(
"{errors} source errors · {warnings} warnings · choose a location for details"
))
.size(12)
.wrapping(widget::text::Wrapping::None)
.into(),
Some(DrawerTab::Status) => text("Source adoption, recovery and text/model undo")
.size(12)
.wrapping(widget::text::Wrapping::None)
.into(),
None => widget::Space::new().into(),
};
let chrome: Element<'a, Message> = if tab.is_some() {
let mut tabs = row![].spacing(4).align_y(iced::Center);
for (destination, title) in [
(DrawerTab::References, "References"),
(DrawerTab::Problems, "Problems"),
(DrawerTab::Status, "Status"),
] {
tabs = tabs.push(
button(text(title).size(12))
.padding([4, 8])
.style(if tab == Some(destination) {
style::selected
} else {
style::nav
})
.on_press(Message::TextWorkspace(Event::ShowDrawer(destination))),
);
}
tabs.push(widget::Space::new().width(Fill))
.push(resize_button("Smaller", measured_drawer.clone(), -60.))
.push(resize_button("Larger", measured_drawer.clone(), 60.))
.push(compact_action(
"Hide",
if tab == Some(DrawerTab::References) {
app.source_symbol_message(crate::source_navigation::SymbolEvent::Close)
} else {
Message::TextWorkspace(Event::HideDrawer)
},
))
.into()
} else {
widget::Space::new().into()
};
let status: Element<'a, Message> = if tab == Some(DrawerTab::Status) {
status_body(app)
} else {
widget::Space::new().into()
};
let body = widget::Column::from_vec(vec![
container(references)
.width(Fill)
.height(if tab == Some(DrawerTab::References) {
Length::Fill
} else {
Length::Fixed(0.)
})
.clip(true)
.into(),
tab_slot(
problems,
tab == Some(DrawerTab::Problems),
"text-problems-body",
),
tab_slot(status, tab == Some(DrawerTab::Status), "text-status-body"),
])
.width(Fill)
.height(Fill);
let drawer = widget::Column::from_vec(vec![
container(chrome).width(Fill).height(32).clip(true).into(),
container(summary).width(Fill).height(24).clip(true).into(),
body.into(),
])
.width(Fill)
.height(Fill);
let mut states = Vec::new();
if app.pending_recovery.is_some() {
states.push("Recovery");
}
if !app.source_workspace.applied {
states.push("Repair needed");
}
if app.source_workspace.detached_dirty() {
states.push("Detached draft");
}
if app.source_dirty() {
states.push("Drafts pending");
}
let state = match states.as_slice() {
[] => "Model in sync".to_owned(),
[only] => (*only).to_owned(),
[first, rest @ ..] => format!("{first} +{} conditions", rest.len()),
};
let source_status = button(
text(format!(
"{errors} errors · {warnings} warnings · {state} · Status…"
))
.size(12),
)
.padding([2, 8])
.width(Fill)
.height(STATUS_HEIGHT)
.style(style::nav)
.on_press(Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Status)));
layout(
container(strip)
.width(Fill)
.height(Fill)
.padding([4, 8])
.style(style::panel)
.into(),
find,
editor.into(),
container(drawer).style(style::panel).into(),
source_status.into(),
LayoutSettings {
find_open: app.source_navigation.open,
drawer_height: tab.map(|_| app.text_workspace.drawer_height()),
measured_drawer,
},
)
}
fn resize_button(
label: &'static str,
height: Rc<Cell<f32>>,
delta: f32,
) -> Element<'static, Message> {
widget::tooltip(
button(text(label).size(12))
.padding([4, 8])
.style(style::nav)
.on_press_with(move || {
Message::TextWorkspace(Event::ResizeDrawer(height.get() + delta))
}),
text(if delta < 0. {
"Smaller drawer · Alt Page Up"
} else {
"Larger drawer · Alt Page Down"
})
.size(12),
widget::tooltip::Position::Top,
)
.into()
}
fn tab_slot<'a>(
body: Element<'a, Message>,
active: bool,
id: &'static str,
) -> Element<'a, Message> {
container(
scrollable(
container(body)
.width(Fill)
.padding(iced::Padding::ZERO.right(14)),
)
.id(id)
.height(Fill)
.width(Fill),
)
.width(Fill)
.height(if active {
Length::Fill
} else {
Length::Fixed(0.)
})
.clip(true)
.into()
}
fn find_bar(app: &App) -> Element<'_, Message> {
use crate::source_navigation::Event as Find;
container(
row![
text_input("Find literal text…", &app.source_navigation.query)
.id("source-find")
.on_input(|value| Message::SourceNavigation(Find::Query(value)))
.on_submit(Message::SourceNavigation(Find::Next(false)))
.size(12)
.padding(5)
.width(Fill),
compact_action("Previous", Message::SourceNavigation(Find::Next(true))),
compact_action("Next", Message::SourceNavigation(Find::Next(false))),
text(app.source_navigation.label()).size(12),
text_input("Line", &app.source_navigation.line)
.id("source-line")
.on_input(|value| Message::SourceNavigation(Find::Line(value)))
.on_submit(Message::SourceNavigation(Find::Go))
.size(12)
.padding(5)
.width(58),
compact_action("Go", Message::SourceNavigation(Find::Go)),
compact_action("Close", app.source_find_close_message()),
]
.spacing(4)
.align_y(iced::Center),
)
.width(Fill)
.height(Fill)
.padding([2, 8])
.into()
}
fn reference_summary<'a, Renderer: iced::advanced::text::Renderer + 'a>(
app: &'a App,
) -> Element<'a, Message, iced::Theme, Renderer> {
let Some(results) = &app.source_symbols.results else {
return text(if app.source_symbols.in_flight.is_some() {
"Finding source symbols…"
} else {
"Run References or Definition from Actions or the editor shortcuts."
})
.size(12)
.wrapping(widget::text::Wrapping::None)
.into();
};
let subject = results.alias.as_deref().map_or_else(
|| "Source symbols".to_owned(),
|alias| format!("@{}", super::short(alias, 32)),
);
let value = format!(
"{subject} · {} declarations · {} uses",
results.total_definitions, results.total_references
);
let mut flags = Vec::new();
if results.stamp.snapshot != app.source_key() {
flags.push("Stale");
}
if results.scope.partial() {
flags.push("Partial");
}
if results.total_definitions > 1 {
flags.push("Collision");
}
if results.scope.detached_files > 0 {
flags.push("Detached");
}
if results.scope.unverified_files > 0 {
flags.push("Unverified");
}
let detail = text(value).size(12).wrapping(widget::text::Wrapping::None);
if flags.is_empty() {
return detail.into();
}
// Exception status owns its width; a long alias can only clip the detail.
row![
text(flags.join(" · "))
.size(12)
.wrapping(widget::text::Wrapping::None),
container(detail).width(Fill).clip(true),
]
.spacing(8)
.align_y(iced::Center)
.width(Fill)
.into()
}
fn compact_action(label: &str, message: Message) -> widget::Button<'_, Message> {
button(text(label.to_owned()).size(12))
.padding([4, 8])
.style(style::nav)
.on_press(message)
}
/// Conditions accumulate: recovery must not conceal detached drafts or repair.
fn status_body(app: &App) -> Element<'_, Message> {
let workspace = &app.source_workspace;
let mut body = column![
text(workspace.active.clone()).font(Font::MONOSPACE).size(12).wrapping(widget::text::Wrapping::WordOrGlyph),
text("Resize this drawer with Smaller / Larger or Alt Page Up / Alt Page Down.").size(13),
text("Undo text affects only this file's draft. Undo model restores accepted source files together (100 changes / about 32 MiB). Undo rename restores the accepted rename transaction when available.").size(13),
row![compact_action("Undo text", app.text_command_message(Command::UndoText)), compact_action("Redo text", app.text_command_message(Command::RedoText))].spacing(6),
].spacing(8).width(Fill);
if let Some(path) = &app.pending_recovery {
let blocked = !workspace.applied && workspace.has_edits();
body = body.push(text(format!("Interrupted save · {}", path.display())).size(13).wrapping(widget::text::Wrapping::WordOrGlyph))
.push(text(if blocked {
"Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them."
} else if workspace.applied {
"Restore files to their state before the interrupted save. Your current model and editor drafts are kept."
} else {
"Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save."
}).size(13))
.push(button(text("Recover interrupted save").size(12)).padding([4, 8]).on_press_maybe((!blocked && !app.busy).then_some(Message::SourceRecover)));
}
if !workspace.applied {
body = body.push(text("Project needs repair. The previous graphical model remains available. Apply all validates and adopts the loaded reachable files as one model change.").size(13));
if workspace.has_edits() {
body = body.push(text("Edited files are retained as drafts. Revert each edited file before recovering an interrupted save; reverting discards that file's edits.").size(13));
}
}
if workspace.detached_dirty() {
body = body.push(text("A detached file has an unsaved draft. Reattach it or revert that file before saving. Closing a detached buffer never deletes its file.").size(13));
}
if app.source_dirty() {
body = body.push(text("Drafts pending. The graphical views continue to show the accepted model until Apply all succeeds.").size(13));
}
body = body.push(
row![
compact_action("Apply all", Message::ApplySource),
compact_action("Revert file", app.text_command_message(Command::RevertFile)),
compact_action("Save all", Message::Save(false)),
]
.spacing(6),
);
body.into()
}
fn diagnostic_counts(app: &App) -> (usize, usize) {
app.source_diagnostics
.iter()
.fold((0, 0), |(errors, warnings), issue| {
match issue.diagnostic.severity {
oa_core::validation::Severity::Error => (errors + 1, warnings),
oa_core::validation::Severity::Warning => (errors, warnings + 1),
}
})
}
/// All five positions exist on every rebuild. In particular, the editor never
/// changes siblings or wrapper types when Find, diagnostics or results change.
pub(crate) fn layout<'a, Renderer: iced::advanced::text::Renderer + 'a>(
strip: Element<'a, Message, iced::Theme, Renderer>,
find: Element<'a, Message, iced::Theme, Renderer>,
editor: Element<'a, Message, iced::Theme, Renderer>,
drawer: Element<'a, Message, iced::Theme, Renderer>,
status: Element<'a, Message, iced::Theme, Renderer>,
settings: LayoutSettings,
) -> Element<'a, Message, iced::Theme, Renderer> {
Element::new(WorkspaceLayout {
children: vec![
container(strip)
.id("text-workspace-strip")
.width(Fill)
.height(Fill)
.clip(true)
.into(),
container(find)
.id("text-workspace-find")
.width(Fill)
.height(Fill)
.clip(true)
.into(),
container(editor)
.id("text-workspace-editor")
.width(Fill)
.height(Fill)
.clip(true)
.into(),
container(drawer)
.id("text-workspace-drawer")
.width(Fill)
.height(Fill)
.clip(true)
.into(),
container(status)
.id("text-workspace-status")
.width(Fill)
.height(Fill)
.clip(true)
.into(),
],
settings,
})
}
pub(crate) struct LayoutSettings {
pub find_open: bool,
pub drawer_height: Option<f32>,
/// Layout publishes the effective height so Smaller immediately shrinks a
/// previously oversized request after a window resize.
pub measured_drawer: Rc<Cell<f32>>,
}
fn slot_heights(height: f32, find: bool, requested: Option<f32>) -> [f32; 5] {
let height = height.max(0.);
let strip = STRIP_HEIGHT.min(height);
let status = STATUS_HEIGHT.min(height - strip);
let find = if find {
FIND_HEIGHT.min(height - strip - status)
} else {
0.
};
let available = (height - strip - status - find).max(0.);
let drawer = requested.map_or(0., |requested| {
let maximum = (available - 240.).max(DRAWER_HEIGHT).min(available);
let requested = if requested.is_finite() {
requested.max(DRAWER_HEIGHT)
} else {
DRAWER_HEIGHT
};
requested.min(maximum)
});
[strip, find, available - drawer, drawer, status]
}
struct WorkspaceLayout<'a, Renderer> {
children: Vec<Element<'a, Message, iced::Theme, Renderer>>,
settings: LayoutSettings,
}
impl<Renderer: iced::advanced::text::Renderer>
iced::advanced::Widget<Message, iced::Theme, Renderer> for WorkspaceLayout<'_, Renderer>
{
fn children(&self) -> Vec<iced::advanced::widget::Tree> {
self.children
.iter()
.map(iced::advanced::widget::Tree::new)
.collect()
}
fn diff(&self, tree: &mut iced::advanced::widget::Tree) {
tree.diff_children(&self.children);
}
fn size(&self) -> iced::Size<Length> {
iced::Size::new(Fill, Fill)
}
fn layout(
&mut self,
tree: &mut iced::advanced::widget::Tree,
renderer: &Renderer,
limits: &iced::advanced::layout::Limits,
) -> iced::advanced::layout::Node {
let size = limits.resolve(Fill, Fill, iced::Size::ZERO);
let heights = slot_heights(
size.height,
self.settings.find_open,
self.settings.drawer_height,
);
self.settings.measured_drawer.set(heights[3]);
let mut y = 0.;
let nodes = self
.children
.iter_mut()
.zip(&mut tree.children)
.zip(heights)
.map(|((child, state), height)| {
let slot = iced::Size::new(size.width, height);
let node = child
.as_widget_mut()
.layout(
state,
renderer,
&iced::advanced::layout::Limits::new(slot, slot),
)
.move_to(iced::Point::new(0., y));
y += height;
node
})
.collect();
iced::advanced::layout::Node::with_children(size, nodes)
}
fn operate(
&mut self,
tree: &mut iced::advanced::widget::Tree,
layout: iced::advanced::Layout<'_>,
renderer: &Renderer,
operation: &mut dyn iced::advanced::widget::Operation,
) {
operation.container(None, layout.bounds());
operation.traverse(&mut |operation| {
for ((child, state), layout) in self
.children
.iter_mut()
.zip(&mut tree.children)
.zip(layout.children())
{
child
.as_widget_mut()
.operate(state, layout, renderer, operation);
}
});
}
fn update(
&mut self,
tree: &mut iced::advanced::widget::Tree,
event: &iced::Event,
layout: iced::advanced::Layout<'_>,
cursor: iced::mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn iced::advanced::Clipboard,
shell: &mut iced::advanced::Shell<'_, Message>,
viewport: &iced::Rectangle,
) {
// Local drawer commands. Popup is the persistent outer host and owns
// keyboard input while a menu is open; closed drawers claim no keys.
if self.settings.drawer_height.is_some()
&& let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key, modifiers, ..
}) = event
&& *modifiers == iced::keyboard::Modifiers::ALT
{
let delta = match key {
iced::keyboard::Key::Named(iced::keyboard::key::Named::PageUp) => Some(-60.),
iced::keyboard::Key::Named(iced::keyboard::key::Named::PageDown) => Some(60.),
_ => None,
};
if let Some(delta) = delta {
shell.publish(Message::TextWorkspace(Event::ResizeDrawer(
self.settings.measured_drawer.get() + delta,
)));
shell.capture_event();
return;
}
}
// Preserve delivery to the hidden permanent Guard: it may need to
// swallow an armed release. Hidden content is emptied by the caller.
for ((child, state), layout) in self
.children
.iter_mut()
.zip(&mut tree.children)
.zip(layout.children())
{
child.as_widget_mut().update(
state, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
}
fn mouse_interaction(
&self,
tree: &iced::advanced::widget::Tree,
layout: iced::advanced::Layout<'_>,
cursor: iced::mouse::Cursor,
viewport: &iced::Rectangle,
renderer: &Renderer,
) -> iced::mouse::Interaction {
self.children
.iter()
.zip(&tree.children)
.zip(layout.children())
.map(|((child, state), layout)| {
child
.as_widget()
.mouse_interaction(state, layout, cursor, viewport, renderer)
})
.max()
.unwrap_or_default()
}
fn draw(
&self,
tree: &iced::advanced::widget::Tree,
renderer: &mut Renderer,
theme: &iced::Theme,
style: &iced::advanced::renderer::Style,
layout: iced::advanced::Layout<'_>,
cursor: iced::mouse::Cursor,
viewport: &iced::Rectangle,
) {
if let Some(viewport) = layout.bounds().intersection(viewport) {
for ((child, state), layout) in self
.children
.iter()
.zip(&tree.children)
.zip(layout.children())
{
child
.as_widget()
.draw(state, renderer, theme, style, layout, cursor, &viewport);
}
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut iced::advanced::widget::Tree,
layout: iced::advanced::Layout<'b>,
renderer: &Renderer,
viewport: &iced::Rectangle,
translation: iced::Vector,
) -> Option<iced::advanced::overlay::Element<'b, Message, iced::Theme, Renderer>> {
iced::advanced::overlay::from_children(
&mut self.children,
tree,
layout,
renderer,
viewport,
translation,
)
}
}
#[cfg(all(test, debug_assertions))]
mod tests {
use super::*;
use crate::views::source_tests::FontLayout;
use iced::widget::column;
use iced::{
Size,
advanced::{
layout,
widget::{Tree, operation::Focusable},
},
};
#[test]
fn five_slots_keep_editor_state_and_selection_through_explicit_and_passive_changes() {
let mut content =
widget::text_editor::Content::<FontLayout>::with_text("// 雨\r\n@assess\r\n");
content.move_to(widget::text_editor::Cursor {
position: widget::text_editor::Position { line: 1, column: 7 },
selection: Some(widget::text_editor::Position { line: 1, column: 1 }),
});
let expected_cursor = content.cursor();
let mut tree = Tree::empty();
let mut state_address = None;
for (find, drawer, issues) in [
(false, false, 0),
(false, false, 100),
(true, false, 1),
(true, true, 100),
(true, true, 0),
(false, true, 1),
(false, false, 0),
] {
let mut notices = column![];
for index in 0..issues {
notices = notices.push(text(format!("Diagnostic {index}")));
}
let editor = widget::text_editor(&content)
.on_action(Message::SourceEdit)
.height(Fill)
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
content.text().into(),
crate::highlight::format,
);
let editor = widget::sensor(editor)
.key(("救援/assess.arcadia".to_owned(), None::<oa_core::Id>))
.on_show(|_| Message::SourceViewport(None))
.on_resize(|_| Message::SourceViewport(None));
let mut view = layout(
text("救援/assess.arcadia").into(),
text("Find").into(),
editor.into(),
scrollable(notices).height(Fill).into(),
text("Status").into(),
LayoutSettings {
find_open: find,
drawer_height: drawer.then_some(DRAWER_HEIGHT),
measured_drawer: Rc::new(Cell::new(0.)),
},
);
tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, Size::new(720., 540.)),
);
assert_eq!(node.children().len(), 5);
let editor_bounds = node.children()[2].bounds();
assert_eq!(
editor_bounds.y,
STRIP_HEIGHT + if find { FIND_HEIGHT } else { 0. }
);
assert_eq!(
editor_bounds.height,
540. - STRIP_HEIGHT
- STATUS_HEIGHT
- if find { FIND_HEIGHT } else { 0. }
- if drawer { DRAWER_HEIGHT } else { 0. }
);
assert!(editor_bounds.height >= 240.);
// Container delegates to the permanent sensor, whose child is the
// actual highlighted editor, matching the production wrapper path.
let state = tree.children[2].children[0]
.state
.downcast_mut::<widget::text_editor::State<crate::highlight::ArcadiaHighlighter>>();
let address = state as *mut _ as usize;
if let Some(expected) = state_address {
assert_eq!(
address, expected,
"the actual editor state must be retained"
);
assert!(state.is_focused());
} else {
state.focus();
state_address = Some(address);
}
assert_eq!(content.cursor(), expected_cursor);
assert_eq!(content.selection().as_deref(), Some("assess"));
}
}
#[test]
fn drawer_clamps_to_actual_center_allocation_including_oversize_requests() {
for (host, find, request, expected_drawer, expected_editor) in [
(Size::new(720., 480.), true, 1200., 180., 200.),
(Size::new(720., 540.), true, 1200., 200., 240.),
(Size::new(1180., 890.), false, 1200., 586., 240.),
(Size::new(1180., 890.), true, 280., 280., 510.),
(Size::new(720., 540.), false, 180., 180., 296.),
] {
let measured = Rc::new(Cell::new(0.));
let mut view: Element<'_, Message, iced::Theme, FontLayout> = layout(
text("Strip").into(),
text("Find").into(),
text("Editor").into(),
text("Drawer").into(),
text("Status").into(),
LayoutSettings {
find_open: find,
drawer_height: Some(request),
measured_drawer: measured.clone(),
},
);
let mut tree = Tree::new(view.as_widget());
let node = view.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, host),
);
assert_eq!(node.size(), host);
assert_eq!(node.children().len(), 5);
assert_eq!(node.children()[3].bounds().height, expected_drawer);
assert_eq!(node.children()[2].bounds().height, expected_editor);
assert_eq!(measured.get(), expected_drawer);
assert_eq!(node.children()[4].bounds().y + STATUS_HEIGHT, host.height);
}
}
#[test]
fn keyboard_resize_uses_effective_height_and_only_exact_open_drawer_shortcut() {
use iced::keyboard::{
self, Key, Modifiers,
key::{Code, Named, Physical},
};
for (open, modifiers, named, expected) in [
(true, Modifiers::ALT, Named::PageUp, Some(140.)),
(true, Modifiers::ALT, Named::PageDown, Some(260.)),
(false, Modifiers::ALT, Named::PageDown, None),
(
true,
Modifiers::CTRL | Modifiers::ALT,
Named::PageDown,
None,
),
(true, Modifiers::ALT | Modifiers::SHIFT, Named::PageUp, None),
] {
let mut view: Element<'_, Message, iced::Theme, FontLayout> = layout(
text("Strip").into(),
text("Find").into(),
text("Editor").into(),
text("Drawer").into(),
text("Status").into(),
LayoutSettings {
find_open: true,
drawer_height: open.then_some(1200.),
measured_drawer: Rc::new(Cell::new(0.)),
},
);
let mut tree = Tree::new(view.as_widget());
let host = Size::new(720., 540.);
let node = view.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, host),
);
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Named(named),
modified_key: Key::Named(named),
physical_key: Physical::Code(Code::PageDown),
location: keyboard::Location::Standard,
modifiers,
text: None,
repeat: false,
});
let mut messages = Vec::new();
let mut shell = iced::advanced::Shell::new(&mut messages);
view.as_widget_mut().update(
&mut tree,
&event,
iced::advanced::Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::with_size(host),
);
assert_eq!(shell.is_event_captured(), expected.is_some());
assert_eq!(messages.len(), usize::from(expected.is_some()));
if let Some(expected) = expected {
assert!(
matches!(messages[0], Message::TextWorkspace(Event::ResizeDrawer(actual)) if actual == expected)
);
}
}
}
#[derive(Default)]
struct ScrollProbe {
scroll: Option<(usize, f32)>,
}
impl iced::advanced::renderer::Renderer for ScrollProbe {
fn start_layer(&mut self, _: iced::Rectangle) {}
fn end_layer(&mut self) {}
fn start_transformation(&mut self, _: iced::Transformation) {}
fn end_transformation(&mut self) {}
fn reset(&mut self, _: iced::Rectangle) {}
fn fill_quad(&mut self, _: iced::advanced::renderer::Quad, _: impl Into<iced::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,
) {
iced::advanced::renderer::Renderer::allocate_image(&mut (), handle, callback);
}
}
impl iced::advanced::text::Renderer for ScrollProbe {
type Font = Font;
type Paragraph = iced::advanced::graphics::text::Paragraph;
type Editor = iced::advanced::graphics::text::Editor;
const ICON_FONT: Font = Font::DEFAULT;
const CHECKMARK_ICON: char = '0';
const ARROW_DOWN_ICON: char = '0';
const SCROLL_UP_ICON: char = '0';
const SCROLL_DOWN_ICON: char = '0';
const SCROLL_LEFT_ICON: char = '0';
const SCROLL_RIGHT_ICON: char = '0';
const ICED_LOGO: char = '0';
fn default_font(&self) -> Font {
Font::DEFAULT
}
fn default_size(&self) -> iced::Pixels {
iced::Pixels(16.)
}
fn fill_paragraph(
&mut self,
_: &Self::Paragraph,
_: iced::Point,
_: iced::Color,
_: iced::Rectangle,
) {
}
fn fill_editor(
&mut self,
editor: &Self::Editor,
_: iced::Point,
_: iced::Color,
_: iced::Rectangle,
) {
let scroll = editor.buffer().scroll();
self.scroll = Some((scroll.line, scroll.vertical));
}
fn fill_text(
&mut self,
_: iced::advanced::text::Text,
_: iced::Point,
_: iced::Color,
_: iced::Rectangle,
) {
}
}
#[test]
fn highlighted_sensor_editor_retains_real_scrolled_buffer_across_panel_rebuilds() {
let source = (0..100)
.map(|index| format!("// 雨 {index} @assess\r\n"))
.collect::<String>();
let mut content = widget::text_editor::Content::<ScrollProbe>::with_text(&source);
content.move_to(widget::text_editor::Cursor {
position: widget::text_editor::Position { line: 2, column: 6 },
selection: Some(widget::text_editor::Position { line: 2, column: 3 }),
});
let cursor = content.cursor();
let mut tree = Tree::empty();
let mut probe = ScrollProbe::default();
let mut baseline_scroll = None;
let host = Size::new(720., 540.);
// First iteration sends a real wheel event and applies its editor action.
for (iteration, (find, drawer)) in [
(false, false),
(false, false),
(true, false),
(true, true),
(true, true),
(false, true),
(false, false),
]
.into_iter()
.enumerate()
{
let mut messages = Vec::new();
{
let editor = widget::text_editor(&content)
.on_action(Message::SourceEdit)
.height(Fill)
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
source.clone().into(),
crate::highlight::format,
);
let editor = widget::sensor(editor)
.key(("救援/assess.arcadia".to_owned(), None::<oa_core::Id>))
.on_show(|_| Message::SourceViewport(None))
.on_resize(|_| Message::SourceViewport(None));
let mut view = layout(
text("Strip").into(),
text("Find").into(),
editor.into(),
text("Details").into(),
text("Status").into(),
LayoutSettings {
find_open: find,
drawer_height: drawer.then_some(180.),
measured_drawer: Rc::new(Cell::new(0.)),
},
);
tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut tree,
&probe,
&layout::Limits::new(Size::ZERO, host),
);
let state = tree.children[2].children[0].state.downcast_mut::<widget::text_editor::State<crate::highlight::ArcadiaHighlighter>>();
if iteration == 0 {
state.focus();
} else {
assert!(state.is_focused());
}
if iteration == 0 {
let mut shell = iced::advanced::Shell::new(&mut messages);
view.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(iced::mouse::Event::WheelScrolled {
delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -120. },
}),
iced::advanced::Layout::new(&node),
iced::mouse::Cursor::Available(iced::Point::new(100., 120.)),
&probe,
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::with_size(host),
);
assert!(shell.is_event_captured());
} else {
view.as_widget().draw(
&tree,
&mut probe,
&style::theme(),
&iced::advanced::renderer::Style {
text_color: style::INK,
},
iced::advanced::Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&iced::Rectangle::with_size(host),
);
let scroll = probe.scroll.expect("actual highlighted editor was drawn");
assert!(
scroll.0 > 0 || scroll.1 > 0.,
"the fixture must actually scroll"
);
if let Some(expected) = baseline_scroll {
assert_eq!(scroll, expected);
} else {
baseline_scroll = Some(scroll);
}
}
}
if iteration == 0 {
assert!(messages.iter().any(|message| matches!(
message,
Message::SourceEdit(widget::text_editor::Action::Scroll { .. })
)));
}
for message in messages {
if let Message::SourceEdit(action) = message {
content.perform(action);
}
}
assert_eq!(content.cursor(), cursor);
}
}
}
#[cfg(all(test, debug_assertions))]
mod summary_visibility_tests;