//! Root overlay adapter. Keep the wrapper mounted while closed so the release //! from a dismissal gesture cannot reach the underlying content. Parent modal //! ownership is supplied by the host (pass no chooser while a modal is active). use super::{Action, Close, Event as ChoiceEvent, MAX_ROWS, Stamp, State as Chooser}; use crate::style; use iced::{ Border, Color, Element, Event, Font, Length, Point, Rectangle, Size, Theme, Vector, advanced::{ Clipboard, Layout, Shell, Widget, layout, overlay, renderer, text::{self, Paragraph as _}, widget::{Operation, Tree, tree}, }, alignment, keyboard, mouse, touch, widget::text_input, window, }; use std::{cell::Cell, rc::Rc}; const ROW: f32 = 34.; const MARGIN: f32 = 8.; #[derive(Debug, Clone, Copy)] pub(super) struct Geometry { pub popup: Rectangle, pub search: Rectangle, pub current: Rectangle, pub detail: Rectangle, pub close: Rectangle, pub previous: Rectangle, pub next: Rectangle, pub clear: Rectangle, pub rows: usize, } impl Geometry { fn new(anchor: Rectangle, viewport: Size) -> Self { let width = anchor .width .clamp(278., 420.) .min((viewport.width - 2. * MARGIN).max(0.)); let height = 360f32.min((viewport.height - 2. * MARGIN).max(0.)); let x = anchor.x.clamp( MARGIN.min(viewport.width / 2.), (viewport.width - MARGIN - width).max(MARGIN.min(viewport.width / 2.)), ); let below = anchor.y + anchor.height + 4.; let preferred = if below + height + MARGIN <= viewport.height { below } else { anchor.y - height - 4. }; let y = preferred.clamp( MARGIN.min(viewport.height / 2.), (viewport.height - MARGIN - height).max(MARGIN.min(viewport.height / 2.)), ); Self::from_bounds(Rectangle { x, y, width, height, }) } pub(super) fn from_bounds(popup: Rectangle) -> Self { let inner = (popup.width - 16.).max(0.); let rect = |left, top, width, height| Rectangle { x: popup.x + left, y: popup.y + top, width, height, }; let footer = (popup.height - 32.).max(0.); let detail_height = if popup.height >= 180. { 32. } else { 0. }; let detail_top = (footer - detail_height - 4.).max(84.); let rows = (((detail_top - 88.).max(0.) / ROW) as usize).min(MAX_ROWS); Self { popup, search: rect(8., 28., inner, 30.), current: rect(8., 62., inner, 18.), detail: rect(8., detail_top, inner, detail_height), close: rect((popup.width - 58.).max(0.), 4., 50f32.min(popup.width), 22.), previous: rect(8., footer, 28., 24.), next: rect(40., footer, 28., 24.), clear: rect((popup.width - 84.).max(0.), footer, 76., 24.), rows, } } pub fn row(&self, index: usize) -> Rectangle { Rectangle { x: self.popup.x + 8., y: self.popup.y + 84. + index as f32 * ROW, width: (self.popup.width - 16.).max(0.), height: ROW, } } fn hit(&self, chooser: &Chooser, point: Point) -> Option { if self.close.contains(point) { return Some(Action::Close(Close::Escape)); } if self.previous.contains(point) && chooser.start() > 0 { return Some(Action::Page { delta: -1, rows: self.rows, }); } if self.next.contains(point) && chooser.start() + self.rows < chooser.count() { return Some(Action::Page { delta: 1, rows: self.rows, }); } if self.clear.contains(point) && chooser.optional() { return Some(Action::Clear); } chooser .visible(self.rows) .enumerate() .find_map(|(i, c)| self.row(i).contains(point).then_some(Action::Choose(c.id))) } } #[derive(Default)] struct Interaction { stamp: Option, serial: u64, pressed: Option<(ChoiceEvent, Pointer)>, closing: bool, swallow_mouse: Option, swallow_touch: Option, held_mouse: Option, held_touch: Option, detail_scroll: f32, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Pointer { Mouse, Touch(touch::Finger), } impl Interaction { fn swallow(&mut self, event: &Event) -> bool { match event { Event::Mouse(mouse::Event::ButtonReleased(button)) if self.swallow_mouse == Some(*button) => { self.swallow_mouse = None; true } Event::Touch( touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. }, ) if self.swallow_touch == Some(*id) => { self.swallow_touch = None; true } _ => false, } } } pub struct Popup<'a, Message, R = iced::Renderer> where R: text::Renderer, { content: Element<'a, Message, Theme, R>, input: Element<'a, String, Theme, R>, chooser: Option<&'a Chooser>, anchor: Rectangle, on_event: Rc Message + 'a>, focus_scope: Option, } impl<'a, Message: Clone + 'a, R: text::Renderer + 'a> Popup<'a, Message, R> { pub fn new( content: impl Into>, chooser: Option<&'a Chooser>, anchor: Rectangle, on_event: impl Fn(ChoiceEvent) -> Message + 'a, ) -> Self { let chooser = chooser.filter(|state| state.is_open()); let input = text_input( "Name, @alias, kind or containment", chooser.map_or("", Chooser::query), ) .on_input(std::convert::identity) .size(12) .padding(6) .width(Length::Fill) .into(); Self { content: content.into(), input, chooser, anchor, on_event: Rc::new(on_event), focus_scope: None, } } pub fn focus_scope(mut self, scope: Option) -> Self { self.focus_scope = scope; self } fn reconcile(&self, tree: &mut Tree) { let state = tree.state.downcast_mut::(); let stamp = self.chooser.map(Chooser::stamp); if state.stamp.as_ref().map(|s| s.invocation) != stamp.as_ref().map(|s| s.invocation) { let input = tree.children[1] .state .downcast_mut::>(); if stamp.is_some() { input.focus(); } else { input.unfocus(); } state.closing = false; state.serial = self.chooser.map_or(0, Chooser::query_serial); } if state.stamp != stamp { state.pressed = None; // A queued Choose can be rejected after a preceding query changed // the result generation. Reconciliation must unlock this view. state.closing = false; state.detail_scroll = 0.; } state.stamp = stamp; } } impl> Widget for Popup<'_, Message, R> { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { tree::State::new(Interaction::default()) } fn children(&self) -> Vec { vec![ Tree::new(self.content.as_widget()), Tree::new(self.input.as_widget()), ] } fn diff(&self, tree: &mut Tree) { tree.children[0].diff(self.content.as_widget()); tree.children[1].diff(self.input.as_widget()); self.reconcile(tree); } fn size(&self) -> Size { self.content.as_widget().size() } fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node { self.reconcile(tree); self.content .as_widget_mut() .layout(&mut tree.children[0], renderer, limits) } fn operate( &mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &R, operation: &mut dyn Operation, ) { let mut owner = super::trigger::FormOwner(self.focus_scope.filter(|_| self.chooser.is_none())); operation.custom(None, layout.bounds(), &mut owner); self.content .as_widget_mut() .operate(&mut tree.children[0], layout, renderer, operation); } fn update( &mut self, tree: &mut Tree, event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &R, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { let state = tree.state.downcast_mut::(); if state.swallow(event) || self.chooser.is_some() && is_input(event) { shell.capture_event(); return; } if is_input(event) && self.focus_scope.is_some_and(|scope| scope.modal) { super::trigger::own_input(&mut self.content, &mut tree.children[0], layout, renderer); } if let Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Tab), modifiers, .. }) = event && let Some(scope) = self.focus_scope && super::trigger::tab( &mut self.content, &mut tree.children[0], layout, renderer, scope, modifiers.shift(), ) { shell.capture_event(); shell.request_redraw(); return; } super::trigger::measure( &mut self.content, &mut tree.children[0], layout, renderer, *viewport, ); self.content.as_widget_mut().update( &mut tree.children[0], event, layout, cursor, renderer, clipboard, shell, viewport, ); } fn draw( &self, tree: &Tree, renderer: &mut R, theme: &Theme, style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, ) { self.content.as_widget().draw( &tree.children[0], renderer, theme, style, layout, if self.chooser.is_some() { mouse::Cursor::Unavailable } else { cursor }, viewport, ); } fn mouse_interaction( &self, tree: &Tree, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, renderer: &R, ) -> mouse::Interaction { if self.chooser.is_some() { mouse::Interaction::None } else { self.content.as_widget().mouse_interaction( &tree.children[0], layout, cursor, viewport, renderer, ) } } fn overlay<'b>( &'b mut self, tree: &'b mut Tree, layout: Layout<'b>, renderer: &R, viewport: &Rectangle, translation: Vector, ) -> Option> { if let Some(chooser) = self.chooser { Some(overlay::Element::new(Box::new(ChooserOverlay { chooser, anchor: self.anchor, state: tree.state.downcast_mut(), input: &mut self.input, input_tree: &mut tree.children[1], on_event: &*self.on_event, }))) } else { self.content.as_widget_mut().overlay( &mut tree.children[0], layout, renderer, viewport, translation, ) } } } impl<'a, Message: Clone + 'a, R: text::Renderer + 'a> From> for Element<'a, Message, Theme, R> { fn from(popup: Popup<'a, Message, R>) -> Self { Element::new(popup) } } struct ChooserOverlay<'b, 'a, Message, R: text::Renderer> { chooser: &'a Chooser, anchor: Rectangle, state: &'b mut Interaction, input: &'b mut Element<'a, String, Theme, R>, input_tree: &'b mut Tree, on_event: &'b dyn Fn(ChoiceEvent) -> Message, } impl> ChooserOverlay<'_, '_, Message, R> { fn geometry(&self, layout: Layout<'_>) -> Geometry { Geometry::from_bounds( layout .children() .next() .expect("measured chooser bounds") .bounds(), ) } fn publish(&mut self, action: Action, shell: &mut Shell<'_, Message>) { if self.state.closing { return; } if action.writes_staged_value() || matches!(action, Action::Close(_)) { self.state.closing = true; self.state.swallow_mouse = self.state.held_mouse.or(self.state.swallow_mouse); self.state.swallow_touch = self.state.held_touch.or(self.state.swallow_touch); } shell.publish((self.on_event)(self.chooser.event(action))); } fn press( &mut self, point: Point, pointer: Pointer, geometry: Geometry, shell: &mut Shell<'_, Message>, ) { if !geometry.popup.contains(point) { self.publish(Action::Close(Close::Outside), shell); } else { self.state.pressed = geometry .hit(self.chooser, point) .map(|action| (self.chooser.event(action), pointer)); } } fn release( &mut self, point: Point, pointer: Pointer, geometry: Geometry, shell: &mut Shell<'_, Message>, ) { if let Some((pressed, origin)) = self.state.pressed.take() && origin == pointer && pressed.stamp == self.chooser.stamp() && geometry.hit(self.chooser, point).as_ref() == Some(&pressed.action) { self.publish(pressed.action, shell); } } } impl> overlay::Overlay for ChooserOverlay<'_, '_, Message, R> { fn layout(&mut self, renderer: &R, bounds: Size) -> layout::Node { let geometry = Geometry::new(self.anchor, bounds); let input = self .input .as_widget_mut() .layout( self.input_tree, renderer, &layout::Limits::new(Size::ZERO, geometry.search.size()), ) .move_to(geometry.search.position()); layout::Node::with_children( bounds, vec![ layout::Node::new(geometry.popup.size()).move_to(geometry.popup.position()), input, ], ) } fn operate(&mut self, layout: Layout<'_>, renderer: &R, operation: &mut dyn Operation) { self.input.as_widget_mut().operate( self.input_tree, layout.children().nth(1).unwrap(), renderer, operation, ); } fn update( &mut self, event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &R, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, ) { let geometry = self.geometry(layout); match event { Event::Mouse(mouse::Event::ButtonPressed(button)) => { self.state.held_mouse = Some(*button) } Event::Mouse(mouse::Event::ButtonReleased(button)) if self.state.held_mouse == Some(*button) => { self.state.held_mouse = None } Event::Touch(touch::Event::FingerPressed { id, .. }) => { self.state.held_touch = Some(*id) } Event::Touch( touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. }, ) if self.state.held_touch == Some(*id) => self.state.held_touch = None, _ => {} } if self.state.swallow(event) { shell.capture_event(); return; } if self.state.closing { if is_input(event) { shell.capture_event(); } return; } let mut handled = false; match event { Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => { use keyboard::key::Named; let action = match key { keyboard::Key::Named(Named::Escape) => Some(Action::Close(Close::Escape)), keyboard::Key::Named(Named::Tab) => Some(Action::Close(Close::Tab { backwards: modifiers.shift(), })), keyboard::Key::Named(Named::ArrowDown) => Some(Action::Move { delta: 1, rows: geometry.rows, }), keyboard::Key::Named(Named::ArrowUp) => Some(Action::Move { delta: -1, rows: geometry.rows, }), keyboard::Key::Named(Named::PageDown) => Some(Action::Page { delta: 1, rows: geometry.rows, }), keyboard::Key::Named(Named::PageUp) => Some(Action::Page { delta: -1, rows: geometry.rows, }), keyboard::Key::Named(Named::Enter) => { handled = true; self.chooser .highlighted() .filter(|c| self.chooser.visible(geometry.rows).any(|v| v.id == c.id)) .map(|c| Action::Choose(c.id)) } keyboard::Key::Character(c) if modifiers.control() && modifiers.shift() && c.eq_ignore_ascii_case("c") => { clipboard.write( iced::advanced::clipboard::Kind::Standard, self.chooser .highlighted() .map_or_else(|| self.chooser.current.label(), |c| c.detail()), ); handled = true; None } _ => None, }; if let Some(action) = action { self.publish(action, shell); handled = true; } } Event::Window(window::Event::Resized(_) | window::Event::Unfocused) => { self.publish(Action::Close(Close::WindowChanged), shell); handled = true; } Event::Mouse(mouse::Event::ButtonPressed(button)) => { if let Some(point) = cursor.position() { if !geometry.popup.contains(point) { self.state.swallow_mouse = Some(*button); self.press(point, Pointer::Mouse, geometry, shell); handled = true; } else if *button == mouse::Button::Left && !geometry.search.contains(point) { self.press(point, Pointer::Mouse, geometry, shell); handled = true; } } } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if self.state.pressed.is_some() => { if let Some(point) = cursor.position() { self.release(point, Pointer::Mouse, geometry, shell); } else { self.state.pressed = None; } handled = true; } Event::Mouse(mouse::Event::CursorMoved { position }) if self.state.pressed.is_none() => { if let Some(Action::Choose(id)) = geometry.hit(self.chooser, *position) && self .chooser .highlighted() .is_none_or(|candidate| candidate.id != id) { self.publish(Action::Highlight(id), shell); } } Event::Mouse(mouse::Event::WheelScrolled { delta }) => { let (x, y) = match delta { mouse::ScrollDelta::Lines { x, y } => (*x * 24., *y * 24.), mouse::ScrollDelta::Pixels { x, y } => (*x, *y), }; self.state.pressed = None; if cursor .position() .is_some_and(|p| geometry.detail.contains(p)) { let detail = self .chooser .highlighted() .map_or_else(|| self.chooser.current.label(), |c| c.detail()); let max = (measure::(&detail, 11., renderer.default_font()) - geometry.detail.width) .max(0.); self.state.detail_scroll = (self.state.detail_scroll - if x != 0. { x } else { y }).clamp(0., max); shell.request_redraw(); } else if y != 0. { self.publish( Action::Page { delta: if y < 0. { 1 } else { -1 }, rows: geometry.rows, }, shell, ); } handled = true; } Event::Touch(touch::Event::FingerPressed { id, position }) if !geometry.search.contains(*position) => { if !geometry.popup.contains(*position) { self.state.swallow_touch = Some(*id); } self.press(*position, Pointer::Touch(*id), geometry, shell); handled = true; } Event::Touch(touch::Event::FingerLifted { id, position }) if self.state.pressed.is_some() => { self.release(*position, Pointer::Touch(*id), geometry, shell); handled = true; } Event::Touch(touch::Event::FingerLost { .. } | touch::Event::FingerMoved { .. }) => { self.state.pressed = None; } _ => {} } if !handled { let mut messages = Vec::new(); let mut input_shell = Shell::new(&mut messages); self.input.as_widget_mut().update( self.input_tree, event, layout.children().nth(1).unwrap(), cursor, renderer, clipboard, &mut input_shell, &geometry.popup, ); let serial = Cell::new(self.state.serial); shell.merge(input_shell, |text| { serial.set(serial.get().saturating_add(1)); (self.on_event)(self.chooser.event(Action::Query { text, serial: serial.get(), })) }); self.state.serial = serial.get(); } if is_input(event) { shell.capture_event(); } } fn draw( &self, renderer: &mut R, theme: &Theme, renderer_style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, ) { let geometry = self.geometry(layout); renderer.fill_quad( renderer::Quad { bounds: geometry.popup, border: Border { color: style::LINE, width: 1., radius: 8.into(), }, ..Default::default() }, Color::WHITE, ); renderer.with_layer(geometry.popup, |renderer| { label( renderer, &self.chooser.scope_label, 11., style::MUTED, Rectangle { x: geometry.popup.x + 8., y: geometry.popup.y + 4., width: (geometry.popup.width - 78.).max(0.), height: 22., }, ); label(renderer, "Close", 11., style::TEAL, geometry.close); label( renderer, &self.chooser.current.label(), 11., style::MUTED, geometry.current, ); for (i, candidate) in self.chooser.visible(geometry.rows).enumerate() { let row = geometry.row(i); if self .chooser .highlighted() .is_some_and(|c| c.id == candidate.id) { renderer.fill_quad( renderer::Quad { bounds: row, ..Default::default() }, Color::from_rgb8(218, 238, 235), ); } let alias_width = (row.width * 0.42).min( measure::( &format!("@{}", candidate.alias), 11., renderer.default_font(), ) + 4., ); label( renderer, &candidate.name, 12., style::INK, Rectangle { width: (row.width - alias_width - 6.).max(0.), height: 17., ..row }, ); label( renderer, &format!("@{}", candidate.alias), 11., style::INK, Rectangle { x: row.x + row.width - alias_width, width: alias_width, height: 17., ..row }, ); label( renderer, &candidate.context_label(), 10., style::MUTED, Rectangle { y: row.y + 17., height: 17., ..row }, ); } if self.chooser.count() == 0 { label( renderer, "No compatible matches. Clear the query.", 11., style::MUTED, geometry.row(0), ); } let detail = self .chooser .highlighted() .map_or_else(|| self.chooser.current.label(), |c| c.detail()); renderer.with_layer(geometry.detail, |renderer| { raw_label( renderer, detail, 11., style::MUTED, Rectangle { x: geometry.detail.x - self.state.detail_scroll, width: geometry.detail.width + self.state.detail_scroll, height: 16., ..geometry.detail }, ); label( renderer, "Scroll details · Ctrl+Shift+C copies", 10., style::MUTED, Rectangle { y: geometry.detail.y + 16., height: 16., ..geometry.detail }, ); }); label( renderer, "‹", 15., if self.chooser.start() > 0 { style::TEAL } else { style::MUTED }, geometry.previous, ); label( renderer, "›", 15., if self.chooser.start() + geometry.rows < self.chooser.count() { style::TEAL } else { style::MUTED }, geometry.next, ); let count = if self.chooser.count() == 0 { "0 results".into() } else { format!( "{}–{} / {}", self.chooser.start() + 1, (self.chooser.start() + geometry.rows).min(self.chooser.count()), self.chooser.count() ) }; label( renderer, &count, 11., style::MUTED, Rectangle { x: geometry.next.x + 30., width: (geometry.clear.x - geometry.next.x - 32.).max(0.), ..geometry.next }, ); if self.chooser.optional() { label( renderer, "Clear reference", 10., style::TEAL, geometry.clear, ); } self.input.as_widget().draw( self.input_tree, renderer, theme, renderer_style, layout.children().nth(1).unwrap(), cursor, &geometry.popup, ); }); } fn mouse_interaction( &self, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &R, ) -> mouse::Interaction { let geometry = self.geometry(layout); if cursor .position() .is_some_and(|p| geometry.hit(self.chooser, p).is_some()) { mouse::Interaction::Pointer } else { self.input.as_widget().mouse_interaction( self.input_tree, layout.children().nth(1).unwrap(), cursor, &geometry.popup, renderer, ) } } } fn is_input(event: &Event) -> bool { matches!( event, Event::Mouse(_) | Event::Touch(_) | Event::Keyboard(_) | Event::InputMethod(_) ) } fn measure>(content: &str, size: f32, font: Font) -> f32 { R::Paragraph::with_text(text::Text { content, bounds: Size::INFINITE, size: size.into(), line_height: text::LineHeight::default(), font, align_x: text::Alignment::Left, align_y: alignment::Vertical::Center, shaping: text::Shaping::Advanced, wrapping: text::Wrapping::None, }) .min_width() } fn label>( renderer: &mut R, content: &str, size: f32, color: Color, bounds: Rectangle, ) { let font = renderer.default_font(); let mut display = content.to_owned(); if measure::(content, size, font) > bounds.width { let ends: Vec<_> = content .char_indices() .map(|(i, _)| i) .chain(std::iter::once(content.len())) .collect(); let (mut low, mut high) = (0, ends.len()); while low + 1 < high { let middle = (low + high) / 2; if measure::(&format!("{}…", &content[..ends[middle]]), size, font) <= bounds.width { low = middle; } else { high = middle; } } display = format!("{}…", &content[..ends[low]]); } raw_label(renderer, display, size, color, bounds); } fn raw_label>( renderer: &mut R, content: String, size: f32, color: Color, bounds: Rectangle, ) { renderer.fill_text( text::Text { content, bounds: bounds.size(), size: size.into(), line_height: text::LineHeight::default(), font: renderer.default_font(), align_x: text::Alignment::Left, align_y: alignment::Vertical::Center, shaping: text::Shaping::Advanced, wrapping: text::Wrapping::None, }, Point::new(bounds.x, bounds.center_y()), color, bounds, ); }