diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 3f1c98ebf..c6890a3bb 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -6,8 +6,12 @@ //! Pan/Zoom, Quick Select, Find, Properties and Options. //! - **While a command runs** ([`command_entries`]): a quick right-click is Enter; holding the //! button for [`HOLD_SECS`] opens Enter, Cancel, Recent Input, the prompt's options, Snap -//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click). With that turned off -//! in OPTIONS (`timeSensitiveRightClick`), every right-click opens the menu. +//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click; Options ▸ User +//! Preferences ▸ Right-Click Customization sets how long, `rightClickDuration`). With that turned +//! off (`timeSensitiveRightClick`), Command mode says what a right-click does +//! (`rightClickCommand`: the menu, Enter, or the menu only when the prompt has options). +//! `rightClickDefault` and `rightClickEdit` can make a right-click with no command running +//! repeat the last command instead of opening the menu. //! - **Shift/Ctrl+right-click** ([`snap_entries`]): the object snap menu. Its items type the point //! modifiers and snap overrides (`_from`, `_endp`, `_non`…) at the current point prompt. //! @@ -41,7 +45,8 @@ const SETTINGS: &[&str] = &["options"]; /// How many recent commands and inputs the menus list. pub const RECENT: usize = 8; /// How long the right button must be held while a command runs to open the command menu instead -/// of acting as Enter (AutoCAD's time-sensitive right-click; 250 ms there by default). +/// of acting as Enter, by default (AutoCAD's time-sensitive right-click; 250 ms there by +/// default). OPTIONS `rightClickDuration` changes it. pub const HOLD_SECS: f64 = 0.25; /// Item ids that are not commands: keys, typed text and snap menu items. @@ -313,6 +318,19 @@ pub fn groups_ui(ui: &mut egui::Ui, groups: &[Vec], clicked: &mut Option< pub fn show(app: &mut CadApp, resp: &egui::Response) { let mut clicked = None; let snap = app.canvas.snap_menu; + // Right-Click Customization: with nothing selected (Default mode) or a selection (Edit mode), + // a right-click may repeat the last command instead of opening the menu. + let o = &app.options; + let mode = + if o.right_click_default == o.right_click_edit || app.session.selection().is_empty() { o.right_click_default } else { o.right_click_edit }; + if !snap && mode == crate::options::IdleClick::Repeat { + if resp.secondary_clicked() + && let Some(last) = app.session.last_command.clone() + { + app.start(&last); + } + return; + } resp.context_menu(|ui| { let groups = if snap { snap_entries(app) } else { entries(app) }; groups_ui(ui, &groups, &mut clicked); @@ -330,8 +348,9 @@ struct Press(f64, egui::Pos2); /// The canvas's right button while a command runs (Shift/Ctrl+right-click is the snap menu's): a /// quick click is Enter — returns `true` when the caller should send it — and a click held for -/// [`HOLD_SECS`] opens the command menu ([`command_entries`]) at the pointer (any click does with -/// the time-sensitive right-click turned off). Call it every frame +/// OPTIONS `rightClickDuration` opens the command menu ([`command_entries`]) at the pointer. With +/// the time-sensitive right-click turned off, `rightClickCommand` says whether a click opens the +/// menu or is Enter. Call it every frame /// with whether the pointer is on the canvas and whether a right-click ended there; it also shows /// the menu while it is open. pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: bool) -> bool { @@ -348,14 +367,23 @@ pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: ctx.data_mut(|d| d.insert_temp(press_id, Press(now, p))); } let running = app.session.prompting() && !app.canvas.snap_menu; + let o = &app.options; + let timed = o.time_sensitive_right_click; + // Without the time-sensitive right-click, Command mode says whether a click opens the menu. + let menu = match o.right_click_command { + crate::options::CommandClick::Menu => true, + crate::options::CommandClick::Enter => false, + crate::options::CommandClick::MenuWithOptions => app.session.current_prompt().is_some_and(|p| !p.keywords.is_empty()), + }; + let hold = f64::from(o.right_click_hold_ms) / 1000.0; let mut open = false; if released && let Some(Press(t0, p0)) = ctx.data_mut(|d| d.remove_temp::(press_id)) { // A drag isn't a click; a click held long enough asks for the menu. let still = pos.is_some_and(|p| p.distance(p0) <= 6.0); - let held = now - t0 >= HOLD_SECS || !app.options.time_sensitive_right_click; + let held = if timed { now - t0 >= hold } else { menu }; open = running && still && held; } - let enter = running && inside && right_clicked && !open && app.options.time_sensitive_right_click; + let enter = running && inside && right_clicked && !open && (timed || !menu); if !running { return false; } diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9819ce4d7..8920337bd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1381,3 +1381,20 @@ menu Modify Edita Ctrl+Enter to apply Ctrl+Enter to apply Edit Text Edit Text The drawing changed. Close the window and open it again. Il disegno è cambiato. Chiudere la finestra e riaprirla. + Right-Click Customization... Personalizzazione pulsante destro... + Right-Click Customization Personalizzazione pulsante destro + Turn on time-sensitive right-click Attiva clic destro sensibile al tempo + Longer click duration: Durata clic lungo: + milliseconds millisecondi + While a command runs, a quick click is Enter and a longer click opens the shortcut menu. Durante un comando un clic rapido equivale a Invio, un clic più lungo apre il menu di scelta rapida. + Default Mode Modalità predefinita + No objects selected and no command running: Nessun oggetto selezionato e nessun comando in corso: + Edit Mode Modalità modifica + Objects selected and no command running: Oggetti selezionati e nessun comando in corso: + Repeat last command Ripeti ultimo comando + Shortcut Menu Menu di scelta rapida + Command Mode Modalità comando + While a command runs: Durante un comando: + ENTER INVIO + Shortcut Menu: always enabled Menu di scelta rapida: sempre attivo + Shortcut Menu: enabled when command options are present Menu di scelta rapida: attivo se il comando ha opzioni diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f846a1854..0c661892c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1405,3 +1405,20 @@ Press the keys Натисніть клавіші Back to CADCraft's shortcut Повернути комбінацію CADCraft A shortcut you give wins over CADCraft's own. Changes apply at once and are kept for next time. Ваша комбінація клавіш має перевагу над комбінацією CADCraft. Зміни застосовуються одразу й зберігаються. + Right-Click Customization... Налаштування правої кнопки... + Right-Click Customization Налаштування правої кнопки + Turn on time-sensitive right-click Увімкнути залежне від часу клацання правою кнопкою + Longer click duration: Тривалість довгого клацання: + milliseconds мілісекунд + While a command runs, a quick click is Enter and a longer click opens the shortcut menu. Під час команди коротке клацання — Enter, довше відкриває контекстне меню. + Default Mode Режим за замовчуванням + No objects selected and no command running: Нічого не вибрано й команда не виконується: + Edit Mode Режим редагування + Objects selected and no command running: Об'єкти вибрано, команда не виконується: + Repeat last command Повторити останню команду + Shortcut Menu Контекстне меню + Command Mode Режим команди + While a command runs: Під час виконання команди: + ENTER Клавіша Enter + Shortcut Menu: always enabled Контекстне меню: завжди + Shortcut Menu: enabled when command options are present Контекстне меню: коли команда має параметри diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index a651335b8..d81b77f7a 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -16,7 +16,10 @@ //! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) | //! | `SAVEFILEPATH` | the folder automatic saves go to | //! | `ISAVEBAK` | 1: saving over a drawing keeps the previous file as `.bak` (default); 0: no backup | -//! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: a right-click always opens the menu | +//! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: `rightClickCommand` decides | +//! | `rightClickDuration` | how long the right button is held to open the menu while a command runs, 100–2000 ms (250) | +//! | `rightClickDefault`, `rightClickEdit` | a right-click with no command running and nothing (`Default`) or something (`Edit`) selected: `"menu"` (the shortcut menu, default) or `"repeat"` (the last command) | +//! | `rightClickCommand` | a right-click while a command runs, with the time-sensitive right-click off: `"menu"` (default), `"enter"`, or `"menuWithOptions"` (the menu when the prompt has options, else Enter) | //! | `systemCursor` | keep the system cursor visible over the drawing area | //! | `historyLines` | command-line history lines, 0–12 | //! | `modelBackground`, `paperBackground` | `"#rrggbb"`, or `null` for the default | @@ -65,6 +68,66 @@ pub const SNAP_MARKER_SIZES: std::ops::RangeInclusive = 5.0..=31.0; /// Interface scales the JSON form accepts (1: the system's own size). pub const INTERFACE_SCALES: std::ops::RangeInclusive = 0.5..=3.0; +/// How long a right-click may be held before it opens the menu, in milliseconds. +pub const RIGHT_CLICK_DURATIONS: std::ops::RangeInclusive = 100..=2000; + +/// What a right-click does with no command running (Right-Click Customization's Default and Edit +/// modes). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum IdleClick { + /// Repeat the last command. + Repeat, + /// The shortcut menu. + #[default] + Menu, +} + +impl IdleClick { + pub const ALL: [IdleClick; 2] = [IdleClick::Repeat, IdleClick::Menu]; + + /// The JSON name. + pub fn as_str(self) -> &'static str { + match self { + IdleClick::Repeat => "repeat", + IdleClick::Menu => "menu", + } + } + + pub fn parse(s: &str) -> Option { + IdleClick::ALL.into_iter().find(|c| c.as_str().eq_ignore_ascii_case(s.trim())) + } +} + +/// What a right-click does while a command runs with the time-sensitive right-click off +/// (Right-Click Customization's Command mode). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CommandClick { + /// Enter. + Enter, + /// The shortcut menu, always. + #[default] + Menu, + /// The shortcut menu when the prompt has options, else Enter. + MenuWithOptions, +} + +impl CommandClick { + pub const ALL: [CommandClick; 3] = [CommandClick::Enter, CommandClick::Menu, CommandClick::MenuWithOptions]; + + /// The JSON name. + pub fn as_str(self) -> &'static str { + match self { + CommandClick::Enter => "enter", + CommandClick::Menu => "menu", + CommandClick::MenuWithOptions => "menuWithOptions", + } + } + + pub fn parse(s: &str) -> Option { + CommandClick::ALL.into_iter().find(|c| c.as_str().eq_ignore_ascii_case(s.trim())) + } +} + /// The interface's own preferences (the drafting ones are engine settings). #[derive(Clone, Copy, Debug, PartialEq)] pub struct UiOptions { @@ -77,8 +140,16 @@ pub struct UiOptions { /// The selection highlight colour; `None` keeps the theme's. pub selection_color: Option, /// While a command runs: a quick right-click is Enter and a held one opens the shortcut menu - /// (AutoCAD's time-sensitive right-click). Off: a right-click always opens the menu. + /// (AutoCAD's time-sensitive right-click). Off: [`Self::right_click_command`] decides. pub time_sensitive_right_click: bool, + /// How long the right button is held to open the menu while a command runs, in milliseconds. + pub right_click_hold_ms: u32, + /// A right-click with no command running and nothing selected (Default mode) or a selection + /// (Edit mode). + pub right_click_default: IdleClick, + pub right_click_edit: IdleClick, + /// A right-click while a command runs, with the time-sensitive right-click off. + pub right_click_command: CommandClick, /// The size of every menu, palette, dialog and the command line (1: the system's own). pub interface_scale: f32, } @@ -91,6 +162,10 @@ impl Default for UiOptions { snap_marker_size: theme::SNAP_MARKER, selection_color: None, time_sensitive_right_click: true, + right_click_hold_ms: (crate::context_menu::HOLD_SECS * 1000.0) as u32, + right_click_default: IdleClick::Menu, + right_click_edit: IdleClick::Menu, + right_click_command: CommandClick::Menu, interface_scale: 1.0, } } @@ -158,6 +233,10 @@ struct UiChanges { savefilepath: Option, isavebak: Option, time_sensitive_right_click: Option, + right_click_hold_ms: Option, + right_click_default: Option, + right_click_edit: Option, + right_click_command: Option, interface_scale: Option, } @@ -201,6 +280,17 @@ fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result { c.save_version = Some(crate::SaveVersion::parse(name.trim()).ok_or("saveVersion must be 2000, 2004, 2007, 2010, 2013 or 2018")?); } "timeSensitiveRightClick" => c.time_sensitive_right_click = Some(v.as_bool().ok_or("timeSensitiveRightClick must be true or false")?), + "rightClickDuration" => { + let (lo, hi) = (f64::from(*RIGHT_CLICK_DURATIONS.start()), f64::from(*RIGHT_CLICK_DURATIONS.end())); + let n = + v.as_f64().filter(|n| n.is_finite() && (lo..=hi).contains(n)).ok_or("rightClickDuration must be from 100 to 2000 milliseconds")?; + c.right_click_hold_ms = Some(n.round() as u32); + } + "rightClickDefault" => c.right_click_default = Some(IdleClick::parse(text()?).ok_or("rightClickDefault must be menu or repeat")?), + "rightClickEdit" => c.right_click_edit = Some(IdleClick::parse(text()?).ok_or("rightClickEdit must be menu or repeat")?), + "rightClickCommand" => { + c.right_click_command = Some(CommandClick::parse(text()?).ok_or("rightClickCommand must be menu, enter or menuWithOptions")?) + } k if k.eq_ignore_ascii_case("SAVETIME") => { let max = cadcraft_engine::autosave::MAX_SAVETIME; let n = v @@ -242,6 +332,10 @@ fn ui_json(app: &CadApp) -> Map { m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath())); m.insert("ISAVEBAK".into(), json!(i32::from(cadcraft_engine::autosave::isavebak()))); m.insert("timeSensitiveRightClick".into(), json!(o.time_sensitive_right_click)); + m.insert("rightClickDuration".into(), json!(o.right_click_hold_ms)); + m.insert("rightClickDefault".into(), json!(o.right_click_default.as_str())); + m.insert("rightClickEdit".into(), json!(o.right_click_edit.as_str())); + m.insert("rightClickCommand".into(), json!(o.right_click_command.as_str())); m } @@ -318,9 +412,26 @@ fn run_json(app: &mut CadApp, params: &Value) -> Result { if let Some(b) = ui.time_sensitive_right_click { app.options.time_sensitive_right_click = b; } + apply_right_click(&mut app.options, &ui); Ok(report(app)) } +/// Right-Click Customization's settings (the time-sensitive switch aside) from `c`. +fn apply_right_click(o: &mut UiOptions, c: &UiChanges) { + if let Some(ms) = c.right_click_hold_ms { + o.right_click_hold_ms = ms; + } + if let Some(m) = c.right_click_default { + o.right_click_default = m; + } + if let Some(m) = c.right_click_edit { + o.right_click_edit = m; + } + if let Some(m) = c.right_click_command { + o.right_click_command = m; + } +} + /// The interface preferences back to their defaults (the interface language stays). fn reset_ui(app: &mut CadApp) { let d = crate::UiState::default(); @@ -349,6 +460,10 @@ impl CadApp { "selectionColor": color_json(self.options.selection_color), "snapMarkerSize": self.options.snap_marker_size, "timeSensitiveRightClick": self.options.time_sensitive_right_click, + "rightClickDuration": self.options.right_click_hold_ms, + "rightClickDefault": self.options.right_click_default.as_str(), + "rightClickEdit": self.options.right_click_edit.as_str(), + "rightClickCommand": self.options.right_click_command.as_str(), "interfaceScale": self.options.interface_scale, }) .to_string() @@ -370,9 +485,19 @@ impl CadApp { cadcraft_engine::cmd::file::restore_aliases(aliases); } let mut c = UiChanges::default(); - for key in - ["systemCursor", "modelBackground", "paperBackground", "selectionColor", "snapMarkerSize", "timeSensitiveRightClick", "interfaceScale"] - { + for key in [ + "systemCursor", + "modelBackground", + "paperBackground", + "selectionColor", + "snapMarkerSize", + "timeSensitiveRightClick", + "rightClickDuration", + "rightClickDefault", + "rightClickEdit", + "rightClickCommand", + "interfaceScale", + ] { if let Some(value) = v.get(key) { let _ = parse_ui(key, value, &mut c); } @@ -396,6 +521,7 @@ impl CadApp { if let Some(b) = c.time_sensitive_right_click { self.options.time_sensitive_right_click = b; } + apply_right_click(&mut self.options, &c); if let Some(n) = c.interface_scale { self.options.interface_scale = n; } @@ -415,4 +541,16 @@ mod tests { assert_eq!(parse_hex(bad), None, "{bad}"); } } + + #[test] + fn idle_and_command_clicks_parse_their_names() { + for c in IdleClick::ALL { + assert_eq!(IdleClick::parse(c.as_str()), Some(c)); + } + for c in CommandClick::ALL { + assert_eq!(CommandClick::parse(c.as_str()), Some(c)); + } + assert_eq!(IdleClick::parse("enter"), None); + assert_eq!(CommandClick::parse("repeat"), None); + } } diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs index 45be0ce67..e1a9fa966 100644 --- a/crates/ui-egui/src/options/dialog.rs +++ b/crates/ui-egui/src/options/dialog.rs @@ -537,8 +537,9 @@ fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map) { + let mut right_click = ui.ctx().data(|d| d.get_temp::(egui::Id::unique(RIGHT_CLICK)).unwrap_or(false)); grid(ui, "options_preferences", |ui| { let language_caption = ui.label(crate::tl!("Interface language")); let mut lang = app.ui.interface_language; @@ -571,6 +572,94 @@ fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map changes.insert("timeSensitiveRightClick".into(), json!(timed)); } hint(ui, crate::tl!("Turned off, a right-click while a command runs always opens the shortcut menu.")); + if ui.button(crate::tl!("Right-Click Customization...")).clicked() { + right_click = true; + } + if right_click { + right_click_window(&app.options, ui.ctx(), &mut right_click, changes); + } + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(RIGHT_CLICK), right_click)); +} + +/// Whether the Right-Click Customization window is open (egui temp data). +const RIGHT_CLICK: &str = "cc_options_right_click"; + +/// Right-Click Customization (Options ▸ User Preferences): the time-sensitive right-click and its +/// duration, and what a right-click does in Default, Edit and Command modes. Changes apply at +/// once, as on the Options tabs. +fn right_click_window(o: &super::UiOptions, ctx: &egui::Context, open: &mut bool, changes: &mut Map) { + use super::{CommandClick, IdleClick}; + let mut close = false; + egui::Window::new(crate::tl!("Right-Click Customization")) + .id(egui::Id::unique("cc_right_click")) + .open(open) + .resizable(false) + .collapsible(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .show(ctx, |ui| { + ui.set_width(460.0); + let mut timed = o.time_sensitive_right_click; + if ui.checkbox(&mut timed, crate::tl!("Turn on time-sensitive right-click")).changed() { + changes.insert("timeSensitiveRightClick".into(), json!(timed)); + } + ui.add_enabled_ui(o.time_sensitive_right_click, |ui| { + ui.horizontal(|ui| { + ui.add_space(20.0); + let caption = ui.label(crate::tl!("Longer click duration:")); + let mut ms = o.right_click_hold_ms; + let range = super::RIGHT_CLICK_DURATIONS; + if ui.add(egui::DragValue::new(&mut ms).range(range).speed(10.0)).labelled_by(caption.id).changed() { + changes.insert("rightClickDuration".into(), json!(ms)); + } + ui.label(crate::tl!("milliseconds")); + }); + hint(ui, crate::tl!("While a command runs, a quick click is Enter and a longer click opens the shortcut menu.")); + }); + ui.add_space(6.0); + for (title, note, key, current) in [ + (crate::tl!("Default Mode"), crate::tl!("No objects selected and no command running:"), "rightClickDefault", o.right_click_default), + (crate::tl!("Edit Mode"), crate::tl!("Objects selected and no command running:"), "rightClickEdit", o.right_click_edit), + ] { + heading(ui, title); + ui.group(|ui| { + ui.set_width(ui.available_width()); + hint(ui, note); + for (choice, label) in [(IdleClick::Repeat, crate::tl!("Repeat last command")), (IdleClick::Menu, crate::tl!("Shortcut Menu"))] { + if ui.radio(current == choice, label).clicked() && current != choice { + changes.insert(key.into(), json!(choice.as_str())); + } + } + }); + } + ui.add_enabled_ui(!o.time_sensitive_right_click, |ui| { + heading(ui, crate::tl!("Command Mode")); + ui.group(|ui| { + ui.set_width(ui.available_width()); + hint(ui, crate::tl!("While a command runs:")); + for (choice, label) in [ + (CommandClick::Enter, crate::tl!("ENTER")), + (CommandClick::Menu, crate::tl!("Shortcut Menu: always enabled")), + (CommandClick::MenuWithOptions, crate::tl!("Shortcut Menu: enabled when command options are present")), + ] { + if ui.radio(o.right_click_command == choice, label).clicked() && o.right_click_command != choice { + changes.insert("rightClickCommand".into(), json!(choice.as_str())); + } + } + }); + }); + ui.separator(); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(crate::tl!("Close")).clicked() { + close = true; + } + }); + }); + }); + if close { + *open = false; + } } fn profiles(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index d9600e80c..a368c491a 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -23,6 +23,7 @@ mod named_viewports_dialog; mod native_menu_text_keys; mod native_windows; mod options_dialog; +mod options_right_click; mod parameters_rename; mod pickdrag; mod properties_units; diff --git a/crates/ui-egui/tests/it/options_right_click.rs b/crates/ui-egui/tests/it/options_right_click.rs new file mode 100644 index 000000000..f12de22c8 --- /dev/null +++ b/crates/ui-egui/tests/it/options_right_click.rs @@ -0,0 +1,210 @@ +//! Options ▸ User Preferences ▸ Right-Click Customization: the time-sensitive right-click's +//! duration, and what a right-click does with nothing selected (Default mode), with a selection +//! (Edit mode) and while a command runs (Command mode), through the OPTIONS JSON form, the +//! drawing area and the dialog. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::options::{CommandClick, IdleClick}; +use cadcraft_ui_egui::{CadApp, Services, menus}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) +} + +/// One frame at `time` (seconds) with `events`; returns the painted text. +fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec, time: f64) -> String { + fn collect(shape: &egui::Shape, text: &mut String) { + match shape { + egui::Shape::Text(s) => { + text.push_str(s.galley.text()); + text.push('\n'); + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, text)), + _ => {} + } + } + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), + time: Some(time), + events, + ..Default::default() + }; + app.logic(ctx); + let mut out = ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + let mut text = String::new(); + out.shapes.iter().for_each(|s| collect(&s.shape, &mut text)); + text +} + +const AT: egui::Pos2 = egui::pos2(800.0, 500.0); + +fn button(pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos: AT, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE } +} + +/// A right-click on the drawing area from `t` held for `held` seconds; returns the text painted +/// just after. +fn right_click(app: &mut CadApp, ctx: &egui::Context, t: f64, held: f64) -> String { + frame(app, ctx, vec![egui::Event::PointerMoved(AT)], t); + frame(app, ctx, vec![button(true)], t + 0.01); + frame(app, ctx, vec![button(false)], t + 0.01 + held); + frame(app, ctx, Vec::new(), t + 0.05 + held) +} + +#[test] +fn right_click_settings_are_checked_and_survive_a_restart() { + let mut a = app(); + let all = a.run("options", json!({})).unwrap(); + assert_eq!( + (all["rightClickDuration"].clone(), all["rightClickDefault"].clone(), all["rightClickEdit"].clone(), all["rightClickCommand"].clone()), + (json!(250), json!("menu"), json!("menu"), json!("menu")) + ); + let r = a + .run( + "options", + json!({"rightClickDuration": 400, "rightClickDefault": "repeat", "rightClickEdit": "Repeat", "rightClickCommand": "menuWithOptions"}), + ) + .unwrap(); + assert_eq!((r["rightClickDuration"].clone(), r["rightClickEdit"].clone()), (json!(400), json!("repeat"))); + assert_eq!(a.options.right_click_command, CommandClick::MenuWithOptions); + + let before = a.run("options", json!({})).unwrap(); + for bad in [ + json!({"rightClickDuration": 50}), + json!({"rightClickDuration": 2001}), + json!({"rightClickDuration": f64::NAN}), + json!({"rightClickDuration": "long"}), + json!({"rightClickDefault": "enter"}), + json!({"rightClickEdit": 1}), + json!({"rightClickCommand": "repeat", "PICKBOX": 9}), + ] { + assert!(a.run("options", bad.clone()).is_err(), "{bad}"); + assert_eq!(a.run("options", json!({})).unwrap(), before, "{bad} changed nothing"); + } + + let mut b = app(); + b.load_profile(&a.profile_json()); + assert_eq!(b.options, a.options); + let mut c = app(); + c.load_profile(r#"{"rightClickDuration": 99999, "rightClickDefault": "fly", "rightClickCommand": null}"#); + assert_eq!(c.options, app().options, "hostile saved values are skipped"); + let r = a.run("options", json!({"reset": true})).unwrap(); + assert_eq!((r["rightClickDuration"].clone(), r["rightClickDefault"].clone()), (json!(250), json!("menu"))); +} + +#[test] +fn an_idle_right_click_can_repeat_the_last_command() { + let mut a = app(); + a.run("options", json!({"rightClickDefault": "repeat"})).unwrap(); + a.cmdline("circle 0,0 5"); + assert!(!a.session.prompting()); + let ctx = egui::Context::default(); + frame(&mut a, &ctx, Vec::new(), 0.0); + right_click(&mut a, &ctx, 0.1, 0.05); + assert!(a.session.prompting(), "CIRCLE runs again: {}", a.session.prompt_text()); + a.cmdline("10,10 2"); + + // With a selection, Edit mode decides: the menu (its default) opens instead. + a.run("selectall", json!({})).unwrap(); + assert!(!a.session.selection().is_empty()); + let text = right_click(&mut a, &ctx, 1.0, 0.05); + assert!(!a.session.prompting(), "no command started: {}", a.session.prompt_text()); + assert!(text.contains("Erase"), "the shortcut menu opens:\n{text}"); +} + +#[test] +fn command_mode_and_click_duration_decide_enter_or_the_menu() { + let ctx = egui::Context::default(); + + // A longer duration: a 0.5 s click is still Enter. + let mut a = app(); + a.run("options", json!({"rightClickDuration": 1000})).unwrap(); + frame(&mut a, &ctx, Vec::new(), 0.0); + a.cmdline("LINE 0,0 10,0"); + right_click(&mut a, &ctx, 0.1, 0.5); + assert!(a.session.running.is_none(), "a click shorter than the duration is Enter: {}", a.session.prompt_text()); + + // Time-sensitive off, Command mode Enter: every right-click is Enter. + let ctx = egui::Context::default(); + let mut a = app(); + a.run("options", json!({"timeSensitiveRightClick": false, "rightClickCommand": "enter"})).unwrap(); + frame(&mut a, &ctx, Vec::new(), 0.0); + a.cmdline("LINE 0,0 10,0"); + let text = right_click(&mut a, &ctx, 0.1, 0.6); + assert!(a.session.running.is_none(), "Enter ends LINE: {}", a.session.prompt_text()); + assert!(!text.contains("Snap Overrides"), "no menu:\n{text}"); + + // Command mode "enabled when command options are present": CIRCLE's first prompt has + // options, so the menu opens and the command keeps running. + a.run("options", json!({"rightClickCommand": "menuWithOptions"})).unwrap(); + a.cmdline("CIRCLE"); + let text = right_click(&mut a, &ctx, 2.0, 0.05); + assert!(text.contains("Cancel"), "the command menu opens:\n{text}"); + assert!(a.session.prompting(), "CIRCLE still runs"); +} + +/// With `CADCRAFT_OPTIONS_SHOTS` set to a folder, the dialog is rendered there for a look. +const SHOTS: &str = "CADCRAFT_OPTIONS_SHOTS"; + +fn harness(app: CadApp) -> Harness<'static, CadApp> { + let builder = Harness::builder().with_size(egui::vec2(1400.0, 900.0)); + let builder = if std::env::var_os(SHOTS).is_some() { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( + |ui, app: &mut CadApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn shot(h: &mut Harness<'static, CadApp>, name: &str) { + if let Some(dir) = std::env::var_os(SHOTS).map(std::path::PathBuf::from) { + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(name)).unwrap(); + } +} + +#[test] +fn the_customization_window_sets_every_mode() { + let mut a = app(); + menus::activate(&mut a, "options"); + a.ui.dialog = Some("options:preferences".into()); + let mut h = harness(a); + h.get_by_label("Right-Click Customization...").click(); + h.run_steps(3); + h.get_by_label("Longer click duration:"); + shot(&mut h, "options-right-click.png"); + + // Default mode's "Repeat last command" comes first, Edit mode's second. + if let Some(n) = h.get_all_by_label("Repeat last command").next() { + n.click(); + } + h.run_steps(3); + assert_eq!(h.state().options.right_click_default, IdleClick::Repeat); + assert_eq!(h.state().options.right_click_edit, IdleClick::Menu); + + // Command mode applies once the time-sensitive right-click is off. + h.get_by_label("Turn on time-sensitive right-click").click(); + h.run_steps(3); + assert!(!h.state().options.time_sensitive_right_click); + h.get_by_label("Shortcut Menu: enabled when command options are present").click(); + h.run_steps(3); + assert_eq!(h.state().options.right_click_command, CommandClick::MenuWithOptions); + + let unnamed = h.unnamed_widgets(); + assert!(unnamed.is_empty(), "unnamed controls: {unnamed:?}"); + // The window's Close comes after the Options dialog's in the tree. + if let Some(n) = h.get_all_by_label("Close").last() { + n.click(); + } + h.run_steps(3); + assert!(h.query_by_label("Longer click duration:").is_none()); + assert_eq!(h.state().ui.dialog.as_deref(), Some("options:preferences")); +}