diff --git a/crates/ui/src/docking/handle_names_tests.rs b/crates/ui/src/docking/handle_names_tests.rs new file mode 100644 index 00000000..04952fe6 --- /dev/null +++ b/crates/ui/src/docking/handle_names_tests.rs @@ -0,0 +1,234 @@ +//! 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 { + Node::Tabs { panels: panels.to_vec(), active } +} + +/// Two tab groups side by side, showing panels 2 and 3. +fn two_groups() -> Layout { + 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 { + 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, 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 { + let mut names: Vec = + 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::::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"); +} + +#[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"))); +} diff --git a/crates/ui/src/docking/labels.rs b/crates/ui/src/docking/labels.rs index 3ad3a9ff..c86cb510 100644 --- a/crates/ui/src/docking/labels.rs +++ b/crates/ui/src/docking/labels.rs @@ -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) -> 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 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}"; diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index df3349c2..9788d0b6 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -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; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 7805ff0d..56279335 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -628,7 +628,7 @@ impl 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 { diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 7259b534..6ba95f49 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -151,10 +151,10 @@ impl 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 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 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 diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 2ceda931..02537b0c 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -200,7 +200,8 @@ impl 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 { diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 256edccd..dbe8fdfa 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -163,6 +163,20 @@ impl 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

) -> 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 = 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 } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 2dc5b7e9..4bff6035 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -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(), diff --git a/docs/ui-docking.md b/docs/ui-docking.md index d2498ac2..68bd821a 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -738,6 +738,17 @@ 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 drag bar, and +the column bar (a group's top border band only drags and is not announced). 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 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