Merge pull request #606 from ZIDAN44/fix/410-docking-handle-names

docking: each move handle has one name, after what it moves (#410)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-05 15:06:16 +01:00
commit a8b996d207
9 files changed
+394 -14

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
//! The accessible names of the handles that move a group or a dock column (#410).
use super::*;
use crate::layout::{SplitAxis, SplitSize};
use egui::accesskit::Role;
use egui::{Id, vec2};
use egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
const GROUP: &str = "Move the {name} tab group";
const COLUMN: &str = "Move the {name} panel column";
fn tabs(panels: &[u32], active: usize) -> Node<u32> {
Node::Tabs { panels: panels.to_vec(), active }
}
/// Two tab groups side by side, showing panels 2 and 3.
fn two_groups() -> Layout<u32> {
Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedSecond(300.0),
first: Box::new(tabs(&[1, 2], 1)),
second: Box::new(tabs(&[3], 0)),
}),
floating: vec![],
}
}
/// A left dock of two columns beside canvas 0: [1, 2] over [3], and [4]; iconic when `iconic`.
fn two_columns(iconic: bool) -> Layout<u32> {
let mut first = Column::new(vec![Group::new(vec![1, 2], 1), Group::new(vec![3], 0)], 200.0);
first.iconic = iconic;
let second = Column::new(vec![Group::new(vec![4], 0)], 200.0);
let dock = Node::Dock { side: Side::Start, columns: vec![first, second] };
Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedFirst(400.0),
first: Box::new(dock),
second: Box::new(tabs(&[0], 0)),
}),
floating: vec![],
}
}
fn titled(panel: &u32) -> String {
format!("Panel {panel}")
}
/// Panels 3 and 4 have no title; panel 1 only spaces.
fn partly_untitled(panel: &u32) -> String {
match panel {
3 | 4 => String::new(),
1 => " ".into(),
_ => titled(panel),
}
}
/// Renders `layout` with the move labels set to `group` and `column` (the defaults when `None`).
/// Untitled tabs have no name, so kittest's check for nameless widgets is off when a panel has
/// none.
fn harness(layout: Layout<u32>, group: Option<&'static str>, column: Option<&'static str>, label: fn(&u32) -> String) -> Harness<'static> {
let check = (1..=4).all(|panel| !label(&panel).trim().is_empty());
let mut h = Harness::builder().with_size(vec2(900.0, 500.0)).with_accessibility_check(check).build_ui(move |ui| {
let mut style = DockStyle::from_ui(ui);
if let Some(text) = group {
style.move_group_label = text.into();
}
if let Some(text) = column {
style.move_column_label = text.into();
}
DockArea::new(Id::unique("handle-names")).show(
ui,
&layout,
&style,
label,
|_| Permissions::default(),
|ui: &mut egui::Ui, _: &u32| {
ui.label("Body");
},
);
});
h.run_steps(3);
h
}
fn buttons(h: &Harness<'_>, label: &str) -> usize {
h.query_all_by_role_and_label(Role::Button, label).count()
}
/// The names of every button whose name starts with "Move", sorted.
fn move_handles(h: &Harness<'_>) -> Vec<String> {
let mut names: Vec<String> =
h.query_all_by_role(Role::Button).filter_map(|node| node.accesskit_node().label()).filter(|name| name.starts_with("Move")).collect();
names.sort();
names
}
#[test]
/// Each group has one move handle, named after the panel it shows, so two groups' handles differ.
fn move_group_handles_are_named_after_the_panel_their_group_shows() {
let h = harness(two_groups(), Some(GROUP), None, titled);
assert_eq!(move_handles(&h), ["Move the Panel 2 tab group", "Move the Panel 3 tab group"]);
}
#[test]
/// Dock groups and column bars are named after what they move: a column after the panels its
/// groups show. No two move handles share a name.
fn dock_group_and_column_handles_are_named_after_what_they_move() {
let h = harness(two_columns(false), Some(GROUP), Some(COLUMN), titled);
assert_eq!(
move_handles(&h),
[
"Move the Panel 0 tab group",
"Move the Panel 2 tab group",
"Move the Panel 2, Panel 3 panel column",
"Move the Panel 3 tab group",
"Move the Panel 4 panel column",
"Move the Panel 4 tab group",
]
);
}
#[test]
/// The grip above each icon group of an iconic column is named after the group's active panel.
fn icon_group_grips_are_named_after_their_active_panel() {
let h = harness(two_columns(true), Some(GROUP), Some(COLUMN), titled);
assert_eq!(buttons(&h, "Move the Panel 2 tab group"), 1);
assert_eq!(buttons(&h, "Move the Panel 3 tab group"), 1);
assert_eq!(buttons(&h, "Move the Panel 2, Panel 3 panel column"), 1);
assert_eq!(buttons(&h, GROUP), 0);
}
#[test]
/// A label without `{name}`, the default or an app's own, is used as it is: once per group (one
/// handle each, as in the issue's strip of a column bar over one group) and once per column.
fn a_label_without_the_placeholder_is_kept_for_every_handle() {
let h = harness(two_columns(false), None, None, titled);
assert_eq!(buttons(&h, "Move tab group"), 4);
assert_eq!(buttons(&h, "Move panel column"), 2);
let h = harness(two_groups(), Some("Déplacer le groupe"), Some("Déplacer la colonne"), titled);
assert_eq!(move_handles(&h), Vec::<String>::new());
assert_eq!(buttons(&h, "Déplacer le groupe"), 2);
}
#[test]
/// An untitled panel leaves the placeholder out rather than an empty name; a column names only
/// its titled panels.
fn untitled_panels_leave_the_name_out() {
let h = harness(two_columns(false), Some(GROUP), Some(COLUMN), partly_untitled);
assert_eq!(
move_handles(&h),
[
"Move the Panel 0 tab group",
"Move the Panel 2 panel column",
"Move the Panel 2 tab group",
"Move the panel column",
"Move the tab group",
"Move the tab group",
]
);
// The hidden tab's spaces do not count either: group [1, 2] shows 1 here.
let mut layout = two_groups();
if let Some(Node::Split { first, .. }) = &mut layout.root {
**first = tabs(&[1, 2], 0);
}
let h = harness(layout, Some("Move tab group: {name}"), None, partly_untitled);
assert_eq!(move_handles(&h), ["Move tab group", "Move tab group"]);
}
#[test]
/// A label that is only the placeholder, with no name to put in, falls back to the default.
fn a_label_that_is_only_the_placeholder_falls_back_to_the_default() {
assert_eq!(labels::named("{name}", labels::MOVE_GROUP, String::new), "Move tab group");
assert_eq!(labels::named("{name}", labels::MOVE_GROUP, || "Layers".into()), "Layers");
assert_eq!(labels::named("Move the {name} column", labels::MOVE_COLUMN, || " \t".into()), "Move the column");
// A digit is enough to keep the label's own text.
assert_eq!(labels::named("{name} 2", labels::MOVE_GROUP, String::new), "2");
assert_eq!(labels::named("({name})", labels::MOVE_GROUP, String::new), "Move tab group");
}
#[test]
/// A blank name takes the brackets, quotes and separator that only made sense around it, and
/// adds no space where the placeholder had none beside it.
fn a_blank_name_leaves_no_stray_punctuation() {
for (label, bare) in [
("Move tab group ({name})", "Move tab group"),
("Déplacer le groupe « {name} »", "Déplacer le groupe"),
("Move “{name}” group", "Move group"),
("Move [{name}]", "Move"),
("Move the {name}, tab group", "Move the tab group"),
("移动{name}标签组", "移动标签组"),
("「{name}」タブグループを移動", "タブグループを移動"),
("Move the {name} tab group", "Move the tab group"),
("Move tab group: {name}", "Move tab group"),
("Move tab group — {name}", "Move tab group"),
("Move {name} and {name} group", "Move and group"),
("“{name}”", "Move tab group"),
] {
assert_eq!(labels::named(label, labels::MOVE_GROUP, String::new), bare, "{label}");
}
}
#[test]
/// A strip whose tabs leave no room for a drag bar still has one announced, focusable move
/// handle per group: its top border band.
fn without_a_drag_bar_the_border_band_is_the_one_move_handle() {
let area = Id::unique("no-drag-bar");
let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![] };
let mut h = Harness::builder().with_size(vec2(300.0, 200.0)).build_ui(move |ui| {
let mut style = DockStyle::from_ui(ui);
style.drag_bar_width = 0.0;
style.move_group_label = GROUP.into();
DockArea::new(area).show(
ui,
&layout,
&style,
|panel| format!("Panel {panel} with a title far wider than its strip, so its tab takes the whole strip"),
|_| Permissions::default(),
|ui: &mut egui::Ui, _: &u32| {
ui.label("Body");
},
);
});
h.run_steps(3);
assert!(h.ctx.read_response(area.with(("drag-bar", Some(&1_u32)))).is_none(), "no drag bar is drawn");
let border = h.ctx.read_response(area.with(("drag-border", Some(&1_u32)))).expect("the border band is drawn");
assert!(border.sense.is_focusable());
let handles: Vec<_> =
h.query_all_by_role(Role::Button).filter(|node| node.accesskit_node().label().is_some_and(|l| l.starts_with("Move"))).collect();
assert_eq!(handles.len(), 1);
assert_eq!(handles[0].rect(), border.rect, "the band is the announced handle");
assert!(handles[0].accesskit_node().label().is_some_and(|l| l.starts_with("Move the Panel 1 with a title")));
}
+105
View File
@@ -43,6 +43,24 @@ impl DockStyle {
/// | `dock.restoreFrame` | `restore_frame_label` | Restore Frame Size |
/// | `dock.autoCollapse` | `auto_collapse_label` | Auto-Collapse Iconic Panels |
/// | `dock.floatHint` | `float_hint_label` | Hold {key} to undock |
///
/// Every group's drag bar and icon grip, and every column bar, has the same default name.
/// Opt in to naming them, in the English label and each translation: a `{name}` in
/// `move_group_label` stands for the logical label of the panel the group shows, and in
/// `move_column_label` for those of the panels the column's groups show, joined by ", ", so a
/// screen reader tells the handles apart. The ", " is fixed. Untitled panels are left out;
/// with no title at all the placeholder goes with the brackets or quotes around it, a
/// separator right after it and one of the spaces beside it ("Move the tab group"). Groups
/// showing panels of the same title still share a name:
///
/// ```
/// let style = craft_ui::docking::DockStyle::default().localized(|key| match key {
/// "dock.moveGroup" => Some("Move the {name} tab group".to_owned()),
/// "dock.moveColumn" => Some("Move the {name} panel column".to_owned()),
/// _ => None,
/// });
/// # assert_eq!(style.move_group_label, "Move the {name} tab group");
/// ```
#[must_use]
pub fn localized(mut self, translate: impl Fn(&str) -> Option<String>) -> Self {
for (key, label) in self.labels_mut() {
@@ -82,3 +100,90 @@ impl DockStyle {
]
}
}
/// `label` with every `{name}` replaced by `name()`, which only runs when there is one. A blank
/// name leaves each placeholder out with what only made sense around it (see [`unnamed`]); a
/// label with no letter or digit left gives `fallback`, so no handle is announced with a hole in
/// its name or without one.
pub(super) fn named(label: &str, fallback: &str, name: impl FnOnce() -> String) -> String {
if !label.contains(NAME) {
return label.to_owned();
}
let name = name();
if !name.trim().is_empty() {
return label.replace(NAME, name.trim());
}
let bare = unnamed(label);
if bare.chars().any(char::is_alphanumeric) { bare } else { fallback.to_owned() }
}
/// `label` without its placeholders. Each goes with the brackets or quotes around it and the
/// spaces inside them, and with a separator right after it. Where both sides were spaces, one
/// stays; where neither was, none is added. Separators and spaces at the ends go too.
fn unnamed(label: &str) -> String {
let mut text = label.to_owned();
// Every pass removes a placeholder, so the text gets shorter each time.
while let Some((before, after)) = text.split_once(NAME) {
let (mut head, mut tail) = (before, after);
let (open, close) = (head.trim_end(), tail.trim_start());
if let (Some(left), Some(right)) = (open.chars().next_back(), close.chars().next())
&& PAIRS.contains(&(left, right))
{
head = open.strip_suffix(left).unwrap_or(open);
tail = close.strip_prefix(right).unwrap_or(close);
}
let next = tail.trim_start();
if let Some(separator) = next.chars().next().filter(|c| SEPARATORS.contains(c))
&& !head.trim().is_empty()
{
tail = next.strip_prefix(separator).unwrap_or(next);
}
text = if head.ends_with(char::is_whitespace) && tail.starts_with(char::is_whitespace) {
format!("{} {}", head.trim_end(), tail.trim_start())
} else {
format!("{head}{tail}")
};
}
text.trim_matches(|c: char| c.is_whitespace() || SEPARATORS.contains(&c) || DASHES.contains(&c)).to_owned()
}
/// Brackets and quotes, opening and closing, that can wrap the placeholder.
const PAIRS: &[(char, char)] = &[
('(', ')'),
('[', ']'),
('<', '>'),
('"', '"'),
('\'', '\''),
('«', '»'),
('»', '«'),
('‹', '›'),
('›', '‹'),
('“', '”'),
('„', '“'),
('”', '”'),
('‘', '’'),
('‚', '‘'),
('’', '’'),
('「', '」'),
('『', '』'),
('(', ')'),
('[', ']'),
('【', '】'),
('〈', '〉'),
('《', '》'),
];
/// Separators that go with a placeholder they follow, or that are left at an end.
const SEPARATORS: &[char] = &[',', ';', ':', '、', ',', ';', ':'];
/// Dashes left at an end.
const DASHES: &[char] = &['-', '–', '—'];
/// The default [`DockStyle::move_group_label`].
pub(super) const MOVE_GROUP: &str = "Move tab group";
/// The default [`DockStyle::move_column_label`].
pub(super) const MOVE_COLUMN: &str = "Move panel column";
/// What [`DockStyle::move_group_label`] and [`DockStyle::move_column_label`] put the moved
/// group's or column's name in place of.
const NAME: &str = "{name}";
+2
View File
@@ -53,6 +53,8 @@ mod floating_tests;
#[cfg(test)]
mod group_tests;
#[cfg(test)]
mod handle_names_tests;
#[cfg(test)]
mod host_hooks_tests;
#[cfg(test)]
mod iconic_tests;
+1 -1
View File
@@ -628,7 +628,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let response = ui.interact(grip, self.id.with(("column-bar", Some(first))), Sense::click_and_drag());
let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response };
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.move_column_label));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), self.move_column_name(column)));
if movable && response.drag_started() {
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center());
let drag = GroupDrag {
+17 -8
View File
@@ -151,10 +151,10 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
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);
let named = self.group_handle(ui, "drag-bar", bar, &owner, movable, true);
// Registered last, so the band wins over the tabs below it.
let border = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, (strip.top() + BORDER_BAND).min(strip.bottom()))));
self.group_handle(ui, "drag-border", border, &owner, movable);
self.group_handle(ui, "drag-border", border, &owner, movable, !named);
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, extra, panels, active);
@@ -191,22 +191,30 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// 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.
fn group_handle(&mut self, ui: &mut Ui, name: &'static str, hit: Rect, owner: &TabGroup<'_, P>, movable: bool) {
/// a left click on the bar only focuses it. Only an `accessible` handle takes focus and is in
/// the accessibility tree, so each group is announced once: the band, which has no keyboard
/// function, only where no bar is drawn. Returns whether the handle is drawn.
fn group_handle(&mut self, ui: &mut Ui, name: &'static str, hit: Rect, owner: &TabGroup<'_, P>, movable: bool, accessible: bool) -> bool {
if !crate::docking::targets::usable(hit) {
return;
return false;
}
let bar = name == "drag-bar";
let sense = if bar { Sense::click_and_drag() } else { Sense::drag() };
let sense = match (bar, accessible) {
(true, _) => Sense::click_and_drag(),
(false, true) => Sense::drag(),
(false, false) => Sense::DRAG,
};
let response = ui.interact(hit, self.id.with((name, owner.panels.first())), sense);
let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response };
self.register_interaction(ui.ctx(), response.id);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.move_group_label));
if accessible {
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), self.move_group_name(owner.selected)));
}
if movable && response.drag_started() {
self.start_group_drag(ui, owner.panels, self.drag_source(owner));
}
if !bar {
return;
return true;
}
if self.minimizes(owner) && response.double_clicked() {
self.output.actions.push(Action::SetMinimized { panel: owner.selected.clone(), minimized: !owner.minimized });
@@ -243,6 +251,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
None
};
egui::Popup::menu(&response).open_memory(command).show(|ui| self.group_menu(ui, owner, owner.selected));
true
}
/// The wheel over a group's strip: down selects the next tab, up the previous, one tab per
+2 -1
View File
@@ -200,7 +200,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let height = group.height.filter(|h| h.is_finite()).unwrap_or(FLYOUT_HEIGHT);
let source = Rect::from_min_size(pos2(grip.left(), top), vec2(width.max(self.style.min_pane), height.max(self.style.tab_height)));
self.record_float(&group.panels, vec2(grip.width(), self.row_height()), source.size());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.move_group_label));
let shown = group.active_panel().unwrap_or(first);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), self.move_group_name(shown)));
if movable && response.drag_started() {
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center());
let drag = GroupDrag {
+14
View File
@@ -163,6 +163,20 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
self.content.logical_label(panel)
}
/// The accessible name of the handle that moves the group showing `shown`.
pub(super) fn move_group_name(&self, shown: &P) -> String {
super::labels::named(&self.style.move_group_label, super::labels::MOVE_GROUP, || self.label_of(shown))
}
/// The accessible name of `column`'s bar: its groups' shown panels that have a title name it.
pub(super) fn move_column_name(&self, column: &super::Column<P>) -> String {
super::labels::named(&self.style.move_column_label, super::labels::MOVE_COLUMN, || {
let shown = column.groups.iter().filter_map(|g| g.active_panel().or(g.panels.first()));
let titles: Vec<String> = shown.map(|panel| self.label_of(panel).trim().to_owned()).filter(|title| !title.is_empty()).collect();
titles.join(", ")
})
}
pub(super) fn permissions(&self, panel: &P) -> Permissions {
let permissions = self.content.permissions(panel);
if self.locked { permissions.locked() } else { permissions }
+4 -4
View File
@@ -32,7 +32,7 @@ pub struct DockStyle {
pub panels_label: String,
pub resize_label: String,
pub resize_window_label: String,
/// Accessibility label of a group's drag bar and top border.
/// Accessibility label of a group's drag bar and top border; `{name}` names the group.
pub move_group_label: String,
pub close_group_label: String,
pub minimize_label: String,
@@ -115,7 +115,7 @@ pub struct DockStyle {
/// [D] Lay docks out right to left: `Side::Start` is the right edge. egui has no app-wide
/// layout direction, so the host sets this from its locale. Splits keep their sides.
pub right_to_left: bool,
/// [D] Accessibility label of a column bar.
/// [D] Accessibility label of a column bar; `{name}` names the column ([`Self::localized`]).
pub move_column_label: String,
/// [D] Column menu and group menu items.
pub collapse_to_icons_label: String,
@@ -194,7 +194,7 @@ impl DockStyle {
panels_label: "Panels".into(),
resize_label: "Resize panels".into(),
resize_window_label: "Resize panel window".into(),
move_group_label: "Move tab group".into(),
move_group_label: super::labels::MOVE_GROUP.into(),
close_group_label: "Close Tab Group".into(),
minimize_label: "Minimize".into(),
expand_label: "Expand".into(),
@@ -221,7 +221,7 @@ impl DockStyle {
iconic_label_width: 96.0,
auto_collapse_label: "Auto-Collapse Iconic Panels".into(),
right_to_left: false,
move_column_label: "Move panel column".into(),
move_column_label: super::labels::MOVE_COLUMN.into(),
collapse_to_icons_label: "Collapse to Icons".into(),
expand_panels_label: "Expand Panels".into(),
collapse_column_label: "Collapse column to icons".into(),
+12
View File
@@ -738,6 +738,18 @@ Keys are `dock.close`, `dock.float`, `dock.collapseToIcons` and so on; the rustd
lists them all. `DockContent::label` and `action_label` can shape panel and action text further;
the logical label stays the accessibility name.
Each group and each column has one move handle in the accessibility tree: the group's drag bar
and the column bar. A group's top border band only drags and is not announced, except in a group
drawn without a drag bar, where the band is its move handle. By default these
share one name per kind. Naming them is opt-in: an app puts `{name}` in `move_group_label` or
`move_column_label`, and in each of its translations ("Move the {name} tab group"). It stands for
what the handle moves: the logical label of the panel the group shows, or of the panels a
column's groups show, joined by ", " (fixed, not localized). An untitled panel is left out. With
no title at all, the placeholder goes with the brackets or quotes around it, a separator right
after it and one of the spaces beside it ("Move the tab group", "Move tab group ({name})" gives
"Move tab group", "移动{name}标签组" gives "移动标签组"); a label left with no letter or digit falls
back to the English default. Two groups showing panels of the same title still get the same name.
## Testing
`DockArea::hit_rects(ctx, id)` lists every gesture source drawn last frame as a `Hit` with its