Key tips: reference command for shared codes, no code over labels

A code the reference shows on two buttons runs the button whose command
the reference runs for it (VC runs Viewport Configuration), decided by
the code table's order. Labels, combo values and panel titles report
their bounds while key tips are open, and each code takes the first
spot nearest the reference place that covers none of them nor another
code; a collapsed panel's title is measured to place its code beside
the words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-10-14 15:17:38 +03:00
1 parent b5ce285386
commit c2c34e45fa
4 files changed
+270 -97

No files matched your search

+17 -6
View File
@@ -302,9 +302,11 @@ pub(super) fn group_title<'a>(
active_tool: Option<&str>,
) -> Element<'a, Message> {
let Some(panel) = panel_for_group(title, group_ids) else {
return container(text(t!(title)).size(9).style(muted_text_style))
.padding([1, 4])
.into();
let label = keytips::label_anchor(
|| format!("title:{title}"),
text(t!(title)).size(9).style(muted_text_style),
);
return container(label).padding([1, 4]).into();
};
let expanded = open.as_deref().and_then(parent_panel) == Some(panel.id);
// A command running from the closed slide-out lights its title, the way a
@@ -323,7 +325,10 @@ pub(super) fn group_title<'a>(
panel.id,
button(
row![
PosReport::new(panel.title_id, text(t!(title)).size(9)),
keytips::label_anchor(
|| keytips::label_id(panel.id),
PosReport::new(panel.title_id, text(t!(title)).size(9)),
),
arrow
]
.spacing(4)
@@ -417,7 +422,10 @@ fn reference_overlay<'a>(ribbon: &Ribbon, panel: Panel, win: (f32, f32)) -> Elem
let edit = button(
row![
make_icon(IconKind::Svg(tool.icon), 16.0),
text(t!(tool.label)).size(11)
keytips::label_anchor(
|| keytips::label_id(&keytips::item_anchor(panel.id, tool.command)),
text(t!(tool.label)).size(11),
),
]
.spacing(6)
.align_y(iced::Center),
@@ -457,7 +465,10 @@ fn reference_overlay<'a>(ribbon: &Ribbon, panel: Panel, win: (f32, f32)) -> Elem
);
let fading = row![
switch,
text(t!("Xref fading")).size(11),
keytips::label_anchor(
|| keytips::label_id(&keytips::item_anchor(panel.id, keytips::XREF_FADE)),
text(t!("Xref fading")).size(11),
),
iced::widget::slider(0..=90u8, amount, Message::XrefFadeSlide)
.on_release(Message::XrefFadeCommit)
.width(Fill),
+193 -62
View File
@@ -86,6 +86,9 @@ static ALT_ALONE: AtomicBool = AtomicBool::new(false);
thread_local! {
/// Anchors that carried a code in the last drawn frame.
static SHOWN: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
/// Label and panel-title widgets a code must not cover.
static OBSTACLES: RefCell<rustc_hash::FxHashSet<String>> =
RefCell::new(rustc_hash::FxHashSet::default());
}
/// Id of the whole ribbon (tab row and tools), for the hint bar.
@@ -121,6 +124,27 @@ pub(super) fn anchor_with<'a>(
}
}
/// Record the bounds of a label or panel title under `id` while key tips
/// show, so no code is drawn over it.
pub(super) fn label_anchor<'a>(
id: impl FnOnce() -> String,
el: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
if !active() {
return el.into();
}
let id = id();
OBSTACLES.with(|ids| {
ids.borrow_mut().insert(id.clone());
});
PosReport::owned(id, el).into()
}
/// Id of the label (or panel title) of the widget with anchor `anchor`.
pub(super) fn label_id(anchor: &str) -> String {
format!("label:{anchor}")
}
/// Anchor id of item `cmd` in dropdown or slide-out `dropdown`.
pub(super) fn item_anchor(dropdown: &str, cmd: &str) -> String {
format!("{dropdown}/{cmd}")
@@ -210,7 +234,8 @@ const QUICK_ACCESS: &[(&str, &str)] = &[
/// the target is a tool id, a dropdown id or a group title; a group title's
/// code opens the panel's slide-out or, while the panel is collapsed, its
/// flyout. In a dropdown's or slide-out's scope the target is an item's
/// command.
/// command. When two buttons share a code the earlier entry runs, as the
/// reference's command for that code.
const CODES: &[(&str, &str, &str)] = &[
// Draw (the reference Home tab).
("draw", "Draw", "DW"),
@@ -397,7 +422,7 @@ const CODES: &[(&str, &str, &str)] = &[
("annotate", "DATALINK", "DL"),
("annotate", "WIPEOUT", "WI"),
("annotate", "REVCLOUD", "AC"),
// The reference shows AC here too; the first one in ribbon order runs.
// The reference shows AC here too; typing it runs Revision Cloud.
("annotate", "ANNOTATION_CURRENT_SCALE", "AC"),
("annotate", "SCALELISTEDIT", "SL"),
("annotate", "OBJECTSCALE", "AS"),
@@ -405,9 +430,10 @@ const CODES: &[(&str, &str, &str)] = &[
// View.
("view", "Viewport Tools", "VT"),
("view", "UCSICON", "UI"),
("view", "NAVVCUBE", "VC"),
// Viewport Configuration carries VC too in the reference.
// The reference shows VC on Viewport Configuration and on the ViewCube;
// typing it runs Viewport Configuration.
("view", "VPORTS", "VC"),
("view", "NAVVCUBE", "VC"),
("view", "PAN", "PA"),
("view", "3DORBIT", "OR"),
("view", "Model Viewports", "VP"),
@@ -445,7 +471,8 @@ const CODES: &[(&str, &str, &str)] = &[
("block_editor", "CCONSTRAINT", "CI"),
("block_editor", "PCONSTRAINT", "PA"),
("block_editor", "TCONSTRAINT", "TA"),
// Collinear and Concentric share CO in the reference; the first runs.
// Collinear and Concentric share CO in the reference; typing it runs
// Collinear.
("block_editor", "LCONSTRAINT", "CO"),
("block_editor", "GCCONCENTRIC", "CO"),
("block_editor", "QCONSTRAINT", "PE"),
@@ -559,7 +586,24 @@ impl Ribbon {
return KeyTipStep::Ignore;
};
let codes = || tips.iter().filter_map(|tip| tip.code.as_deref());
let exact = |typed: &str| tips.iter().find(|tip| tip.code.as_deref() == Some(typed));
let scope = if state.top {
""
} else {
self.open_dropdown
.as_deref()
.unwrap_or_else(|| self.modules.get(self.active).map_or("", |m| m.id()))
};
let rank = |tip: &Tip| {
CODES
.iter()
.position(|(s, t, _)| *s == scope && *t == tip.target)
.unwrap_or(usize::MAX)
};
let exact = |typed: &str| {
tips.iter()
.filter(|tip| tip.code.as_deref() == Some(typed))
.min_by_key(|tip| rank(tip))
};
let longer = |typed: &str| codes().any(|code| code.len() > typed.len() && code.starts_with(typed));
let typed = ch.map(|ch| format!("{}{ch}", state.typed));
let chosen = match typed {
@@ -810,13 +854,21 @@ impl Ribbon {
/// The hint bar layer and the codes layer.
pub fn keytip_layers(&self) -> Option<(Element<'_, Message>, Element<'_, Message>)> {
let state = self.keytips.as_ref()?;
let tips: Vec<(String, String, bool)> = self
let tips: Vec<(String, String, bool, f32)> = self
.keytip_list()
.into_iter()
.filter_map(|tip| {
let code = tip.code?;
let dropdown = matches!(tip.action, Action::Open(Message::ToggleRibbonDropdown(_)));
code.starts_with(&state.typed).then_some((tip.anchor, code, dropdown))
// A collapsed panel's title is centred in a wider box: its
// text width places the code beside the words.
let title_w = if tip.anchor.starts_with(COLLAPSED_PREFIX) {
text_width(&crate::t!(tip.target), 9.0, iced::Font::default())
} else {
0.0
};
code.starts_with(&state.typed)
.then_some((tip.anchor, code, dropdown, title_w))
})
.collect();
let mut path = state.path.join(" › ");
@@ -862,8 +914,8 @@ impl Ribbon {
/// they sit above flyouts and dropdowns, after their widgets reported where
/// they are.
struct KeyTipLayer {
/// (anchor, code, opens a dropdown).
tips: Vec<(String, String, bool)>,
/// (anchor, code, opens a dropdown, collapsed panel title width).
tips: Vec<(String, String, bool, f32)>,
/// Characters of a partial code, shown dimmed on matching tips.
typed: usize,
bar: Option<[String; 3]>,
@@ -1018,36 +1070,58 @@ impl overlay::Overlay<Message, Theme, Renderer> for KeyTipOverlay<'_> {
let font = bold(renderer);
let mut shown = Vec::new();
renderer.with_layer(window, |renderer| {
// Place every code; a panel-title code keeps the reference place
// and a button code it would cover rises just clear of it.
let mut pills: Vec<(&String, &String, Rectangle, bool)> = self
.layer
.tips
.iter()
.filter_map(|(anchor, code, dropdown)| {
let b = fresh_bounds(anchor)?;
let width = text_width(code, PILL_TEXT, font) + 10.0;
let (center, panel) = pill_center(anchor, b, width, *dropdown);
let x = (center.x - width / 2.0)
.clamp(window.x, (window.width - width).max(window.x));
let y = (center.y - PILL_H / 2.0)
.clamp(window.y, (window.height - PILL_H).max(window.y));
Some((anchor, code, Rectangle::new(Point::new(x, y), Size::new(width, PILL_H)), panel))
})
.collect();
let panels: Vec<Rectangle> = pills.iter().filter(|p| p.3).map(|p| p.2).collect();
for pill in pills.iter_mut().filter(|p| !p.3) {
for panel in &panels {
// Touching edges do not count.
if panel
.intersection(&pill.2)
.is_some_and(|r| r.width > 1.0 && r.height > 1.0)
{
pill.2.y = pill.2.y.min(panel.y - PILL_H - 1.0);
}
// Place every code at the first spot, nearest the reference
// place first, that covers no label, panel title or code placed
// before it.
// A collapsed panel's title counts only where its words are.
let narrow = |id: &str, rect: Rectangle| {
let title_w = self.layer.tips.iter().find_map(|(anchor, _, _, title_w)| {
(*title_w > 0.0 && label_id(anchor) == id).then_some(*title_w)
});
match title_w {
Some(w) if w < rect.width => Rectangle::new(
Point::new(rect.center_x() - w / 2.0, rect.y),
Size::new(w, rect.height),
),
_ => rect,
}
};
let mut blocked: Vec<Rectangle> = OBSTACLES.with(|ids| {
ids.borrow()
.iter()
.filter_map(|id| fresh_bounds(id).map(|rect| narrow(id, rect)))
.collect()
});
let free = |rect: &Rectangle, blocked: &[Rectangle]| {
// Touching edges do not count.
!blocked.iter().any(|other| {
other.intersection(rect).is_some_and(|r| r.width > 1.0 && r.height > 1.0)
})
};
let mut pills: Vec<(&String, &String, Rectangle)> = Vec::new();
for (anchor, code, dropdown, title_w) in &self.layer.tips {
let Some(b) = fresh_bounds(anchor) else {
continue;
};
let width = text_width(code, PILL_TEXT, font) + 10.0;
let rects: Vec<Rectangle> = pill_candidates(anchor, b, width, *dropdown, *title_w)
.into_iter()
.map(|center| {
let x = (center.x - width / 2.0)
.clamp(window.x, (window.width - width).max(window.x));
let y = (center.y - PILL_H / 2.0)
.clamp(window.y, (window.height - PILL_H).max(window.y));
Rectangle::new(Point::new(x, y), Size::new(width, PILL_H))
})
.collect();
let Some(&first) = rects.first() else {
continue;
};
let pill = rects.iter().copied().find(|rect| free(rect, &blocked)).unwrap_or(first);
blocked.push(pill);
pills.push((anchor, code, pill));
}
for (anchor, code, pill, _) in pills {
for (anchor, code, pill) in pills {
renderer.fill_quad(
renderer::Quad {
bounds: pill,
@@ -1138,43 +1212,100 @@ pub(super) fn ribbon_area<'a>(el: impl Into<Element<'a, Message>>) -> Element<'a
anchor(RIBBON_AREA, el)
}
/// Where a code sits on its widget, after the reference ribbon: under a
/// tab's label; with its corner at the icon's centre on quick-access, small
/// and slide-out buttons; centred on a panel title or the corner launcher
/// (`true`); under a large button's label, or on a large dropdown's arrow.
/// Menu rows of dropdown lists, which the reference gives no codes, carry it
/// at their right end.
fn pill_center(anchor: &str, b: Rectangle, width: f32, dropdown: bool) -> (Point, bool) {
let top_left = |x: f32, y: f32| Point::new(x + width / 2.0, y + PILL_H / 2.0);
/// Spots for a code on its widget, nearest the reference ribbon's place
/// first: under a tab's label; with its corner at the icon's centre on
/// quick-access, small and slide-out buttons; under a large button's label or
/// on a large dropdown's arrow strip; beside a panel title (the reference
/// centres it on the title) or on the corner launcher. Menu rows of dropdown
/// lists, which the reference gives no codes, carry it at their right end.
/// Later spots move off a label: the icon's other corners, a combo box's
/// arrow, above the label, beside the widget.
fn pill_candidates(
anchor: &str,
b: Rectangle,
width: f32,
dropdown: bool,
title_w: f32,
) -> Vec<Point> {
let corner = |x: f32, y: f32| Point::new(x + width / 2.0, y + PILL_H / 2.0);
let left_corner = |x: f32, y: f32| Point::new(x - width / 2.0, y + PILL_H / 2.0);
let label = fresh_bounds(&label_id(anchor));
if anchor.starts_with("tab:") {
return (Point::new(b.center_x(), b.y + b.height - 4.0 + PILL_H / 2.0), false);
return vec![Point::new(b.center_x(), b.y + b.height - 4.0 + PILL_H / 2.0)];
}
if anchor.starts_with("qa:")
|| anchor == super::widgets::UNDO_HISTORY_ID
|| anchor == super::widgets::REDO_HISTORY_ID
{
return (top_left(b.x + 14.0, b.center_y()), false);
return vec![corner(b.x + 14.0, b.center_y()), Point::new(b.x + 14.0, b.center_y())];
}
if anchor.ends_with("_extension")
|| anchor == XREF_LAUNCHER
|| (anchor.starts_with(COLLAPSED_PREFIX) && b.height < 40.0)
{
return (b.center(), true);
if anchor == XREF_LAUNCHER {
return vec![
b.center(),
Point::new(b.x + b.width + 2.0 + width / 2.0, b.center_y()),
Point::new(b.center_x(), b.y - 1.0 - PILL_H / 2.0),
Point::new(b.x - 2.0 - width / 2.0, b.center_y()),
];
}
if anchor.ends_with("_extension") || anchor.starts_with(COLLAPSED_PREFIX) {
let mut title = label.unwrap_or(b);
if title_w > 0.0 && title_w < title.width {
title = Rectangle::new(
Point::new(title.center_x() - title_w / 2.0, title.y),
Size::new(title_w, title.height),
);
}
let mut spots = vec![
Point::new(title.x + title.width + 3.0 + width / 2.0, title.center_y()),
Point::new(title.x - 3.0 - width / 2.0, title.center_y()),
Point::new(title.center_x(), title.y - PILL_H / 2.0 - 1.0),
];
if b.height >= 40.0 {
spots.insert(0, Point::new(b.center_x(), b.y + b.height + 1.0));
}
return spots;
}
let slide_out_item = anchor
.split_once('/')
.is_some_and(|(parent, _)| parent.ends_with("_extension"));
if anchor.contains('/') && !slide_out_item && b.width >= 60.0 {
return (Point::new(b.x + b.width - width / 2.0 - 4.0, b.center_y()), false);
return vec![
Point::new(b.x + b.width - width / 2.0 - 4.0, b.center_y()),
Point::new(b.x + width / 2.0 + 2.0, b.center_y()),
];
}
if b.height >= 40.0 {
// A large dropdown's arrow strip lies under its label.
let below_label = if dropdown {
b.y + b.height - super::widgets::LARGE_ARR / 2.0
} else {
b.y + b.height + 1.0
};
return (Point::new(b.center_x(), below_label), false);
let label = label.unwrap_or(Rectangle::new(
Point::new(b.x, b.y + b.height - 18.0),
Size::new(b.width, 14.0),
));
let icon = Point::new(b.center_x(), (b.y + label.y) / 2.0);
let mut spots = Vec::new();
if dropdown {
// The arrow strip lies under the label.
spots.push(Point::new(b.center_x(), b.y + b.height - super::widgets::LARGE_ARR / 2.0));
}
spots.extend([
Point::new(b.center_x(), label.y + label.height + 1.0 + PILL_H / 2.0),
corner(icon.x, icon.y),
left_corner(icon.x, icon.y),
Point::new(b.center_x(), label.y - 1.0 - PILL_H / 2.0),
icon,
]);
return spots;
}
(top_left(b.x + (b.width / 2.0).min(15.0), b.center_y()), false)
let icon = Point::new(b.x + (b.width / 2.0).min(15.0), b.center_y());
let mut spots = vec![corner(icon.x, icon.y), icon, left_corner(icon.x, icon.y)];
if b.width >= 60.0 {
// A combo box: on its arrow, at the right end.
spots.push(Point::new(b.x + b.width - width / 2.0 - 2.0, b.center_y()));
}
spots.extend([
corner(icon.x, icon.y - PILL_H),
left_corner(icon.x, icon.y - PILL_H),
Point::new(b.x - 2.0 - width / 2.0, b.center_y()),
Point::new(b.x + b.width + 2.0 + width / 2.0, b.center_y()),
Point::new(icon.x, b.y + b.height + PILL_H / 2.0 - 2.0),
]);
spots
}
+20 -12
View File
@@ -979,6 +979,8 @@ impl Ribbon {
color: (!is_current || disabled)
.then_some(theme.palette().background.base.text.scale_alpha(0.72)),
});
let label_el =
keytips::label_anchor(|| keytips::label_id(&keytips::item_anchor(dd_id, cmd)), label_el);
let item = button(
row![checkmark, icon_el, label_el]
@@ -1560,12 +1562,15 @@ fn collapse_button<'a>(
column![
icon,
row![
text(localized_title.clone())
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
keytips::label_anchor(
|| keytips::label_id(&keytips::collapsed_anchor(title)),
text(localized_title.clone())
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
),
crate::ui::icons::themed_secondary_arrow_down(8.0),
]
.spacing(3)
@@ -1632,12 +1637,15 @@ fn collapse_button<'a>(
let opener = button(
row![
text(localized_title.clone())
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
keytips::label_anchor(
|| keytips::label_id(&keytips::collapsed_anchor(title)),
text(localized_title.clone())
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
),
crate::ui::icons::themed_secondary_arrow_down(8.0),
]
.spacing(3)
+40 -17
View File
@@ -560,7 +560,10 @@ pub(super) fn render_small<'a>(
let tip_text = tool_tip_text(&label, t.id);
let content = row![
container(make_icon(t.icon, SMALL_ICON)).width(Length::Fixed(SMALL_W)),
text(label).size(10).wrapping(advanced_text::Wrapping::None),
super::keytips::label_anchor(
|| super::keytips::label_id(t.id),
text(label).size(10).wrapping(advanced_text::Wrapping::None),
),
]
.spacing(3)
.align_y(iced::Center);
@@ -699,9 +702,12 @@ pub(super) fn render_small<'a>(
};
let face = row![
container(make_icon(cur_icon, SMALL_ICON)).width(Length::Fixed(SMALL_W)),
text(localized_label.clone())
.size(10)
.wrapping(advanced_text::Wrapping::None),
super::keytips::label_anchor(
|| super::keytips::label_id(id),
text(localized_label.clone())
.size(10)
.wrapping(advanced_text::Wrapping::None),
),
]
.spacing(3)
.align_y(iced::Center);
@@ -832,11 +838,14 @@ pub(super) fn render_large_dropdown<'a>(
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center),
text(label.clone())
.size(10)
.width(Fill)
.align_x(iced::Center)
.wrapping(advanced_text::Wrapping::Word),
super::keytips::label_anchor(
|| super::keytips::label_id(id),
text(label.clone())
.size(10)
.width(Fill)
.align_x(iced::Center)
.wrapping(advanced_text::Wrapping::Word),
),
]
.align_x(iced::Center)
.spacing(0)
@@ -957,11 +966,14 @@ pub(super) fn render_large<'a>(item: &RibbonItem, ctx: &RenderCtx<'_>) -> Elemen
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center),
text(label.clone())
.size(10)
.width(Fill)
.align_x(iced::Center)
.wrapping(advanced_text::Wrapping::Word),
super::keytips::label_anchor(
|| super::keytips::label_id(t.id),
text(label.clone())
.size(10)
.width(Fill)
.align_x(iced::Center)
.wrapping(advanced_text::Wrapping::Word),
),
]
.align_x(iced::Center)
.spacing(0)
@@ -1087,7 +1099,10 @@ pub(super) fn render_large<'a>(item: &RibbonItem, ctx: &RenderCtx<'_>) -> Elemen
freeze_icon,
lock_icon,
swatch,
container(text(active_layer_label).size(11))
container(super::keytips::label_anchor(
|| super::keytips::label_id(LAYER_COMBO_ID),
text(active_layer_label).size(11),
))
.width(name_w)
.clip(true),
icons::themed_arrow_down(9.0),
@@ -1157,7 +1172,12 @@ pub(super) fn render_large<'a>(item: &RibbonItem, ctx: &RenderCtx<'_>) -> Elemen
button(
row![
swatch_el,
container(text(label).size(10)).width(Fill).clip(true),
container(super::keytips::label_anchor(
|| super::keytips::label_id(dd_id),
text(label).size(10),
))
.width(Fill)
.clip(true),
if is_open {
icons::themed_arrow_up(8.0)
} else {
@@ -1226,7 +1246,10 @@ pub(super) fn render_large<'a>(item: &RibbonItem, ctx: &RenderCtx<'_>) -> Elemen
// ── combo button ──
let combo_btn = button(
row![
container(text(active.clone()).size(11))
container(super::keytips::label_anchor(
|| super::keytips::label_id(combo_id),
text(active.clone()).size(11),
))
.width(Fill)
.clip(true),
if is_open {