mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 12:13:20 +00:00
docking: each move handle has one name, after what it moves (#410)
A group's drag bar and its top border band were both buttons with
move_group_label, so a screen reader heard every group twice, and every
group and column bar had the same name. The band, which only drags, is
now out of the accessibility tree and keyboard focus (unless no bar is
drawn), and a `{name}` in move_group_label or move_column_label stands
for what the handle moves: the logical label of the panel the group
shows, or the titled panels a column's groups show, joined by ", ". A
blank title leaves the placeholder out with the brackets, quotes or
separator around it. Labels without it, today's defaults included, are
used unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d836bea31b
commit
b4bd8e6f70
@@ -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<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");
|
||||
}
|
||||
|
||||
#[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")));
|
||||
}
|
||||
@@ -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 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}";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user