From 86429bf439139d42e61b8a664eb4fc10af4893c0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:18:58 +0300 Subject: [PATCH] Ribbon key tips: Alt shows codes on tabs, buttons and menu items A lone Alt shows a code on every ribbon tab and quick-access button. Typing a tab code opens the tab and shows the codes of its buttons, dropdowns, slide-outs and collapsed panels; a button code runs it, a dropdown or slide-out code opens it and shows its item codes, Esc goes back one level. Codes follow the reference ribbon; buttons without a reference counterpart get none and unnamed menu items are numbered. Codes are drawn as accent pills over the widgets that reported their bounds in the current frame, with a hint bar under the ribbon. While the tips are open keys go to them only. The automation gets a keytips action and read operation. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 7 + locales/bg-BG/opencadstudio.ftl | 7 + locales/cs-CZ/opencadstudio.ftl | 7 + locales/de-DE/opencadstudio.ftl | 7 + locales/el-GR/opencadstudio.ftl | 7 + locales/en-US/opencadstudio.ftl | 7 + locales/es-ES/opencadstudio.ftl | 7 + locales/fi-FI/opencadstudio.ftl | 7 + locales/fr-FR/opencadstudio.ftl | 7 + locales/hi-IN/opencadstudio.ftl | 7 + locales/hu-HU/opencadstudio.ftl | 7 + locales/it-IT/opencadstudio.ftl | 7 + locales/ja-JP/opencadstudio.ftl | 7 + locales/ko-KR/opencadstudio.ftl | 7 + locales/lo-LA/opencadstudio.ftl | 7 + locales/nl-NL/opencadstudio.ftl | 7 + locales/pl-PL/opencadstudio.ftl | 7 + locales/pt-BR/opencadstudio.ftl | 7 + locales/ru-RU/opencadstudio.ftl | 7 + locales/tr-TR/opencadstudio.ftl | 7 + locales/zh-CN/opencadstudio.ftl | 7 + locales/zh-TW/opencadstudio.ftl | 7 + src/app/control/actions.rs | 23 + src/app/control/mod.rs | 2 + src/app/mod.rs | 2 + src/app/update/keytips.rs | 80 +++ src/app/update/mod.rs | 6 + src/app/view/mod.rs | 31 +- src/mcp.rs | 1 + src/ui/ribbon/draw_panel.rs | 64 +- src/ui/ribbon/keytips.rs | 1129 +++++++++++++++++++++++++++++++ src/ui/ribbon/mod.rs | 29 +- src/ui/ribbon/widgets.rs | 60 +- src/ui/wrap_bar.rs | 41 +- 34 files changed, 1569 insertions(+), 53 deletions(-) create mode 100644 src/app/update/keytips.rs create mode 100644 src/ui/ribbon/keytips.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index f210ce5eb..24f90d9df 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6501,3 +6501,10 @@ ref-edit = .redefined = تمت إعادة تعريف __ocs_fmt_0__. .ok = موافق .nested = متداخلة + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 694d37d17..bfebaea4e 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6489,3 +6489,10 @@ ref-edit = .redefined = __ocs_fmt_0__ е предефиниран. .ok = Ок .nested = Вложени + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 55dc8ab97..1755990b0 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6486,3 +6486,10 @@ ref-edit = .redefined = __ocs_fmt_0__ předefinován. .ok = Ok .nested = Vnořené + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index ef91c0ea0..c815aa51f 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6487,3 +6487,10 @@ ref-edit = .redefined = __ocs_fmt_0__ neu definiert. .ok = Ok .nested = Verschachtelt + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index afa07b2fb..46cf1d0cf 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6489,3 +6489,10 @@ ref-edit = .redefined = Το __ocs_fmt_0__ επαναορίστηκε. .ok = Ok .nested = Ένθετα + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index c0a15929d..ab8a59798 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6488,3 +6488,10 @@ ref-edit = .redefined = __ocs_fmt_0__ redefined. .ok = Ok .nested = Nested + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 786b46eaf..65ee35c14 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6489,3 +6489,10 @@ ref-edit = .redefined = __ocs_fmt_0__ redefinido. .ok = Aceptar .nested = Anidados + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 5381c7037..cb8f629f9 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6486,3 +6486,10 @@ ref-edit = .redefined = __ocs_fmt_0__ määritetty uudelleen. .ok = Ok .nested = Sisäkkäiset + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index b9f67f2da..adf4daf6c 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6489,3 +6489,10 @@ ref-edit = .redefined = __ocs_fmt_0__ redéfini. .ok = Ok .nested = Imbriqués + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 1626cd702..13c95d6db 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6477,3 +6477,10 @@ ref-edit = .redefined = __ocs_fmt_0__ पुनः परिभाषित किया गया। .ok = ठीक .nested = नेस्टेड + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index fb17ef462..1c21a6f9e 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6473,3 +6473,10 @@ ref-edit = .redefined = __ocs_fmt_0__ újradefiniálva. .ok = Ok .nested = Beágyazott + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 85e49acad..1e943471a 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6488,3 +6488,10 @@ ref-edit = .redefined = __ocs_fmt_0__ ridefinito. .ok = Ok .nested = Nidificati + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 72df97414..1e9a3945c 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6477,3 +6477,10 @@ ref-edit = .redefined = __ocs_fmt_0__ を再定義しました。 .ok = OK .nested = ネスト + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 9899dbd75..6fa99aad3 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6488,3 +6488,10 @@ ref-edit = .redefined = __ocs_fmt_0__이(가) 재정의되었습니다. .ok = 확인 .nested = 내포 + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 3b9358f63..3b0a9115d 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -6489,3 +6489,10 @@ ref-edit = .redefined = ນິຍາມ __ocs_fmt_0__ ໃໝ່ແລ້ວ. .ok = ຕົກລົງ .nested = ຊ້ອນ + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 25ce0ed57..f751c3802 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6487,3 +6487,10 @@ ref-edit = .redefined = __ocs_fmt_0__ opnieuw gedefinieerd. .ok = Ok .nested = Genest + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index eb2c2c96c..1c6f13779 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6488,3 +6488,10 @@ ref-edit = .redefined = Przedefiniowano __ocs_fmt_0__. .ok = Ok .nested = Zagnieżdżone + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 120d5b580..68953f147 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6489,3 +6489,10 @@ ref-edit = .redefined = __ocs_fmt_0__ redefinido. .ok = Ok .nested = Aninhados + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index e0838f93c..b4f8972ea 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6490,3 +6490,10 @@ ref-edit = .redefined = __ocs_fmt_0__ переопределен. .ok = Ок .nested = Вложенные + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index c16d192e1..685500876 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6410,3 +6410,10 @@ ref-edit = .redefined = __ocs_fmt_0__ yeniden tanımlandı. .ok = Tamam .nested = İç içe + +keytips = + .title = KeyTip + .typed = Yazılan: { $path } + .hint-tabs = Sekme harfini yazın · Esc kapatır + .hint-tab = { $tab } sekmesi · Esc bir düzey geri + .hint-items = Öğe harfini yazın · Esc bir düzey geri diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 8a81b1bc1..ff2c1e732 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6472,3 +6472,10 @@ ref-edit = .redefined = 已重定义 __ocs_fmt_0__。 .ok = 确定 .nested = 嵌套 + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 28ac1a835..558401c5e 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6484,3 +6484,10 @@ ref-edit = .redefined = 已重新定義 __ocs_fmt_0__。 .ok = 確定 .nested = 巢狀 + +keytips = + .title = KeyTip + .typed = Typed: { $path } + .hint-tabs = Type a tab letter · Esc closes + .hint-tab = { $tab } tab · Esc goes back one level + .hint-items = Type an item letter · Esc goes back one level diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 6fa262f35..65ecc3bf8 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -114,6 +114,7 @@ pub(super) const NAMES: &[&str] = &[ "pc_section", "ribbon_tab", "ribbon_dropdown", + "keytips", "dialog_ok", "attdef_dialog", "field_dialog", @@ -1884,6 +1885,28 @@ impl OpenCADStudio { }) } "ribbon_dropdown" => Message::ToggleRibbonDropdown(string(req, "value")?.into()), + // Ribbon key tips: `show`, `alt` (a lone Alt), `type:`, `enter`, + // `esc` or `hide`. Read them back with the `keytips` read operation. + "keytips" => { + use crate::ui::ribbon::KeyTipInput as K; + let value = string(req, "value")?; + let inputs: Vec = match value { + "show" => vec![K::Open { refocus: false }], + "alt" => vec![K::AltTap], + "esc" => vec![K::Escape], + "hide" => vec![K::Hide], + "enter" => vec![K::Enter], + _ => match value.strip_prefix("type:") { + Some(codes) if !codes.is_empty() => codes + .chars() + .map(|ch| K::Char(ch.to_uppercase().collect())) + .collect(), + _ => return Err(failure("invalid_value", "Use show, alt, type:, enter, esc or hide")), + }, + }; + let tasks: Vec<_> = inputs.into_iter().map(|input| self.on_keytip(input)).collect(); + return Ok(Task::batch(tasks)); + } // The -VIEW Background dialog, one field per call: "type=gradient", // "color|top|middle|bottom=r,g,b", "three=1", "rotation=30", // "image=", "position=center|stretch|tile", "aspect=1", "offset=x,y" (pixels), "scale=x,y", diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index b78eebea4..fb73518bb 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -559,6 +559,7 @@ impl OpenCADStudio { | "audit" | "text_search" | "text_audit" + | "keytips" ); if !query && !self.control.enabled { return ( @@ -850,6 +851,7 @@ impl OpenCADStudio { } } "grips" => self.control_grips(), + "keytips" => self.ribbon.keytips_json(req["detail"].as_str() == Some("full")), "xdata_get" => self.xdata_read(&req), "get_selection" => self.control_get_selection(), "text_search" => self.control_text_search(&req).unwrap_or_else(|e| e), diff --git a/src/app/mod.rs b/src/app/mod.rs index 5fd2a51ca..5c2da5ccf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2754,6 +2754,8 @@ pub enum Message { SetProjection(bool), /// Select a ribbon module tab by index. RibbonSelectTab(usize), + /// Ribbon key tips: Alt, typed codes and Esc. + KeyTip(crate::ui::ribbon::KeyTipInput), /// Change the ribbon tool-panel density (persisted). SetRibbonCollapseMode(crate::ui::ribbon::CollapseMode), /// A ribbon tool button was clicked — highlights the tool and dispatches its event. diff --git a/src/app/update/keytips.rs b/src/app/update/keytips.rs new file mode 100644 index 000000000..c38e812d5 --- /dev/null +++ b/src/app/update/keytips.rs @@ -0,0 +1,80 @@ +//! Ribbon key tips: a lone Alt opens them, typed codes walk the ribbon and +//! Esc goes back one level. + +use iced::Task; + +use crate::app::{Message, OpenCADStudio}; +use crate::ui::ribbon::{KeyTipInput, KeyTipStep}; + +impl OpenCADStudio { + pub(in crate::app) fn on_keytip(&mut self, input: KeyTipInput) -> Task { + match input { + KeyTipInput::AltTap if self.ribbon.keytips_active() => self.hide_keytips(), + KeyTipInput::AltTap => { + if self.active_modal.is_some() || self.clean_screen || self.ribbon_closed { + return Task::none(); + } + // A focused field other than the command line keeps Alt. + iced::advanced::widget::operate( + iced::advanced::widget::operation::focusable::count(), + ) + .then(|count| { + if count.focused.is_none() { + return Task::done(Message::KeyTip(KeyTipInput::Open { refocus: false })); + } + iced::widget::operation::is_focused(iced::widget::Id::new( + crate::ui::command_line::CMD_INPUT_ID, + )) + .map(|command_line| { + if command_line { + Message::KeyTip(KeyTipInput::Open { refocus: true }) + } else { + Message::Noop + } + }) + }) + } + KeyTipInput::Open { refocus } => { + self.ribbon.show_keytips(refocus); + // Typed codes must not reach the command line. + if refocus { + self.unfocus_widgets() + } else { + Task::none() + } + } + KeyTipInput::Hide => self.hide_keytips(), + KeyTipInput::Escape => { + if self.ribbon.keytip_escape() { + Task::none() + } else { + self.hide_keytips() + } + } + KeyTipInput::Char(_) | KeyTipInput::Enter => { + let ch = match &input { + KeyTipInput::Char(ch) => Some(ch.as_str()), + _ => None, + }; + match self.ribbon.keytip_type(ch) { + KeyTipStep::Ignore | KeyTipStep::Stay => Task::none(), + KeyTipStep::Open(message) => self.update(message), + KeyTipStep::Run(message) => { + let refocus = self.hide_keytips(); + Task::batch([refocus, self.update(message)]) + } + } + } + } + } + + /// Close the key tips, returning the focus to the command line if it had + /// it. + pub(in crate::app) fn hide_keytips(&mut self) -> Task { + if self.ribbon.hide_keytips() { + self.focus_cmd_input() + } else { + Task::none() + } + } +} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 184afbe13..66f1a2dbb 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -102,6 +102,7 @@ mod dialog; mod dynamic; mod file; mod graphic_attributes; +mod keytips; pub(in crate::app) use file::background_task; #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) use file::native_paths_match; @@ -621,6 +622,10 @@ impl OpenCADStudio { if Some(id) == self.main_window { self.spacemouse_focused = focused; } + // Key tips close when the window loses the focus. + if !focused && self.ribbon.keytips_active() { + return self.hide_keytips(); + } Task::none() } Message::SpaceMouseEnabled(enabled) => { @@ -2277,6 +2282,7 @@ impl OpenCADStudio { Message::PcManager(message) => self.update_pc_manager(message), Message::Count(message) => self.on_count(message), Message::SheetSet(message) => self.on_sheet_set(message), + Message::KeyTip(input) => self.on_keytip(input), Message::RibbonSelectTab(idx) => { self.ribbon.select(idx); Task::none() diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 281bf3ead..fd8412ab8 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2252,12 +2252,23 @@ bg={bg_ms:.1}ms n={view_count}" iced::widget::Space::new().width(0).height(0).into() }; + // Key tips: the hint bar under the dropdowns, the codes above all. + let (keytip_bar, keytip_layer): (Element<'_, Message>, Element<'_, Message>) = + self.ribbon.keytip_layers().unwrap_or_else(|| { + ( + iced::widget::Space::new().width(0).height(0).into(), + iced::widget::Space::new().width(0).height(0).into(), + ) + }); + let composed = stack![ main_ui, + keytip_bar, dropdown_layer, qselect_layer, snap_override_layer, open_progress_layer, + keytip_layer, ]; // If the Plot Style editor was launched from PLOT, keep the Plot dialog @@ -2547,9 +2558,18 @@ impl OpenCADStudio { event::listen_with(|ev, status, win_id| { use iced::event::Status; match ev { + iced::Event::Mouse(iced::mouse::Event::ButtonPressed(_)) + if crate::ui::ribbon::keytips_active() => + { + crate::ui::ribbon::keytip_pointer_pressed(); + Some(Message::KeyTip(crate::ui::ribbon::KeyTipInput::Hide)) + } iced::Event::Mouse(iced::mouse::Event::ButtonPressed( iced::mouse::Button::Left, - )) => Some(Message::PropPointerPressed), + )) => { + crate::ui::ribbon::keytip_pointer_pressed(); + Some(Message::PropPointerPressed) + } iced::Event::Window(window::Event::CloseRequested) => { Some(Message::WindowCloseRequested(win_id)) } @@ -2562,6 +2582,9 @@ impl OpenCADStudio { iced::Event::Window(window::Event::FileDropped(path)) => { Some(Message::FileDropped(path)) } + iced::Event::Keyboard(keyboard::Event::KeyReleased { key, .. }) => { + crate::ui::ribbon::keytip_key_released(&key) + } iced::Event::Keyboard(keyboard::Event::ModifiersChanged(m)) => { // `command()` is Cmd on macOS, Ctrl elsewhere — the // platform multi-select modifier for the layer list. @@ -2575,8 +2598,14 @@ impl OpenCADStudio { physical_key, modifiers, text, + repeat, .. }) => { + if let Some(keytip) = + crate::ui::ribbon::keytip_key_pressed(&key, modifiers, repeat) + { + return keytip; + } #[cfg(target_arch = "wasm32")] let accel = modifiers.command(); // AltGr reaches Windows apps as Ctrl+Alt, and a macOS diff --git a/src/mcp.rs b/src/mcp.rs index 449cee556..42d9b8dd3 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -68,6 +68,7 @@ const READ_OPS: &[&str] = &[ "audit", "text_search", "text_audit", + "keytips", "capture", ]; const EXECUTE_OPS: &[&str] = &[ diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index daf71a345..ee0b84896 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -9,7 +9,7 @@ use super::widgets::{ make_icon, make_tip, muted_text_style, popup_panel_style, popup_row_style, tip_style, tool_btn_style, LARGE_DROPDOWN_ARROW_SIZE, }; -use super::{dropdown_backdrop, position_ribbon_dropdown, Ribbon}; +use super::{dropdown_backdrop, keytips, position_ribbon_dropdown, Ribbon}; use crate::app::Message; use crate::modules::IconKind; use crate::t; @@ -248,6 +248,32 @@ fn panel_for_group(title: &str, group_ids: &[&'static str]) -> Option { }) } +/// The slide-out under the group `title`, if it has one. +pub(super) fn slide_out_id(title: &str, group_ids: &[&'static str]) -> Option<&'static str> { + panel_for_group(title, group_ids).map(|panel| panel.id) +} + +/// What the open slide-out or tool submenu `id` offers, as (dropdown id, +/// command, opens a submenu) in display order. +pub(super) fn keytip_items(id: &str) -> Vec<(&'static str, &'static str, bool)> { + let Some(panel) = panel_for_dropdown(id) else { + return Vec::new(); + }; + if panel.id == id { + return panel + .tools + .iter() + .map(|tool| (panel.id, tool.command, !tool.options.is_empty())) + .collect(); + } + panel + .tools + .iter() + .filter(|tool| tool.command == id) + .flat_map(|tool| tool.options.iter().map(|(cmd, _)| (tool.command, *cmd, false))) + .collect() +} + /// Every command the slide-out of the group `title` offers: each tool and /// each of its submenu options. pub(super) fn slide_out_commands( @@ -337,6 +363,7 @@ pub(super) fn group_title<'a>( .delay(Duration::from_millis(400)) .style(tip_style); // Shrink-sized: a Fill here would make the panel claim the whole row. + let launcher = keytips::anchor(keytips::XREF_LAUNCHER, launcher); row![title_button, launcher] .spacing(6) .align_y(iced::Center) @@ -362,6 +389,7 @@ fn tool_button(tool: &Tool, active: bool, panel_id: &'static str) -> Element<'st .delay(Duration::from_millis(400)) .style(tip_style) .into(); + let face = keytips::anchor_with(|| keytips::item_anchor(panel_id, tool.command), face); if !tool.options.is_empty() { row![ face, @@ -401,6 +429,7 @@ fn reference_overlay<'a>(ribbon: &Ribbon, panel: Panel, win: (f32, f32)) -> Elem .style(popup_row_style) .width(Fill) .padding([3, 6]); + let edit = keytips::anchor_with(|| keytips::item_anchor(panel.id, tool.command), edit); let fade = ribbon.xref_fade; let on = fade > 0; let switch = button(make_icon( @@ -411,18 +440,23 @@ fn reference_overlay<'a>(ribbon: &Ribbon, panel: Panel, win: (f32, f32)) -> Elem .style(move |theme: &Theme, status| tool_btn_style(theme, on, status)) .padding(3); let amount = fade.unsigned_abs().min(90) as u8; + let switch = tooltip( + switch, + make_tip(format!( + "{}\n{} XDWGFADECTL", + t!("Xref fading"), + t!("Command:") + )), + tooltip::Position::Bottom, + ) + .delay(Duration::from_millis(400)) + .style(tip_style); + let switch = keytips::anchor_with( + || keytips::item_anchor(panel.id, keytips::XREF_FADE), + switch, + ); let fading = row![ - tooltip( - switch, - make_tip(format!( - "{}\n{} XDWGFADECTL", - t!("Xref fading"), - t!("Command:") - )), - tooltip::Position::Bottom - ) - .delay(Duration::from_millis(400)) - .style(tip_style), + switch, text(t!("Xref fading")).size(11), iced::widget::slider(0..=90u8, amount, Message::XrefFadeSlide) .on_release(Message::XrefFadeCommit) @@ -468,7 +502,7 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element tool.options .iter() .map(|&(cmd, label)| { - button(text(t!(label)).size(11)) + let item = button(text(t!(label)).size(11)) .on_press(Message::DropdownSelectItem { dropdown_id: tool.command, cmd, @@ -476,8 +510,8 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element .style(popup_row_style) .width(Fill) .height(OPTION_HEIGHT) - .padding([3, 7]) - .into() + .padding([3, 7]); + keytips::anchor_with(|| keytips::item_anchor(tool.command, cmd), item) }) .collect::>>(), ) diff --git a/src/ui/ribbon/keytips.rs b/src/ui/ribbon/keytips.rs new file mode 100644 index 000000000..906646c18 --- /dev/null +++ b/src/ui/ribbon/keytips.rs @@ -0,0 +1,1129 @@ +//! Key tips: a lone Alt shows a code on every ribbon tab and quick-access +//! button; typing a tab's code opens it and shows the codes of its buttons. +//! A button runs, a dropdown or slide-out opens and shows the codes of its +//! items, and Esc goes back one level. The codes come from the reference +//! ribbon; buttons it has no counterpart for get none. + +use std::cell::RefCell; +use std::sync::atomic::{AtomicBool, Ordering}; + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::text::{self as advanced_text, Paragraph as _, Renderer as _}; +use iced::advanced::widget::{self, Widget}; +use iced::advanced::{mouse, overlay, renderer, Renderer as _}; +use iced::keyboard::{self, key::Named, Key}; +use iced::{ + Background, Border, Color, Element, Length, Pixels, Point, Rectangle, Renderer, Shadow, Size, + Theme, Vector, +}; + +use super::{draw_panel, item_id, Ribbon}; +use crate::app::Message; +use crate::modules::{RibbonItem, ToolDef}; +use crate::ui::wrap_bar::{fresh_bounds, PosReport}; + +/// Key tip input, from the keyboard listener or automation. +#[derive(Debug, Clone)] +pub enum KeyTipInput { + /// Alt was pressed and released on its own. + AltTap, + /// Show the tab level; `refocus` returns the focus to the command line + /// when the tips close. + Open { refocus: bool }, + /// One typed character (upper case). + Char(String), + /// Settle a code that also starts a longer one. + Enter, + Escape, + Hide, +} + +/// What a typed code leads to. +pub enum KeyTipStep { + /// Nothing matched; the key is ignored. + Ignore, + /// A partial code, or a tab that is now open. + Stay, + /// A dropdown, slide-out or collapsed panel opens; the tips stay. + Open(Message), + /// A command runs and the tips close. + Run(Message), +} + +#[derive(Clone)] +enum Action { + Tab(usize), + Open(Message), + Run(Message), +} + +/// One code shown on one widget. +pub struct Tip { + /// The id the widget reports its bounds under. + pub anchor: String, + /// The table key: tool id, dropdown id, group title or command. + pub target: &'static str, + pub code: Option, + action: Action, +} + +/// Open key tips. +pub(super) struct State { + /// Showing the tab codes rather than the open tab's buttons. + top: bool, + /// Codes of the levels entered so far. + path: Vec, + /// The start of a longer code. + typed: String, + /// The command line had the focus when the tips opened. + refocus: bool, +} + +static ACTIVE: AtomicBool = AtomicBool::new(false); +/// Alt is down and nothing else was pressed since. +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()) }; +} + +/// Id of the whole ribbon (tab row and tools), for the hint bar. +const RIBBON_AREA: &str = "KEYTIP_RIBBON_AREA"; +/// The Reference panel's corner arrow. +pub(super) const XREF_LAUNCHER: &str = "XREF_LAUNCHER"; +/// The xref fading switch in the Reference slide-out. +pub(super) const XREF_FADE: &str = "XREF_FADE"; +const COLLAPSED_PREFIX: &str = "panel:"; + +pub fn active() -> bool { + ACTIVE.load(Ordering::Relaxed) +} + +/// Record `el`'s bounds under `id` while key tips show. +pub(super) fn anchor<'a>(id: &'static str, el: impl Into>) -> Element<'a, Message> { + if active() { + PosReport::new(id, el).into() + } else { + el.into() + } +} + +/// `anchor` under an id built only while key tips show. +pub(super) fn anchor_with<'a>( + id: impl FnOnce() -> String, + el: impl Into>, +) -> Element<'a, Message> { + if active() { + PosReport::owned(id(), el).into() + } else { + el.into() + } +} + +/// Anchor id of item `cmd` in dropdown or slide-out `dropdown`. +pub(super) fn item_anchor(dropdown: &str, cmd: &str) -> String { + format!("{dropdown}/{cmd}") +} + +/// Anchor id of a quick-access button (its command can also be a tool's). +pub(super) fn quick_access_anchor(id: &str) -> String { + if id == super::widgets::UNDO_HISTORY_ID || id == super::widgets::REDO_HISTORY_ID { + id.to_string() + } else { + format!("qa:{id}") + } +} + +/// Anchor id of a collapsed panel's button. +pub(super) fn collapsed_anchor(title: &str) -> String { + format!("{COLLAPSED_PREFIX}{title}") +} + +/// Keyboard listener hook for a key press. `Some` while key tips own the +/// keyboard (the inner value is the message to send, if any). +pub fn key_pressed(key: &Key, modifiers: keyboard::Modifiers, repeat: bool) -> Option> { + let alt = matches!(key, Key::Named(Named::Alt)); + if !repeat { + ALT_ALONE.store( + alt && !modifiers.control() && !modifiers.shift() && !modifiers.logo(), + Ordering::Relaxed, + ); + } + if !active() { + return None; + } + Some(match key { + Key::Named(Named::Escape) => Some(Message::KeyTip(KeyTipInput::Escape)), + Key::Named(Named::Enter) => Some(Message::KeyTip(KeyTipInput::Enter)), + Key::Character(value) if !modifiers.control() && !modifiers.alt() => value + .chars() + .next() + .filter(|ch| ch.is_alphanumeric()) + // A dotted capital I (Turkish layouts) types the code letter I. + .map(|ch| if ch == 'İ' { 'I' } else { ch }) + .map(|ch| Message::KeyTip(KeyTipInput::Char(ch.to_uppercase().collect()))), + _ => None, + }) +} + +/// Keyboard listener hook for a key release: a lone Alt toggles the tips. +pub fn key_released(key: &Key) -> Option { + (matches!(key, Key::Named(Named::Alt)) && ALT_ALONE.swap(false, Ordering::Relaxed)) + .then_some(Message::KeyTip(KeyTipInput::AltTap)) +} + +/// A pointer press ends a lone Alt. +pub fn pointer_pressed() { + ALT_ALONE.store(false, Ordering::Relaxed); +} + +/// Codes of the ribbon tabs, by module id. +fn tab_code(module: &str) -> Option<&'static str> { + Some(match module { + "draw" => "H", + "parametric" => "PA", + "model" => "3D", + "insert" => "IN", + "annotate" => "AN", + "view" => "VI", + "manage" => "MA", + "block_editor" => "BE", + _ => return None, + }) +} + +/// Quick-access buttons in order; their codes are their positions. +const QUICK_ACCESS: &[&str] = &[ + "QNEW", + "OPEN", + "SAVE", + "SAVEAS", + "PRINT", + super::widgets::UNDO_HISTORY_ID, + super::widgets::REDO_HISTORY_ID, +]; + +/// Reference codes as (scope, target, code). In a tab's scope (its module id) +/// 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. +const CODES: &[(&str, &str, &str)] = &[ + // Draw (the reference Home tab). + ("draw", "Draw", "DW"), + ("draw", "LINE", "LI"), + ("draw", "PLINE", "PL"), + ("draw", "CIRCLE", "CI"), + ("draw", "ARC", "AR"), + ("draw", "SHAPES", "RE"), + ("draw", "ELLIPSE", "EL"), + ("draw", "HATCH", "HA"), + ("SHAPES", "RECT", "RE"), + ("SHAPES", "POLY", "PO"), + ("HATCH", "HATCH", "HA"), + ("HATCH", "GRADIENT", "GR"), + ("HATCH", "BOUNDARY", "BO"), + ("draw_extension", "SPLINE", "SF"), + ("draw_extension", "SPLINECV", "SC"), + ("draw_extension", "XLINE", "XL"), + ("draw_extension", "RAY", "RA"), + ("draw_extension", "DIVIDE", "DI"), + ("draw_extension", "MEASURE", "ME"), + ("draw_extension", "REGION", "RG"), + ("draw_extension", "HELIX", "HE"), + ("draw_extension", "DONUT", "DO"), + ("draw_extension", "MULTIPOINT", "MP"), + ("draw", "Modify", "MD"), + ("draw", "MOVE", "MO"), + ("draw", "COPY", "CO"), + ("draw", "STRETCH", "ST"), + ("draw", "ROTATE", "RO"), + ("draw", "MIRROR", "MI"), + ("draw", "SCALE", "SC"), + ("draw", "trim_extend", "TR"), + ("draw", "fillet_chamfer", "FI"), + ("draw", "array_type", "AY"), + ("draw", "ERASE", "ER"), + ("draw", "EXPLODE", "EX"), + ("draw", "OFFSET", "OF"), + ("modify_extension", "SETBYLAYER", "SL"), + ("modify_extension", "LENGTHEN", "LE"), + ("modify_extension", "PEDIT", "EP"), + ("modify_extension", "SPLINEDIT", "ES"), + ("modify_extension", "HATCHEDIT", "EH"), + ("modify_extension", "ALIGN", "AL"), + ("modify_extension", "BREAK", "BR"), + ("modify_extension", "BREAKATPOINT", "BP"), + ("modify_extension", "JOIN", "JO"), + ("modify_extension", "REVERSE", "RV"), + ("modify_extension", "NCOPY", "CN"), + ("modify_extension", "DRAWORDER_FRONT", "DR"), + ("draw", "Annotation", "AN"), + ("draw", "ANNOTATION_TEXT", "MT"), + ("draw", "ANNOTATION_DIMENSIONS", "DI"), + ("draw", "ANNOTATION_LEADER", "ML"), + ("draw", "Layers", "LL"), + ("draw", "LAYERS", "LP"), + ("draw", "LAYER_COMBO", "LA"), + ("draw", "LAYOFF", "LO"), + ("draw", "LAYFRZ", "LF"), + ("draw", "LAYLCK", "LK"), + ("draw", "LAYMCUR", "LC"), + ("draw", "LAYISO", "LS"), + ("draw", "LAYON", "LN"), + ("draw", "LAYTHW", "LW"), + ("draw", "LAYULK", "UL"), + ("draw", "LAYMATCH", "LM"), + ("draw", "LAYUNISO", "LU"), + ("draw", "Block", "BK"), + ("draw", "BLOCK", "BC"), + ("draw", "BEDIT", "BE"), + ("draw", "ATTEDIT", "AE"), + ("home_block_extension", "ATTDEF", "AD"), + ("home_block_extension", "ATTMAN", "AM"), + ("home_block_extension", "ATTSYNC", "AS"), + ("home_block_extension", "BASE", "BA"), + ("draw", "Properties", "PE"), + ("draw", "PROP_LINETYPE", "PY"), + ("draw", "PROP_LW", "PW"), + ("draw", "Groups", "GR"), + ("draw", "GROUP", "GG"), + ("draw", "UNGROUP", "GU"), + ("draw", "PASTE_MENU", "PA"), + ("draw", "COPYCLIP", "CC"), + ("draw", "CUTCLIP", "CX"), + ("draw", "MEASURE_MENU", "DT"), + // Parametric. + ("parametric", "AUTOCONSTRAIN", "AC"), + ("parametric", "CCONSTRAINT", "CI"), + ("parametric", "PCONSTRAINT", "PA"), + ("parametric", "TCONSTRAINT", "TA"), + ("parametric", "LCONSTRAINT", "CL"), + ("parametric", "QCONSTRAINT", "PE"), + ("parametric", "GCSMOOTH", "SM"), + ("parametric", "GCCONCENTRIC", "CO"), + ("parametric", "GCHORIZONTAL", "HO"), + ("parametric", "SYCONSTRAINT", "SY"), + ("parametric", "FXCONSTRAINT", "FI"), + ("parametric", "VCONSTRAINT", "VE"), + ("parametric", "ECONSTRAINT", "EQ"), + ("parametric", "GCVISIBILITY", "SH"), + ("parametric", "GCSHOWALL", "SA"), + ("parametric", "GCHIDEALL", "HA"), + ("parametric", "Dimensional", "DM"), + ("parametric", "DC_LINEAR_MENU", "DC"), + ("parametric", "DCALIGNED", "AL"), + ("parametric", "DCRADIUS", "RA"), + ("parametric", "DCDIAMETER", "DI"), + ("parametric", "DCANGULAR", "AN"), + ("parametric", "DCCONVERT", "DO"), + ("parametric", "DCVISIBILITY", "TD"), + ("parametric", "DCSHOWALL", "SD"), + ("parametric", "DCHIDEALL", "HD"), + ("parametric", "DELCONSTRAINT", "DE"), + ("parametric", "PARAMETERS", "PM"), + // Model (the reference Solid tab). + ("model", "MODEL_PRIMITIVES", "SS"), + ("MODEL_PRIMITIVES", "BOX", "BO"), + ("MODEL_PRIMITIVES", "CYLINDER", "CY"), + ("MODEL_PRIMITIVES", "CONE", "CO"), + ("MODEL_PRIMITIVES", "SPHERE", "SP"), + ("MODEL_PRIMITIVES", "PYRAMID", "PY"), + ("MODEL_PRIMITIVES", "WEDGE", "WE"), + ("MODEL_PRIMITIVES", "TORUS", "TO"), + ("MODEL_PRIMITIVES", "POLYSOLID", "SR"), + ("model", "EXTRUDE", "EX"), + ("model", "REVOLVE", "RV"), + ("model", "LOFT", "LO"), + ("model", "SWEEP", "SY"), + ("model", "PRESSPULL", "PU"), + ("model", "UNION", "UN"), + ("model", "SUBTRACT", "SU"), + ("model", "INTERSECT", "IN"), + ("model", "FILLETEDGE", "ED"), + ("model", "CHAMFEREDGE", "CE"), + ("model", "SHELL", "SH"), + // Insert. + ("insert", "Block", "BK"), + ("insert", "INSERT_GALLERY", "BI"), + ("insert", "ATTEDIT_DROPDOWN", "AE"), + ("insert", "ATTMODE_DROPDOWN", "AT"), + ("insert", "Block Definition", "BD"), + ("insert", "BLOCK", "BC"), + ("insert", "ATTDEF", "AD"), + ("insert", "ATTMAN", "AM"), + ("insert", "BEDIT", "BE"), + ("block_definition_extension", "BASE", "BA"), + ("block_definition_extension", "ATTSYNC", "AS"), + ("insert", "Reference", "RE"), + ("insert", XREF_LAUNCHER, "XR"), + ("insert", "XATTACH", "XA"), + ("insert", "UNDERLAY_ATTACH", "UA"), + ("insert", "CLIP", "CL"), + ("insert", "ADJUST", "AJ"), + ("insert", "UNDERLAYLAYERS", "UN"), + ("insert", "FRAMES_DROPDOWN", "FR"), + ("insert", "UOSNAP_DROPDOWN", "SN"), + ("reference_extension", "REFEDIT", "ER"), + ("reference_extension", XREF_FADE, "XF"), + // Annotate. + ("annotate", "Text", "TE"), + ("annotate", "ANNOTATE_TEXT", "MT"), + ("annotate", "TEXT_STYLE_COMBO", "TY"), + ("annotate", "Dimensions", "DM"), + ("annotate", "ANNOTATE_DIM", "DN"), + ("annotate", "DIM_STYLE_COMBO", "DY"), + ("annotate", "QDIM", "QD"), + ("annotate", "DIMCONTINUE", "CO"), + ("annotate", "TOLERANCE", "TO"), + ("annotate", "DIMEDIT", "DE"), + ("annotate", "DIMBREAK", "BR"), + ("annotate", "DIMSPACE", "SP"), + ("annotate", "DIMJOGLINE", "DJ"), + ("annotate", "Centerlines", "CE"), + ("annotate", "CENTERMARK", "CM"), + ("annotate", "CENTERLINE", "CL"), + ("annotate", "ANNOTATE_LEADER", "ML"), + ("annotate", "MLEADER_STYLE_COMBO", "MY"), + ("annotate", "MLEADERADD", "MAD"), + ("annotate", "MLEADERREMOVE", "MV"), + ("annotate", "MLEADERALIGN", "MAL"), + ("annotate", "MLEADERCOLLECT", "MC"), + ("annotate", "TABLE", "TB"), + ("annotate", "TABLE_STYLE_COMBO", "TL"), + ("annotate", "DATALINK", "DL"), + ("annotate", "WIPEOUT", "WI"), + ("annotate", "REVCLOUD", "AC"), + ("annotate", "SCALELISTEDIT", "SL"), + ("annotate", "OBJECTSCALE", "AS"), + ("annotate", "ANNORESET", "SS"), + // View. + ("view", "Viewport Tools", "VT"), + ("view", "UCSICON", "UI"), + ("view", "NAVVCUBE", "VC"), + ("view", "PAN", "PA"), + ("view", "3DORBIT", "OR"), + ("view", "Model Viewports", "VP"), + ("view", "VPORTS_NAMED", "NP"), + ("view", "VPJOIN", "JV"), + ("view", "VPORTS_RESTORE", "RV"), + ("view", "Visual Style", "VY"), + ("view", "VISUAL_STYLE", "VS"), + ("view", "TOOLPALETTES", "TP"), + ("view", "PROPERTIES", "PR"), + ("view", "COUNTLIST", "CT"), + ("view", "SHEETSET", "SS"), + ("view", "FILETAB", "FT"), + ("view", "LAYOUTTAB", "LT"), + ("view", "HORIZONTAL", "TH"), + ("view", "VERTICAL", "TV"), + ("view", "CASCADE", "CA"), + // Manage. + ("manage", "CUI", "CU"), + ("manage", "TOOLPALETTES", "TP"), + ("manage", "CUIIMPORT", "IM"), + ("manage", "CUIEXPORT", "EX"), + ("manage", "ALIASEDIT_DROPDOWN", "EA"), + ("ALIASEDIT_DROPDOWN", "ALIASEDIT", "EA"), + ("manage", "FINDNONPURGEABLE", "FN"), + ("manage", "PURGE", "PU"), + ("manage", "OVERKILL", "OK"), + ("manage", "AUDIT", "AU"), + // Block editor. + ("block_editor", "Open and Save", "BO"), + ("block_editor", "BEDIT", "BE"), + ("block_editor", "BSAVE", "BS"), + ("block_editor", "ATTDEF", "AD"), + ("block_editor", "AUTOCONSTRAIN", "AC"), + ("block_editor", "CCONSTRAINT", "CI"), + ("block_editor", "PCONSTRAINT", "PA"), + ("block_editor", "TCONSTRAINT", "TA"), + // The reference gives Concentric the same code; Collinear comes first. + ("block_editor", "LCONSTRAINT", "CO"), + ("block_editor", "QCONSTRAINT", "PE"), + ("block_editor", "GCSMOOTH", "SM"), + ("block_editor", "GCHORIZONTAL", "HO"), + ("block_editor", "SYCONSTRAINT", "SY"), + ("block_editor", "FXCONSTRAINT", "FI"), + ("block_editor", "VCONSTRAINT", "VE"), + ("block_editor", "ECONSTRAINT", "EQ"), + ("block_editor", "GCVISIBILITY", "SH"), + ("block_editor", "GCSHOWALL", "SA"), + ("block_editor", "GCHIDEALL", "HA"), + ("block_editor", "DC_LINEAR_MENU", "DC"), + ("block_editor", "DCALIGNED", "AL"), + ("block_editor", "DCRADIUS", "RA"), + ("block_editor", "DCDIAMETER", "DI"), + ("block_editor", "DCANGULAR", "AN"), + ("block_editor", "DCCONVERT", "BV"), + ("block_editor", "DELCONSTRAINT", "DE"), + ("block_editor", "PARAMETERS", "PM"), +]; + +fn code(scope: &str, target: &str) -> Option<&'static str> { + CODES + .iter() + .find(|(s, t, _)| *s == scope && *t == target) + .map(|(_, _, code)| *code) +} + +fn tool_tip(scope: &str, tool: &ToolDef) -> Tip { + Tip { + anchor: tool.id.to_string(), + target: tool.id, + code: code(scope, tool.id).map(str::to_string), + action: Action::Run(Message::RibbonToolClick { + tool_id: tool.id.to_string(), + event: tool.event.clone(), + }), + } +} + +fn dropdown_tip(scope: &str, id: &'static str) -> Tip { + Tip { + anchor: id.to_string(), + target: id, + code: code(scope, id).map(str::to_string), + action: Action::Open(Message::ToggleRibbonDropdown(id.to_string())), + } +} + +impl Ribbon { + pub fn keytips_active(&self) -> bool { + self.keytips.is_some() + } + + pub fn show_keytips(&mut self, refocus: bool) { + self.close_dropdown(); + self.collapsed_open = None; + self.keytips = Some(State { + top: true, + path: Vec::new(), + typed: String::new(), + refocus, + }); + ACTIVE.store(true, Ordering::Relaxed); + // Bounds reported before now belong to an earlier frame. + crate::ui::wrap_bar::end_frame(); + } + + /// Close the tips; true when the command line should get the focus back. + pub fn hide_keytips(&mut self) -> bool { + ACTIVE.store(false, Ordering::Relaxed); + self.keytips.take().is_some_and(|state| state.refocus) + } + + /// Esc: drop a partial code, close the open dropdown or flyout, or go + /// back to the tab codes. False when the tips should close. + pub fn keytip_escape(&mut self) -> bool { + let dropdown_open = self.open_dropdown.is_some(); + let flyout_open = self.collapsed_open.is_some(); + let Some(state) = self.keytips.as_mut() else { + return false; + }; + if !state.typed.is_empty() { + state.typed.clear(); + return true; + } + if state.top { + return false; + } + state.path.pop(); + if dropdown_open { + // A submenu goes back to its slide-out. + if !self.escape_extension() { + self.close_dropdown(); + } + } else if flyout_open { + self.collapsed_open = None; + } else { + state.top = true; + state.path.clear(); + } + true + } + + /// One typed character; `None` confirms a code that is also the start of + /// a longer one (Enter). + pub fn keytip_type(&mut self, ch: Option<&str>) -> KeyTipStep { + let tips = self.keytip_list(); + let Some(state) = self.keytips.as_mut() else { + 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 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 { + Some(typed) if codes().any(|code| code.starts_with(&typed)) => { + if longer(&typed) || exact(&typed).is_none() { + state.typed = typed; + return KeyTipStep::Stay; + } + typed + } + // A key that extends no code (or Enter) settles a code that is + // also the start of a longer one ("3" beside "3D"). + _ if exact(&state.typed).is_some() => std::mem::take(&mut state.typed), + _ => return KeyTipStep::Ignore, + }; + let Some(tip) = exact(&chosen) else { + return KeyTipStep::Ignore; + }; + state.typed.clear(); + state.path.push(chosen); + match tip.action.clone() { + Action::Tab(index) => { + state.top = false; + self.select(index); + KeyTipStep::Stay + } + Action::Open(message) => KeyTipStep::Open(message), + Action::Run(message) => KeyTipStep::Run(message), + } + } + + /// The codes of the current level, with the widgets they sit on. + pub fn keytip_list(&self) -> Vec { + let Some(state) = &self.keytips else { + return Vec::new(); + }; + if state.top { + return self.tab_tips(); + } + if let Some(open) = self.open_dropdown.as_deref() { + return self.item_tips(self.active, open); + } + self.module_tips(self.active) + } + + fn tab_tips(&self) -> Vec { + let tabs = self.modules.iter().enumerate().filter(|(_, module)| { + module.id() != "layout" + && (module.id() != "block_editor" || self.block_editing.is_some()) + }); + let mut tips: Vec = tabs + .map(|(index, module)| Tip { + anchor: format!("tab:{}", module.id()), + target: module.id(), + code: tab_code(module.id()).map(str::to_string), + action: Action::Tab(index), + }) + .collect(); + tips.extend(QUICK_ACCESS.iter().enumerate().map(|(index, &id)| Tip { + anchor: quick_access_anchor(id), + target: id, + code: Some((index + 1).to_string()), + action: Action::Run(match id { + super::widgets::UNDO_HISTORY_ID => Message::Undo, + super::widgets::REDO_HISTORY_ID => Message::Redo, + command => Message::Command(command.to_string()), + }), + })); + tips + } + + /// Every button, dropdown, slide-out and collapsed panel of tab `index`. + fn module_tips(&self, index: usize) -> Vec { + let Some(module) = self.modules.get(index) else { + return Vec::new(); + }; + let scope = module.id(); + let mut tips = Vec::new(); + for group in module.ribbon_groups() { + for item in &group.tools { + match item { + RibbonItem::Tool(tool) + | RibbonItem::LabeledTool(tool) + | RibbonItem::LargeTool(tool) => tips.push(tool_tip(scope, tool)), + RibbonItem::Dropdown { id, .. } + | RibbonItem::LabeledDropdown { id, .. } + | RibbonItem::LargeDropdown { id, .. } => tips.push(dropdown_tip(scope, id)), + RibbonItem::ToolGrid { columns } => { + tips.extend(columns.iter().flatten().map(|tool| tool_tip(scope, tool))) + } + RibbonItem::LayerComboGroup { row2, row3 } => { + tips.push(dropdown_tip(scope, super::widgets::LAYER_COMBO_ID)); + tips.extend(row2.iter().chain(row3).map(|tool| tool_tip(scope, tool))); + } + RibbonItem::PropertiesGroup { match_prop } => { + tips.push(tool_tip(scope, match_prop)); + for id in [ + super::widgets::PROP_COLOR_ID, + super::widgets::PROP_LINETYPE_ID, + super::widgets::PROP_LW_ID, + ] { + tips.push(dropdown_tip(scope, id)); + } + } + RibbonItem::StyleComboGroup { combo_id, rows, .. } => { + tips.push(dropdown_tip(scope, combo_id)); + tips.extend(rows.iter().flatten().map(|tool| tool_tip(scope, tool))); + } + } + } + // The panel code opens the collapsed panel's flyout, or else the + // panel's slide-out. Both carry it; only the drawn one shows, and + // the form the panel had in the last frame comes first, so the + // code runs that one. + let ids: Vec<&'static str> = group.tools.iter().filter_map(item_id).collect(); + let collapsed = collapsed_anchor(group.title); + let was_collapsed = crate::ui::wrap_bar::recent_bounds(&collapsed).is_some(); + let flyout = Tip { + anchor: collapsed, + target: group.title, + code: code(scope, group.title).map(str::to_string), + action: Action::Open(Message::ToggleRibbonPanel(group.title.to_string())), + }; + let Some(id) = draw_panel::slide_out_id(group.title, &ids) else { + tips.push(flyout); + continue; + }; + let slide_out = Tip { + anchor: id.to_string(), + target: group.title, + code: code(scope, group.title).map(str::to_string), + action: Action::Open(Message::ToggleRibbonDropdown(id.to_string())), + }; + // In the open flyout the code reaches the slide-out. + if was_collapsed && self.collapsed_open.as_deref() != Some(group.title) { + tips.extend([flyout, slide_out]); + } else { + tips.extend([slide_out, flyout]); + } + if id == draw_panel::REFERENCE_PANEL_ID { + tips.push(Tip { + anchor: XREF_LAUNCHER.to_string(), + target: XREF_LAUNCHER, + code: code(scope, XREF_LAUNCHER).map(str::to_string), + action: Action::Run(Message::ToggleXrefManager), + }); + } + } + tips + } + + /// The items of the open dropdown, slide-out or submenu `open` of tab + /// `index`. Items the reference gives no code are numbered. + fn item_tips(&self, index: usize, open: &str) -> Vec { + let mut items = draw_panel::keytip_items(open); + if items.is_empty() { + let groups = self.modules.get(index).map(|m| m.ribbon_groups()).unwrap_or(&[]); + for item in groups.iter().flat_map(|group| &group.tools) { + if let RibbonItem::Dropdown { id, items: list, .. } + | RibbonItem::LabeledDropdown { id, items: list, .. } + | RibbonItem::LargeDropdown { id, items: list, .. } = item + { + if *id == open { + items = list + .iter() + .filter(|(cmd, _, _)| !super::widgets::is_disabled_item(cmd)) + .map(|(cmd, _, _)| (*id, *cmd, false)) + .collect(); + break; + } + } + } + } + let mut tips: Vec = items + .into_iter() + .enumerate() + .map(|(position, (dropdown_id, cmd, submenu))| Tip { + anchor: item_anchor(dropdown_id, cmd), + target: cmd, + code: Some(code(open, cmd).map_or_else(|| (position + 1).to_string(), str::to_string)), + action: if submenu { + Action::Open(Message::ToggleRibbonDropdown(cmd.to_string())) + } else { + Action::Run(Message::DropdownSelectItem { dropdown_id, cmd }) + }, + }) + .collect(); + if open == draw_panel::REFERENCE_PANEL_ID { + tips.push(Tip { + anchor: item_anchor(open, XREF_FADE), + target: XREF_FADE, + code: code(open, XREF_FADE).map(str::to_string), + action: Action::Run(Message::XrefFadeToggle), + }); + } + tips + } + + /// The state for automation: level, path, and the codes with whether + /// their widget showed in the last frame. `all` adds every tab's buttons + /// and their dropdown items, coded or not. + pub fn keytips_json(&self, all: bool) -> serde_json::Value { + use serde_json::json; + let shown = SHOWN.with(|shown| shown.borrow().clone()); + let entry = |tip: &Tip| { + json!({ + "code": tip.code, + "target": tip.target, + "anchor": tip.anchor, + "shown": shown.contains(&tip.anchor), + }) + }; + let mut out = match &self.keytips { + Some(state) => json!({ + "active": true, + "level": if state.top { "tabs" } else if self.open_dropdown.is_some() { "items" } else { "tab" }, + "tab": self.modules.get(self.active).map(|m| m.id()), + "open": self.open_dropdown, + "path": state.path, + "typed": state.typed, + "tips": self.keytip_list().iter().filter(|tip| tip.code.is_some()).map(entry).collect::>(), + }), + None => json!({"active": false}), + }; + if all { + let tabs: Vec<_> = (0..self.modules.len()) + .map(|index| { + let buttons: Vec<_> = self + .module_tips(index) + .iter() + .map(|tip| { + let mut value = entry(tip); + let items = self.item_tips(index, &tip.anchor); + if !items.is_empty() { + value["items"] = items.iter().map(entry).collect(); + } + value + }) + .collect(); + json!({"tab": self.modules[index].id(), "code": tab_code(self.modules[index].id()), "buttons": buttons}) + }) + .collect(); + out["all"] = json!(tabs); + } + out + } + + /// 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)> = self + .keytip_list() + .into_iter() + .filter_map(|tip| { + let code = tip.code?; + code.starts_with(&state.typed).then_some((tip.anchor, code)) + }) + .collect(); + let mut path = state.path.join(" › "); + if !state.top { + path.push_str(" › "); + } + path.push_str(&state.typed); + if path.is_empty() { + path.push('—'); + } + let hint = if state.top { + crate::tr!("keytips", "hint-tabs") + } else if self.open_dropdown.is_some() || self.collapsed_open.is_some() { + crate::tr!("keytips", "hint-items") + } else { + let module = &self.modules[self.active]; + crate::tr!( + "keytips", + "hint-tab", + tab = crate::i18n::ribbon_module_title(module.id(), module.title()) + ) + }; + let bar = KeyTipLayer { + tips: Vec::new(), + typed: 0, + bar: Some([ + crate::tr!("keytips", "title"), + crate::tr!("keytips", "typed", path = path), + hint, + ]), + }; + let codes = KeyTipLayer { + tips, + typed: state.typed.chars().count(), + bar: None, + }; + Some((Element::new(bar), Element::new(codes))) + } +} + +/// Fills the window without taking input. With `bar` it draws the hint bar +/// (beneath open dropdowns); otherwise it draws the codes in an overlay, so +/// they sit above flyouts and dropdowns, after their widgets reported where +/// they are. +struct KeyTipLayer { + tips: Vec<(String, String)>, + /// Characters of a partial code, shown dimmed on matching tips. + typed: usize, + bar: Option<[String; 3]>, +} + +impl Widget for KeyTipLayer { + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Fill) + } + + fn layout( + &mut self, + _tree: &mut widget::Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::Node::new(limits.max()) + } + + fn draw( + &self, + _tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + _layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + if let Some(parts) = &self.bar { + draw_bar(renderer, theme, parts); + } + } + + fn overlay<'b>( + &'b mut self, + _tree: &'b mut widget::Tree, + _layout: Layout<'b>, + _renderer: &Renderer, + _viewport: &Rectangle, + _translation: Vector, + ) -> Option> { + self.bar + .is_none() + .then(|| overlay::Element::new(Box::new(KeyTipOverlay { layer: self }))) + } +} + +struct KeyTipOverlay<'b> { + layer: &'b KeyTipLayer, +} + +const PILL_H: f32 = 16.0; +const PILL_TEXT: f32 = 11.0; +const BAR_H: f32 = 24.0; +const BAR_TEXT: f32 = 12.0; + +fn bold(renderer: &Renderer) -> iced::Font { + iced::Font { + weight: iced::font::Weight::Semibold, + ..renderer.default_font() + } +} + +fn text_width(content: &str, size: f32, font: iced::Font) -> f32 { + ::Paragraph::with_text(advanced_text::Text { + content, + bounds: Size::INFINITE, + size: Pixels(size), + line_height: advanced_text::LineHeight::default(), + font, + align_x: advanced_text::Alignment::Left, + align_y: iced::alignment::Vertical::Top, + shaping: advanced_text::Shaping::Advanced, + wrapping: advanced_text::Wrapping::None, + ellipsis: advanced_text::Ellipsis::None, + hint_factor: None, + }) + .min_bounds() + .width +} + +fn draw_text( + renderer: &mut Renderer, + content: &str, + at: Point, + size: f32, + font: iced::Font, + color: Color, + centered: bool, + clip: Rectangle, +) { + renderer.fill_text( + advanced_text::Text { + content: content.to_string(), + bounds: Size::INFINITE, + size: Pixels(size), + line_height: advanced_text::LineHeight::default(), + font, + align_x: if centered { + advanced_text::Alignment::Center + } else { + advanced_text::Alignment::Left + }, + align_y: iced::alignment::Vertical::Center, + shaping: advanced_text::Shaping::Advanced, + wrapping: advanced_text::Wrapping::None, + ellipsis: advanced_text::Ellipsis::None, + hint_factor: None, + }, + at, + color, + clip, + ); +} + +/// Pill and text colours: a dark accent-edged pill on dark themes, a light +/// one on light themes. +fn colors(theme: &Theme) -> (Color, Color, Color) { + let palette = theme.palette(); + let accent = palette.primary.base.color; + if palette.is_dark { + ( + Color::from_rgb8(0x0f, 0x2a, 0x36), + accent, + Color::from_rgb8(0x7f, 0xe0, 0xff), + ) + } else { + ( + Color::from_rgb8(0xe3, 0xf5, 0xfd), + accent, + Color::from_rgb8(0x00, 0x4a, 0x6b), + ) + } +} + +impl overlay::Overlay for KeyTipOverlay<'_> { + fn layout(&mut self, _renderer: &Renderer, bounds: Size) -> layout::Node { + layout::Node::new(bounds) + } + + fn draw( + &self, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + ) { + let window = layout.bounds(); + let (fill, edge, ink) = colors(theme); + let font = bold(renderer); + let mut shown = Vec::new(); + renderer.with_layer(window, |renderer| { + for (anchor, code) in &self.layer.tips { + let Some(b) = fresh_bounds(anchor) else { + continue; + }; + let width = text_width(code, PILL_TEXT, font) + 10.0; + let top_row = anchor.starts_with("tab:") + || anchor.starts_with("qa:") + || anchor == super::widgets::UNDO_HISTORY_ID + || anchor == super::widgets::REDO_HISTORY_ID; + let center = if top_row { + // Tabs and quick-access buttons: on the lower edge, so the + // label stays readable. + Point::new(b.center_x(), b.y + b.height - 2.0) + } else if anchor.contains('/') && b.width >= 60.0 { + // Menu rows: at the right end, clear of the label. + Point::new(b.x + b.width - width / 2.0 - 4.0, b.center_y()) + } else if anchor.contains('/') { + // Slide-out icons: on the lower edge. + Point::new(b.center_x(), b.y + b.height - 2.0) + } else if b.height >= 40.0 { + // Large buttons: over the label. + Point::new(b.center_x(), b.y + b.height - PILL_H / 2.0 - 2.0) + } else { + // Small buttons: at the lower left, over the icon. + Point::new(b.x + width / 2.0, b.y + b.height - PILL_H / 2.0 + 3.0) + }; + 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)); + let pill = Rectangle::new(Point::new(x, y), Size::new(width, PILL_H)); + renderer.fill_quad( + renderer::Quad { + bounds: pill, + border: Border { + color: edge, + width: 1.0, + radius: (PILL_H / 2.0).into(), + }, + shadow: Shadow { + color: Color::BLACK.scale_alpha(0.6), + offset: Vector::new(0.0, 1.0), + blur_radius: 4.0, + }, + snap: true, + }, + Background::Color(fill), + ); + // The part already typed is dimmed. + let split = code + .char_indices() + .nth(self.layer.typed) + .map_or(code.len(), |(at, _)| at); + let (done, rest) = code.split_at(split); + let done_w = text_width(done, PILL_TEXT, font); + let rest_w = text_width(rest, PILL_TEXT, font); + let left = pill.center_x() - (done_w + rest_w) / 2.0; + let clip = Rectangle { + width: pill.width + 2.0, + ..pill + }; + if !done.is_empty() { + draw_text(renderer, done, Point::new(left, pill.center_y() - 0.5), PILL_TEXT, font, ink.scale_alpha(0.45), false, clip); + } + draw_text(renderer, rest, Point::new(left + done_w, pill.center_y() - 0.5), PILL_TEXT, font, ink, false, clip); + shown.push(anchor.clone()); + } + }); + SHOWN.with(|cell| *cell.borrow_mut() = shown); + crate::ui::wrap_bar::end_frame(); + } +} + +/// The hint bar under the ribbon: title, the typed path and what to do next. +fn draw_bar(renderer: &mut Renderer, theme: &Theme, parts: &[String; 3]) { + let Some(ribbon) = fresh_bounds(RIBBON_AREA) else { + return; + }; + let (fill, edge, ink) = colors(theme); + let font = bold(renderer); + let bar = Rectangle::new( + Point::new(ribbon.x, ribbon.y + ribbon.height), + Size::new(ribbon.width, BAR_H), + ); + renderer.fill_quad( + renderer::Quad { + bounds: bar, + border: Border::default(), + shadow: Shadow::default(), + snap: true, + }, + Background::Color(fill), + ); + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new(bar.position(), Size::new(bar.width, 1.0)), + border: Border::default(), + shadow: Shadow::default(), + snap: true, + }, + Background::Color(edge), + ); + let mut x = bar.x + 12.0; + let y = bar.center_y(); + let regular = renderer.default_font(); + for (index, part) in parts.iter().enumerate() { + let (part_font, color) = match index { + 0 => (font, ink), + 1 => (regular, ink), + _ => (regular, ink.scale_alpha(0.6)), + }; + draw_text(renderer, part, Point::new(x, y), BAR_TEXT, part_font, color, false, bar); + x += text_width(part, BAR_TEXT, part_font) + 18.0; + } +} + +/// Anchor for the whole ribbon (the hint bar sits under it). +pub(super) fn ribbon_area<'a>(el: impl Into>) -> Element<'a, Message> { + anchor(RIBBON_AREA, el) +} diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index c4274b7e6..86680973b 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -31,6 +31,12 @@ use widgets::{StyleContext, *}; pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID}; mod collapse; mod locate; +mod keytips; +pub use keytips::{ + active as keytips_active, key_pressed as keytip_key_pressed, + key_released as keytip_key_released, pointer_pressed as keytip_pointer_pressed, KeyTipInput, + KeyTipStep, +}; use crate::t; use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow}; pub use collapse::CollapseMode; @@ -122,6 +128,8 @@ pub struct Ribbon { block_editing: Option, /// XDWGFADECTL as the Reference slide-out shows it (negative = off). pub xref_fade: i32, + /// Open key tips (Alt). + keytips: Option, } /// Per-layer display data shown in the ribbon layer dropdown. @@ -224,6 +232,7 @@ impl Ribbon { point_cloud_ctx: None, block_editing: None, xref_fade: 50, + keytips: None, } } @@ -662,7 +671,7 @@ impl Ribbon { }, ..Default::default() }); - acc.push(btn.into()); + acc.push(keytips::anchor_with(|| format!("tab:{}", module.id()), btn)); acc }, ); @@ -800,7 +809,7 @@ impl Ribbon { tool_bar }; - column![tab_bar, tool_bar].into() + keytips::ribbon_area(column![tab_bar, tool_bar]) } // ── Dropdown overlay ────────────────────────────────────────────────── @@ -971,7 +980,7 @@ impl Ribbon { .then_some(theme.palette().background.base.text.scale_alpha(0.72)), }); - button( + let item = button( row![checkmark, icon_el, label_el] .spacing(4) .align_y(iced::Center), @@ -982,8 +991,8 @@ impl Ribbon { })) .style(popup_row_style) .width(Fill) - .padding([4, 10]) - .into() + .padding([4, 10]); + keytips::anchor_with(|| keytips::item_anchor(dd_id, cmd), item) }) .collect(); @@ -1571,7 +1580,10 @@ fn collapse_button<'a>( .style(opener_style(holds)) .width(Fill) .padding([3, 5]); - return automatic_large_button(localized_title, content.into()); + return keytips::anchor_with( + || keytips::collapsed_anchor(title), + automatic_large_button(localized_title, content.into()), + ); } // Collapsed (not yet tight): a large representative-tool face — a live button @@ -1636,7 +1648,10 @@ fn collapse_button<'a>( .style(opener_style(holds && !face_lit)) .width(Fill) .padding([1, 4]); - let opener = automatic_large_button(localized_title, opener.into()); + let opener = keytips::anchor_with( + || keytips::collapsed_anchor(title), + automatic_large_button(localized_title, opener.into()), + ); // The large face fills a fixed slot so a collapsed panel is shorter than a full // 3-row panel, letting `CollapsePanels` shrink the ribbon row. diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 316d252c2..f019f85ad 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -543,11 +543,13 @@ pub(super) fn render_small<'a>( .width(Length::Fixed(SMALL_W)) .height(ROW_H) .padding([4, 4]); - tooltip(btn, make_tip(tip_text), TipPos::Right) - .gap(6.0) - .delay(Duration::from_millis(400)) - .style(tip_style) - .into() + super::keytips::anchor( + t.id, + tooltip(btn, make_tip(tip_text), TipPos::Right) + .gap(6.0) + .delay(Duration::from_millis(400)) + .style(tip_style), + ) } RibbonItem::LabeledTool(t) => { @@ -568,11 +570,13 @@ pub(super) fn render_small<'a>( .width(Length::Fixed(LABELED_SMALL_W + ARROW_W)) .height(ROW_H) .padding([3, 4]); - tooltip(btn, make_tip(tip_text), TipPos::Right) - .gap(6.0) - .delay(Duration::from_millis(400)) - .style(tip_style) - .into() + super::keytips::anchor( + t.id, + tooltip(btn, make_tip(tip_text), TipPos::Right) + .gap(6.0) + .delay(Duration::from_millis(400)) + .style(tip_style), + ) } RibbonItem::Dropdown { @@ -895,7 +899,7 @@ fn tool_row<'a>(tools: &[ToolDef], active_tool: &Option) -> Element<'a, let event = t.event.clone(); let icon_el: Element = make_icon(t.icon, 16.0); let msg = module_event_to_message(event); - tooltip( + let btn = tooltip( button(icon_el) .on_press(msg) .style(move |theme: &Theme, status| tool_btn_style(theme, is_active, status)) @@ -905,8 +909,8 @@ fn tool_row<'a>(tools: &[ToolDef], active_tool: &Option) -> Element<'a, ) .gap(4.0) .delay(Duration::from_millis(400)) - .style(tip_style) - .into() + .style(tip_style); + super::keytips::anchor(t.id, btn) }) .collect(); row(btns).spacing(2).align_y(iced::Center).into() @@ -974,15 +978,17 @@ pub(super) fn render_large<'a>(item: &RibbonItem, ctx: &RenderCtx<'_>) -> Elemen bottom: 4.0, left: 4.0, }); - tooltip( - automatic_large_button(label, btn.into()), - make_tip(tip_text), - TipPos::Right, + super::keytips::anchor( + t.id, + tooltip( + automatic_large_button(label, btn.into()), + make_tip(tip_text), + TipPos::Right, + ) + .gap(6.0) + .delay(Duration::from_millis(400)) + .style(tip_style), ) - .gap(6.0) - .delay(Duration::from_millis(400)) - .style(tip_style) - .into() } RibbonItem::LargeDropdown { @@ -1308,11 +1314,13 @@ pub(super) fn quick_access_btn<'a>( .width(Length::Fixed(TOP_HIST_W)) .height(24) .padding([2, 0]); - tooltip(btn, make_tip(t!(label).into_owned()), TipPos::Bottom) - .gap(6.0) - .delay(Duration::from_millis(400)) - .style(tip_style) - .into() + super::keytips::anchor_with( + || super::keytips::quick_access_anchor(cmd), + tooltip(btn, make_tip(t!(label).into_owned()), TipPos::Bottom) + .gap(6.0) + .delay(Duration::from_millis(400)) + .style(tip_style), + ) } pub(super) fn render_history_control<'a>( diff --git a/src/ui/wrap_bar.rs b/src/ui/wrap_bar.rs index d24ab31f0..68c41710a 100644 --- a/src/ui/wrap_bar.rs +++ b/src/ui/wrap_bar.rs @@ -34,14 +34,46 @@ use crate::app::Message; thread_local! { /// Screen bounds of every ribbon dropdown button, keyed by dropdown id, so /// an open dropdown's overlay can anchor directly below its widget at any - /// size. Written by `PosReport` on draw, read by `dropdown_bounds`. - static DD_BOUNDS: RefCell> = + /// size. Written by `PosReport` on draw, read by `dropdown_bounds`. Each + /// entry keeps the frame it was drawn in (see `fresh_bounds`). + static DD_BOUNDS: RefCell> = RefCell::new(FxHashMap::default()); + /// Advanced by `end_frame` once everything above the widgets has drawn. + static FRAME: Cell = const { Cell::new(0) }; } /// Last-drawn screen bounds of the dropdown button with this id. pub fn dropdown_bounds(id: &str) -> Option { - DD_BOUNDS.with(|m| m.borrow().get(id).copied()) + DD_BOUNDS.with(|m| m.borrow().get(id).map(|(bounds, _)| *bounds)) +} + +/// Bounds of the widget `id` only when it was drawn in the current frame, so +/// a widget that left the screen does not report its old place. +pub fn fresh_bounds(id: &str) -> Option { + let frame = FRAME.with(Cell::get); + DD_BOUNDS.with(|m| { + m.borrow() + .get(id) + .filter(|(_, drawn)| *drawn == frame) + .map(|(bounds, _)| *bounds) + }) +} + +/// Bounds of the widget `id` when it was drawn in the current or the last +/// finished frame (for use between frames). +pub fn recent_bounds(id: &str) -> Option { + let frame = FRAME.with(Cell::get); + DD_BOUNDS.with(|m| { + m.borrow() + .get(id) + .filter(|(_, drawn)| drawn + 1 >= frame) + .map(|(bounds, _)| *bounds) + }) +} + +/// Close the current frame for `fresh_bounds`. +pub fn end_frame() { + FRAME.with(|frame| frame.set(frame.get() + 1)); } pub struct WrapBar<'a> { @@ -1038,7 +1070,8 @@ impl<'a> Widget for PosReport<'a> { viewport: &Rectangle, ) { DD_BOUNDS.with(|m| { - m.borrow_mut().insert(self.id.to_string(), layout.bounds()); + m.borrow_mut() + .insert(self.id.to_string(), (layout.bounds(), FRAME.with(Cell::get))); }); self.child.as_widget().draw( &tree.children[0],