Fix native Text navigation layout and focus regressions
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user