From edc41f68fa24c46651c3b5117d10f0ead75ec9cb Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:10:52 -0400 Subject: [PATCH 1/3] craft-menu: shrink-to-fit bar, bar-run Quit, Window list opt-out on macOS; parse the minus sign (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add craft-menu: Mac menu-bar layout and native-menu driver A platform-free menu model (built directly or from flat rows), the macOS layout (app menu, Window, Help, system-key clash resolution, translatable labels), and NativeMenu, which feeds a backend's key equivalents to egui as key presses, runs Minimize and craft-quit's Quit, and re-syncs only when the app's state changes. Generalised from PhotoCraft's native_menu.rs. * craft-menu: macOS menu bar through muda * craft-menu: clipboard keys, layout-assigned shortcuts, Hide Others and atomic rebuilds * craft-menu: menu key equivalents run while a text field has the keys * craft-menu: commands as CommandRef, item kinds, craft-keymap chords Items and rows run a craft_keymap::CommandRef and take a craft_keymap::Chord shortcut; crates/menu/src/chord.rs is gone. Native ids are the command's key, made unique with #2, #3 when a command is shown twice. Item.checked becomes ItemKind { Plain, Check, Radio } (radio shows as a check item on macOS). The built-in items' commands are Builtin::{Hide, HideOthers, Minimize, Quit}. MenuRole gains Edit, View and Other; tag_submenu finds its menu by role. While text editing, the keys left to the field are the clipboard keys and craft_keymap::text_editing_chords. Adds the Platforms, Document and Gate types (data only) and MenuBar::documents. * craft-menu: Gate::allows, the one rule for what may run from the menus * craft-menu: Windows, Linux and web layouts, documents in Window, keymap adapter layout(bar, Target, options) dispatches to mac_layout, desktop_layout or web_layout. Every layout drops what the target's Platforms leave out and lists MenuBar::documents as radio items last in the Window menu (ItemRole::Document is gone). Windows and Linux put About in Help, Settings in Edit as Preferences and Quit in File (Exit on Windows); the web layout has no Quit and takes the browser's own shortcuts off the items, reporting clashes. tidy drops empty submenus. apply_keymap sets shortcuts and toggle check items from a craft-keymap Keymap; apply_state sets enabled and checked per frame. * craft-menu: the egui-drawn menu bar for Windows, Linux and the web * craft-menu: gate, command hook and one muda handler for the native menu - NativeMenu::set_gate: app items greyed in place while modal or recording a shortcut; take_clicks drops clicks the gate forbids when they are taken. While recording, key equivalents reach egui as plain key presses. - NativeMenu::on_command: a hook called from the menu event handler for each allowed app click; take_clicks still returns every command. - Dispatch, shared between the driver and the backend's handler (Backend::attach replaces Backend::set_text_editing). - Only Cmd or Ctrl chords become key equivalents; other shortcuts are not shown natively (building an attributed title needs unsafe objc2 calls). - mac: one muda event handler per process, routed to the latest install; mac::install_app_menu ports craft-quit's minimal app menu onto it. - craft_quit::install_app_menu is deprecated in favor of craft_menu::mac::install_app_menu. * craft-menu: the bar takes Target, README section and NOTICE credit * craft-menu: bar tests beside the layout and native ones; only a click outside closes the menus * craft-menu: clippy (complex types, Copy clones, elided lifetimes) * craft-menu: keyboard access to the egui bar A tap on Alt alone or F10 focuses the bar, Alt+letter opens a menu and a letter in an open menu runs or opens the row with that mnemonic, as in a Windows menu bar. Mnemonics are assigned per level from the shown labels, underlined only while the keyboard drives the bar, and a label without an ASCII letter shows its English letter after it ("文件(F)") through the optional english hook. Preferred letters come from MenuBarUi::mnemonics. A Mac target has none, and a dialog or the shortcut recorder keeps the keys. Ported from PhotoCraft's menu_keys.rs and menu_keys_tests.rs. * craft-menu: one installer or the other; sort mnemonics before checking uniqueness * craft-menu: egui 0.37 * craft-menu: text fields keep their Edit items; web names by the browser's system; sync per frame; stable check kinds; handled hook clicks * craft-menu: sync reports a clash change on an unchanged bar * craft-menu: shrink-to-fit bar, bar runs Quit and Minimize, mnemonic before an ellipsis documented; craft-keymap: parse the minus sign * craft-keymap: test the arrow glyphs as keys * craft-menu: mac::install_with and MacOptions::window_list, for apps that list their documents themselves * craft-menu: a negative fit limit draws no bar; Window list docs name the Window menu --- crates/keymap/src/chord.rs | 4 ++- crates/keymap/tests/chord.rs | 18 +++++++++++++ crates/menu/README.md | 25 ++++++++++-------- crates/menu/src/bar/mod.rs | 50 +++++++++++++++++++++++++++++++----- crates/menu/src/mac.rs | 45 ++++++++++++++++++++++++++++---- crates/menu/src/tests/bar.rs | 48 +++++++++++++++++++++++++++++++--- 6 files changed, 165 insertions(+), 25 deletions(-) diff --git a/crates/keymap/src/chord.rs b/crates/keymap/src/chord.rs index 3d079868a..df1ecb515 100644 --- a/crates/keymap/src/chord.rs +++ b/crates/keymap/src/chord.rs @@ -126,12 +126,14 @@ const KEY_ALIASES: &[(&str, Key)] = &[ ("grave", Key::Backtick), ("backquote", Key::Backtick), ("apostrophe", Key::Quote), + // The minus sign menus and docs print (U+2212), not the hyphen-minus the key types. + ("−", Key::Minus), ]; impl Key { /// Reads a key name: canonical (`K`, `PageUp`, `;`), egui variant names (`ArrowUp`, `Num5`, /// `Semicolon`) and the aliases `esc`, `return`, `del`, `pgup`, `pgdn`, `spacebar`, `ins`, - /// and the Mac key glyphs. Case does not matter. + /// the Mac key glyphs and the minus sign `−`. Case does not matter. pub fn parse(name: &str) -> Option { Key::ALL .iter() diff --git a/crates/keymap/tests/chord.rs b/crates/keymap/tests/chord.rs index 116b357e4..4a8440d7e 100644 --- a/crates/keymap/tests/chord.rs +++ b/crates/keymap/tests/chord.rs @@ -27,6 +27,22 @@ fn canonical_text_round_trips_for_every_key_and_modifier_set() { assert_eq!(serde_json::to_string(&chord("shift+cmd+k")).unwrap(), "\"Cmd+Shift+K\""); } +#[test] +fn plus_and_minus_keys_parse_from_their_glyphs() { + assert_eq!(Key::parse("+"), Some(Key::Plus)); + assert_eq!(Key::parse("-"), Some(Key::Minus)); + assert_eq!(Key::parse("−"), Some(Key::Minus), "U+2212"); +} + +#[test] +fn arrow_glyphs_parse_as_arrow_keys() { + for (glyph, key) in [("←", Key::Left), ("→", Key::Right), ("↑", Key::Up), ("↓", Key::Down)] { + assert_eq!(Key::parse(glyph), Some(key), "{glyph}"); + } + assert_eq!(Chord::parse("⌘←").unwrap().to_string(), "Cmd+Left"); + assert_eq!(Chord::parse("Alt+Shift+→").unwrap().to_string(), "Alt+Shift+Right"); +} + #[test] fn aliases_parse_to_canonical_chords() { let cases = [ @@ -50,6 +66,8 @@ fn aliases_parse_to_canonical_chords() { ("Cmd+Plus", "Cmd++"), ("cmd+Equals", "Cmd+="), ("Cmd+-", "Cmd+-"), + ("Cmd+−", "Cmd+-"), + ("⌘−", "Cmd+-"), ("Cmd + K", "Cmd+K"), ("esc", "Escape"), ("return", "Enter"), diff --git a/crates/menu/README.md b/crates/menu/README.md index 42c77b30c..063c2d0e4 100644 --- a/crates/menu/README.md +++ b/crates/menu/README.md @@ -106,9 +106,7 @@ let laid_out = craft_menu::layout(&menu_bar, Target::current(), &LayoutOptions:: egui::Panel::top("menu-bar").show(ui, |ui| { let gate = Gate { modal: dialog_open, recording_shortcut, text_editing: ui.ctx().text_edit_focused() }; for command in MenuBarUi::new(&laid_out).gate(gate).label(&tr).show(ui) { - if !craft_quit::on_menu_event(ui.ctx(), &command.id) { - app.run(&command); - } + app.run(&command); } }); ``` @@ -129,9 +127,9 @@ egui::Panel::top("menu-bar").show(ui, |ui| { keyboard. Each menu level assigns them from the labels as shown, so translated: unique within the level, the first unused letter, word starts first; `.mnemonics(&[("View", 'W'), ("file.open", 'P')])` names preferred letters by title as shown or by command id. A label - without an ASCII letter or digit shows its English label's letter after it, "文件(F)", when - `.english(&hook)` returns that English label for the `Labeled::Command` or `Labeled::Title`; - without one it has no mnemonic. While the bar has the keyboard the app's shortcuts wait, as + without an ASCII letter or digit shows its English label's letter after it, "文件(F)", before a + trailing ellipsis ("開く(O)…"), when `.english(&hook)` returns that English label for the + `Labeled::Command` or `Labeled::Title`; without one it has no mnemonic. While the bar has the keyboard the app's shortcuts wait, as for an open menu (`craft_menu::bar::is_focused(ctx)`); call `craft_menu::bar::leave_for_shortcut(ctx)` before them so that a command shortcut (Ctrl+S, F7) leaves the bar and runs. While a dialog is open or a shortcut is being recorded (`Gate`), @@ -147,10 +145,13 @@ egui::Panel::top("menu-bar").show(ui, |ui| { choose). - **Narrow windows:** the menus that don't fit move into a trailing `⋯` menu; when not even the first fits, one `Menu` title holds them all. `.label(&tr)` translates `⋯`, `Menu` and the - scroll arrows' names, with the same hook as `LayoutOptions::label`. + scroll arrows' names, with the same hook as `LayoutOptions::label`. The bar fills its row; + `.shrink_to_fit(max_width)` makes it take only the width its titles need, up to `max_width`, + so other controls can share the row, and the menus past that overflow. - **Quit:** without an app Quit item, the Windows and Linux layouts add `Builtin::Quit` - (`craft_quit::QUIT_MENU_ID`). The bar returns it like any other command; pass it to - `craft_quit::on_menu_event`, which asks the window to close so the unsaved-changes check runs. + (`craft_quit::QUIT_MENU_ID`). The bar runs it itself through `craft_quit::on_menu_event`, + which asks the window to close so the unsaved-changes check runs, and doesn't return it; the + same goes for `Builtin::Minimize`. - **Accessibility:** AccessKit sees a menu bar of menu items, the open menus, check and radio items with their state, disabled items, and each item's shortcut and mnemonic (`access_key`). - **Style:** `BarStyle::from_style(ui.style())` by default; `.style(..)` sets title padding and @@ -186,7 +187,11 @@ so the app's unsaved-changes prompt runs). Each menu event wakes egui, so a chos once rather than with the next input event. The backend calls AppKit's Window and Help menu hooks for the menus with `MenuRole::Window` and -`MenuRole::Help`; it never looks at titles. Services is AppKit's own submenu. +`MenuRole::Help`; it never looks at titles. Services is AppKit's own submenu. AppKit's Window +menu lists the app's windows after the menu's own items. An app that keeps its documents in one +window and lists them in the Window menu itself (`MenuBar::documents`) would see each twice, so it +installs with +`mac::install_with(ctx, &bar, MacOptions::default().window_list(false))`. Only shortcuts with ⌘ or ⌃ become key equivalents. The others (`B`, `⇧B`, `⌥B`, a bare `F5`) are tool keys the app handles itself; as key equivalents AppKit would take them from text fields. diff --git a/crates/menu/src/bar/mod.rs b/crates/menu/src/bar/mod.rs index 0a79c1025..d034b9ad3 100644 --- a/crates/menu/src/bar/mod.rs +++ b/crates/menu/src/bar/mod.rs @@ -18,6 +18,7 @@ use egui::containers::menu::{MenuConfig, MenuState, SubMenu, SubMenuButton}; use egui::text::LayoutJob; use egui::{Color32, Id, PopupCloseBehavior, Response, TextFormat, TextStyle, Ui, UiKind, UiStackInfo, WidgetText}; +use crate::model::Builtin; use crate::{Gate, Item, ItemKind, Menu, MenuBar, Node, Target}; use keys::Keys; use nav::Nav; @@ -106,6 +107,7 @@ pub struct MenuBarUi<'a> { english: Option<&'a EnglishHook<'a>>, preferred: &'a [(&'a str, char)], id: Id, + fit: Option, } impl<'a> MenuBarUi<'a> { @@ -120,6 +122,7 @@ impl<'a> MenuBarUi<'a> { english: None, preferred: &[], id: Id::unique("craft-menu-bar"), + fit: None, } } @@ -154,7 +157,7 @@ impl<'a> MenuBarUi<'a> { /// The English label of a row or title, for labels the keyboard can't type a letter of /// (Chinese, Japanese, Korean…): such a label shows its English label's mnemonic after it, - /// "文件(F)", as Windows menus do. Without this hook (or when it returns `None`) such a label + /// "文件(F)", before a trailing ellipsis ("開く(O)…"), as Windows menus do. Without this hook (or when it returns `None`) such a label /// has no mnemonic. Not asked on a Mac target, which has no mnemonics. pub fn english(mut self, english: &'a dyn Fn(Labeled<'_>) -> Option) -> MenuBarUi<'a> { self.english = Some(english); @@ -170,6 +173,14 @@ impl<'a> MenuBarUi<'a> { self } + /// Take only the width the titles need, at most `max_width` (`f32::INFINITY` for no limit) + /// and the room the `Ui` has, so that other controls can share the row; past that the menus + /// overflow into "⋯". By default the bar fills the row. + pub fn shrink_to_fit(mut self, max_width: f32) -> MenuBarUi<'a> { + self.fit = Some(max_width); + self + } + /// The bar's id, where its state lives in egui memory: tells two bars in one window apart. pub fn id(mut self, id: Id) -> MenuBarUi<'a> { self.id = id; @@ -178,6 +189,8 @@ impl<'a> MenuBarUi<'a> { /// Draw the bar and its open menu; returns the commands chosen this frame (at most one), /// clicked or run with ↩ or Space. Disabled items, and items the gate forbids, never run. + /// [`Builtin::Quit`] (asks the window to close, see [`craft_quit::on_menu_event`]) and + /// [`Builtin::Minimize`] run here and are left out. pub fn show(self, ui: &mut Ui) -> Vec { let ctx = ui.ctx().clone(); let style = self.style.clone().unwrap_or_else(|| BarStyle::from_style(ui.style())); @@ -207,7 +220,13 @@ impl<'a> MenuBarUi<'a> { press: self.id.with("press"), clicked: None, }; - ui.scope(|ui| { + let room = self.fit.map_or(ui.available_width(), |max| max.min(ui.available_width())).max(0.0); + let width = match self.fit { + // The half point keeps rounding from pushing the last title into "⋯". + Some(_) => (self.title_widths(ui, &draw, style.title_padding.x).iter().sum::() + 0.5).min(room), + None => room, + }; + ui.allocate_ui(egui::vec2(width, ui.available_height()), |ui| { ui.spacing_mut().item_spacing.x = 0.0; egui::MenuBar::new().ui(ui, |ui| { ctx.accesskit_node_builder(ui.unique_id(), |n| n.set_role(Role::MenuBar)); @@ -282,7 +301,28 @@ impl<'a> MenuBarUi<'a> { if ctx.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) { ctx.data_mut(|d| d.remove::(draw.press)); } - draw.clicked.into_iter().collect() + draw.clicked + .into_iter() + .filter(|command| match Builtin::of(command) { + Some(Builtin::Quit) => !craft_quit::on_menu_event(&ctx, Builtin::Quit.id()), + Some(Builtin::Minimize) => { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + false + } + _ => true, + }) + .collect() + } + + /// Each title's width as shown, with any "(F)" it takes, and `pad` on either side. + fn title_widths(&self, ui: &Ui, draw: &Draw<'_>, pad: f32) -> Vec { + let font = TextStyle::Button.resolve(ui.style()); + let names: Vec<(&str, Name<'_>)> = self.bar.menus.iter().map(|m| (m.title.as_str(), Name::App(Labeled::Title(&m.title)))).collect(); + draw.mnemonics(&names) + .0 + .iter() + .map(|t| ui.painter().layout_no_wrap(t.to_owned(), font.clone(), Color32::PLACEHOLDER).size().x + 2.0 * pad) + .collect() } /// The titles to draw: every menu when they fit in the row; else the menus that fit with @@ -293,9 +333,7 @@ impl<'a> MenuBarUi<'a> { let pad = 2.0 * ui.spacing().button_padding.x; let width = |t: &str| ui.painter().layout_no_wrap(t.to_owned(), font.clone(), Color32::PLACEHOLDER).size().x + pad; let room = ui.available_width(); - // Measured as shown, with any "(F)" a title takes. - let names: Vec<(&str, Name<'_>)> = menus.iter().map(|m| (m.title.as_str(), Name::App(Labeled::Title(&m.title)))).collect(); - let widths: Vec = draw.mnemonics(&names).0.iter().map(|t| width(t)).collect(); + let widths = self.title_widths(ui, draw, ui.spacing().button_padding.x); if widths.iter().sum::() <= room { return menus.iter().map(Top::Menu).collect(); } diff --git a/crates/menu/src/mac.rs b/crates/menu/src/mac.rs index f4f4126fc..e5bdd306d 100644 --- a/crates/menu/src/mac.rs +++ b/crates/menu/src/mac.rs @@ -73,11 +73,40 @@ thread_local! { /// or the deprecated `craft_quit::install_app_menu`, never both: called first, that one leaves /// this menu without events; called after, it replaces this menu bar with its app menu. pub fn install(ctx: &egui::Context, bar: &MenuBar) -> Option { + install_with(ctx, bar, MacOptions::default()) +} + +/// How [`install_with`] sets up the native menus. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct MacOptions { + /// Make the [`MenuRole::Window`] menu AppKit's Windows menu, which lists the app's windows + /// after the menu's own items. On by default. An app that lists its documents itself + /// in the Window menu ([`MenuBar::documents`]) and keeps them in one window turns it off: AppKit + /// would list that window as well. + pub window_list: bool, +} + +impl Default for MacOptions { + fn default() -> MacOptions { + MacOptions { window_list: true } + } +} + +impl MacOptions { + pub fn window_list(mut self, on: bool) -> MacOptions { + self.window_list = on; + self + } +} + +/// [`install`] with `options`. +pub fn install_with(ctx: &egui::Context, bar: &MenuBar, options: MacOptions) -> Option { MainThreadMarker::new()?; if INSTALLED.swap(true, Ordering::SeqCst) { return None; } - let mut menu = MacMenu::new(ctx); + let mut menu = MacMenu::new(ctx, options); if !menu.build(bar) { INSTALLED.store(false, Ordering::SeqCst); return None; @@ -237,10 +266,11 @@ struct MacMenu { window: Option, /// The latest menus, waiting for the open menu to close. pending: Option, + options: MacOptions, } impl MacMenu { - fn new(ctx: &egui::Context) -> MacMenu { + fn new(ctx: &egui::Context, options: MacOptions) -> MacMenu { let (tx, rx) = channel(); MacMenu { ctx: ctx.clone(), @@ -252,6 +282,7 @@ impl MacMenu { structure: 0, window: None, pending: None, + options, } } @@ -274,7 +305,7 @@ impl MacMenu { if m.role == MenuRole::Window { window = Some((sub.clone(), ids, window_key(bar))); } - if matches!(m.role, MenuRole::Window | MenuRole::Help) { + if m.role == MenuRole::Help || (m.role == MenuRole::Window && self.options.window_list) { special.push((m.role, sub)); } } @@ -302,7 +333,9 @@ impl MacMenu { if let Some(old) = &self.menu { let _ = catch_unwind(AssertUnwindSafe(|| { old.init_for_nsapp(); - if let Some((sub, ..)) = &self.window { + if let Some((sub, ..)) = &self.window + && self.options.window_list + { sub.set_as_windows_menu_for_nsapp(); } })); @@ -334,7 +367,9 @@ impl MacMenu { let _ = menu.insert(&old, at); return Err(e); } - sub.set_as_windows_menu_for_nsapp(); + if self.options.window_list { + sub.set_as_windows_menu_for_nsapp(); + } Ok((sub, ids, window_key(bar))) })); match replaced { diff --git a/crates/menu/src/tests/bar.rs b/crates/menu/src/tests/bar.rs index 9375dae91..5227839be 100644 --- a/crates/menu/src/tests/bar.rs +++ b/crates/menu/src/tests/bar.rs @@ -65,6 +65,11 @@ struct Bar { pointer: Pos2, tree: Vec<(NodeId, accesskit::Node)>, clicked: Vec, + /// `shrink_to_fit`'s limit; the bar then shares a row with a label, which starts at `after`. + fit: Option, + after: Option, + /// The viewport commands the bar sent. + sent: Vec, } impl Bar { @@ -88,6 +93,9 @@ impl Bar { pointer: pos2(-10.0, -10.0), tree: Vec::new(), clicked: Vec::new(), + fit: None, + after: None, + sent: Vec::new(), }; b.run(3); b @@ -102,16 +110,29 @@ impl Bar { events: std::mem::take(&mut self.events), ..Default::default() }; - let (bar, gate, target, english, preferred) = (&self.bar, self.gate, self.target, self.english, &self.preferred); + let (bar, gate, target, english, preferred, fit) = (&self.bar, self.gate, self.target, self.english, &self.preferred, self.fit); let mut out = Vec::new(); + let mut after = None; let mut full = self.ctx.run_ui(raw, |ui| { let mut ui_bar = MenuBarUi::new(bar).gate(gate).target(target).mnemonics(preferred); if let Some(english) = &english { ui_bar = ui_bar.english(english); } - out = ui_bar.show(ui); + match fit { + Some(max) => { + ui.horizontal(|ui| { + out = ui_bar.shrink_to_fit(max).show(ui); + after = Some(ui.label("after").rect.left()); + }); + } + None => out = ui_bar.show(ui), + } }); full.textures_delta.clear(); + self.after = after; + if let Some(root) = full.viewport_output.remove(&egui::ViewportId::ROOT) { + self.sent.extend(root.commands); + } if let Some(update) = full.platform_output.accesskit_update { self.tree = update.nodes; } @@ -273,7 +294,8 @@ fn disabled_and_gate_forbidden_items_return_nothing() { b.click("New"); assert!(b.take().is_empty(), "and they don't run"); b.click("Quit"); - assert_eq!(b.take(), vec![Builtin::Quit.command()], "the built-in Quit still runs"); + assert!(b.take().is_empty(), "the bar runs the built-in Quit itself"); + assert!(b.sent.contains(&egui::ViewportCommand::Close), "and it still runs: the window is asked to close"); b.gate = Gate { text_editing: true, ..Gate::default() }; b.click("File"); @@ -446,6 +468,26 @@ fn the_web_shows_the_browsers_system_names_and_the_mac_its_glyphs() { } } +/// `shrink_to_fit` leaves the rest of the row to other controls; past its limit the menus +/// overflow into "⋯". +#[test] +fn a_bar_shrunk_to_fit_shares_its_row() { + let mut b = wide(); + b.fit = Some(f32::INFINITY); + b.run(2); + let after = b.after.expect("the label after the bar"); + assert!(after < b.size.x / 2.0, "the bar takes only its titles' width: {after}"); + assert!(b.title("Help").is_some() && b.title("⋯").is_none()); + b.fit = Some(100.0); + b.run(2); + assert!(b.title("⋯").is_some(), "the menus past 100 points overflow"); + // The bar's 100 points and the row's item spacing (8). + assert!(b.after.is_some_and(|x| x <= 109.0), "{:?}", b.after); + // A limit an app computed from a full row can go negative. + b.fit = Some(-20.0); + b.run(2); +} + /// A menu far taller than the window. fn tall() -> Bar { let rows = (0..60).map(|i| item(&format!("cmd.{i}"), &format!("Item {i}")).into()).collect(); From 512b54355cd1418f4e8c56a3fbd7483c3c274bf3 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:12:14 -0400 Subject: [PATCH 2/3] docking: apps can draw extra tabs after a group's panel tabs (#356) DockContent::group_extra_tabs_width and group_extra_tabs draw host tabs right after a group's panel tabs, before the drag bar and the header. They fit with the panel tabs as one more tab that never overflows: both shrink widest first to min_tab_width, and in a narrower row the panel tabs overflow while the extra tabs keep that width. --- crates/ui/src/docking/group_tests.rs | 36 +++++++++++++++ crates/ui/src/docking/render/group.rs | 64 ++++++++++++++++++++------- crates/ui/src/docking/surface.rs | 19 ++++++++ docs/ui-docking.md | 6 ++- 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 665e8d734..d6b28adc7 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -49,6 +49,9 @@ struct TestContent { header_clicks: usize, /// The strip width the last header was sized for. strip_width: std::cell::Cell, + /// Width the extra tabs ask for, and the rect they were last drawn in. + extra: f32, + extra_rect: Option, floating_size: Option, host_owned: bool, whole_group: bool, @@ -95,6 +98,12 @@ impl DockContent for TestContent { self.header_clicks += 1; } } + fn group_extra_tabs_width(&self, _: &[u32], _: &u32, _: bool) -> f32 { + self.extra + } + fn group_extra_tabs(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) { + self.extra_rect = Some(ui.max_rect()); + } } /// Renders a layout through `show_content` in `area` of an 800x600 window and applies what @@ -288,6 +297,33 @@ fn drag_bar_survives_a_strip_full_of_tabs() { assert_eq!(d.layout.floating[0].groups()[0].panels.len(), 8); } +#[test] +/// The extra tabs follow the last panel tab, before the drag bar and the header. In a narrow row +/// they shrink to the minimum tab width while the panel tabs overflow, and the bar keeps its +/// width; a tab dropped on them goes after the last panel tab. +fn extra_tabs_follow_the_panel_tabs_and_overflow_with_them() { + let content = TestContent { header: 24.0, extra: 100.0, ..Default::default() }; + let layout = Layout { root: Some(tabs(&[1, 2], 0)), floating: Vec::new() }; + let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0))); + let (extra, bar) = (d.content.extra_rect.expect("extra tabs drawn"), d.rect("drag-bar", 1)); + assert_eq!((extra.left(), extra.width()), (d.tab(2).right(), 100.0)); + assert_eq!((bar.left(), bar.right()), (extra.right(), 600.0 - 24.0), "the bar and the header follow"); + d.drag(d.tab(1).center(), extra.center()); + assert_eq!(d.layout.root, Some(tabs(&[2, 1], 1)), "a drop on the extra tabs appends"); + + let content = TestContent { header: 24.0, extra: 100.0, ..Default::default() }; + let layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: Vec::new() }; + let d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 400.0))); + let overflow = d.ctx.read_response(Id::unique(AREA).with(("overflow", &1u32))).expect("the panel tabs overflow").rect; + let (extra, bar) = (d.content.extra_rect.expect("extra tabs drawn"), d.rect("drag-bar", 1)); + assert_eq!(overflow.left(), d.tab(1).right()); + assert_eq!(extra.left(), overflow.right(), "the extra tabs follow the overflow button"); + let min = DockStyle::default().min_tab_width; + assert!(extra.width() >= min - 0.5 && extra.width() < 100.0, "{extra:?}"); + assert_eq!((bar.left(), bar.right()), (extra.right(), 200.0 - 24.0)); + assert!(bar.width() >= 40.0, "{bar:?}"); +} + /// Panels with ordinary names. struct Named; diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 1ddf3bca9..e4e04cda1 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -3,7 +3,7 @@ use std::{fmt::Debug, hash::Hash, sync::Arc}; use egui::{Color32, Galley, Id, Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; use super::{Renderer, clamp_float, stripless}; -use crate::docking::{Action, DropTarget, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING}; +use crate::docking::{Action, DockContent, DropTarget, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING}; use crate::tabs; /// Where a group is drawn. It decides the drop targets the group offers and its gestures. @@ -42,9 +42,9 @@ impl Renderer<'_, P> { /// its whole rect is the body and its only tab has `Rect::NOTHING`. Side splits are offered /// only for a `Frame` or `Floating` group whose selected panel accepts splits. Tab labels /// shrink widest-first to the minimum width, then the remaining tabs move to the overflow menu; - /// hidden overflow tabs still appear in the drop target with `Rect::NOTHING`. `minimized` hides - /// the body but keeps the strip. Tabs, strip, header and drag bar are mirrored in right-to-left - /// layout. + /// hidden overflow tabs still appear in the drop target with `Rect::NOTHING`. The host's extra + /// tabs follow the panel tabs and fit with them ([`fit_with_extra`]). `minimized` hides the body + /// but keeps the strip. Tabs, strip, header and drag bar are mirrored in right-to-left layout. 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 }; self.record_float(panels, rect.size(), rect.size()); @@ -81,13 +81,7 @@ impl Renderer<'_, P> { let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; if header_width > 0.0 { let header = mirror(Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max)); - let mut child = ui.new_child(UiBuilder::new().scope_id(self.id.with(("header", selected))).max_rect(header)); - child.set_clip_rect(header.intersect(ui.clip_rect())); - self.content.group_header(&mut child, panels, selected, floating); - // A press in the header focuses the shown panel, as a press on its tab does. - if child.rect_contains_pointer(header) && child.input(|i| i.pointer.any_pressed()) { - self.output.focused = Some(selected.clone()); - } + self.host_area(ui, "header", header, selected, |content, ui| content.group_header(ui, panels, selected, floating)); } // The drag bar keeps its reserved width: tabs shrink, then overflow, before it narrows. let bar_width = self.style.drag_bar_width.clamp(0.0, (strip.width() - header_width).max(0.0)); @@ -103,10 +97,12 @@ impl Renderer<'_, P> { + TAB_PADDING }) .collect(); + let wanted = self.content.group_extra_tabs_width(panels, selected, floating); + let wanted = if wanted.is_finite() { wanted.clamp(0.0, tabs_width) } else { 0.0 }; // The widest tabs shrink first, to a common width no narrower than `min_tab_width`; only // when every tab at that width cannot fit do the rest go to the overflow menu. A tab // narrower than its name cuts it with "…" and names it in a tooltip. - let fit = tabs::fit(&widths, active, tabs_width, self.style.min_tab_width, OVERFLOW_WIDTH); + let (fit, extra_width) = fit_with_extra(&widths, active, tabs_width, wanted, self.style.min_tab_width); let owner = TabGroup { panels, selected, minimized, kind, rect }; let mut x = strip.left(); let mut shown = Vec::new(); @@ -138,6 +134,12 @@ impl Renderer<'_, P> { }); x += fit.overflow_width; } + let mut extra = Rect::NOTHING; + if extra_width > 0.0 { + extra = mirror(Rect::from_min_size(pos2(x, strip.top()), vec2(extra_width, strip.height()))); + self.host_area(ui, "extra-tabs", extra, selected, |content, ui| content.group_extra_tabs(ui, panels, selected, floating)); + x += extra_width; + } let bar = mirror(Rect::from_min_max(pos2(x.min(strip.right() - header_width), strip.top()), pos2(strip.right() - header_width, strip.bottom()))); self.group_handle(ui, "drag-bar", bar, &owner, movable); @@ -146,7 +148,7 @@ impl Renderer<'_, P> { 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); + self.wheel_tabs(ui, tabs_area, extra, panels, active); } // Logical order, hidden tabs included without a hit area, for no-op detection. let tabs = @@ -166,6 +168,17 @@ impl Renderer<'_, P> { ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); } + /// A part of the strip the host draws (`header` or `extra-tabs`) at `rect`. A press in it + /// focuses the shown panel, as a press on its tab does. + fn host_area(&mut self, ui: &mut Ui, name: &'static str, rect: Rect, selected: &P, draw: impl FnOnce(&mut dyn DockContent

, &mut Ui)) { + let mut child = ui.new_child(UiBuilder::new().scope_id(self.id.with((name, selected))).max_rect(rect)); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + draw(&mut *self.content, &mut child); + if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { + self.output.focused = Some(selected.clone()); + } + } + /// The drag bar (`drag-bar`: drag, double-click, and the group menu on a right click, Enter /// or Space) or the top border band (`drag-border`: drag only). Both move the whole group; /// a left click on the bar only focuses it. @@ -209,10 +222,11 @@ impl Renderer<'_, P> { } /// 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) { + /// notch, stopping at the ends. Trackpad scrolling adds up to a notch. The host's extra tabs + /// (`extra`) keep the wheel for their own widgets. + fn wheel_tabs(&mut self, ui: &Ui, strip: Rect, extra: Rect, panels: &[P], active: usize) { let key = self.id.with(("tab-wheel", panels.first())); - if !ui.rect_contains_pointer(strip) { + if !ui.rect_contains_pointer(strip) || ui.rect_contains_pointer(extra) { ui.ctx().data_mut(|d| d.remove::(key)); return; } @@ -476,3 +490,21 @@ impl Renderer<'_, P> { }); } } + +/// Fit the panel tabs and the host's extra tabs (`extra` wide), which fit as one more tab that +/// never overflows: in a row too narrow for every tab at its minimum width, the panel tabs +/// overflow into the room the extra tabs leave at theirs. Returns the extra tabs' width. +fn fit_with_extra(widths: &[f32], active: usize, available: f32, extra: f32, minimum: f32) -> (tabs::TabFit, f32) { + if extra <= 0.0 { + return (tabs::fit(widths, active, available, minimum, OVERFLOW_WIDTH), 0.0); + } + let all: Vec = widths.iter().copied().chain([extra]).collect(); + let mut fit = tabs::fit(&all, active, available, minimum, OVERFLOW_WIDTH); + if fit.overflow.is_empty() { + let width = fit.shown.pop().filter(|(index, _)| *index == widths.len()).map_or(0.0, |(_, width)| width); + return (fit, width); + } + let fit = tabs::fit(widths, active, (available - extra.min(minimum)).max(0.0), minimum, OVERFLOW_WIDTH); + let used = fit.shown.iter().map(|(_, width)| width).sum::() + fit.overflow_width; + (fit, extra.min(available - used).max(0.0)) +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index ec3cc0491..3c6417ddc 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -911,6 +911,19 @@ pub trait DockContent

