From 33433b89244dee10c7b238813e62b5ee2f306a0c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:15:51 +0000 Subject: [PATCH 1/6] fix(dock): keep an auto-hiding panel out while its colour popup is open The Layer Manager's colour dropdown draws as an overlay, which the docked edge's mouse area reads as the pointer leaving. The auto-hide timer then collapsed the panel and the dropdown with it, so no colour could be picked. Defer the hide while a pallet colour dropdown or the Select Color window is open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ihuqzxdUhd9UbioPn5MGu --- src/app/dock/tests.rs | 24 ++++++++++++++++++++++++ src/app/dock/update.rs | 20 ++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index eeda37b0e..4276df96b 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -393,6 +393,30 @@ fn dock_hover_reveals_groups_only_on_an_auto_hiding_edge() { assert!(app.dock_edge_expanded(DockSide::Left)); } +#[test] +fn dock_auto_hide_waits_for_an_open_layer_color_dropdown() { + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); + app.dock.left.push(DockGroup::single(PanelId::Layers)); + let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Layers)); + let _ = app.on_dock(DockMsg::Hover(PanelId::Layers)); + let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen)); + assert_eq!(app.dock_peek, Some(PanelId::Layers)); + // Opening a row's colour swatch, then moving onto its dropdown, reads + // as the pointer leaving the edge: the panel must stay out. + let _ = app.update(Message::LayerColorPickerToggle("0".to_string())); + assert!(app.tabs[app.active_tab].layers.color_picker_row.is_some()); + let _ = app.on_dock(DockMsg::HoverExit); + let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen)); + assert_eq!(app.dock_peek, Some(PanelId::Layers)); + let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen)); + assert_eq!(app.dock_peek, Some(PanelId::Layers)); + // Once the dropdown closes with the pointer still away, it hides. + let _ = app.update(Message::LayerColorPickerToggle("0".to_string())); + let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen)); + assert_eq!(app.dock_peek, None); +} + #[test] fn dock_float_resize_grows_the_panel() { use crate::ui::dock::{DockMsg, FloatPanel, PanelId}; diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index e77857c45..514ab2f50 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -207,6 +207,15 @@ impl OpenCADStudio { Some(HoverPending::Hide) if self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) => { + // A dropdown or the colour window opened from the + // panel draws as an overlay, which the edge's + // mouse area reads as the pointer leaving. Keep + // the panel out until it closes, so a pick can + // still land; then hide unless the pointer is + // back (its re-entry cancels this). + if self.dock_popup_open() { + return self.dock_hover_arm(HoverPending::Hide); + } self.dock_peek = None; } _ => {} @@ -412,6 +421,17 @@ impl OpenCADStudio { }) } + /// Whether a popup a pallet opened is showing: a colour dropdown in the + /// Layer Manager or Properties, or the Select Color window. + fn dock_popup_open(&self) -> bool { + let tab = &self.tabs[self.active_tab]; + tab.layers.color_picker_row.is_some() + || tab.properties.color_picker_open + || tab.properties.bg_color_picker_open + || tab.properties.open_color_field.is_some() + || self.color_picker.is_some() + } + /// Drop any pending delayed reveal / hide. fn dock_hover_cancel(&mut self) { self.dock_hover_gen = self.dock_hover_gen.wrapping_add(1); From c74dca4f3259db8ce3c3ace8ed38ef01ddc2558b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:30:37 +0000 Subject: [PATCH 2/6] fix(dock): keep auto-hiding panels shown while the pointer is on their dropdowns iced gives the layer under an overlay a levitating cursor, so the dock edge's mouse_area read the pointer on any dropdown or combo list in a pallet as leaving, and auto-hide collapsed the panel mid-pick. Track enter/leave with a small hover_zone wrapper that lands the cursor before its bounds test, on docked edges and floating pallets alike. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ihuqzxdUhd9UbioPn5MGu --- src/app/dock/update.rs | 12 +-- src/app/dock/view.rs | 23 +++--- src/ui/hover_zone.rs | 175 +++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + 4 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 src/ui/hover_zone.rs diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index 514ab2f50..bbbf46ff5 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -207,12 +207,12 @@ impl OpenCADStudio { Some(HoverPending::Hide) if self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) => { - // A dropdown or the colour window opened from the - // panel draws as an overlay, which the edge's - // mouse area reads as the pointer leaving. Keep - // the panel out until it closes, so a pick can - // still land; then hide unless the pointer is - // back (its re-entry cancels this). + // The edge's hover zone keeps a dropdown inside + // it from reading as a leave; a colour popup can + // still reach past it (or be the Select Color + // window). Keep the panel out until it closes, so + // a pick can still land; then hide unless the + // pointer is back (its re-entry cancels this). if self.dock_popup_open() { return self.dock_hover_arm(HoverPending::Hide); } diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index a1735fd93..2bb0e5e5f 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -164,12 +164,13 @@ impl OpenCADStudio { children.push(strip); } } - Some( - mouse_area(row(children).height(Fill)) - .on_enter(Message::Dock(crate::ui::dock::DockMsg::HoverStay)) - .on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit)) - .into(), - ) + // Not a `mouse_area`: the pointer on one of the pallet's own + // dropdowns must not read as leaving the edge. + Some(crate::ui::hover_zone::hover_zone( + row(children).height(Fill), + Message::Dock(crate::ui::dock::DockMsg::HoverStay), + Message::Dock(crate::ui::dock::DockMsg::HoverExit), + )) } /// An edge's vertical icon strip: per group its open pallets' icons in a @@ -555,10 +556,14 @@ impl OpenCADStudio { // on its empty areas don't fall through to the drawing below. let panel = mouse_area(framed) .on_press(Message::Dock(DockMsg::FloatRaise(f.id))) - .on_enter(Message::Dock(DockMsg::Hover(f.id))) - .on_exit(Message::Dock(DockMsg::HoverExit)) .interaction(iced::mouse::Interaction::Idle); - place_at(panel.into(), x, y) + // Enter / leave as on a docked edge: its dropdowns keep it shown. + let panel = crate::ui::hover_zone::hover_zone( + panel, + Message::Dock(DockMsg::Hover(f.id)), + Message::Dock(DockMsg::HoverExit), + ); + place_at(panel, x, y) } /// Where floating panel `f` draws. diff --git a/src/ui/hover_zone.rs b/src/ui/hover_zone.rs new file mode 100644 index 000000000..38c4a17e7 --- /dev/null +++ b/src/ui/hover_zone.rs @@ -0,0 +1,175 @@ +//! A pointer-enter / leave area that keeps counting the pointer as inside +//! while it rests on an overlay (a dropdown, a combo box list) drawn over +//! its bounds. +//! +//! `mouse_area` reads such a pointer as gone: iced hands the layer below an +//! overlay a levitating cursor, which has no position. An auto-hiding dock +//! edge then hides as soon as one of its own dropdowns opens under the +//! pointer, taking the dropdown with it. Here the cursor is landed before the +//! bounds test, so only a pointer that really leaves the area exits it. + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::widget::{self, tree, Tree, Widget}; +use iced::advanced::{mouse, overlay, renderer, Shell}; +use iced::{Element, Event, Length, Rectangle, Renderer, Size, Theme, Vector}; + +use crate::app::Message; + +/// Wrap `content`, publishing `on_enter` / `on_exit` as the pointer enters +/// and leaves its bounds, overlays drawn over them included. +pub fn hover_zone<'a>( + content: impl Into>, + on_enter: Message, + on_exit: Message, +) -> Element<'a, Message> { + Element::new(HoverZone { + content: content.into(), + on_enter, + on_exit, + }) +} + +struct HoverZone<'a> { + content: Element<'a, Message>, + on_enter: Message, + on_exit: Message, +} + +#[derive(Default)] +struct State { + hovered: bool, +} + +impl Widget for HoverZone<'_> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&mut self, tree: &mut Tree) { + tree.diff_children(std::slice::from_mut(&mut self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + 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: &Renderer, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + shell, + viewport, + ); + // Unlike `mouse_area`, this runs for captured events too: a pointer + // move over a dropdown is captured by the dropdown but must still + // not read as leaving. + let hovered = cursor.land().is_over(layout.bounds()); + let state = tree.state.downcast_mut::(); + if hovered != state.hovered { + state.hovered = hovered; + shell.publish(if hovered { + self.on_enter.clone() + } else { + self.on_exit.clone() + }); + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4b7608424..89c69faa6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,6 +5,7 @@ pub const ROW_H: f32 = 26.0; pub mod color_select; pub mod command_line; pub mod dock; +pub mod hover_zone; pub mod icons; pub mod menu_bar; pub mod modal; From e69b1bc207ad9ec055679ad3c8dc3af73c69b759 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:40:58 +0000 Subject: [PATCH 3/6] fix(dock): drop hover_zone's children method, gone from iced's Widget trait Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ihuqzxdUhd9UbioPn5MGu --- src/ui/hover_zone.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ui/hover_zone.rs b/src/ui/hover_zone.rs index 38c4a17e7..5e9fc4ab7 100644 --- a/src/ui/hover_zone.rs +++ b/src/ui/hover_zone.rs @@ -49,10 +49,6 @@ impl Widget for HoverZone<'_> { tree::State::new(State::default()) } - fn children(&self) -> Vec { - vec![Tree::new(&self.content)] - } - fn diff(&mut self, tree: &mut Tree) { tree.diff_children(std::slice::from_mut(&mut self.content)); } From f17dc11e337c41190e87f0a3fc5d5f483e984d22 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:54:40 +0000 Subject: [PATCH 4/6] fix(dock): hold the hover state while the pointer is on a dropdown At this iced revision the layer under an overlay that wants the pointer gets an unavailable cursor, not a levitating one, so hover_zone still read a pointer on an open linetype list as leaving the edge. Keep the last state on an unavailable cursor and exit only on a position outside the bounds or the pointer leaving the window. A test drives a real open dropdown through iced's UserInterface for both mouse_area and the zone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ihuqzxdUhd9UbioPn5MGu --- src/app/dock/tests.rs | 75 +++++++++++++++++++++++++++++++++++++++++++ src/ui/hover_zone.rs | 20 +++++++----- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 4276df96b..653d3bfdb 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -582,6 +582,81 @@ fn drive_view(app: &mut OpenCADStudio, at: iced::Point, events: &[iced::Event]) } } +/// The hover messages an open dropdown inside a hover zone (`zone`) or a +/// `mouse_area` publishes as the pointer goes from the field onto the +/// dropdown's list, then out of the area, each tagged with where the pointer +/// was. +fn hover_messages_over_an_open_dropdown(zone: bool) -> Vec<(&'static str, &'static str)> { + use crate::ui::dock::DockMsg; + use iced::widget::{button, column, container, mouse_area, text}; + use iced_test::core::{mouse, shell, window}; + use iced_test::runtime::user_interface::{Cache, UserInterface}; + let view = || -> iced::Element<'static, Message> { + let field = button(text("ByLayer")).width(120).height(20); + // A pointer-wanting list, like a linetype combo's, under the field. + let list = button(column![text("Continuous"), text("Dashed")]) + .on_press(Message::Dock(DockMsg::HoverEnd(crate::ui::dock::PanelId::Layers))) + .width(120) + .height(100); + let dd = iced_aw::DropDown::new(field, list, true) + .alignment(iced_aw::drop_down::Alignment::Bottom); + let area = container(dd).width(300).height(400); + let (enter, exit) = ( + Message::Dock(DockMsg::HoverStay), + Message::Dock(DockMsg::HoverExit), + ); + if zone { + crate::ui::hover_zone::hover_zone(area, enter, exit) + } else { + mouse_area(area).on_enter(enter).on_exit(exit).into() + } + }; + let mut renderer = headless_renderer(); + let mut cache = Cache::default(); + let mut seen = Vec::new(); + // On the field, on the open list (both inside the area), then outside. + for (place, x, y) in [ + ("field", 60.0, 10.0), + ("list", 60.0, 70.0), + ("list", 60.0, 90.0), + ("outside", 500.0, 70.0), + ] { + let at = iced::Point::new(x, y); + let mut messages = Vec::new(); + let mut ui = UserInterface::build(view(), VIEW_SIZE, cache, &mut renderer); + let _ = ui.update( + &window::Headless, + &shell::Waker::noop(), + &[iced::Event::Mouse(mouse::Event::CursorMoved { position: at })], + mouse::Cursor::Available(at), + &mut renderer, + &mut messages, + ); + cache = ui.into_cache(); + seen.extend(messages.into_iter().filter_map(|m| match m { + Message::Dock(DockMsg::HoverStay) => Some(("enter", place)), + Message::Dock(DockMsg::HoverExit) => Some(("exit", place)), + _ => None, + })); + } + seen +} + +#[test] +fn an_open_dropdown_in_a_hover_zone_does_not_read_as_leaving() { + with_stack(|| { + // The bug: a mouse area reads the pointer on the list as gone. + assert_eq!( + hover_messages_over_an_open_dropdown(false), + [("enter", "field"), ("exit", "list")] + ); + assert_eq!( + hover_messages_over_an_open_dropdown(true), + [("enter", "field"), ("exit", "outside")] + ); + }); +} + #[test] fn docked_title_bar_double_click_floats_the_pallet_in_the_real_view() { // Building the real view is deeper than a default test thread's stack. diff --git a/src/ui/hover_zone.rs b/src/ui/hover_zone.rs index 5e9fc4ab7..c9269194e 100644 --- a/src/ui/hover_zone.rs +++ b/src/ui/hover_zone.rs @@ -3,10 +3,11 @@ //! its bounds. //! //! `mouse_area` reads such a pointer as gone: iced hands the layer below an -//! overlay a levitating cursor, which has no position. An auto-hiding dock -//! edge then hides as soon as one of its own dropdowns opens under the -//! pointer, taking the dropdown with it. Here the cursor is landed before the -//! bounds test, so only a pointer that really leaves the area exits it. +//! overlay that wants the pointer an unavailable cursor, with no position. An +//! auto-hiding dock edge then hid as soon as one of its own dropdowns opened +//! under the pointer, taking the dropdown with it. Here an unavailable cursor +//! keeps the last state, so only a pointer seen outside the bounds, or one +//! that leaves the window, exits the area. use iced::advanced::layout::{self, Layout}; use iced::advanced::widget::{self, tree, Tree, Widget}; @@ -99,10 +100,13 @@ impl Widget for HoverZone<'_> { shell, viewport, ); - // Unlike `mouse_area`, this runs for captured events too: a pointer - // move over a dropdown is captured by the dropdown but must still - // not read as leaving. - let hovered = cursor.land().is_over(layout.bounds()); + // Unlike `mouse_area`, this runs for captured events too, and a + // cursor without a position (on an overlay) changes nothing. + let hovered = match (event, cursor.land()) { + (Event::Mouse(mouse::Event::CursorLeft), _) => false, + (_, mouse::Cursor::Available(p)) => layout.bounds().contains(p), + _ => return, + }; let state = tree.state.downcast_mut::(); if hovered != state.hovered { state.hovered = hovered; From 412c14a393826f74fa7f95e1283b2e27c653b425 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:54:59 +0000 Subject: [PATCH 5/6] style(dock): rustfmt the dropdown hover test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ihuqzxdUhd9UbioPn5MGu --- src/app/dock/tests.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 653d3bfdb..2e3df1aa1 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -595,7 +595,9 @@ fn hover_messages_over_an_open_dropdown(zone: bool) -> Vec<(&'static str, &'stat let field = button(text("ByLayer")).width(120).height(20); // A pointer-wanting list, like a linetype combo's, under the field. let list = button(column![text("Continuous"), text("Dashed")]) - .on_press(Message::Dock(DockMsg::HoverEnd(crate::ui::dock::PanelId::Layers))) + .on_press(Message::Dock(DockMsg::HoverEnd( + crate::ui::dock::PanelId::Layers, + ))) .width(120) .height(100); let dd = iced_aw::DropDown::new(field, list, true) @@ -627,7 +629,9 @@ fn hover_messages_over_an_open_dropdown(zone: bool) -> Vec<(&'static str, &'stat let _ = ui.update( &window::Headless, &shell::Waker::noop(), - &[iced::Event::Mouse(mouse::Event::CursorMoved { position: at })], + &[iced::Event::Mouse(mouse::Event::CursorMoved { + position: at, + })], mouse::Cursor::Available(at), &mut renderer, &mut messages, From 2ff93f479bd76473327cced7e80cc6ae8de3aa19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 19:09:51 +0000 Subject: [PATCH 6/6] fix(ui): open drop_down_below lists right under their field in pallets iced_aw's DropDown keeps its list on screen by testing it against the viewport's width and height only, as if the viewport started at the window corner. Inside a scrollable the viewport is the visible area in window coordinates, so a field in a dock pallet saw its list overflow and the list jumped left by its own width (or up over the field); the Properties Transparency dropdown showed it. Hand the drop-down a viewport reaching from the corner to the visible area's far edges. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ihuqzxdUhd9UbioPn5MGu --- src/app/dock/tests.rs | 56 ++++++++++++++++ src/ui/color_select/mod.rs | 2 +- src/ui/drop_down_fit.rs | 131 +++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/ui/drop_down_fit.rs diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 2e3df1aa1..b8858fa2c 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -661,6 +661,62 @@ fn an_open_dropdown_in_a_hover_zone_does_not_read_as_leaving() { }); } +/// Bounds of the field (text `ByLayer`) and of its open list (text `Opaque`) +/// for a `drop_down_below` in a scrollable that, like a pallet, sits away +/// from the window's top-left corner. +fn drop_down_below_in_an_offset_scrollable() -> (iced::Rectangle, iced::Rectangle) { + use iced::widget::{button, column, container, row, scrollable, text, Space}; + use iced_test::core::widget; + use iced_test::runtime::user_interface::{Cache, UserInterface}; + use iced_test::Selector; + let view = || -> iced::Element<'static, Message> { + let field = container(text("ByLayer")).width(200).height(20); + let list = column![ + button(text("Opaque")).width(iced::Fill), + button(text("50")).width(iced::Fill), + ]; + let dd = crate::ui::color_select::drop_down_below( + field.into(), + list.into(), + None, + iced::Length::Shrink, + Message::PropEditChoiceToggle, + ); + let pallet = scrollable(column![Space::new().height(300), dd]).height(500); + row![ + Space::new().width(1000), + container(pallet).width(300).height(iced::Fill) + ] + .into() + }; + let mut renderer = headless_renderer(); + let mut ui = UserInterface::build(view(), VIEW_SIZE, Cache::default(), &mut renderer); + let mut find = |label: &str| { + let mut find = Selector::find(label); + ui.operate(&renderer, &mut widget::operation::black_box(&mut find)); + match widget::Operation::finish(&find) { + widget::operation::Outcome::Some(Some(target)) => target.bounds(), + _ => panic!("{label} not found"), + } + }; + (find("ByLayer"), find("Opaque")) +} + +#[test] +fn a_drop_down_in_a_pallet_opens_right_below_its_field() { + with_stack(|| { + let (field, item) = drop_down_below_in_an_offset_scrollable(); + assert!( + item.y >= field.y + field.height && item.y < field.y + field.height + 20.0, + "list starts below the field: field {field:?}, first item {item:?}" + ); + assert!( + item.x >= field.x - 1.0 && item.x < field.x + field.width, + "list lines up with the field: field {field:?}, first item {item:?}" + ); + }); +} + #[test] fn docked_title_bar_double_click_floats_the_pallet_in_the_real_view() { // Building the real view is deeper than a default test thread's stack. diff --git a/src/ui/color_select/mod.rs b/src/ui/color_select/mod.rs index 445a52bbc..2eb812bf8 100644 --- a/src/ui/color_select/mod.rs +++ b/src/ui/color_select/mod.rs @@ -322,5 +322,5 @@ pub fn drop_down_below<'a>( if let Some(w) = popup_width { dd = dd.width(w); } - dd.into() + crate::ui::drop_down_fit::drop_down_fit(dd) } diff --git a/src/ui/drop_down_fit.rs b/src/ui/drop_down_fit.rs new file mode 100644 index 000000000..127ef93e1 --- /dev/null +++ b/src/ui/drop_down_fit.rs @@ -0,0 +1,131 @@ +//! Keeps an `iced_aw::DropDown` list under its field inside a scrollable. +//! +//! The drop-down keeps its list on screen by testing it against the viewport +//! it is handed, but reads only that viewport's width and height, as if it +//! started at the window's top-left corner. Inside a scrollable the viewport +//! is the scrollable's visible area in window coordinates, so a field in a +//! dock pallet (which does not start at the corner) sees its list "overflow", +//! and the list jumps left by its own width or up over the field. Wrapped +//! here, the drop-down gets a viewport reaching from the window corner to the +//! visible area's far edges: the test then trips only where the list really +//! leaves the visible area. + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::widget::{self, tree, Tree, Widget}; +use iced::advanced::{mouse, overlay, renderer, Shell}; +use iced::{Element, Event, Length, Rectangle, Renderer, Size, Theme, Vector}; + +use crate::app::Message; + +/// Wrap a drop-down so its list is placed against the visible area's real +/// edges. +pub fn drop_down_fit<'a>(drop_down: impl Into>) -> Element<'a, Message> { + Element::new(DropDownFit { + content: drop_down.into(), + }) +} + +struct DropDownFit<'a> { + content: Element<'a, Message>, +} + +impl Widget for DropDownFit<'_> { + fn tag(&self) -> tree::Tag { + self.content.as_widget().tag() + } + + fn state(&self) -> tree::State { + self.content.as_widget().state() + } + + fn diff(&mut self, tree: &mut Tree) { + self.content.as_widget_mut().diff(tree); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content.as_widget_mut().layout(tree, renderer, limits) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(tree, layout, renderer, operation); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.content + .as_widget_mut() + .update(tree, event, layout, cursor, renderer, shell, viewport); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content + .as_widget() + .mouse_interaction(tree, layout, cursor, viewport, renderer) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content + .as_widget() + .draw(tree, renderer, theme, style, layout, cursor, viewport); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + let from_corner = Rectangle { + x: 0.0, + y: 0.0, + width: viewport.x + viewport.width, + height: viewport.y + viewport.height, + }; + self.content + .as_widget_mut() + .overlay(tree, layout, renderer, &from_corner, translation) + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 89c69faa6..c894b3abf 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,6 +5,7 @@ pub const ROW_H: f32 = 26.0; pub mod color_select; pub mod command_line; pub mod dock; +pub mod drop_down_fit; pub mod hover_zone; pub mod icons; pub mod menu_bar;