Merge pull request #43 from harrisonoconnorhover/codex/ribbon-keytips
Add initial Alt ribbon keytips and legacy shortcuts (#33)
This commit is contained in:
commit
cb794080b9
23
README.md
23
README.md
|
|
@ -117,6 +117,29 @@ cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sa
|
|||
You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the
|
||||
browser's file picker (dropping files works too) and Save downloads the workbook.
|
||||
|
||||
### Ribbon access keys
|
||||
|
||||
Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,
|
||||
then type a sequence below one key at a time. Keytips appear on the supported Home
|
||||
controls and menu items. On macOS, use F10 (Fn+F10 if required by your keyboard); Option
|
||||
remains available for entering accented characters.
|
||||
|
||||
| Sequence after Alt/F10 | Action |
|
||||
|---|---|
|
||||
| `H A L` / `H A C` / `H A R` | Align left / center / right |
|
||||
| `H V S` or legacy `E S` | Open Paste Special |
|
||||
| `H O I` or legacy `O C A` | AutoFit selected columns |
|
||||
|
||||
In Paste Special, **T/V/F/C** select formats/values/formulas/comments, and **E** toggles
|
||||
Transpose. **Enter** applies the selected options; **Escape** cancels the dialog.
|
||||
These letters also work with Alt held on Windows/Linux/BSD.
|
||||
|
||||
Escape backs out one keytip level; Alt/F10 again exits keytips. Clicking, scrolling,
|
||||
or switching windows cancels navigation. Access keys do not start while editing text
|
||||
or using another dialog. This is initial support for these Home and legacy paths;
|
||||
other ribbon commands and full mouse-free navigation remain to be implemented.
|
||||
Browsers or desktop environments may reserve Alt/F10 for their own menus.
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its log to standard error and to `gridcraft.log` in the `logs` folder
|
||||
|
|
|
|||
|
|
@ -188,10 +188,10 @@ impl Dialog {
|
|||
label: "Paste",
|
||||
options: vec![
|
||||
("all", "All"),
|
||||
("formulas", "Formulas"),
|
||||
("values", "Values"),
|
||||
("formats", "Formats"),
|
||||
("comments", "Comments and notes"),
|
||||
("formulas", "Formulas (F)"),
|
||||
("values", "Values (V)"),
|
||||
("formats", "Formats (T)"),
|
||||
("comments", "Comments and notes (C)"),
|
||||
("validation", "Validation"),
|
||||
("allExceptBorders", "All except borders"),
|
||||
("columnWidths", "Column widths"),
|
||||
|
|
@ -205,7 +205,7 @@ impl Dialog {
|
|||
options: vec![("none", "None"), ("add", "Add"), ("subtract", "Subtract"), ("multiply", "Multiply"), ("divide", "Divide")],
|
||||
},
|
||||
Check { key: "skipBlanks", label: "Skip blanks" },
|
||||
Check { key: "transpose", label: "Transpose" },
|
||||
Check { key: "transpose", label: "Transpose (E)" },
|
||||
Check { key: "link", label: "Paste Link" },
|
||||
],
|
||||
json!({"what": "all", "operation": "none"}),
|
||||
|
|
@ -632,6 +632,9 @@ fn build_params(d: &Dialog) -> Json {
|
|||
|
||||
pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
if d.name == "pasteSpecial" {
|
||||
paste_special_shortcuts(&mut d, ctx);
|
||||
}
|
||||
let mut open = true;
|
||||
let mut win_open = true;
|
||||
let mut confirm = false;
|
||||
|
|
@ -828,6 +831,71 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Paste Special's legacy mnemonics select options; Enter still performs the paste.
|
||||
fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
|
||||
if ctx.text_edit_focused() {
|
||||
return;
|
||||
}
|
||||
let right_alt = ctx.input(|i| {
|
||||
i.key_down(Key::AltRight)
|
||||
|| i.events.iter().any(|event| {
|
||||
matches!(event, egui::Event::Key { key: Key::AltRight, .. })
|
||||
|| matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. })
|
||||
})
|
||||
});
|
||||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt));
|
||||
let mnemonic = |key| match key {
|
||||
Key::T => Some('t'),
|
||||
Key::V => Some('v'),
|
||||
Key::F => Some('f'),
|
||||
Key::C => Some('c'),
|
||||
Key::E => Some('e'),
|
||||
_ => None,
|
||||
};
|
||||
let mut text_to_consume = Vec::new();
|
||||
for event in ctx.input(|i| i.events.clone()) {
|
||||
let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue };
|
||||
if !allowed(modifiers) {
|
||||
continue;
|
||||
}
|
||||
let Some(letter) = mnemonic(key) else { continue };
|
||||
text_to_consume.push(letter);
|
||||
if repeat {
|
||||
continue;
|
||||
}
|
||||
if key == Key::E {
|
||||
let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false);
|
||||
d.values.insert("transpose".into(), json!(!transpose));
|
||||
} else {
|
||||
let what = match key {
|
||||
Key::T => "formats",
|
||||
Key::V => "values",
|
||||
Key::F => "formulas",
|
||||
_ => "comments",
|
||||
};
|
||||
d.values.insert("what".into(), json!(what));
|
||||
}
|
||||
}
|
||||
// Browsers can deliver Text before or after Key. Act only on Key, then remove
|
||||
// its matching text so one physical press cannot toggle Transpose twice.
|
||||
ctx.input_mut(|i| {
|
||||
i.events.retain(|event| match event {
|
||||
egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()),
|
||||
egui::Event::Text(text) if text.len() == 1 => {
|
||||
let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase();
|
||||
if let Some(index) = text_to_consume.iter().position(|&c| c == letter) {
|
||||
text_to_consume.remove(index);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
//! Sequential ribbon shortcuts. The host paints hints from `prefix` and runs returned actions.
|
||||
|
||||
use egui::{Context, Event, Key, Modifiers, os::OperatingSystem};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Home,
|
||||
Command(&'static str),
|
||||
Dialog(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct KeyTips {
|
||||
prefix: Option<String>,
|
||||
alt_armed: bool,
|
||||
// Keep consuming a held mnemonic after its action closes keytips.
|
||||
held: Vec<Key>,
|
||||
}
|
||||
|
||||
impl KeyTips {
|
||||
pub fn prefix(&self) -> Option<&str> {
|
||||
self.prefix.as_deref()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.prefix = None;
|
||||
self.alt_armed = false;
|
||||
self.held.clear();
|
||||
}
|
||||
|
||||
fn toggle(&mut self) {
|
||||
self.prefix = if self.prefix.is_some() { None } else { Some(String::new()) };
|
||||
self.alt_armed = false;
|
||||
}
|
||||
|
||||
/// Remove only handled events before any text editors or the grid see them.
|
||||
pub fn process(&mut self, ctx: &Context, enabled: bool) -> Vec<Action> {
|
||||
if !enabled || !ctx.input(|i| i.focused) {
|
||||
self.clear();
|
||||
return Vec::new();
|
||||
}
|
||||
let mac = ctx.os() == OperatingSystem::Mac;
|
||||
let events = ctx.input_mut(|i| std::mem::take(&mut i.events));
|
||||
let pairs = text_pairs(&events);
|
||||
let mut consumed = std::collections::HashSet::new();
|
||||
let mut actions = Vec::new();
|
||||
for (index, event) in events.iter().enumerate() {
|
||||
let mut handled = false;
|
||||
match event {
|
||||
Event::PointerButton { pressed: true, .. } | Event::MouseWheel { .. } | Event::WindowFocused(false) | Event::Ime(_) => self.clear(),
|
||||
Event::Copy | Event::Cut | Event::Paste(_) => self.clear(),
|
||||
Event::ModifiersChanged(m) => {
|
||||
if command_modifier(*m) || (mac && m.alt) {
|
||||
self.clear();
|
||||
} else if m.shift {
|
||||
self.alt_armed = false;
|
||||
}
|
||||
}
|
||||
Event::Key { key: Key::AltLeft, pressed, repeat, modifiers, .. } if !mac && !command_modifier(*modifiers) && !modifiers.shift => {
|
||||
if *pressed {
|
||||
if !repeat {
|
||||
self.alt_armed = true;
|
||||
}
|
||||
handled = true;
|
||||
} else if self.alt_armed {
|
||||
self.toggle();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Event::Key { key, pressed, repeat, modifiers, .. } => {
|
||||
self.alt_armed = false;
|
||||
if command_modifier(*modifiers)
|
||||
|| modifiers.alt
|
||||
|| matches!(key, Key::AltLeft | Key::AltRight | Key::ControlLeft | Key::ControlRight | Key::SuperLeft | Key::SuperRight)
|
||||
{
|
||||
self.clear();
|
||||
} else if !pressed {
|
||||
handled = self.held.contains(key);
|
||||
self.held.retain(|held| held != key);
|
||||
} else if *repeat && (self.prefix.is_some() || self.held.contains(key)) {
|
||||
handled = true;
|
||||
} else if *key == Key::F10 && modifiers.is_none() {
|
||||
self.toggle();
|
||||
handled = true;
|
||||
} else if self.prefix.is_some() && !matches!(key, Key::ShiftLeft | Key::ShiftRight) {
|
||||
handled = true;
|
||||
if *key == Key::Escape {
|
||||
if let Some(prefix) = self.prefix.as_mut()
|
||||
&& prefix.pop().is_none()
|
||||
{
|
||||
self.prefix = None;
|
||||
}
|
||||
} else if key.name().len() == 1 {
|
||||
let next = format!("{}{key_name}", self.prefix.as_deref().unwrap_or_default(), key_name = key.name());
|
||||
match next.as_str() {
|
||||
"H" => {
|
||||
self.prefix = Some(next);
|
||||
actions.push(Action::Home);
|
||||
}
|
||||
"HA" | "HV" | "HO" | "E" | "O" | "OC" => self.prefix = Some(next),
|
||||
"HAR" => self.finish(Action::Command("home.alignRight"), &mut actions),
|
||||
"HAL" => self.finish(Action::Command("home.alignLeft"), &mut actions),
|
||||
"HAC" => self.finish(Action::Command("home.alignCenter"), &mut actions),
|
||||
"HVS" | "ES" => self.finish(Action::Dialog("pasteSpecial"), &mut actions),
|
||||
"HOI" | "OCA" => self.finish(Action::Command("home.autofitColumnWidth"), &mut actions),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if handled && *pressed && !self.held.contains(key) {
|
||||
self.held.push(*key);
|
||||
}
|
||||
}
|
||||
Event::Text(_) => {
|
||||
self.alt_armed = false;
|
||||
// Paired text is decided with its key, which may occur later on web.
|
||||
handled = self.prefix.is_some() && !pairs.iter().any(|(_, text)| *text == index);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if handled {
|
||||
consumed.insert(index);
|
||||
if let Some((_, text)) = pairs.iter().find(|(key, _)| *key == index) {
|
||||
consumed.insert(*text);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.input_mut(|i| {
|
||||
i.events = events.into_iter().enumerate().filter_map(|(index, event)| (!consumed.contains(&index)).then_some(event)).collect()
|
||||
});
|
||||
actions
|
||||
}
|
||||
|
||||
fn finish(&mut self, action: Action, actions: &mut Vec<Action>) {
|
||||
self.prefix = None;
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
fn command_modifier(m: Modifiers) -> bool {
|
||||
m.ctrl || m.command || m.mac_cmd
|
||||
}
|
||||
|
||||
/// Native integrations emit Key then Text; browsers can emit Text then Key.
|
||||
fn text_pairs(events: &[Event]) -> Vec<(usize, usize)> {
|
||||
let mut pairs = Vec::new();
|
||||
for (text_index, event) in events.iter().enumerate() {
|
||||
let Event::Text(_) = event else { continue };
|
||||
let before = events.iter().enumerate().take(text_index).rev().find(|(_, e)| !matches!(e, Event::ModifiersChanged(_)));
|
||||
let after = events.iter().enumerate().skip(text_index + 1).find(|(_, e)| !matches!(e, Event::ModifiersChanged(_)));
|
||||
for (key_index, event) in before.into_iter().chain(after) {
|
||||
if let Event::Key { key, pressed: true, modifiers, .. } = event
|
||||
&& (key.name().len() == 1 || modifiers.alt || command_modifier(*modifiers))
|
||||
&& !pairs.iter().any(|(paired, _)| *paired == key_index)
|
||||
{
|
||||
pairs.push((key_index, text_index));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_mode_preserves_modified_text_before_key() {
|
||||
for (os, letter, text, modifiers) in [
|
||||
(OperatingSystem::Windows, Key::E, "€", Modifiers::CTRL | Modifiers::ALT),
|
||||
(OperatingSystem::Nix, Key::E, "€", Modifiers::ALT),
|
||||
(OperatingSystem::Mac, Key::O, "œ", Modifiers::ALT),
|
||||
(OperatingSystem::Mac, Key::C, "c", Modifiers::COMMAND),
|
||||
] {
|
||||
let ctx = Context::default();
|
||||
ctx.set_os(os);
|
||||
let mut tips = KeyTips { prefix: Some("H".into()), ..Default::default() };
|
||||
let events = vec![Event::Text(text.into()), key(letter, true, modifiers)];
|
||||
let mut remaining = Vec::new();
|
||||
let mut output = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| {
|
||||
assert!(tips.process(ui.ctx(), true).is_empty());
|
||||
remaining = ui.input(|i| i.events.clone());
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(tips.prefix(), None);
|
||||
assert_eq!(remaining, events);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_between_alt_press_and_release_does_not_activate() {
|
||||
let ctx = Context::default();
|
||||
ctx.set_os(OperatingSystem::Windows);
|
||||
let mut tips = KeyTips::default();
|
||||
let events =
|
||||
vec![key(Key::AltLeft, true, Modifiers::ALT), key(Key::ShiftLeft, true, Modifiers::NONE), key(Key::AltLeft, false, Modifiers::NONE)];
|
||||
let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
|
||||
assert!(tips.process(ui.ctx(), true).is_empty());
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(tips.prefix(), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ pub mod editor;
|
|||
pub mod formula_bar;
|
||||
pub mod grid;
|
||||
pub mod icons;
|
||||
pub mod keytips;
|
||||
pub mod panes;
|
||||
pub mod pivot_pane;
|
||||
pub mod ribbon;
|
||||
|
|
@ -132,6 +133,8 @@ pub struct SheetApp {
|
|||
fonts_set: bool,
|
||||
effective_dark: bool,
|
||||
pub name_box: Option<String>,
|
||||
/// Transient ribbon keyboard navigation; never saved with UI preferences.
|
||||
pub keytips: keytips::KeyTips,
|
||||
pub(crate) shots: control::Shots,
|
||||
/// Chart selected on the sheet (id).
|
||||
pub selected_chart: Option<u32>,
|
||||
|
|
@ -162,6 +165,7 @@ impl SheetApp {
|
|||
fonts_set: false,
|
||||
effective_dark: false,
|
||||
name_box: None,
|
||||
keytips: keytips::KeyTips::default(),
|
||||
shots: control::Shots::default(),
|
||||
selected_chart: None,
|
||||
started: now_ms(),
|
||||
|
|
@ -444,6 +448,7 @@ impl SheetApp {
|
|||
}
|
||||
let t0 = now_ms();
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
self.ribbon_keys(&ctx);
|
||||
ribbon::title_bar(self, ui);
|
||||
ribbon::show(self, ui);
|
||||
if self.ui.formula_bar {
|
||||
|
|
@ -496,6 +501,43 @@ impl SheetApp {
|
|||
}
|
||||
}
|
||||
|
||||
fn ribbon_keys(&mut self, ctx: &egui::Context) {
|
||||
let before = self.keytips.prefix().map(str::to_string);
|
||||
let enabled = self.editor.is_none()
|
||||
&& self.dialog.is_none()
|
||||
&& self.message.is_none()
|
||||
&& self.name_box.is_none()
|
||||
&& !ctx.text_edit_focused()
|
||||
&& self.grid.context_menu.is_none()
|
||||
&& self.grid.header_menu.is_none()
|
||||
&& self.grid.filter_menu.is_none()
|
||||
&& self.grid.list_picker.is_none()
|
||||
&& self.grid.renaming_tab.is_none()
|
||||
&& (before.is_some() || !egui::Popup::is_any_open(ctx));
|
||||
let actions = self.keytips.process(ctx, enabled);
|
||||
if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) {
|
||||
// Leave pointer cancellation to the popup so clicking a menu item still works.
|
||||
egui::Popup::close_all(ctx);
|
||||
}
|
||||
// The legacy Edit/Format paths lead to the same existing Home menus. Switch once, when
|
||||
// the prefix changes, not every frame (that would reset a tab the user picked meanwhile).
|
||||
let after = self.keytips.prefix();
|
||||
if after != before.as_deref() && matches!(after, Some("E" | "O" | "OC")) {
|
||||
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
|
||||
self.ui.ribbon_collapsed = false;
|
||||
}
|
||||
for action in actions {
|
||||
match action {
|
||||
keytips::Action::Home => {
|
||||
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
|
||||
self.ui.ribbon_collapsed = false;
|
||||
}
|
||||
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
|
||||
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current selection's top-left cell text as A1 (for the Name Box).
|
||||
pub fn name_box_text(&self) -> String {
|
||||
let Some(d) = self.session.active() else { return String::new() };
|
||||
|
|
|
|||
|
|
@ -168,6 +168,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
|||
if resp.double_clicked() {
|
||||
app.ui.ribbon_collapsed = !collapsed;
|
||||
}
|
||||
if tab == "Home" && app.keytips.prefix() == Some("") {
|
||||
keytip(app, ui, "H", pos2(r.center().x, r.bottom() - 1.0));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -269,13 +272,36 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) {
|
|||
app.run_or_alert(id, params);
|
||||
}
|
||||
|
||||
/// Keytips are painted, not focusable widgets: the grid retains keyboard focus.
|
||||
fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) {
|
||||
let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("ribbon_keytips"))).with_clip_rect(ui.clip_rect());
|
||||
let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text);
|
||||
let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0));
|
||||
painter.rect_filled(rect, 2.0, t.input_bg);
|
||||
painter.rect_stroke(rect, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
|
||||
painter.galley(rect.min + vec2(3.5, 1.5), text, t.text);
|
||||
}
|
||||
|
||||
fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) {
|
||||
for (label, id, p) in items {
|
||||
if *label == "-" {
|
||||
ui.separator();
|
||||
continue;
|
||||
}
|
||||
if ui.add(egui::Button::new(*label).frame(false).min_size(vec2(220.0, 22.0))).clicked() {
|
||||
let response = ui.add(egui::Button::new(*label).frame(false).min_size(vec2(220.0, 22.0)));
|
||||
let sequence = match (app.keytips.prefix(), *id) {
|
||||
(Some("HV"), "dialog:pasteSpecial") => Some("HVS"),
|
||||
(Some("E"), "dialog:pasteSpecial") => Some("ES"),
|
||||
(Some("HO"), "home.autofitColumnWidth") => Some("HOI"),
|
||||
(Some("OC"), "home.autofitColumnWidth") => Some("OCA"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(sequence) = sequence {
|
||||
keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y));
|
||||
}
|
||||
if response.clicked() {
|
||||
if let Some(d) = id.strip_prefix("dialog:") {
|
||||
app.open_dialog(d, p.clone());
|
||||
} else {
|
||||
|
|
@ -290,6 +316,9 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||
let st = active_style(app);
|
||||
// Clipboard
|
||||
let paste = big_button(ui, Icon::Paste, "Paste", "Paste (⌘V)", true);
|
||||
if app.keytips.prefix() == Some("H") {
|
||||
keytip(app, ui, "HV", paste.rect.center_bottom());
|
||||
}
|
||||
if paste.clicked() {
|
||||
if let Some(text) = app.grid.system_clipboard.take() {
|
||||
act(app, "edit.paste", json!({"text": text}));
|
||||
|
|
@ -297,7 +326,29 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||
act(app, "edit.paste", json!({}));
|
||||
}
|
||||
}
|
||||
egui::Popup::context_menu(&paste).show(|ui| {
|
||||
let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E"));
|
||||
// Keep a keyboard-opened menu anchored to the button when a pointer click
|
||||
// cancels keytips, so its items remain in place and can still be clicked.
|
||||
let anchor_id = paste.id.with("keytip_anchor");
|
||||
let anchored = ui.ctx().data_mut(|data| {
|
||||
let anchored = if paste_keys {
|
||||
true
|
||||
} else if paste.secondary_clicked() {
|
||||
false
|
||||
} else {
|
||||
data.get_temp::<bool>(anchor_id).unwrap_or(false)
|
||||
};
|
||||
data.insert_temp(anchor_id, anchored);
|
||||
anchored
|
||||
});
|
||||
let mut paste_popup = egui::Popup::context_menu(&paste);
|
||||
if anchored {
|
||||
paste_popup = paste_popup.anchor(&paste);
|
||||
}
|
||||
if paste_keys {
|
||||
paste_popup = paste_popup.open_memory(egui::SetOpenCommand::Bool(true));
|
||||
}
|
||||
paste_popup.show(|ui| {
|
||||
menu_items(
|
||||
app,
|
||||
ui,
|
||||
|
|
@ -481,7 +532,16 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||
(Icon::AlignCenter, HAlign::Center, "home.alignCenter", "Center"),
|
||||
(Icon::AlignRight, HAlign::Right, "home.alignRight", "Align Right"),
|
||||
] {
|
||||
if toggle_button(ui, icon, st.align.h == h, tip).clicked() {
|
||||
let response = toggle_button(ui, icon, st.align.h == h, tip);
|
||||
if matches!(app.keytips.prefix(), Some("H" | "HA")) {
|
||||
let sequence = match h {
|
||||
HAlign::Left => "HAL",
|
||||
HAlign::Center => "HAC",
|
||||
_ => "HAR",
|
||||
};
|
||||
keytip(app, ui, sequence, response.rect.center_bottom());
|
||||
}
|
||||
if response.clicked() {
|
||||
act(app, id, json!({}));
|
||||
}
|
||||
}
|
||||
|
|
@ -597,7 +657,17 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||
);
|
||||
});
|
||||
let fmt = small_button(ui, Icon::Format, "Format", "Format", true);
|
||||
egui::Popup::menu(&fmt).show(|ui| {
|
||||
match app.keytips.prefix() {
|
||||
Some("H") => keytip(app, ui, "HO", fmt.rect.center_bottom()),
|
||||
Some("O") => keytip(app, ui, "OC", fmt.rect.center_bottom()),
|
||||
_ => {}
|
||||
}
|
||||
let format_keys = matches!(app.keytips.prefix(), Some("HO" | "OC"));
|
||||
let mut format_popup = egui::Popup::menu(&fmt);
|
||||
if format_keys {
|
||||
format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true));
|
||||
}
|
||||
format_popup.show(|ui| {
|
||||
menu_items(
|
||||
app,
|
||||
ui,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,320 @@
|
|||
//! Ribbon access keys through the complete egui input and UI path.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, os::OperatingSystem};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_engine::model::HAlign;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
harness_on(session, OperatingSystem::Nix)
|
||||
}
|
||||
|
||||
fn harness_on(session: Session, os: OperatingSystem) -> Harness {
|
||||
let mut h = egui_kittest::Harness::builder().with_os(os).with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn blank() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
fn events(h: &mut Harness, events: impl IntoIterator<Item = Event>) {
|
||||
h.input_mut().events.extend(events);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness, key: Key) {
|
||||
events(h, [key_event(key, true, Modifiers::NONE), key_event(key, false, Modifiers::NONE)]);
|
||||
}
|
||||
|
||||
fn letter(h: &mut Harness, key: Key, text_first: bool) {
|
||||
let text = Event::Text(key.name().to_ascii_lowercase());
|
||||
let pressed = key_event(key, true, Modifiers::NONE);
|
||||
let pair = if text_first { [text, pressed] } else { [pressed, text] };
|
||||
events(h, pair.into_iter().chain([key_event(key, false, Modifiers::NONE)]));
|
||||
}
|
||||
|
||||
fn sequence(h: &mut Harness, letters: &[Key], text_first: bool) {
|
||||
key(h, Key::F10);
|
||||
for &k in letters {
|
||||
letter(h, k, text_first);
|
||||
}
|
||||
}
|
||||
|
||||
fn value(h: &Harness, cell: &str) -> Value {
|
||||
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
|
||||
}
|
||||
|
||||
fn alignment(h: &Harness) -> HAlign {
|
||||
let d = h.state().session.active().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).align.h
|
||||
}
|
||||
|
||||
fn copied_row() -> Session {
|
||||
let mut s = blank();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=20+22"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "99"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1:B1"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_sequences_consume_key_and_text_events_in_either_order() {
|
||||
for text_first in [false, true] {
|
||||
let mut s = blank();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "keep me"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
h.state_mut().ui.ribbon_tab = "Insert".into();
|
||||
for (k, expected) in [(Key::R, HAlign::Right), (Key::L, HAlign::Left), (Key::C, HAlign::Center)] {
|
||||
sequence(&mut h, &[Key::H, Key::A, k], text_first);
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Home");
|
||||
assert_eq!(alignment(&h), expected);
|
||||
assert_eq!(value(&h, "A1"), Value::from("keep me"));
|
||||
assert!(h.state().editor.is_none(), "access-key letters must not begin a cell edit");
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
}
|
||||
// Consuming the terminal access key must not swallow the next ordinary keystroke.
|
||||
letter(&mut h, Key::X, text_first);
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(value(&h, "A1"), Value::from("x"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_options_wait_for_enter_and_transpose_values() {
|
||||
let mut h = harness(copied_row());
|
||||
sequence(&mut h, &[Key::H, Key::V, Key::S], true);
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
|
||||
for (k, what) in [(Key::T, "formats"), (Key::V, "values"), (Key::F, "formulas"), (Key::C, "comments")] {
|
||||
letter(&mut h, k, true);
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("what"), Some(&json!(what)));
|
||||
assert_eq!(value(&h, "D4"), Value::Empty, "choosing a paste option must not paste yet");
|
||||
}
|
||||
letter(&mut h, Key::V, true);
|
||||
letter(&mut h, Key::E, true);
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("transpose"), Some(&json!(true)));
|
||||
for modifiers in [Modifiers::CTRL, Modifiers::COMMAND] {
|
||||
events(
|
||||
&mut h,
|
||||
[
|
||||
Event::ModifiersChanged(modifiers),
|
||||
key_event(Key::T, true, modifiers),
|
||||
key_event(Key::T, false, modifiers),
|
||||
key_event(Key::E, true, modifiers),
|
||||
key_event(Key::E, false, modifiers),
|
||||
Event::ModifiersChanged(Modifiers::NONE),
|
||||
],
|
||||
);
|
||||
let dialog = h.state().dialog.as_ref().unwrap();
|
||||
assert_eq!(dialog.values.get("what"), Some(&json!("values")), "Ctrl/Cmd must not select a paste mnemonic");
|
||||
assert_eq!(dialog.values.get("transpose"), Some(&json!(true)), "Ctrl/Cmd must not toggle transpose");
|
||||
}
|
||||
// Non-macOS Alt+E selects the dialog mnemonic without activating ribbon access keys.
|
||||
events(
|
||||
&mut h,
|
||||
[
|
||||
Event::ModifiersChanged(Modifiers::ALT),
|
||||
Event::Text("e".into()),
|
||||
key_event(Key::E, true, Modifiers::ALT),
|
||||
key_event(Key::E, false, Modifiers::ALT),
|
||||
Event::ModifiersChanged(Modifiers::NONE),
|
||||
],
|
||||
);
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("transpose"), Some(&json!(false)));
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
letter(&mut h, Key::E, false);
|
||||
key(&mut h, Key::Enter);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(value(&h, "D4"), Value::Number(42.0));
|
||||
assert_eq!(value(&h, "D5"), Value::Number(99.0));
|
||||
assert_eq!(value(&h, "E4"), Value::Empty);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert!(sh.cell(CellRef::parse("D4").unwrap()).unwrap().formula.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_opened_paste_menu_accepts_a_mouse_click_after_keytips_cancel() {
|
||||
let mut h = harness(copied_row());
|
||||
sequence(&mut h, &[Key::H, Key::V], false);
|
||||
assert_eq!(h.state().keytips.prefix(), Some("HV"));
|
||||
assert!(h.state().dialog.is_none());
|
||||
|
||||
// This sends actual pointer events at the accessible menu item's rectangle.
|
||||
// The press cancels keytips, but must not relocate or close the menu before the click.
|
||||
h.get_by_label("Paste Special…").click();
|
||||
h.run_steps(3);
|
||||
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
|
||||
assert_eq!(value(&h, "D4"), Value::Empty);
|
||||
assert!(h.state().editor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_paste_special_keeps_formulas_when_requested() {
|
||||
let mut h = harness(copied_row());
|
||||
sequence(&mut h, &[Key::E, Key::S], false);
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
|
||||
letter(&mut h, Key::F, false);
|
||||
assert_eq!(value(&h, "D4"), Value::Empty);
|
||||
key(&mut h, Key::Enter);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.cell(CellRef::parse("D4").unwrap()).unwrap().formula.as_ref().unwrap().text, "20+22");
|
||||
assert_eq!(value(&h, "D4"), Value::Number(42.0));
|
||||
assert_eq!(value(&h, "E4"), Value::Number(99.0));
|
||||
assert!(h.state().editor.is_none());
|
||||
key(&mut h, Key::ArrowDown);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "D5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ribbon_and_legacy_autofit_sequences_resize_the_selected_column() {
|
||||
for route in [&[Key::H, Key::O, Key::I][..], &[Key::O, Key::C, Key::A][..]] {
|
||||
let mut s = blank();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "A long heading that needs a wider column"})).unwrap();
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "width": 12})).unwrap();
|
||||
let mut h = harness(s);
|
||||
let before = h.state().session.active().unwrap().wb.active().unwrap().col_width(0);
|
||||
sequence(&mut h, route, false);
|
||||
let after = h.state().session.active().unwrap().wb.active().unwrap().col_width(0);
|
||||
assert!(after > before, "autofit must widen the narrow column");
|
||||
assert_eq!(value(&h, "A1"), Value::from("A long heading that needs a wider column"));
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
letter(&mut h, Key::X, false);
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(value(&h, "A1"), Value::from("x"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_prefix_switches_to_home_once_not_every_frame() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().ui.ribbon_tab = "Insert".into();
|
||||
sequence(&mut h, &[Key::E], false);
|
||||
assert_eq!(h.state().keytips.prefix(), Some("E"));
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Home");
|
||||
// While the prefix stays the same, later frames must not keep forcing the Home tab.
|
||||
h.state_mut().ui.ribbon_tab = "Insert".into();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().keytips.prefix(), Some("E"));
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Insert");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_backs_out_without_clearing_copy_and_f10_toggles_off() {
|
||||
let mut h = harness(copied_row());
|
||||
sequence(&mut h, &[Key::H, Key::A], false);
|
||||
assert_eq!(h.state().keytips.prefix(), Some("HA"));
|
||||
for expected in [Some("H"), Some(""), None] {
|
||||
key(&mut h, Key::Escape);
|
||||
assert_eq!(h.state().keytips.prefix(), expected);
|
||||
assert_eq!(h.state().session.clipboard.as_ref().unwrap().range.a1(), "A1:B1");
|
||||
}
|
||||
key(&mut h, Key::F10);
|
||||
assert_eq!(h.state().keytips.prefix(), Some(""));
|
||||
key(&mut h, Key::F10);
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
assert!(h.state().session.clipboard.is_some());
|
||||
|
||||
// A bare left Alt is the desktop entry point on non-macOS systems.
|
||||
events(&mut h, [Event::ModifiersChanged(Modifiers::ALT), key_event(Key::AltLeft, true, Modifiers::ALT)]);
|
||||
events(&mut h, [Event::ModifiersChanged(Modifiers::NONE), key_event(Key::AltLeft, false, Modifiers::NONE)]);
|
||||
assert_eq!(h.state().keytips.prefix(), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_press_and_window_focus_loss_cancel_keytips() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, &[Key::H], false);
|
||||
let pos = h.state().grid.cells_rect.unwrap().center();
|
||||
events(
|
||||
&mut h,
|
||||
[
|
||||
Event::PointerMoved(pos),
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
|
||||
],
|
||||
);
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
key(&mut h, Key::F10);
|
||||
assert_eq!(h.state().keytips.prefix(), Some(""));
|
||||
h.input_mut().focused = false;
|
||||
events(&mut h, [Event::WindowFocused(false)]);
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
assert!(h.state().editor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_keys_do_not_toggle_twice_or_leak_repeated_letters() {
|
||||
let mut h = harness(blank());
|
||||
// egui derives repeats from held-key state, so send presses across frames before release.
|
||||
events(&mut h, [key_event(Key::F10, true, Modifiers::NONE)]);
|
||||
events(&mut h, [key_event(Key::F10, true, Modifiers::NONE)]);
|
||||
assert_eq!(h.state().keytips.prefix(), Some(""));
|
||||
events(&mut h, [key_event(Key::F10, false, Modifiers::NONE)]);
|
||||
events(&mut h, [key_event(Key::H, true, Modifiers::NONE), Event::Text("h".into())]);
|
||||
events(&mut h, [key_event(Key::H, true, Modifiers::NONE), Event::Text("h".into())]);
|
||||
assert_eq!(h.state().keytips.prefix(), Some("H"));
|
||||
events(&mut h, [key_event(Key::H, false, Modifiers::NONE)]);
|
||||
letter(&mut h, Key::A, false);
|
||||
events(
|
||||
&mut h,
|
||||
[
|
||||
key_event(Key::R, true, Modifiers::NONE),
|
||||
Event::Text("r".into()),
|
||||
key_event(Key::R, true, Modifiers::NONE),
|
||||
Event::Text("r".into()),
|
||||
key_event(Key::R, false, Modifiers::NONE),
|
||||
],
|
||||
);
|
||||
assert_eq!(alignment(&h), HAlign::Right);
|
||||
assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.alignRight").count(), 1);
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(value(&h, "A1"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_typing_editor_shortcuts_and_altgr_remain_text_input() {
|
||||
let mut h = harness(blank());
|
||||
events(&mut h, [Event::Text("hello".into())]);
|
||||
key(&mut h, Key::F10);
|
||||
assert_eq!(h.state().keytips.prefix(), None, "F10 must not interrupt the cell editor");
|
||||
assert_eq!(h.state().editor.as_ref().unwrap().text, "hello");
|
||||
events(&mut h, [Event::Text("!".into())]);
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(value(&h, "A1"), Value::from("hello!"));
|
||||
|
||||
let mut h = harness(blank());
|
||||
let altgr = Modifiers { alt: true, ctrl: true, ..Modifiers::NONE };
|
||||
events(&mut h, [Event::ModifiersChanged(altgr), key_event(Key::AltRight, true, altgr), key_event(Key::Q, true, altgr), Event::Text("@".into())]);
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
assert_eq!(h.state().editor.as_ref().unwrap().text, "@");
|
||||
|
||||
let mut h = harness_on(blank(), OperatingSystem::Mac);
|
||||
events(&mut h, [Event::ModifiersChanged(Modifiers::ALT), key_event(Key::AltLeft, true, Modifiers::ALT)]);
|
||||
events(&mut h, [Event::ModifiersChanged(Modifiers::NONE), key_event(Key::AltLeft, false, Modifiers::NONE), Event::Text("é".into())]);
|
||||
assert_eq!(h.state().keytips.prefix(), None, "macOS Option must remain available for text composition");
|
||||
assert_eq!(h.state().editor.as_ref().unwrap().text, "é");
|
||||
}
|
||||
Loading…
Reference in New Issue