diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index ee0b84896..30b644d0c 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -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), diff --git a/src/ui/ribbon/keytips.rs b/src/ui/ribbon/keytips.rs index c0bdd0d76..a0e8dced5 100644 --- a/src/ui/ribbon/keytips.rs +++ b/src/ui/ribbon/keytips.rs @@ -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> = const { RefCell::new(Vec::new()) }; + /// Label and panel-title widgets a code must not cover. + static OBSTACLES: RefCell> = + 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> { + 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 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 = 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 = 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 = 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 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 { + 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 } diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 86680973b..f1a5fbeec 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -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) diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index f019f85ad..46adb8d14 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -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 {