diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 5a0565b4e..13e12f33b 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -166,10 +166,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match active { DockTab::Properties => { - egui::ScrollArea::vertical() - .id_salt("props") - .auto_shrink([false, false]) - .show(ui, |ui| panels::properties::show(app, ui)); + widgets::panel_scroll(ui, egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]), |ui| { + panels::properties::show(app, ui) + }); } DockTab::Layers => panels::layers::show(app, ui), DockTab::Libraries => panels::libraries(app, ui), @@ -512,12 +511,11 @@ pub(crate) fn panel_body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: // At least `tall` when the sections are taller: the flyout's area keeps the size its // content needs, and egui sizes a new area at most 400 points high, so a scroll area // that only took the room it was given never grew past it. - egui::ScrollArea::vertical() - .id_salt("props-flyout") - .max_height(tall) - .min_scrolled_height(tall) - .auto_shrink([false, true]) - .show(ui, |ui| panels::properties::show(app, ui)); + widgets::panel_scroll( + ui, + egui::ScrollArea::vertical().id_salt("props-flyout").max_height(tall).min_scrolled_height(tall).auto_shrink([false, true]), + |ui| panels::properties::show(app, ui), + ); } Some(DockTab::Layers) => { ui.set_max_height(tall); @@ -682,6 +680,49 @@ mod tests { assert!(icons.last().unwrap().bottom() <= 400.0, "scrolled into view: {:?}", icons.last()); } + /// #3093: in a window too short for the Properties panel, its scroll bar takes a column of its + /// own instead of floating over the panel's right edge, so it covers none of the controls + /// there, such as the arrow of an image's Image Trace button. + #[test] + fn the_properties_scroll_bar_covers_no_control() { + use egui::accesskit::{Action, Role}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + app.run("object.rasterize", json!({})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1440.0, 600.0)); + let mut nodes = vec![]; + for k in 0..4 { + let input = egui::RawInput { time: Some(f64::from(k) * 0.1), screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + nodes = out.platform_output.accesskit_update.unwrap().nodes; + } + let rect = |n: &egui::accesskit::Node| { + n.bounds().map(|b| egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32))) + }; + // The Properties panel's is the right-most (the icon column's slim strip bar is left of it). + let bar = nodes + .iter() + .filter(|(_, n)| n.role() == Role::ScrollBar) + .filter_map(|(_, n)| rect(n)) + .max_by(|a, b| a.left().total_cmp(&b.left())) + .filter(|bar| bar.left() > screen.right() - DOCK_WIDTH); + let bar = bar.expect("the Properties panel scrolls in a 600 pt window"); + let mut covered = vec![]; + for (_, n) in &nodes { + let Some(r) = rect(n) else { continue }; + let control = n.role() != Role::ScrollBar && (n.supports_action(Action::Click) || n.supports_action(Action::Focus)); + if control && r.intersect(bar).is_positive() { + covered.push(format!("{:?} {:?} at {r:?}", n.role(), n.label())); + } + } + assert!(covered.is_empty(), "under the scroll bar at {bar:?}: {covered:#?}"); + } + const SCREEN: egui::Vec2 = vec2(1600.0, 1000.0); /// A frame of the dock and its flyout on a 1600 × 1000 window. diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 8daa6e890..9efe0789d 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -29,7 +29,7 @@ use serde_json::{Value, json}; use crate::state::{DockTab, FloatingPanels, UiState, all_panels}; use crate::theme::{self, Tokens}; -use crate::{VectorcraftApp, dock, icons, panels}; +use crate::{VectorcraftApp, dock, icons, panels, widgets}; /// Height of a floating group's title bar (its grip and ×). pub const TITLE: f32 = 14.0; @@ -911,7 +911,7 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec // The open panels share the column's height, each at least `MIN_TALL`: more than fit // scroll, with the column. let tall = ((ui.available_height() - n * STRIP - (n - 1.0) * GAP - 24.0 * open) / open).max(MIN_TALL); - egui::ScrollArea::vertical().id_salt(("dock-column-scroll", first)).auto_shrink([false, false]).show(ui, |ui| { + widgets::panel_scroll(ui, egui::ScrollArea::vertical().id_salt(("dock-column-scroll", first)).auto_shrink([false, false]), |ui| { ui.spacing_mut().item_spacing = Vec2::ZERO; let width = ui.available_width(); let top = ui.cursor().top(); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index b0bc66e8a..c1808694e 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -771,10 +771,11 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { if crate::state::DockTab::from_id(panel).is_some() { crate::dock::panel_body(self.app, ui, panel, width, height); } else { - egui::ScrollArea::vertical() - .id_salt(("shared-panel-body", panel)) - .auto_shrink([false, false]) - .show(ui, |ui| crate::dock::panel_body(self.app, ui, panel, width, height)); + // The body is as wide as the scroll area leaves it beside its scroll bar. + crate::widgets::panel_scroll(ui, egui::ScrollArea::vertical().id_salt(("shared-panel-body", panel)).auto_shrink([false, false]), |ui| { + let width = ui.available_width(); + crate::dock::panel_body(self.app, ui, panel, width, height) + }); } } @@ -1205,7 +1206,7 @@ fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE) .show(ui, |ui| { crate::theme::card(ui, &t, egui::Margin { left: 3, right: 3, top: 6, bottom: 6 }, |ui| { - egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| { + crate::widgets::strip_scroll(ui, "shared-panel-icons-scroll", |ui| { ui.spacing_mut().item_spacing.y = 2.0; // The tabbed group's panels dropped on the column head it, then the icon panels, // then the other panels dropped on it (those the column has no place for). diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fef0aaca4..0036de7b6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1177,6 +1177,22 @@ pub fn strip_scroll(ui: &mut Ui, id: &str, add_contents: impl FnOnce(&mut Ui) .inner } +/// A panel's scrolling body (Properties, a dock column, a panel in the shared dock): `area` with +/// the scroll bar of a menu's list ([`list_scroll_style`]), in a column of its own at the right +/// edge, so it never covers the controls there, such as a dropdown's arrow (#3093). The scroll +/// areas inside the body keep their own style. +pub fn panel_scroll(ui: &mut Ui, area: egui::ScrollArea, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + ui.scope(|ui| { + let inner = std::mem::replace(&mut ui.spacing_mut().scroll, list_scroll_style()); + area.show(ui, |ui| { + ui.spacing_mut().scroll = inner; + add_contents(ui) + }) + .inner + }) + .inner +} + /// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a /// [`BlendMode::group`] starts. pub fn blend_separator_before(i: usize) -> bool {