diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index dfb5e69..23876f3 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -21,86 +21,105 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let full = ui.max_rect(); let mut menus_end = full.min.x; let mut tools_start = full.max.x; - ui.horizontal_centered(|ui| { - if icons::button(ui, "home", 24.0, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() { - app.session_home(); - } - ui.add_space(6.0); - if !app.native_menu { - ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; - ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; - ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; - ui.style_mut().spacing.button_padding = vec2(7.0, 3.0); - crate::menus::menu_bar(app, ui); - } - menus_end = ui.min_rect().max.x; - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(4.0); - // Search field. - let (r, _) = ui.allocate_exact_size(vec2(125.0, 18.0), Sense::click()); - ui.painter().rect(r, 1.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon); - crate::rtl::paint( - ui.painter(), - r.min + vec2(20.0, 9.0), - egui::Align2::LEFT_CENTER, - crate::i18n::tr(&app.ui.language, "Search"), - egui::FontId::proportional(11.0), - t.text_dim, - ); - ui.add_space(8.0); - let current = app.ui.workspace.clone(); - let shown = crate::i18n::workspace_name(&app.ui.language, ¤t); - ui.menu_button(crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| { - let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); - for w in - ["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"] - .into_iter() - .map(str::to_string) - .chain(customs.iter().cloned()) - { - if ui.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))).clicked() + // The right-hand controls' width, measured last frame: in a window too narrow for them + // and the menus they follow the menus, and the bar scrolls sideways. + let tools_w_id = ui.id().with("tools_width"); + let tools_w: f32 = ui.data(|d| d.get_temp(tools_w_id)).unwrap_or(0.0); + crate::widgets::overflow_scrolling(ui); + egui::ScrollArea::horizontal().id_salt("app_bar_scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal_centered(|ui| { + if icons::button(ui, "home", 24.0, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() { + app.session_home(); + } + ui.add_space(6.0); + if !app.native_menu { + ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; + ui.style_mut().spacing.button_padding = vec2(7.0, 3.0); + crate::menus::menu_bar(app, ui); + } + menus_end = ui.min_rect().max.x; + let size = vec2(ui.available_width().max(tools_w), ui.available_height()); + let tools = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + // Search field. + let (r, _) = ui.allocate_exact_size(vec2(125.0, 18.0), Sense::click()); + ui.painter().rect(r, 1.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon); + crate::rtl::paint( + ui.painter(), + r.min + vec2(20.0, 9.0), + egui::Align2::LEFT_CENTER, + crate::i18n::tr(&app.ui.language, "Search"), + egui::FontId::proportional(11.0), + t.text_dim, + ); + ui.add_space(8.0); + let current = app.ui.workspace.clone(); + let shown = crate::i18n::workspace_name(&app.ui.language, ¤t); + ui.menu_button(crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| { + let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); + for w in + ["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"] + .into_iter() + .map(str::to_string) + .chain(customs.iter().cloned()) { - let _ = app.run("window.workspace", json!({"name": w})); + if ui + .selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))) + .clicked() + { + let _ = app.run("window.workspace", json!({"name": w})); + ui.close(); + } + } + ui.separator(); + if ui + .button(crate::rtl::widget( + ui, + format!( + "{} {}", + crate::i18n::tr(&app.ui.language, "Reset"), + crate::i18n::workspace_name(&app.ui.language, ¤t) + ), + )) + .clicked() + { + let _ = app.run("window.resetWorkspace", json!({})); ui.close(); } - } - ui.separator(); - if ui - .button(crate::rtl::widget( - ui, - format!("{} {}", crate::i18n::tr(&app.ui.language, "Reset"), crate::i18n::workspace_name(&app.ui.language, ¤t)), - )) - .clicked() - { - let _ = app.run("window.resetWorkspace", json!({})); - ui.close(); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() { - let _ = app.run("window.newWorkspace", json!({})); - ui.close(); - } - if !customs.is_empty() { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| { - for w in &customs { - if ui.button(w).clicked() { - let _ = app.run("window.deleteWorkspace", json!({"name": w})); - ui.close(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() { + let _ = app.run("window.newWorkspace", json!({})); + ui.close(); + } + if !customs.is_empty() { + ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| { + for w in &customs { + if ui.button(w).clicked() { + let _ = app.run("window.deleteWorkspace", json!({"name": w})); + ui.close(); + } } - } - }); + }); + } + }); + ui.add_space(6.0); + if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() { + app.status("Export a PDF, IDML or package to share — no cloud account needed."); } + ui.add_space(8.0); + // Always one click away: the ArtCraft community Discord. + if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) { + let _ = app.run("help.discord", json!({})); + } + tools_start = ui.min_rect().min.x; + ui.min_rect().width() }); - ui.add_space(6.0); - if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() { - app.status("Export a PDF, IDML or package to share — no cloud account needed."); + if (tools.inner - tools_w).abs() > 0.5 { + ui.data_mut(|d| d.insert_temp(tools_w_id, tools.inner)); + ui.ctx().request_discard("app bar: the right-hand controls changed width"); } - ui.add_space(8.0); - // Always one click away: the ArtCraft community Discord. - if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) { - let _ = app.run("help.discord", json!({})); - } - tools_start = ui.min_rect().min.x; }); }); // Centred title. @@ -142,14 +161,18 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 8, right: 8, top: 5, bottom: 3 }).stroke(Stroke::new(1.0, t.divider)), ) .show(ui, |ui| { - ui.horizontal(|ui| { - let text_mode = - matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some()); - if text_mode { - control_text(app, ui); - } else { - control_object(app, ui); - } + // Wider than the window: the bar scrolls sideways. + crate::widgets::overflow_scrolling(ui); + egui::ScrollArea::horizontal().id_salt("control_bar_scroll").auto_shrink([false, true]).show(ui, |ui| { + ui.horizontal(|ui| { + let text_mode = + matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some()); + if text_mode { + control_text(app, ui); + } else { + control_object(app, ui); + } + }); }); }); } @@ -716,3 +739,86 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); } + +#[cfg(test)] +mod tests { + use egui::{pos2, vec2}; + use egui_kittest::kittest::Queryable; + + use crate::test_window::{self, wheel}; + + /// The Control panel's last control with nothing selected. + const LAST_CONTROL: &str = "Jump to Next Column"; + const FIRST_CONTROL: &str = "Rotate 90° Counterclockwise"; + + fn app() -> crate::DesignApp { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.control_bar = true; + app + } + + fn rect(h: &egui_kittest::Harness<'static, test_window::Window>, label: &str) -> egui::Rect { + h.get_by_label(label).rect() + } + + #[test] + fn a_narrow_window_scrolls_the_control_bar() { + let w = 700.0; + let mut h = test_window::open(app(), vec2(w, 600.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + assert!(rect(&h, LAST_CONTROL).max.x > w, "the controls are wider than the window"); + // A plain mouse wheel scrolls the bar sideways. + let first = rect(&h, FIRST_CONTROL).center(); + wheel(&mut h, first, vec2(0.0, -3000.0)); + let last = rect(&h, LAST_CONTROL); + let window = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(w, 600.0)); + assert!(window.contains_rect(last) && bar.contains_rect(last), "{last:?} outside the bar {bar:?}"); + assert_eq!(test_window::panel_rect(&h, "control_bar").height(), bar.height(), "the bar keeps its height"); + // A popup opened from a scrolled control opens at that control. + let fill = h.get_by_label("Fill").rect(); + let chip = pos2(fill.max.x + 15.0, fill.center().y); + h.hover_at(chip); + h.drag_at(chip); + h.drop_at(chip); + h.run_steps(4); + let item = rect(&h, "[Black]"); + assert!(item.min.y > fill.max.y && (item.min.x - chip.x).abs() < 120.0, "popup item at {item:?}, control at {chip:?}"); + } + + #[test] + fn a_narrow_window_scrolls_the_app_bar() { + let w = 640.0; + let mut h = test_window::open(app(), vec2(w, 600.0)); + let (help, share) = (rect(&h, "Help"), rect(&h, "Share")); + assert!(help.max.x <= share.min.x, "the menus ({help:?}) and the workspace controls ({share:?}) overlap"); + wheel(&mut h, help.center(), vec2(0.0, -3000.0)); + let share = rect(&h, "Share"); + let window = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(w, 600.0)); + assert!(window.contains_rect(share), "{share:?} outside the window"); + } + + #[test] + fn a_wide_window_does_not_scroll_the_bars() { + let mut h = test_window::open(app(), vec2(1400.0, 900.0)); + let before = [rect(&h, FIRST_CONTROL), rect(&h, LAST_CONTROL), rect(&h, "Help"), rect(&h, "Share")]; + assert!(before.iter().all(|r| r.max.x <= 1400.0), "{before:?}"); + wheel(&mut h, before[0].center(), vec2(0.0, -3000.0)); + wheel(&mut h, before[2].center(), vec2(0.0, -3000.0)); + let after = [rect(&h, FIRST_CONTROL), rect(&h, LAST_CONTROL), rect(&h, "Help"), rect(&h, "Share")]; + assert_eq!(after, before); + } + + #[test] + fn a_scrolled_bar_keeps_its_captions_on_one_line() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap(); + app.run("selection.set", serde_json::json!({"ids": [f["id"]]})).unwrap(); + let mut h = test_window::open(app, vec2(700.0, 600.0)); + let bar = test_window::panel_rect(&h, "control_bar").center(); + wheel(&mut h, bar, vec2(0.0, -3000.0)); + // The text frame's last group: its caption gets only what is left of the row. + let columns = h.get_by_label("Columns").rect(); + assert!(columns.height() < 20.0 && columns.width() > columns.height(), "Columns wraps: {columns:?}"); + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7e21460..69415e9 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -805,6 +805,8 @@ pub fn button(ui: &mut egui::Ui, name: &str, size: f32, selected: bool, tip: &st } let pad = size * 0.16; paint(ui.painter(), r.shrink(pad), name, if selected { t.text_strong } else { t.icon }); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, tip)); if tip.is_empty() { resp } else { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 43a0816..275018c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -984,3 +984,54 @@ mod tests { assert!(app.ui.dialog.is_none(), "a control-channel app.open doesn't raise an alert"); } } + +/// The whole window in a headless test harness, at a chosen window size. +#[cfg(test)] +pub(crate) mod test_window { + use egui_kittest::Harness; + + pub struct Window { + pub app: crate::DesignApp, + ready: bool, + } + + /// The window at `size` with a new document open. + pub fn open(mut app: crate::DesignApp, size: egui::Vec2) -> Harness<'static, Window> { + if app.session.active().is_none() { + app.run("file.new", serde_json::json!({})).unwrap(); + } + let mut h = Harness::builder().with_size(size).with_max_steps(1000).build_ui_state( + |ui, w: &mut Window| { + // The builder runs a frame before the texture size can be raised: skip it. + if !w.ready { + return; + } + let ctx = ui.ctx().clone(); + w.app.logic(&ctx); + w.app.ui(ui); + }, + Window { app, ready: false }, + ); + h.input_mut().max_texture_side = Some(8192); + h.state_mut().ready = true; + h.run_steps(6); + h + } + + /// The mouse wheel turned over `pos`: positive `delta` moves the content right and down. + pub fn wheel(h: &mut Harness<'static, Window>, pos: egui::Pos2, delta: egui::Vec2) { + h.hover_at(pos); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta, + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(10); + } + + /// The outer rect of a side or top panel, from egui's memory. + pub fn panel_rect(h: &Harness<'static, Window>, id: &str) -> egui::Rect { + egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new(id)).map(|s| s.outer_rect).unwrap() + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a3d4954..5a5e1f0 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -12,27 +12,33 @@ const BTN: f32 = 24.0; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let cols = if app.ui.tools_double_column { 2 } else { 1 }; + let frame = egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.divider)); + // A single column that doesn't fit the window's height becomes two, without changing the + // preference; whatever two columns still can't fit scrolls. + let single_h_id = egui::Id::new("tools_single_column_height"); + let single_h: Option = ui.data(|d| d.get_temp(single_h_id)); + let viewport_h = ui.available_height() - frame.total_margin().sum().y; + let crowded = !app.ui.tools_double_column && single_h.is_some_and(|h| h > viewport_h); + let cols = if app.ui.tools_double_column || crowded { 2 } else { 1 }; let width = 8.0 + BTN * cols as f32 + 4.0 * (cols as f32 - 1.0) + 8.0; - let r = egui::Panel::left("tools") - .exact_size(width) - .resizable(false) - .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.divider))) - .show(ui, |ui| { + let r = egui::Panel::left("tools").exact_size(width).resizable(false).frame(frame).show(ui, |ui| { + crate::widgets::overflow_scrolling(ui); + let scroll = egui::ScrollArea::vertical().id_salt("tools_scroll").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 1.0; ui.add_space(3.0); - // Collapse chevrons. + // Collapse chevrons (nothing to toggle while the window forces two columns). ui.horizontal(|ui| { ui.add_space(width / 2.0 - 8.0); - if icons::button( - ui, - if cols == 1 { "double-chevron-right" } else { "double-chevron-left" }, - 16.0, - false, - crate::i18n::tr(&app.ui.language, "Toggle double column"), - ) - .clicked() - { + let toggle = ui.add_enabled_ui(!crowded, |ui| { + icons::button( + ui, + if cols == 1 { "double-chevron-right" } else { "double-chevron-left" }, + 16.0, + false, + crate::i18n::tr(&app.ui.language, "Toggle double column"), + ) + }); + if toggle.inner.clicked() { app.ui.tools_double_column = !app.ui.tools_double_column; } }); @@ -169,6 +175,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(8.0); fill_stroke_proxy(app, ui, width); }); + if cols == 1 { + let h = scroll.content_size.y; + ui.data_mut(|d| d.insert_temp(single_h_id, h)); + // Laid out in one column that turned out too tall: lay out again in two. + if h > viewport_h { + ui.ctx().request_discard("tools panel: one column does not fit"); + } + } + }); if std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some() { eprintln!("tools panel rect {:?} (wanted width {width}); remaining {:?}", r.response.rect, ui.available_rect_before_wrap()); } @@ -252,9 +267,11 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { paint_stroke(p); paint_fill(p); } + let fill_tip = crate::i18n::tr(&app.ui.language, "Fill (X)"); let fresp = ui.interact(fill_r, ui.id().with("proxy_fill"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fill (X)")); + crate::rtl::label(ui, fill_tip); }); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fill_tip)); let sresp = ui.interact(stroke_r.translate(vec2(0.0, 0.0)), ui.id().with("proxy_stroke"), Sense::click()).on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Stroke (X)")); }); @@ -387,7 +404,82 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: ui.painter().rect_filled(egui::Rect::from_center_size(r.center(), egui::vec2(28.0, 20.0)), 2.0, t.hover); } icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(17.0, 17.0)), icon, if active { t.text_strong } else { t.icon }); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); resp.on_hover_ui(|ui| { crate::rtl::label(ui, tip); }) } + +#[cfg(test)] +mod tests { + use egui::{pos2, vec2}; + use egui_kittest::kittest::Queryable; + + use crate::test_window::{self, wheel}; + + /// The last tool group's button and the Fill proxy, near the bottom of the panel. + const LAST_TOOL: &str = "Zoom Tool (Z)"; + const FILL: &str = "Fill (X)"; + /// Status bar height: the Tools panel ends above it. + const STATUS_H: f32 = 17.0; + + fn app(double: bool) -> crate::DesignApp { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.tools_double_column = double; + app + } + + fn column_width(cols: f32) -> f32 { + 8.0 + super::BTN * cols + 4.0 * (cols - 1.0) + 8.0 + } + + fn rect(h: &egui_kittest::Harness<'static, test_window::Window>, label: &str) -> egui::Rect { + h.get_by_label(label).rect() + } + + #[test] + fn a_short_window_reaches_every_tool() { + for double in [false, true] { + let (w, ht) = (1000.0, 500.0); + let mut h = test_window::open(app(double), vec2(w, ht)); + let panel = test_window::panel_rect(&h, "tools"); + wheel(&mut h, panel.center(), vec2(0.0, -2000.0)); + let visible = egui::Rect::from_min_max(pos2(0.0, panel.min.y), pos2(w, ht - STATUS_H)); + for label in [LAST_TOOL, FILL] { + let r = rect(&h, label); + assert!(visible.contains_rect(r), "double column {double}: {label} at {r:?} is outside {visible:?}"); + } + assert_eq!(h.state().app.ui.tools_double_column, double, "the preference is the user's"); + } + } + + #[test] + fn tools_take_two_columns_while_one_does_not_fit() { + let (w, ht) = (1086.0, 612.0); + let mut h = test_window::open(app(false), vec2(w, ht)); + assert_eq!(test_window::panel_rect(&h, "tools").width(), column_width(2.0)); + let visible = egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(w, ht - STATUS_H)); + for label in [LAST_TOOL, FILL] { + let r = rect(&h, label); + assert!(visible.contains_rect(r), "{label} at {r:?} is outside {visible:?} without scrolling"); + } + assert!(!h.state().app.ui.tools_double_column); + // A taller window brings the single column back. + h.set_size(vec2(1400.0, 900.0)); + h.run_steps(4); + assert_eq!(test_window::panel_rect(&h, "tools").width(), column_width(1.0)); + assert!(!h.state().app.ui.tools_double_column); + } + + #[test] + fn a_tall_window_keeps_one_column_and_does_not_scroll() { + let mut h = test_window::open(app(false), vec2(1400.0, 900.0)); + let panel = test_window::panel_rect(&h, "tools"); + assert_eq!(panel.width(), column_width(1.0)); + let before = (rect(&h, LAST_TOOL), rect(&h, FILL)); + assert!(panel.contains_rect(before.0) && panel.contains_rect(before.1), "{before:?} outside {panel:?}"); + wheel(&mut h, panel.center(), vec2(0.0, -2000.0)); + assert_eq!((rect(&h, LAST_TOOL), rect(&h, FILL)), before); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8e6b721..dd7f3d8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -572,6 +572,21 @@ pub fn segmented(ui: &mut Ui, labels: &[&str], active: usize, width: f32) -> Opt out } +/// Scrolling for a bar that a small window cuts off (Tools panel, Control panel, application +/// bar): the mouse wheel scrolls the bar's only direction, and a thin scroll bar floats over the +/// contents, so nothing moves and nothing shows while everything fits. +pub fn overflow_scrolling(ui: &mut Ui) { + let style = ui.style_mut(); + style.always_scroll_the_only_direction = true; + // A caption at the end of a scrolled row gets only what is left of it: extend, don't wrap. + style.wrap_mode = Some(egui::TextWrapMode::Extend); + let s = &mut style.spacing.scroll; + *s = egui::style::ScrollStyle::floating(); + s.bar_width = 6.0; + // A faint handle while the pointer is elsewhere: the bar shows there is more. + s.dormant_handle_opacity = 0.5; +} + #[cfg(test)] mod tests { use super::*;