Fix native Text navigation layout and focus regressions

This commit is contained in:
2026-09-11 12:44:17 -07:00
parent f4e426ae2a
commit eeeaa12627
53 changed files with 21231 additions and 5 deletions
+35 -2
View File
@@ -424,6 +424,7 @@ pub enum Message {
SourceSymbolsReady(crate::source_navigation::Completion),
SourceForSelection,
SourceNavigation(crate::source_navigation::Event),
SourceFindClose(crate::source_navigation::SymbolContext, Id),
Save(bool),
SavePath(Option<PathBuf>),
Saved(Result<Option<FileSnapshot>, SaveFailure>),
@@ -497,6 +498,7 @@ pub struct App {
pub source_jump: Option<text_editor::Cursor>,
source_select_line: bool,
pub source_navigation: crate::source_navigation::State,
source_find_instance: Id,
pub source_symbols: crate::source_navigation::SymbolState,
pub model_issues: Vec<validation::Issue>,
pub migration: Option<persistence::MigrationReport>,
@@ -603,6 +605,7 @@ impl App {
source_jump: None,
source_select_line: false,
source_navigation: crate::source_navigation::State::default(),
source_find_instance: Id::new_v4(),
source_symbols: crate::source_navigation::SymbolState::default(),
model_issues,
migration: None,
@@ -1058,7 +1061,7 @@ impl App {
self.behavior.creation.has_pending()
|| self.behavior.source_active()
|| (self.page == Page::Text
&& self.source_symbols.open
&& (self.source_symbols.open || self.source_navigation.open)
&& self.architecture.is_none()),
)
.map(|()| Message::Escape),
@@ -1106,6 +1109,23 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
let message = match message {
Message::SourceFindClose(context, instance) => {
if context != self.source_symbol_context()
|| instance != self.source_find_instance
|| !self.source_navigation.open
|| !self.source_navigation_available()
{
return Task::none();
}
// Only the checked envelope may enter the legacy Close handler.
Message::SourceNavigation(crate::source_navigation::Event::Close)
}
Message::SourceNavigation(crate::source_navigation::Event::Close) => {
return Task::none();
}
message => message,
};
if let Message::SourceSymbols(context, _) = &message
&& (context.session != self.source_workspace.session()
|| context.path != self.source_workspace.active)
@@ -1838,6 +1858,8 @@ impl App {
return self.update(Message::Add(child));
}
Message::Escape => {
let restore_source_focus = self.source_navigation_available()
&& (self.source_navigation.open || self.source_symbols.open);
if self.behavior.source_back() {
self.status =
"Returned to the unchanged parent form; no source was created.".into();
@@ -1857,6 +1879,9 @@ impl App {
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
if restore_source_focus {
self.restore_source_editor_focus();
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
}
@@ -2971,6 +2996,7 @@ impl App {
}
Message::SourceRelated(location) => self.jump_to_source(location),
Message::SourceViewport(ticket) => return self.source_viewport(ticket),
Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it
Message::SourceSymbols(_, event) => return self.source_symbol_event(event),
Message::SourceSymbolsReady(completion) => {
return self.source_symbols_ready(completion);
@@ -3019,6 +3045,7 @@ impl App {
return Task::none();
}
self.page = Page::Text;
self.source_find_instance = Id::new_v4();
self.source_navigation.open = true;
self.source_navigation.refresh(&self.source_editor.text());
return iced::widget::operation::focus(if go_to_line {
@@ -3027,7 +3054,13 @@ impl App {
"source-find"
});
}
Event::Close => self.source_navigation.open = false,
Event::Close => {
let was_open = self.source_navigation.open;
self.source_navigation.open = false;
if was_open {
self.restore_source_editor_focus();
}
}
Event::Query(query) => {
self.source_navigation.query = query;
self.source_navigation.refresh(&self.source_editor.text());
+16 -1
View File
@@ -41,6 +41,10 @@ impl App {
pub fn source_symbol_message(&self, event: nav::SymbolEvent) -> Message {
Message::SourceSymbols(self.source_symbol_context(), event)
}
pub fn source_find_close_message(&self) -> Message {
Message::SourceFindClose(self.source_symbol_context(), self.source_find_instance)
}
pub fn source_key(&self) -> query::SnapshotKey {
query::SnapshotKey {
workspace: self.source_workspace.session(),
@@ -134,6 +138,15 @@ impl App {
});
}
pub(super) fn restore_source_editor_focus(&mut self) {
let cursor = self.source_editor.cursor();
if self.source_navigation_available()
&& nav::exact_cursor(self.source_workspace.text(), cursor)
{
self.queue_source_reveal(cursor, false);
}
}
pub(super) fn source_viewport(&mut self, ticket: Option<Id>) -> Task<Message> {
let Some(reveal) = &self.source_symbols.reveal else {
return Task::none();
@@ -180,8 +193,10 @@ impl App {
self.source_symbols.notice = None;
}
nav::SymbolEvent::Back => {
self.source_symbols.open = true;
self.source_symbol_back();
// A successful Back needs no empty results panel. Retain an
// already open panel, or reveal an actionable return notice.
self.source_symbols.open |= self.source_symbols.notice.is_some();
}
nav::SymbolEvent::Choose {
stamp,
@@ -461,6 +461,10 @@ fn escape_dismisses_modal_before_underlying_source_results() {
app.hosted.visible = true;
send(&mut app, Message::Escape);
assert!(!app.hosted.visible);
assert!(
app.source_reveal_ticket().is_none(),
"modal dismissal must not focus the editor"
);
assert!(app.source_symbols.open);
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
@@ -473,6 +477,303 @@ fn escape_dismisses_modal_before_underlying_source_results() {
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice() {
for changed in [false, true] {
let mut app = fixture();
let accepted = app.document.snapshot();
let origin = app.source_editor.cursor();
finish(&mut app, query::Action::Definition);
reveal(&mut app);
symbol(&mut app, nav::SymbolEvent::Close);
assert!(!app.source_symbols.open && app.source_symbols.results.is_none());
if changed {
// Use the workspace edit API to give the origin a new generation.
show(&mut app, USES);
let text = app.source_workspace.text().to_owned();
send(
&mut app,
Message::SourceEdit(text_editor::Action::SelectAll),
);
send(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new(format!("{text}// current cursor\r\n")),
))),
);
show(&mut app, DEFINITIONS);
}
let raw = app.source_workspace.bundle();
symbol(&mut app, nav::SymbolEvent::Back);
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_symbols.open, changed);
assert_eq!(app.source_symbols.notice.is_some(), changed);
reveal(&mut app);
if !changed {
assert_eq!(app.source_editor.cursor(), origin);
}
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
let mut app = fixture();
symbol(&mut app, nav::SymbolEvent::Back);
assert!(app.source_symbols.open && app.source_symbols.notice.is_some());
}
#[test]
fn find_only_captured_escape_closes_once_and_preserves_raw_query_and_selection() {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
app.source_navigation.open = true;
app.source_navigation.query = "送水 🚀".into();
assert!(!app.source_symbols.open);
let mut messages = captured_input_subscription_messages(&app);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
assert!(!app.source_navigation.open && !app.source_symbols.open);
assert_eq!(app.source_navigation.query, "送水 🚀");
assert!(app.source_reveal_ticket().is_some());
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
assert!(captured_input_subscription_messages(&app).is_empty());
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn old_find_close_cannot_cancel_a_new_file_session_or_reopened_find_intent() {
for replacement in 0..3 {
let mut app = fixture();
if replacement == 0 {
show(&mut app, "project.arcadia");
}
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
let old_context = app.source_symbol_context();
let old_instance = app.source_find_instance;
let old_close = app.source_find_close_message();
match replacement {
0 => {
let project = app.project().clone();
app.reset(project, None);
app.page = Page::Text;
assert_eq!(app.source_workspace.active, old_context.path);
assert_ne!(app.source_workspace.session(), old_context.session);
}
1 => {
show(&mut app, DEFINITIONS);
assert_eq!(app.source_workspace.session(), old_context.session);
assert_ne!(app.source_workspace.active, old_context.path);
}
2 => {
send(&mut app, old_close.clone());
assert!(!app.source_navigation.open);
assert!(app.source_reveal_ticket().is_some());
assert_eq!(app.source_symbol_context(), old_context);
}
_ => unreachable!(),
}
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
assert_ne!(app.source_find_instance, old_instance);
send(
&mut app,
Message::SourceNavigation(nav::Event::Query("Current Find 雨".into())),
);
let text = app.source_workspace.text();
let (offset, len) = match replacement {
0 => (text.find("project model").unwrap() + "project ".len(), 5),
1 => (
text.find("oa_activity pump").unwrap() + "oa_activity ".len(),
4,
),
_ => (text.find("source @pump").unwrap() + "source @".len(), 4),
};
let cursor = selection(text, offset, offset + len, true);
app.source_editor.move_to(cursor);
let request = begin(&mut app, query::Action::References);
let ready = completion(&app, request);
app.queue_source_reveal(cursor, false);
let find_instance = app.source_find_instance;
let mut stale = vec![old_close, Message::SourceNavigation(nav::Event::Close)];
if replacement != 2 {
// Independently require the session/path guard, even with a current
// invocation token. A token check alone is not the location contract.
stale.push(Message::SourceFindClose(old_context, find_instance));
}
assert_stale_envelopes_preserve_current(&mut app, &stale);
assert!(app.source_navigation.open);
assert_eq!(app.source_navigation.query, "Current Find 雨");
assert_eq!(app.source_find_instance, find_instance);
send(&mut app, Message::SourceSymbolsReady(ready));
assert_stale_envelopes_preserve_current(&mut app, &stale);
assert!(app.source_navigation.open);
assert_eq!(app.source_navigation.query, "Current Find 雨");
let current_close = app.source_find_close_message();
send(&mut app, current_close);
assert!(!app.source_navigation.open);
assert_eq!(app.source_navigation.query, "Current Find 雨");
assert!(app.source_reveal_ticket().is_some());
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
}
}
#[tokio::test]
async fn find_close_releases_removed_input_and_guarded_focus_respects_later_input() {
use iced::advanced::{Layout, layout, widget};
use iced::futures::StreamExt;
use iced::widget::{column, text_input};
#[derive(Default)]
struct Focus(Vec<(Option<widget::Id>, bool)>);
impl widget::Operation for Focus {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
_: iced::Rectangle,
state: &mut dyn widget::operation::Focusable,
) {
self.0.push((id.cloned(), state.is_focused()));
}
}
fn view(app: &App, later: bool) -> iced::Element<'static, Message, iced::Theme, ()> {
let mut content = column![
text_input("Editor", "unchanged source")
.id(app.source_editor_id())
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s)))
];
if app.source_navigation.open {
content = content.push(
text_input("Find", "送水")
.id("source-find")
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s))),
);
}
if later {
content = content.push(
text_input("Other", "preserve focus")
.id("later-input")
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s))),
);
}
content.into()
}
fn run(
ui: &mut iced::Element<'_, Message, iced::Theme, ()>,
tree: &mut widget::Tree,
node: &layout::Node,
mut op: Box<dyn widget::Operation>,
) {
loop {
ui.as_widget_mut()
.operate(tree, Layout::new(node), &(), op.as_mut());
match op.finish() {
widget::operation::Outcome::Chain(next) => op = next,
widget::operation::Outcome::None => break,
widget::operation::Outcome::Some(()) => panic!("focus must not produce a message"),
}
}
}
for escape in [false, true] {
for later in 0..3 {
let mut app = fixture();
app.source_navigation.open = true;
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let mut ui = view(&app, false);
let mut tree = widget::Tree::new(ui.as_widget());
let limits = layout::Limits::new(iced::Size::ZERO, iced::Size::new(500., 250.));
let node = ui.as_widget_mut().layout(&mut tree, &(), &limits);
run(
&mut ui,
&mut tree,
&node,
Box::new(widget::operation::focusable::focus(widget::Id::new(
"source-find",
))),
);
let mut focus = Focus::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &(), &mut focus);
assert!(
focus
.0
.contains(&(Some(widget::Id::new("source-find")), true))
);
let close = if escape {
Message::Escape
} else {
app.source_find_close_message()
};
send(&mut app, close);
assert!(!app.source_navigation.open);
let ticket = app
.source_reveal_ticket()
.expect("closing an open Find queues focus");
ui = view(&app, later != 0);
tree.diff(ui.as_widget());
let node = ui.as_widget_mut().layout(&mut tree, &(), &limits);
let task = app.update(Message::SourceViewport(Some(ticket)));
if later != 0 {
run(
&mut ui,
&mut tree,
&node,
Box::new(widget::operation::focusable::focus(widget::Id::new(
"later-input",
))),
);
}
if later == 2 {
app.source_symbols
.raw_interaction
.fetch_add(1, Ordering::SeqCst);
}
let mut actions =
iced_runtime::task::into_stream(task).expect("guarded widget operation");
while let Some(action) = actions.next().await {
let iced_runtime::Action::Widget(op) = action else {
panic!("focus-only task");
};
run(&mut ui, &mut tree, &node, op);
}
let mut focus = Focus::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &(), &mut focus);
assert!(
!focus
.0
.iter()
.any(|(id, _)| id.as_ref() == Some(&widget::Id::new("source-find")))
);
assert!(
focus
.0
.contains(&(Some(app.source_editor_id()), later == 0))
);
if later != 0 {
assert!(
focus
.0
.contains(&(Some(widget::Id::new("later-input")), true))
);
}
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
}
/// Feed the real App subscription recipes the status produced by a mounted
/// TextInput. Poll only immediately available output: no timer wait, worker,
/// file dialog or App task is executed by this helper.
+419 -2
View File
@@ -1020,7 +1020,7 @@ fn source(app: &App) -> View<'_, Message> {
),
action("Next", Message::SourceNavigation(SourceEvent::Next(false))),
label(app.source_navigation.label()),
action("Close", Message::SourceNavigation(SourceEvent::Close)),
action("Close", app.source_find_close_message()),
]
.spacing(6)
.align_y(iced::Center),
@@ -1208,8 +1208,18 @@ fn source(app: &App) -> View<'_, Message> {
} else {
container(editor).width(Fill).height(Fill).into()
};
source_layout(header.into(), workspace, app.window_size.height)
}
fn source_layout<'a, Renderer: iced::advanced::text::Renderer + 'a>(
header: View<'a, Message, iced::Theme, Renderer>,
workspace: View<'a, Message, iced::Theme, Renderer>,
window_height: f32,
) -> View<'a, Message, iced::Theme, Renderer> {
// Lint clears and repopulates diagnostics asynchronously. Reserve the same
// outer slot so a pointer aimed at the editor cannot land in the header.
column![
container(scrollable(header)).max_height((app.window_size.height * 0.42).max(180.)),
container(scrollable(header).id("source-header")).height((window_height * 0.42).max(180.)),
workspace,
]
.height(Fill)
@@ -1222,6 +1232,19 @@ fn source_binding(
use crate::source_navigation::{SymbolEvent, query::Action};
use iced::keyboard::{Key, key::Named};
use widget::text_editor::{Binding, Status};
// Windows owns Alt+Space for its window menu. Iced's default binding would
// otherwise insert a supplied space payload, even after that menu closes.
// Keep Ctrl+Alt (AltGr), shifted input, and ordinary Space unchanged.
#[cfg(target_os = "windows")]
if matches!(press.status, Status::Focused { .. })
&& press.modifiers == iced::keyboard::Modifiers::ALT
&& matches!(
press.key.as_ref(),
Key::Named(Named::Space) | Key::Character(" ")
)
{
return None;
}
if matches!(press.status, Status::Focused { .. })
&& !press.modifiers.control()
&& !press.modifiers.logo()
@@ -1248,6 +1271,400 @@ fn source_binding(
}
Binding::from_key_press(press)
}
#[cfg(all(test, debug_assertions))]
mod source_tests {
use super::*;
use iced::advanced::{
Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget,
};
use iced::widget::column;
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector};
// Measure real Iced paragraphs/editor text without constructing a window or GPU.
struct FontLayout;
impl renderer::Renderer for FontLayout {
fn start_layer(&mut self, _: Rectangle) {}
fn end_layer(&mut self) {}
fn start_transformation(&mut self, _: Transformation) {}
fn end_transformation(&mut self) {}
fn reset(&mut self, _: Rectangle) {}
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 (), handle, callback);
}
}
impl shaped::Renderer for FontLayout {
type Font = Font;
type Paragraph = graphics::text::Paragraph;
type Editor = 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) -> Pixels {
Pixels(16.)
}
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
}
#[derive(Default)]
struct Geometry {
header: Option<(Rectangle, Rectangle, Vector)>,
editor: Option<Rectangle>,
}
impl core_widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&core_widget::Id>, bounds: Rectangle) {
if id == Some(&core_widget::Id::new("test-source-editor")) {
self.editor = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&core_widget::Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn core_widget::operation::Scrollable,
) {
if id == Some(&core_widget::Id::new("source-header")) {
self.header = Some((bounds, content, offset));
}
}
}
fn mounted_source<'a>(
content: &'a widget::text_editor::Content<FontLayout>,
diagnostics: usize,
find: bool,
window_height: f32,
) -> View<'a, Message, iced::Theme, FontLayout> {
let mut notices = column![].spacing(5);
for index in 0..diagnostics {
notices = notices.push(button(text(format!(
"救援/assess.arcadia:{}:5 · Warning: verify this participant's engineering allocation", index + 1,
)).size(12)).padding([4, 6]).width(Fill));
}
let find: View<'_, Message, iced::Theme, FontLayout> = if find {
column![
text_input("Find literal text", "assess").on_input(|s| Message::SourceNavigation(
crate::source_navigation::Event::Query(s)
)),
text_input("Line", "1").on_input(|s| Message::SourceNavigation(
crate::source_navigation::Event::Line(s)
)),
]
.spacing(8)
.into()
} else {
Space::new().into()
};
let header = container(
column![
text("Textual model").size(24),
text("Edit the current file. Apply text validates the complete included model.")
.size(11),
row![
button(text("Apply text")),
button(text("Undo text")),
button(text("Model Undo"))
]
.spacing(8),
text("救援/assess.arcadia · All files applied to the accepted model").size(11),
text("Undo text affects this draft. Model Undo restores accepted files together.")
.size(11),
find,
row![
button(text("Definition · F12")),
button(text("References · Shift F12")),
button(text("Back"))
]
.spacing(7),
scrollable(notices).height((diagnostics as f32 * 32.).min(132.)),
]
.spacing(12),
)
.padding(18);
let editor = container(
widget::text_editor(content)
.on_action(Message::SourceEdit)
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Fill),
)
.id("test-source-editor")
.height(Fill)
.width(Fill);
source_layout(header.into(), editor.into(), window_height)
}
#[test]
fn source_header_keeps_editor_bounds_stable_through_lint_and_find_rebuilds() {
for (host, window_height) in [
(Size::new(720., 540.), 700.),
(Size::new(1180., 890.), 1080.),
] {
let content = widget::text_editor::Content::<FontLayout>::with_text(
"// is excluded\r\n// 雨\r\n",
);
let mut tree = core_widget::Tree::empty();
let mut baseline = None;
for (diagnostics, find) in [
(0, false),
(6, false),
(0, false),
(6, true),
(1, true),
(0, false),
] {
let mut ui = mounted_source(&content, diagnostics, find, window_height);
tree.diff(ui.as_widget());
let node = ui.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, host),
);
let mut geometry = Geometry::default();
ui.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&FontLayout,
&mut geometry,
);
let editor = geometry.editor.unwrap();
let (header, _, _) = geometry.header.unwrap();
let slot = node.children()[0].bounds();
assert_eq!(slot.height, (window_height * 0.42).max(180.));
assert!(
header.height <= slot.height,
"short content may shrink inside the fixed slot"
);
assert!(
editor.height >= 240.,
"remaining editor must stay usable: {editor:?}"
);
assert_eq!(editor.y, slot.y + slot.height);
if let Some(expected) = baseline {
assert_eq!(editor, expected, "diagnostics={diagnostics}, find={find}");
} else {
baseline = Some(editor);
}
}
}
}
#[test]
fn source_header_remains_scrollable_and_preserves_offset_across_diagnostic_refresh() {
let content = widget::text_editor::Content::<FontLayout>::with_text("// 雨\r\n");
let host = Size::new(720., 540.);
let mut tree = core_widget::Tree::empty();
let mut saved = None;
for diagnostics in [6, 7] {
let mut ui = mounted_source(&content, diagnostics, true, 700.);
tree.diff(ui.as_widget());
let node = ui.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, host),
);
let mut geometry = Geometry::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
let (bounds, contents, _) = geometry.header.unwrap();
assert!(
contents.height > bounds.height,
"fixture must actually overflow"
);
if saved.is_none() {
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
ui.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(iced::mouse::Event::WheelScrolled {
delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -45. },
}),
Layout::new(&node),
iced::mouse::Cursor::Available(Point::new(bounds.x + 8., bounds.y + 8.)),
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut shell,
&Rectangle::with_size(host),
);
assert!(shell.is_event_captured());
}
let mut geometry = Geometry::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
let offset = geometry.header.unwrap().2;
assert!(offset.y > 0., "the actual mounted Scrollable must move");
if let Some(before) = saved {
assert_eq!(offset, before);
} else {
saved = Some(offset);
}
}
}
#[cfg(target_os = "windows")]
#[test]
fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() {
use core_widget::operation::Focusable;
use iced::keyboard::{
self, Key, Modifiers,
key::{Code, Named, Physical},
};
use widget::text_editor::{Binding, Content, KeyPress, State, Status};
let baseline = "// is excluded\r\n// 雨\r\n";
let context = crate::source_navigation::SymbolContext {
session: Id::new_v4(),
path: "救援/assess.arcadia".into(),
};
for (key, physical_key, modifiers, payload, inserted) in [
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::ALT,
" ",
false,
),
(
Key::Character(" ".into()),
Physical::Code(Code::Space),
Modifiers::ALT,
" ",
false,
),
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::empty(),
" ",
true,
),
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::CTRL | Modifiers::ALT,
" ",
true,
),
(
Key::Character("e".into()),
Physical::Code(Code::KeyE),
Modifiers::CTRL | Modifiers::ALT,
"€",
true,
),
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::ALT | Modifiers::SHIFT,
" ",
true,
),
] {
let mut content = Content::<FontLayout>::with_text(baseline);
content.move_to(widget::text_editor::Cursor {
position: widget::text_editor::Position { line: 0, column: 5 },
selection: None,
});
let press = KeyPress {
key: key.clone(),
modified_key: key.clone(),
physical_key,
modifiers,
text: Some(payload.into()),
status: Status::Focused { is_hovered: false },
};
assert!(
matches!(
Binding::<Message>::from_key_press(press),
Some(Binding::Insert(_))
),
"the supplied event must expose the default insertion path"
);
let mut messages = Vec::new();
{
let mut editor: View<'_, Message, iced::Theme, FontLayout> =
widget::text_editor(&content)
.on_action(Message::SourceEdit)
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Fill)
.key_binding(|press| source_binding(press, &context))
.into();
let mut tree = core_widget::Tree::new(editor.as_widget());
let size = Size::new(600., 240.);
let node = editor.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, size),
);
tree.state
.downcast_mut::<State<shaped::highlighter::PlainText>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key,
location: keyboard::Location::Standard,
modifiers,
text: Some(payload.into()),
repeat: false,
});
let mut shell = Shell::new(&mut messages);
editor.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut shell,
&Rectangle::with_size(size),
);
}
assert_eq!(
messages.len(),
usize::from(inserted),
"modifiers={modifiers:?}"
);
for message in messages {
let Message::SourceEdit(action) = message else {
panic!("unexpected navigation action");
};
content.perform(action);
}
let expected = if inserted {
format!("// is{payload} excluded\r\n// 雨\r\n")
} else {
baseline.into()
};
assert_eq!(content.text(), expected);
}
}
}
fn checks(app: &App) -> View<'_, Message> {
let issues = &app.model_issues;
let mut out = column![heading(