mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-15 15:02:12 +00:00
Merge branch 'lang-3-fonts' into lang-4-window
This commit is contained in:
commit
819fd27f00
13 files changed
+504
-93
No files matched your search
@@ -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> {
|
||||
Key::ALL
|
||||
.iter()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+15
-10
@@ -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.
|
||||
|
||||
@@ -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<f32>,
|
||||
}
|
||||
|
||||
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<String>) -> 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<CommandRef> {
|
||||
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::<f32>() + 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::<bool>(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<f32> {
|
||||
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<f32> = 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::<f32>() <= room {
|
||||
return menus.iter().map(Top::Menu).collect();
|
||||
}
|
||||
|
||||
+40
-5
@@ -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<NativeMenu> {
|
||||
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<NativeMenu> {
|
||||
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<WindowMenu>,
|
||||
/// The latest menus, waiting for the open menu to close.
|
||||
pending: Option<MenuBar>,
|
||||
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 {
|
||||
|
||||
@@ -65,6 +65,11 @@ struct Bar {
|
||||
pointer: Pos2,
|
||||
tree: Vec<(NodeId, accesskit::Node)>,
|
||||
clicked: Vec<CommandRef>,
|
||||
/// `shrink_to_fit`'s limit; the bar then shares a row with a label, which starts at `after`.
|
||||
fit: Option<f32>,
|
||||
after: Option<f32>,
|
||||
/// The viewport commands the bar sent.
|
||||
sent: Vec<egui::ViewportCommand>,
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -49,6 +49,9 @@ struct TestContent {
|
||||
header_clicks: usize,
|
||||
/// The strip width the last header was sized for.
|
||||
strip_width: std::cell::Cell<f32>,
|
||||
/// Width the extra tabs ask for, and the rect they were last drawn in.
|
||||
extra: f32,
|
||||
extra_rect: Option<Rect>,
|
||||
floating_size: Option<Vec2>,
|
||||
host_owned: bool,
|
||||
whole_group: bool,
|
||||
@@ -95,6 +98,12 @@ impl DockContent<u32> 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;
|
||||
|
||||
|
||||
@@ -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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P>, &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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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::<f32>(key));
|
||||
return;
|
||||
}
|
||||
@@ -476,3 +490,21 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<f32> = 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::<f32>() + fit.overflow_width;
|
||||
(fit, extra.min(available - used).max(0.0))
|
||||
}
|
||||
@@ -911,6 +911,19 @@ pub trait DockContent<P> {
|
||||
/// [`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<P, C: DockContent<P> + ?Sized> DockContent<P> 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
|
||||
|
||||
+133
-40
@@ -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<P> {
|
||||
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<P>),
|
||||
}
|
||||
|
||||
impl<P> PaneRequest<P> {
|
||||
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<P> {
|
||||
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<Id>,
|
||||
}
|
||||
|
||||
/// `panes` with `pane` docked at the target's edge and at its place in the order.
|
||||
fn arrange<P: Clone>(panes: &[Shown<P>], pane: Id, target: Target) -> Vec<Shown<P>> {
|
||||
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<P: Clone>(panes: &[Shown<P>], pane: Id, target: Target) -> Vec<PaneRequest<P>> {
|
||||
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<P>]| list.iter().filter(|s| s.state.place == docked).map(|s| s.id).collect::<Vec<_>>();
|
||||
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<P: Clone, C: PaneContent<P>>(&self, shown: &[Shown<P>], pane: Id, edge: Edge, content: &C, bounds: Rect) -> Option<Rect> {
|
||||
let mut after: Vec<Shown<P>> = 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<P, C: PaneContent<P>>(&self, panes: &[Shown<P>], 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<P: Clone, C: PaneContent<P>>(&self, pointer: Pos2, panes: &[Shown<P>], pane: Id, content: &C, bounds: Rect) -> Option<Target> {
|
||||
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<Id>| 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<P: Clone, C: PaneContent<P>>(&self, panes: &[Shown<P>], pane: Id, target: Target, content: &C, bounds: Rect) -> Option<Rect> {
|
||||
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<P: Clone, C: PaneContent<P>>(
|
||||
&self,
|
||||
preview: &egui::Painter,
|
||||
style: &ChromeStyle,
|
||||
shown: &[Shown<P>],
|
||||
panes: &[Shown<P>],
|
||||
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<P: Clone, C: PaneContent<P>>(
|
||||
&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)));
|
||||
}
|
||||
|
||||
@@ -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<f32>| 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<Rect>| {
|
||||
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]
|
||||
|
||||
+5
-1
@@ -131,7 +131,11 @@ its own command path, so pointer, keyboard and control commands share one path.
|
||||
`DockContent<P>` 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.
|
||||
|
||||
|
||||
+6
-4
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user