//! 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::( 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>, 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, /// Layout publishes the effective height so Smaller immediately shrinks a /// previously oversized request after a window resize. pub measured_drawer: Rc>, } fn slot_heights(height: f32, find: bool, requested: Option) -> [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>, settings: LayoutSettings, } impl iced::advanced::Widget for WorkspaceLayout<'_, Renderer> { fn children(&self) -> Vec { 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 { 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::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::::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::( content.text().into(), crate::highlight::format, ); let editor = widget::sensor(editor) .key(("救援/assess.arcadia".to_owned(), None::)) .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::>(); 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) { } fn allocate_image( &mut self, handle: &iced::advanced::image::Handle, callback: impl FnOnce( Result, ) + 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::(); let mut content = widget::text_editor::Content::::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::( source.clone().into(), crate::highlight::format, ); let editor = widget::sensor(editor) .key(("救援/assess.arcadia".to_owned(), None::)) .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::>(); 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;