diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 81b0c32f..cad73cfc 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -629,3 +629,40 @@ fn tab_focus_follows_only_the_users_input() { d.step(vec![]); assert_eq!(d.ctx.memory(|m| m.focused()), None); } + +#[test] +fn frame_tabs_follow_the_wheel_and_close_on_a_middle_click() { + let second = |d: &Driver| match &d.layout.root { + Some(Node::Split { second, .. }) => (**second).clone(), + _ => panic!("{:?}", d.layout.root), + }; + let wheel = |unit, y| Event::MouseWheel { unit, delta: vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }; + let middle = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Middle, pressed, modifiers: Modifiers::NONE }; + + // Off by default in the Dock profile. + let mut d = Driver::new(frames()); + let over = d.tab(2).center(); + d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]); + d.step(vec![middle(over, true)]); + d.step(vec![middle(over, false)]); + assert_eq!(second(&d), tabs(&[2, 3], 1)); + + let mut d = Driver::new(frames()); + d.tweak = |style| style.profile = Profile::Frame; + d.step(vec![]); + let over = d.tab(2).center(); + d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]); + assert_eq!(second(&d), tabs(&[2, 3], 0), "wheel up selects the previous tab"); + d.step(vec![wheel(egui::MouseWheelUnit::Line, 1.0)]); + assert_eq!(second(&d), tabs(&[2, 3], 0), "and stops at the first"); + d.step(vec![wheel(egui::MouseWheelUnit::Point, -30.0)]); + assert_eq!(second(&d), tabs(&[2, 3], 0), "trackpad scrolling adds up to a notch"); + let out = d.step(vec![wheel(egui::MouseWheelUnit::Point, -30.0)]); + assert_eq!(second(&d), tabs(&[2, 3], 1)); + assert_eq!(out.focused, Some(3)); + + d.step(vec![middle(over, true)]); + let out = d.step(vec![middle(over, false)]); + assert_eq!(out.actions, vec![Action::Close { panel: 2 }]); + assert_eq!(second(&d), tabs(&[3], 0)); +} diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index b87dfca2..cb44cd8a 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -32,6 +32,9 @@ const BORDER_BAND: f32 = 4.0; /// Width of the "…" button that lists the panels a strip has no room for. const OVERFLOW_WIDTH: f32 = 22.0; +/// Points of smooth (trackpad) scrolling that make one wheel notch. +const WHEEL_NOTCH: f32 = 40.0; + impl Renderer<'_, P> { pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) { let Some(selected) = panels.get(active) else { return }; @@ -127,6 +130,10 @@ impl Renderer<'_, P> { // Registered last, so the band wins over the tabs below it. let border = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, (strip.top() + BORDER_BAND).min(strip.bottom())))); self.group_handle(ui, "drag-border", border, &owner, movable); + if self.style.wheel_selects_tabs() { + let tabs_area = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, strip.bottom()))); + self.wheel_tabs(ui, tabs_area, panels, active); + } // Logical order, hidden tabs included without a hit area, for no-op detection. let tabs = panels.iter().map(|panel| (panel.clone(), shown.iter().find(|(p, _)| p == panel).map_or(Rect::NOTHING, |(_, rect)| *rect))).collect(); @@ -200,6 +207,42 @@ impl Renderer<'_, P> { egui::Popup::menu(&response).open_memory(command).show(|ui| self.group_menu(ui, owner, owner.selected)); } + /// The wheel over a group's strip: down selects the next tab, up the previous, one tab per + /// notch, stopping at the ends. Trackpad scrolling adds up to a notch. + fn wheel_tabs(&mut self, ui: &Ui, strip: Rect, panels: &[P], active: usize) { + let key = self.id.with(("tab-wheel", panels.first())); + if !ui.rect_contains_pointer(strip) { + ui.ctx().data_mut(|d| d.remove::(key)); + return; + } + let notches: f32 = ui.input(|i| { + i.events + .iter() + .map(|event| match event { + egui::Event::MouseWheel { unit, delta, modifiers, .. } if !modifiers.command && !modifiers.ctrl => match unit { + egui::MouseWheelUnit::Point => delta.y / WHEEL_NOTCH, + egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => delta.y, + }, + _ => 0.0, + }) + .sum() + }); + if notches == 0.0 || !notches.is_finite() { + return; + } + let total = ui.ctx().data(|d| d.get_temp::(key)).unwrap_or(0.0) + notches; + let steps = total.trunc(); + ui.ctx().data_mut(|d| d.insert_temp(key, total - steps)); + // A wheel turned down scrolls content up: a negative delta selects the next tab. + let to = (active as i64 - steps as i64).clamp(0, panels.len().saturating_sub(1) as i64) as usize; + if to != active + && let Some(panel) = panels.get(to) + { + self.output.actions.push(Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + } + } + fn start_group_drag(&mut self, ui: &Ui, panels: &[P], rect: Rect) { let Some(first) = panels.first() else { return }; let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(rect.center()); @@ -361,6 +404,9 @@ impl Renderer<'_, P> { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized }); } } + if self.style.middle_click_closes_tabs() && policy.closable && response.clicked_by(egui::PointerButton::Middle) { + self.output.actions.push(Action::Close { panel: panel.clone() }); + } if policy.movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), group) { self.latch_undock(ui.ctx(), owner.map(|owner| owner.kind)); } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 411fb03a..99a8e4b9 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -99,6 +99,12 @@ pub struct DockStyle { pub close_menu_items: bool, /// Show × on the title bar of a floating window whose panels are all closable. pub close_button: bool, + /// The mouse wheel over a tab strip selects the next or previous tab, one per notch. + /// `None`: on in [`Profile::Frame`], off in [`Profile::Dock`]. + pub wheel_selects_tabs: Option, + /// A middle click on a tab closes its panel when the panel is closable. `None`: on in + /// [`Profile::Frame`], off in [`Profile::Dock`]. + pub middle_click_closes_tabs: Option, } /// Which arrangement a [`DockStyle`] renders and labels. @@ -167,9 +173,19 @@ impl DockStyle { new_window_label: "Move Panel to New Window".into(), close_menu_items: true, close_button: true, + wheel_selects_tabs: None, + middle_click_closes_tabs: None, } } + pub(super) fn wheel_selects_tabs(&self) -> bool { + self.wheel_selects_tabs.unwrap_or(self.profile == Profile::Frame) + } + + pub(super) fn middle_click_closes_tabs(&self) -> bool { + self.middle_click_closes_tabs.unwrap_or(self.profile == Profile::Frame) + } + pub(super) fn valid(&self) -> bool { [ self.tab_height, diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 7ca9779d..d159c887 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -176,6 +176,9 @@ A flyout fits its content the same way, without the title bar. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, a filled half for a split. Escape or losing window focus cancels a drag. - Double-click a tab or drag bar to minimize a group to its strip. +- In the Frame profile, the mouse wheel over a tab strip selects the next or previous tab, and a + middle click on a closable panel's tab closes it. `DockStyle::wheel_selects_tabs` and + `middle_click_closes_tabs` turn either on or off in any profile. - A column's »/« button collapses it to icons, one icon per panel with a separator between groups. Widen an iconic column to show panel names. Click an icon to open its group as a flyout on the canvas side. Drag an icon to move its panel, or the grip above an icon group to move the group.