{ /// [`DockArea::new`], `active` and the widget's rect when its response reports /// `drag_started()`; the panel then moves as its tab would. fn group_header(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {} + /// Width the group's extra tabs want: the host's own tabs, drawn right after the group's + /// panel tabs (and their "…" button), before the drag bar. They fit with the panel tabs: + /// both shrink widest first to [`DockStyle::min_tab_width`] (the extra tabs as one tab); in a + /// row narrower still, the panel tabs overflow and the extra tabs keep that width. Zero draws + /// none. + fn group_extra_tabs_width(&self, _panels: &[P], _active: &P, _floating: bool) -> f32 { + 0.0 + } + /// Draw the extra tabs into `ui.max_rect()`, which can be narrower than + /// [`Self::group_extra_tabs_width`] asked for; the host fits its tabs into it, with an + /// overflow of its own. Their widgets take their own input as the header's do, and a press + /// among them focuses the shown panel. A drop on them adds the panel after the last panel tab. + fn group_extra_tabs(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {} } /// Host-owned drop resolution request, see [`DockContent::resolve_drag`]. @@ -995,6 +1008,12 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { fn group_header(&mut self, ui: &mut Ui, panels: &[P], active: &P, floating: bool) { self.inner.group_header(ui, panels, active, floating); } + fn group_extra_tabs_width(&self, panels: &[P], active: &P, floating: bool) -> f32 { + self.inner.group_extra_tabs_width(panels, active, floating) + } + fn group_extra_tabs(&mut self, ui: &mut Ui, panels: &[P], active: &P, floating: bool) { + self.inner.group_extra_tabs(ui, panels, active, floating); + } } /// Optional renderer for applications without an existing docking surface. Apps with custom diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 7fb944ab2..f1fd57907 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -131,7 +131,11 @@ its own command path, so pointer, keyboard and control commands share one path. `DockContent

` is the app's adapter. It draws each panel body and gives its label, icon, permissions, size limits, dock column heights, preferred floating size, panel menu and group header. The group header sits at the end of the tab strip; `group_header_width` gets the strip width, so a header that -draws its own tabs can size them to the room left. An icon is an image (`icon`), or drawn in code by `paint_icon`, which +draws its own tabs can size them to the room left. Extra tabs (`group_extra_tabs_width` and +`group_extra_tabs`) sit right after the group's panel tabs, before the drag bar, and fit with +them: both shrink widest first to `min_tab_width`, the extra tabs as one tab, and in a row +narrower still the panel tabs overflow while the extra tabs keep that width. The host fits its +own tabs into the rect it gets. An icon is an image (`icon`), or drawn in code by `paint_icon`, which gets a painter, the icon rect and the tint color and returns true when it painted. Panel bodies keep a stable egui id when they move, so widget memory survives. From d7b9b5618f39a37aa2c898a96ab2bb8b833f04c4 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:13:24 -0400 Subject: [PATCH 3/3] panes: a pane dropped over a docked pane's outer or inner third docks beside it there (#357) Dragging a pane (a docked pane's title or a floating pane) over another pane docked at a side edge offers two dock zones: the outer third docks it directly outside that pane, the inner third directly inside it. The middle third is no target. The window-edge strip docks a pane outermost on that edge. The new PaneRequest::Before(pane, before) asks the app to move the pane to directly before another in its open list, which sets its position on the edge; the drop preview is laid out from that order, so it matches where the pane lands. With one slot per edge, a closable pane dropped on an edge takes the slot. --- crates/ui/src/panes/mod.rs | 173 +++++++++++++++++++++++++++-------- crates/ui/src/panes/tests.rs | 99 ++++++++++++++++++-- docs/ui-panes.md | 10 +- 3 files changed, 231 insertions(+), 51 deletions(-) diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs index 07365378d..61db4e80c 100644 --- a/crates/ui/src/panes/mod.rs +++ b/crates/ui/src/panes/mod.rs @@ -4,7 +4,9 @@ //! a title bar (title, ▾ menu, ×) and a resizable inner edge. Call it before the app's //! `CentralPanel`; the document never becomes a panel. The app owns which panes are open and //! where they are: the host takes the open list and each pane's [`PaneState`] every frame and -//! returns [`PaneRequest`]s for the app to run through its own commands. Docking chrome +//! returns [`PaneRequest`]s for the app to run through its own commands. The order of `open` +//! is the order of the panes on each edge, the first outermost; a drop that picks a position on +//! an edge asks for a new order with [`PaneRequest::Before`]. Docking chrome //! ([`crate::docking::chrome`]) draws the floating frames and title bars. [`Rail`] draws an icon //! strip at one edge that opens one pane at a time. @@ -28,7 +30,7 @@ mod rail; pub use rail::{RAIL_BUTTON, RAIL_WIDTH, Rail, RailItem, RailOutput, RailRequest}; /// A title dragged this far out of a docked pane floats it; a pane dropped this close to the -/// host's left or right edge docks there. +/// host's left or right edge docks there, outermost. pub const DOCK_SNAP: f32 = 32.0; /// Height of a pane's title bar. pub const TITLE_BAR_HEIGHT: f32 = 24.0; @@ -77,7 +79,7 @@ impl PaneState { PaneRequest::Place(_, place) => self.place = *place, PaneRequest::Resize(_, size) => self.size = Some(*size), PaneRequest::FloatRect(_, rect) => self.float_rect = Some(*rect), - PaneRequest::Close(_) | PaneRequest::Focus(_) => {} + PaneRequest::Close(_) | PaneRequest::Focus(_) | PaneRequest::Before(..) => {} } } } @@ -85,11 +87,16 @@ impl PaneState { /// How docked panes share an edge. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum SidePolicy { - /// Every pane on an edge is shown, side by side in `open` order, the first outermost. + /// Every pane on an edge is shown, side by side in `open` order, the first outermost. A drop + /// over the outer third of a docked side pane docks directly outside it, over its inner third + /// directly inside it, and at the host's left or right edge outermost; the host asks for that + /// position with [`PaneRequest::Before`]. #[default] Columns, /// One pane per edge: the last of the edge's closable panes in `open`. Panes that are not - /// closable (context panes the app shows from its own state) are shown beside the slot. + /// closable (context panes the app shows from its own state) are shown beside the slot. A + /// closable pane dropped on an edge takes its slot: [`PaneRequest::Before`] moves it after the + /// slot's pane in `open`. OneSlot, } @@ -102,12 +109,16 @@ pub enum PaneRequest

{ FloatRect(P, [f32; 4]), /// The pane took focus from a click, keyboard focus or F6. Focus(P), + /// Move the first pane in `open` to directly before the second (`None`: after every pane), + /// which puts it directly outside the second on their edge. Sent after the `Place` of a drop + /// that picks a position on an edge, and only when `open` changes. + Before(P, Option

), } impl

PaneRequest

{ pub fn pane(&self) -> &P { match self { - Self::Close(p) | Self::Place(p, _) | Self::Resize(p, _) | Self::FloatRect(p, _) | Self::Focus(p) => p, + Self::Close(p) | Self::Place(p, _) | Self::Resize(p, _) | Self::FloatRect(p, _) | Self::Focus(p) | Self::Before(p, _) => p, } } } @@ -226,6 +237,49 @@ struct Shown

{ state: PaneState, } +/// Where a dragged pane docks when dropped: at `edge`, directly before the pane `before` in +/// `open` (`None`: after every pane). +#[derive(Clone, Copy, Debug, PartialEq)] +struct Target { + edge: Edge, + before: Option, +} + +/// `panes` with `pane` docked at the target's edge and at its place in the order. +fn arrange(panes: &[Shown

], pane: Id, target: Target) -> Vec> { + let mut after = panes.to_vec(); + let Some(index) = after.iter().position(|s| s.id == pane) else { return after }; + let mut moved = after.remove(index); + moved.state.place = Place::Docked(target.edge); + let at = match target.before { + Some(before) => after.iter().position(|s| s.id == before).unwrap_or(index), + None => after.len(), + }; + after.insert(at.min(after.len()), moved); + after +} + +/// The requests that dock `pane` at `target`: its `Place` when the edge changes, then `Before` +/// when `open` changes. None when it lands where it is. +fn dock_requests(panes: &[Shown

], pane: Id, target: Target) -> Vec> { + let mut requests = Vec::new(); + let Some(s) = panes.iter().find(|s| s.id == pane) else { return requests }; + let after = arrange(panes, pane, target); + let docked = Place::Docked(target.edge); + let on_edge = |list: &[Shown

]| list.iter().filter(|s| s.state.place == docked).map(|s| s.id).collect::>(); + if s.state.place == docked && on_edge(panes) == on_edge(&after) { + return requests; + } + if s.state.place != docked { + requests.push(PaneRequest::Place(s.pane.clone(), docked)); + } + if !panes.iter().map(|s| s.id).eq(after.iter().map(|s| s.id)) { + let before = target.before.and_then(|id| panes.iter().find(|s| s.id == id)).map(|s| s.pane.clone()); + requests.push(PaneRequest::Before(s.pane.clone(), before)); + } + requests +} + /// Where a pane was drawn, for focus tracking. struct Region { pane: Id, @@ -497,24 +551,15 @@ impl PaneHost { s.state.size.filter(|v| v.is_finite()).or(default.filter(|v| v.is_finite())).unwrap_or(min).clamp(min, max) } - /// The rect `pane` takes on the next frame once it is docked at the side `edge`. Side panels - /// are laid out as [`Self::show`] draws them: `Start` panes, then `End` panes, each in `open` - /// order from the outside in and no wider than the space left. `None` when - /// [`SidePolicy::OneSlot`] would hide the pane there. - fn landing>(&self, shown: &[Shown

], pane: Id, edge: Edge, content: &C, bounds: Rect) -> Option { - let mut after: Vec> = shown - .iter() - .map(|s| { - let place = if s.id == pane { Place::Docked(edge) } else { s.state.place }; - Shown { pane: s.pane.clone(), id: s.id, state: PaneState { place, ..s.state } } - }) - .collect(); - self.one_slot(&mut after, content); + /// The rects of the side panes in `panes` as [`Self::show`] draws them: `Start` panes, then + /// `End` panes, each in order from the outside in and no wider than the space left. + fn side_rects>(&self, panes: &[Shown

], content: &C, bounds: Rect) -> Vec<(Id, Edge, Rect)> { + let mut rects = Vec::new(); let mut free = bounds; - for side_edge in [Edge::Start, Edge::End] { - for s in after.iter().filter(|s| s.state.place == Place::Docked(side_edge)) { - let size = self.docked_size(content, s, side_edge, bounds).min(free.width()).max(0.0); - let rect = if self.side(side_edge) == Side::Left { + for edge in [Edge::Start, Edge::End] { + for s in panes.iter().filter(|s| s.state.place == Place::Docked(edge)) { + let size = self.docked_size(content, s, edge, bounds).min(free.width()).max(0.0); + let rect = if self.side(edge) == Side::Left { let rect = Rect::from_x_y_ranges(free.left()..=free.left() + size, free.y_range()); free.min.x = rect.right(); rect @@ -523,27 +568,76 @@ impl PaneHost { free.max.x = rect.left(); rect }; - if s.id == pane { - return Some(rect); - } + rects.push((s.id, edge, rect)); } } - None + rects } - /// Paints where `pane` lands when it is dropped docked at the side `edge`. + /// Where `pane`, dragged with the pointer at `pointer`, docks if dropped: at the host's left or + /// right edge, outermost there, or over the outer or inner third of another docked side pane, + /// directly outside or inside it. `panes` is the open list before the one-slot filter. + fn target>(&self, pointer: Pos2, panes: &[Shown

], pane: Id, content: &C, bounds: Rect) -> Option { + if !pointer.is_finite() { + return None; + } + let others = || panes.iter().filter(|s| s.id != pane); + // The pane after `id` in `open`, skipping the dragged pane: directly after `id` is + // directly before that one. + let after = |id: Id| others().skip_while(|s| s.id != id).nth(1).map(|s| s.id); + // Directly before the pane that follows it, the dragged pane keeps its place in `open`. + let keep = panes.iter().skip_while(|s| s.id != pane).nth(1).map(|s| s.id); + let mut visible = panes.to_vec(); + self.one_slot(&mut visible, content); + // One slot per edge leaves no position to pick: a closable pane takes the edge's slot by + // going after the slot's pane. + let slot = |edge: Edge, columns: Option| match self.policy { + SidePolicy::Columns => columns, + SidePolicy::OneSlot => { + let closable = panes.iter().any(|s| s.id == pane && content.closable(&s.pane)); + match visible.iter().find(|s| closable && s.id != pane && s.state.place == Place::Docked(edge) && content.closable(&s.pane)) { + Some(taken) => after(taken.id), + None => keep, + } + } + }; + if let Some(edge) = self.snap(pointer, bounds) { + let first = others().find(|s| s.state.place == Place::Docked(edge)).map(|s| s.id); + return Some(Target { edge, before: slot(edge, first.or(keep)) }); + } + let (id, edge, rect) = self.side_rects(&visible, content, bounds).into_iter().find(|(id, _, rect)| *id != pane && rect.contains(pointer))?; + let third = rect.width() / 3.0; + let from_outside = if self.side(edge) == Side::Left { pointer.x - rect.left() } else { rect.right() - pointer.x }; + if from_outside < third { + Some(Target { edge, before: slot(edge, Some(id)) }) + } else if from_outside > rect.width() - third { + Some(Target { edge, before: slot(edge, after(id)) }) + } else { + None + } + } + + /// The rect `pane` takes on the next frame once it is docked at `target`. `None` when + /// [`SidePolicy::OneSlot`] would hide it there. + fn landing>(&self, panes: &[Shown

], pane: Id, target: Target, content: &C, bounds: Rect) -> Option { + let mut after = arrange(panes, pane, target); + self.one_slot(&mut after, content); + self.side_rects(&after, content, bounds).into_iter().find(|(id, _, _)| *id == pane).map(|(_, _, rect)| rect) + } + + /// Paints where `pane` lands when it is dropped at `target`. #[allow(clippy::too_many_arguments)] fn preview_dock>( &self, preview: &egui::Painter, style: &ChromeStyle, - shown: &[Shown

], + panes: &[Shown

], pane: Id, - edge: Edge, + target: Target, content: &C, bounds: Rect, ) { - if let Some(rect) = self.landing(shown, pane, edge, content, bounds) { + if let Some(rect) = self.landing(panes, pane, target, content, bounds) { chrome::drop_preview(preview, rect.shrink(1.0), style.accent, true); } } @@ -656,8 +750,8 @@ impl PaneHost { rect } - /// A docked pane's title dragged beyond [`DOCK_SNAP`] of the pane: dropped near the host's - /// left or right edge it docks there, elsewhere it floats under the pointer. + /// A docked pane's title dragged beyond [`DOCK_SNAP`] of the pane: dropped on a dock target + /// ([`Self::target`]) it docks there, elsewhere it floats under the pointer. #[allow(clippy::too_many_arguments)] fn drag_out>( &self, @@ -686,15 +780,14 @@ impl PaneHost { let outside = if edge == Edge::Bottom { !near.y_range().contains(pointer.y) } else { !near.x_range().contains(pointer.x) }; let size = s.state.float_rect.map_or(FLOAT_DEFAULT, |r| vec2(r[2], r[3])); let float = float_rect(Some(rect_array(Rect::from_min_size(pointer - FLOAT_GRAB, size))), bounds, vec2(0.0, FLOAT_MIN_HEIGHT)); - let target = self.snap(pointer, bounds); + let target = self.target(pointer, shown, s.id, content, bounds); if bar.drag_stopped() { memory.drag_out = None; if drag.canceled || !outside { return; } match target { - Some(target) if target != edge => requests.push(PaneRequest::Place(s.pane.clone(), Place::Docked(target))), - Some(_) => {} + Some(target) => requests.extend(dock_requests(shown, s.id, target)), None => { requests.push(PaneRequest::Place(s.pane.clone(), Place::Floating)); requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(float))); @@ -755,17 +848,17 @@ impl PaneHost { if content.movable(&s.pane) { let step = chrome::live_move(ctx, &shown.inner, shown.rect, bounds); if let Some(MoveStep::Moving(_)) = step - && let Some(edge) = ctx.pointer_latest_pos().and_then(|pointer| self.snap(pointer, bounds)) + && let Some(target) = ctx.pointer_latest_pos().and_then(|pointer| self.target(pointer, panes, s.id, content, bounds)) { - self.preview_dock(preview, style, panes, s.id, edge, content, bounds); + self.preview_dock(preview, style, panes, s.id, target, content, bounds); } match step { Some(MoveStep::Moving(next) | MoveStep::Canceled(next)) if next != shown.rect => { requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(next))); } Some(MoveStep::Dropped { rect, pointer }) => { - if let Some(edge) = self.snap(pointer, bounds) { - requests.push(PaneRequest::Place(s.pane.clone(), Place::Docked(edge))); + if let Some(target) = self.target(pointer, panes, s.id, content, bounds) { + requests.extend(dock_requests(panes, s.id, target)); } else if rect != shown.rect { requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(rect))); } diff --git a/crates/ui/src/panes/tests.rs b/crates/ui/src/panes/tests.rs index f39e207d4..de50d853d 100644 --- a/crates/ui/src/panes/tests.rs +++ b/crates/ui/src/panes/tests.rs @@ -128,6 +128,11 @@ impl App { match request { PaneRequest::Close(p) => self.open.retain(|o| o != p), PaneRequest::Focus(_) => {} + PaneRequest::Before(p, before) => { + self.open.retain(|o| o != p); + let at = before.and_then(|b| self.open.iter().position(|o| *o == b)).unwrap_or(self.open.len()); + self.open.insert(at, *p); + } other => { let pane = *other.pane(); let state = self.states.entry(pane).or_insert_with(|| Content.default_state(&pane)); @@ -389,12 +394,13 @@ fn a_floating_pane_near_a_side_edge_previews_the_column_it_docks_in() { assert!(previews(&h).is_empty(), "away from the edges"); let to = pos2(L + 10.0, 300.0); send(&mut h, vec![Event::PointerMoved(to)]); - let landing = Rect::from_x_y_ranges(L + 250.0..=L + 550.0, 8.0..=592.0); - assert_eq!(previews(&h), vec![landing], "beside Nav, 300 pt wide"); + let landing = Rect::from_x_y_ranges(L..=L + 300.0, 8.0..=592.0); + assert_eq!(previews(&h), vec![landing], "outside Nav, 300 pt wide"); send(&mut h, vec![button(to, false)]); h.run_steps(2); assert_eq!(h.state().place(Pane::Styles), Place::Docked(Edge::Start)); assert_eq!(panel(&h, Pane::Styles), landing); + assert_eq!(edge(&h, Pane::Nav), L + 550.0); } #[test] @@ -403,25 +409,104 @@ fn a_docked_pane_dragged_across_previews_the_column_it_lands_in() { let from = title(&h, Pane::Comments).center(); let to = pos2(L + 10.0, 300.0); hold(&mut h, from, to); - let landing = Rect::from_x_y_ranges(L + 250.0..=L + 500.0, 8.0..=592.0); - assert_eq!(previews(&h), vec![landing], "right of Nav, which comes first in open"); + let landing = Rect::from_x_y_ranges(L..=L + 250.0, 8.0..=592.0); + assert_eq!(previews(&h), vec![landing], "the window edge docks outermost"); send(&mut h, vec![button(to, false)]); h.run_steps(2); - assert_eq!(h.state().place(Pane::Comments), Place::Docked(Edge::Start)); + assert_eq!( + changes(h.state()), + vec![PaneRequest::Place(Pane::Comments, Place::Docked(Edge::Start)), PaneRequest::Before(Pane::Comments, Some(Pane::Nav))] + ); assert_eq!(panel(&h, Pane::Comments), landing); + assert_eq!(panel(&h, Pane::Nav), Rect::from_x_y_ranges(L + 250.0..=L + 500.0, 8.0..=592.0)); - // Right to left: Start is the right edge, and Nav stays outermost there. + // Right to left: Start is the right edge. let mut app = App::new(&[Pane::Nav, Pane::Comments]); app.rtl = true; let mut h = harness(app); let from = title(&h, Pane::Comments).center(); let to = pos2(R - 10.0, 300.0); hold(&mut h, from, to); - let landing = Rect::from_x_y_ranges(R - 500.0..=R - 250.0, 8.0..=592.0); + let landing = Rect::from_x_y_ranges(R - 250.0..=R, 8.0..=592.0); assert_eq!(previews(&h), vec![landing]); send(&mut h, vec![button(to, false)]); h.run_steps(2); assert_eq!(panel(&h, Pane::Comments), landing); + assert_eq!(panel(&h, Pane::Nav), Rect::from_x_y_ranges(R - 500.0..=R - 250.0, 8.0..=592.0)); +} + +#[test] +fn a_pane_dropped_over_a_docked_panes_outer_or_inner_third_docks_beside_it_there() { + let full = |x: std::ops::RangeInclusive| Rect::from_x_y_ranges(x, 8.0..=592.0); + // Drags Comments' title to `to`, checks the preview, drops it and returns the harness. + let drop = |app: App, to: Pos2, preview: Option| { + let mut h = harness(app); + let from = title(&h, Pane::Comments).center(); + hold(&mut h, from, to); + let previewed = previews(&h); + match preview { + Some(rect) => assert_eq!(previewed, vec![rect], "at {to:?}"), + None => assert!(previewed.len() == 1 && previewed[0].height() == FLOAT_DEFAULT.y, "a float preview at {to:?}: {previewed:?}"), + } + send(&mut h, vec![button(to, false)]); + h.run_steps(2); + h + }; + + // Over Nav's outer third, beyond the window-edge strip: outside Nav. + let h = drop(App::new(&[Pane::Nav, Pane::Comments]), pos2(L + 40.0, 300.0), Some(full(L..=L + 250.0))); + assert_eq!( + changes(h.state()), + vec![PaneRequest::Place(Pane::Comments, Place::Docked(Edge::Start)), PaneRequest::Before(Pane::Comments, Some(Pane::Nav))] + ); + assert_eq!((panel(&h, Pane::Comments), panel(&h, Pane::Nav)), (full(L..=L + 250.0), full(L + 250.0..=L + 500.0))); + + // Over its inner third: inside Nav, innermost. + let h = drop(App::new(&[Pane::Comments, Pane::Nav]), pos2(L + 220.0, 300.0), Some(full(L + 250.0..=L + 500.0))); + assert_eq!(changes(h.state()), vec![PaneRequest::Place(Pane::Comments, Place::Docked(Edge::Start)), PaneRequest::Before(Pane::Comments, None)]); + assert_eq!(panel(&h, Pane::Comments), full(L + 250.0..=L + 500.0)); + + // Over its middle third: no dock target, so it floats. + let h = drop(App::new(&[Pane::Nav, Pane::Comments]), pos2(L + 125.0, 300.0), None); + assert_eq!(h.state().place(Pane::Comments), Place::Floating); + + // Right to left: Nav is at the right edge, and its outer third is on its right. + let mut app = App::new(&[Pane::Nav, Pane::Comments]); + app.rtl = true; + let h = drop(app, pos2(R - 40.0, 300.0), Some(full(R - 250.0..=R))); + assert_eq!((panel(&h, Pane::Comments), panel(&h, Pane::Nav)), (full(R - 250.0..=R), full(R - 500.0..=R - 250.0))); + + // One slot per edge: a pane dropped over the slot's pane takes the slot. + let mut app = App::new(&[Pane::Comments, Pane::Nav]); + app.policy = SidePolicy::OneSlot; + let h = drop(app, pos2(L + 40.0, 300.0), Some(full(L..=L + 250.0))); + assert_eq!(h.state().open, vec![Pane::Nav, Pane::Comments]); + assert_eq!(panel(&h, Pane::Comments), full(L..=L + 250.0)); + + // On the same edge: over the outer third of the pane outside it moves it outermost; over the + // inner third of that pane, where it already is, nothing changes. + let mut h = harness(App::new(&[Pane::Nav, Pane::Styles])); + let from = title(&h, Pane::Styles).center(); + drag(&mut h, from, pos2(L + 200.0, 300.0)); + assert!(changes(h.state()).is_empty(), "{:?}", h.state().log); + drag(&mut h, from, pos2(L + 40.0, 300.0)); + assert_eq!(changes(h.state()), vec![PaneRequest::Before(Pane::Styles, Some(Pane::Nav))]); + assert_eq!((edge(&h, Pane::Styles), edge(&h, Pane::Nav)), (L + 250.0, L + 500.0)); + + // A floating pane: no target over Nav's middle third, beside it over its inner third. + let mut app = App::new(&[Pane::Nav, Pane::Styles]); + app.states.insert(Pane::Styles, PaneState { place: Place::Floating, size: Some(300.0), float_rect: Some([300.0, 100.0, 270.0, 400.0]) }); + let mut h = harness(app); + let from = title(&h, Pane::Styles).center(); + hold(&mut h, from, pos2(L + 125.0, 300.0)); + assert!(previews(&h).is_empty()); + let to = pos2(L + 220.0, 300.0); + send(&mut h, vec![Event::PointerMoved(to)]); + assert_eq!(previews(&h), vec![full(L + 250.0..=L + 550.0)]); + send(&mut h, vec![button(to, false)]); + h.run_steps(2); + assert_eq!(h.state().place(Pane::Styles), Place::Docked(Edge::Start)); + assert_eq!(panel(&h, Pane::Styles), full(L + 250.0..=L + 550.0)); } #[test] diff --git a/docs/ui-panes.md b/docs/ui-panes.md index c31378b28..9d043c661 100644 --- a/docs/ui-panes.md +++ b/docs/ui-panes.md @@ -40,6 +40,8 @@ for request in out.requests { PaneRequest::Place(pane, place) => prefs.set_place(pane, place), PaneRequest::Resize(pane, size) => prefs.set_size(pane, size), PaneRequest::FloatRect(pane, rect) => prefs.set_float_rect(pane, rect), + // Move `pane` to directly before `before` in the open list (`None`: to its end). + PaneRequest::Before(pane, before) => app.move_pane_before(pane, before), PaneRequest::Focus(_) => {} } } @@ -55,13 +57,13 @@ egui::CentralPanel::default().show(ui, |ui| { | Piece | Behavior | |---|---| | `Edge::{Start, End, Bottom}` | Logical edges. `Start` is left, or right with `.rtl(true)`. | -| `SidePolicy::Columns` | Every docked pane shows; panes on one edge sit side by side in `open` order, the first outermost. | -| `SidePolicy::OneSlot` | One closable pane per edge: the last one in `open`. Non-closable context panes (PivotTable Fields) show beside the slot. | +| `SidePolicy::Columns` | Every docked pane shows; panes on one edge sit side by side in `open` order, the first outermost. A drop that picks a position on an edge sends `Before(pane, next)` after its `Place`, so the app's `open` order puts the pane there. | +| `SidePolicy::OneSlot` | One closable pane per edge: the last one in `open`. Non-closable context panes (PivotTable Fields) show beside the slot. A closable pane dropped on an edge takes the slot: `Before` moves it after the slot's pane. | | `.max_fraction(f)` | One docked pane takes at most `f` of the host's width (height for bottom panes). | | Chrome | Title, ▾ menu (app items, Move to Start, Move to End, Move to Bottom for bottom panes, Float, Close), ×, a resize edge with pointer and arrow keys. The edge takes presses on both sides of its line, over the document's own widgets too, and stays under floating windows. The title bar mirrors with `.rtl(true)`. Its accessible name is `PaneLabels::move_pane` with `{title}` replaced by the pane's title ("Move Styles pane"). | | Size | A docked pane reserves exactly its size and clips its body, so a body wider than the pane never pushes into the document. | -| Drag out | A docked pane's title dragged more than `DOCK_SNAP` (32 pt) out of the pane floats it under the pointer when dropped; dropped within 32 pt of the host's left or right edge it docks there. While the pointer is there, a preview shows the rect the pane will take: with `Columns`, after the panes on that edge that come before it in `open`. | -| Floating | `FloatingFrame` with its resize handles; the title bar moves it. Escape or window focus loss during a move puts it back. A drop within 32 pt of the left or right edge docks it, with the same preview as a drag out. Rects are clamped to the host area. | +| Drag out | A docked pane's title dragged more than `DOCK_SNAP` (32 pt) out of the pane floats it under the pointer when dropped, unless it is dropped on a dock target: within 32 pt of the host's left or right edge it docks there, outermost; over the outer third of another side pane (toward the window edge) it docks directly outside that pane, over its inner third directly inside it. The middle third is no target. While the pointer is on a target, a preview shows the rect the pane will take once the app applies the requests. A drop where the pane already is sends nothing. | +| Floating | `FloatingFrame` with its resize handles; the title bar moves it. Escape or window focus loss during a move puts it back. A drop on a dock target docks it, with the same targets and preview as a drag out. Rects are clamped to the host area. | | Focus | `out.focused` is the pane holding keyboard focus or last clicked; a click or focus in the document clears it. F6 and Shift+F6 cycle through the shown panes and then report `out.to_document`: give focus to the document. `.f6(false)` leaves F6 to the app, which calls `panes::focus_next(ctx, id, reverse)` instead. | State: