From 0dab3ec54a56b15edcaeb5f0f892b495f02a25bd Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sun, 6 Sep 2026 02:13:09 +0100 Subject: [PATCH] feat: add COMMANDLINEFADETIME sysvar for overlay history fade Controls how long command-line history lines stay visible, in ms (0-60000, default 3000). Bare form stays active until a value or Esc. --- src/app/commands/mod.rs | 1 + src/app/commands/styleprops.rs | 55 ++++++++++++++++++++++- src/app/mod.rs | 3 ++ src/app/settings.rs | 24 +++++++++++ src/app/update/file.rs | 8 ++++ src/ui/command_line.rs | 79 ++++++++++++++++++++++++++-------- 6 files changed, 150 insertions(+), 20 deletions(-) diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index a4d0f4578..b69daec39 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -534,6 +534,7 @@ inventory::submit!(crate::command::CommandRegistration { "SKPOLY", "SKTOLERANCE", "CLIPROMPTLINES", + "COMMANDLINEFADETIME", "COLORTHEME", "SELECTIONAREA", "SELECTIONAREAOPACITY", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 65565df2d..390c34f6d 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -801,6 +801,20 @@ impl OpenCADStudio { } } + // ── COMMANDLINEFADETIME — command-line history fade time ─── + // How long history lines stay visible above the command window, + // in ms (0–60000, default 3000). Bare form prompts for a value + // (Enter reports current); 0 hides transient overlay lines. + "COMMANDLINEFADETIME" => { + use crate::command::ValuePromptCommand; + let c = ValuePromptCommand::new( + "COMMANDLINEFADETIME", + "COMMANDLINEFADETIME overlay fade time in ms (0-60000, 0 = hide) :", + ); + self.command_line.push_info(&c.prompt()); + self.tabs[i].active_cmd = Some(Box::new(c)); + } + // ── SETVAR — read / write system variables ─────────────────── // SETVAR → report the value // SETVAR → set it @@ -821,6 +835,7 @@ impl OpenCADStudio { | "SNAPANG" | "TEXTFILL" | "CLIPROMPTLINES" + | "COMMANDLINEFADETIME" | "ATTREQ" | "ATTDIA" | "DIMASSOC" @@ -925,7 +940,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE (read-only)", + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { if matches!(name.as_str(), "SHOWHIST" | "SOLIDHIST") { @@ -1503,6 +1518,20 @@ impl OpenCADStudio { false, )), }, + "COMMANDLINEFADETIME" => match &value { + Some(v) => match v.parse::() { + Ok(n) if (0..=60000).contains(&n) => { + self.commandline_fade_ms = n; + self.command_line.set_commandline_fade_ms(n as u32); + Ok((format!("COMMANDLINEFADETIME = {n}"), true)) + } + _ => Err("SETVAR: integer from 0 to 60000 required.".into()), + }, + None => Ok(( + format!("COMMANDLINEFADETIME = {}", self.commandline_fade_ms), + false, + )), + }, "ATTREQ" => match &value { Some(v) => parse_bool(v) .map(|b| { @@ -2083,6 +2112,7 @@ impl OpenCADStudio { | "CURSORTYPE" | "SNAPANG" | "CLIPROMPTLINES" + | "COMMANDLINEFADETIME" | "COLORTHEME" | "SELECTIONAREA" | "SELECTIONAREAOPACITY" @@ -3121,4 +3151,27 @@ mod tests { assert_eq!(app.model_space.grip_hot, 0); assert_eq!(app.model_space.grip_hover, 0); } + + #[test] + fn bare_commandlinefadetime_stays_active_until_value_or_cancel() { + let mut app = fresh_app(); + let _ = app.run_command_line("COMMANDLINEFADETIME"); + // Like RECTANGLE and PICKADD: bare form installs an active command + // whose pinned prompt stays until a value, Enter, or Esc. + assert!( + app.tabs[app.active_tab].active_cmd.is_some(), + "bare COMMANDLINEFADETIME must stay active" + ); + // A value sets the variable and ends the command. + let _ = app.run_command_line("COMMANDLINEFADETIME 5000"); + assert_eq!(app.commandline_fade_ms, 5000); + assert_eq!(app.command_line.commandline_fade_ms(), 5000); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + // Bare again, then Esc cancels without changing the value. + let _ = app.run_command_line("COMMANDLINEFADETIME"); + assert!(app.tabs[app.active_tab].active_cmd.is_some()); + let _ = app.update(crate::app::Message::CommandEscape); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + assert_eq!(app.commandline_fade_ms, 5000); + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index ad85aaffc..0d430db89 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -534,6 +534,8 @@ pub(super) struct OpenCADStudio { /// CLIPROMPTLINES: how many temporary prompt lines for a single command /// are displayed above the command window (0–50, Registry, default 3). cliprompt_lines: i32, + /// COMMANDLINEFADETIME: overlay history visible time in ms (0–60000, default 3000). + commandline_fade_ms: i32, /// MRU list of block names inserted via INSERT, most recent first, capped to 20. block_mru: Vec, /// Insertion frequency per block name (uppercase key → count), capped. @@ -3363,6 +3365,7 @@ impl OpenCADStudio { default_bg_color: None, default_paper_bg_color: None, cliprompt_lines: 3, + commandline_fade_ms: 3000, block_mru: Vec::new(), block_freq: std::collections::HashMap::new(), #[cfg(not(target_arch = "wasm32"))] diff --git a/src/app/settings.rs b/src/app/settings.rs index 097fbad9d..da1643a3e 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -243,6 +243,13 @@ pub struct UserSettings { /// are displayed above the command window (0–50, Registry, default 3). #[serde(default = "default_clipromptlines", deserialize_with = "deserialize_clipromptlines")] pub cliprompt_lines: i32, + /// COMMANDLINEFADETIME: how long command-line overlay history lines stay + /// visible, in milliseconds (0–60000, default 3000). 0 skips transient lines. + #[serde( + default = "default_commandline_fade_ms", + deserialize_with = "deserialize_commandline_fade_ms" + )] + pub commandline_fade_ms: i32, /// Most-recently-inserted block names, most recent first, capped to 20. /// Used to rank INSERT suggestions without touching the drawing file. #[serde(default)] @@ -256,6 +263,22 @@ fn default_clipromptlines() -> i32 { 3 } +fn default_commandline_fade_ms() -> i32 { + 3000 +} + +fn deserialize_commandline_fade_ms<'de, D>(de: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let v = i32::deserialize(de).unwrap_or(3000); + Ok(v.clamp(0, 60000)) +} + +pub fn clamp_commandline_fade_ms(v: i32) -> i32 { + v.clamp(0, 60000) +} + fn default_dimension_continue_mode() -> i16 { 1 } @@ -312,6 +335,7 @@ impl Default for UserSettings { paper_bg_color: None, language: crate::i18n::Language::default(), cliprompt_lines: 3, + commandline_fade_ms: 3000, block_mru: Vec::new(), block_freq: std::collections::HashMap::new(), } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 53db29cc0..13d2c160f 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -397,6 +397,9 @@ impl OpenCADStudio { paper_bg_color: None, language: self.language, cliprompt_lines: crate::app::settings::clamp_clipromptlines(self.cliprompt_lines), + commandline_fade_ms: crate::app::settings::clamp_commandline_fade_ms( + self.commandline_fade_ms, + ), block_mru: self.block_mru.clone(), block_freq: self.block_freq.clone(), } @@ -462,6 +465,11 @@ impl OpenCADStudio { self.cliprompt_lines = crate::app::settings::clamp_clipromptlines(s.cliprompt_lines); self.command_line .set_cliprompt_lines(self.cliprompt_lines.clamp(0, 50) as u8); + self.commandline_fade_ms = + crate::app::settings::clamp_commandline_fade_ms(s.commandline_fade_ms); + self.command_line.set_commandline_fade_ms( + self.commandline_fade_ms.clamp(0, 60000) as u32, + ); // Block usage: clone but cap to sane sizes (MRU 20, freq map 200) self.block_mru = s.block_mru.iter().take(20).cloned().collect(); self.block_freq = s diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index e3c555a18..3651ba6dc 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -19,7 +19,13 @@ pub const HISTORY_SCROLL_ID: &str = "command_history_scroll"; /// How long a history entry stays visible on the overlay before fading /// out. Picking the full archive happens through the dropdown button. -const HISTORY_VISIBLE_SECS: f32 = 3.0; +/// Default for `COMMANDLINEFADETIME` (ms); the live value lives on +/// [`CommandLine::fade_ms`] so it can be changed via SETVAR. +pub const DEFAULT_COMMANDLINE_FADE_MS: u32 = 3000; +/// COMMANDLINEFADETIME bounds in milliseconds: 0 skips the overlay entirely, +/// 60000 keeps lines for a full minute. +pub const COMMANDLINE_FADE_MIN_MS: u32 = 0; +pub const COMMANDLINE_FADE_MAX_MS: u32 = 60000; /// Resizable full-history editor bounds. The live upper bound also follows the /// window height so the command input and a useful drawing area remain visible. @@ -129,6 +135,10 @@ pub struct CommandLine { /// CLIPROMPTLINES: how many temporary prompt lines for a single command /// are displayed above the command window (0–50, default 3). cliprompt_lines: u8, + /// COMMANDLINEFADETIME: how long overlay history lines stay visible, + /// in milliseconds (0–60000, default 3000). 0 skips drawing transient + /// lines entirely; the pinned step prompt still shows. + fade_ms: u32, } impl Default for CommandLine { @@ -151,6 +161,7 @@ impl Default for CommandLine { step_prompt: None, step_options: Vec::new(), cliprompt_lines: 3, + fade_ms: DEFAULT_COMMANDLINE_FADE_MS, } } } @@ -160,8 +171,8 @@ pub struct HistoryEntry { pub kind: EntryKind, pub text: String, /// When this entry was pushed. Used by the overlay to fade entries - /// out after `HISTORY_VISIBLE_SECS`. The dropdown popup ignores it - /// and always shows the whole list. + /// out after `fade_ms` (`COMMANDLINEFADETIME`). The dropdown popup + /// ignores it and always shows the whole list. pub created_at: Instant, /// The active command step's prompt is pinned so it does not fade /// while the user is still working on that step. When the step @@ -415,17 +426,32 @@ impl CommandLine { self.cliprompt_lines = n.min(50); } + /// COMMANDLINEFADETIME mirror (ms, 0–60000). `0` skips transient overlay + /// lines entirely; the pinned step prompt still shows. + pub fn set_commandline_fade_ms(&mut self, ms: u32) { + self.fade_ms = ms.min(COMMANDLINE_FADE_MAX_MS); + } + + pub fn commandline_fade_ms(&self) -> u32 { + self.fade_ms + } + + fn fade_secs(&self) -> f32 { + self.fade_ms as f32 / 1000.0 + } + + fn entry_visible(&self, e: &HistoryEntry) -> bool { + e.pinned || (self.fade_ms > 0 && e.created_at.elapsed().as_secs_f32() < self.fade_secs()) + } + /// Visible overlay count respecting CLIPROMPTLINES (0–50). Used in tests. #[cfg(test)] pub fn visible_history_count(&self) -> usize { if self.cliprompt_lines == 0 { return 0; } - let visible: Vec<&HistoryEntry> = self - .history - .iter() - .filter(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS) - .collect(); + let visible: Vec<&HistoryEntry> = + self.history.iter().filter(|e| self.entry_visible(e)).collect(); visible.len().min(self.cliprompt_lines as usize) } @@ -437,9 +463,7 @@ impl CommandLine { if self.cliprompt_lines == 0 { return false; } - self.history - .iter() - .any(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS) + self.history.iter().any(|e| self.entry_visible(e)) } pub fn toggle_history(&mut self) { @@ -528,14 +552,11 @@ impl CommandLine { control_enabled: bool, control_busy: bool, ) -> Element<'a, Message> { - // Only the most recent entries pushed within the last few - // seconds show on the overlay. The dropdown button keeps the - // full backlog reachable when the user actually wants it. - let mut visible: Vec<&HistoryEntry> = self - .history - .iter() - .filter(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS) - .collect(); + // Only the most recent entries pushed within COMMANDLINEFADETIME + // show on the overlay (0 skips transient lines). The dropdown button + // keeps the full backlog reachable when the user actually wants it. + let mut visible: Vec<&HistoryEntry> = + self.history.iter().filter(|e| self.entry_visible(e)).collect(); // Keep the active prompt/options immediately above the input. Commands // may emit informational lines while waiting for the next option; those // lines belong above the pinned interaction row, not below it. @@ -1128,6 +1149,26 @@ mod tests { ); } + #[test] + fn commandline_fade_defaults_to_3000ms() { + let line = CommandLine::new(); + assert_eq!(line.commandline_fade_ms(), 3000); + } + + #[test] + fn commandline_fade_zero_hides_unpinned_but_keeps_pinned() { + let mut line = CommandLine::new(); + line.push_info("transient"); + assert!(line.has_visible_history()); + line.set_commandline_fade_ms(0); + // Non-pinned entries are skipped entirely at 0. + assert!(!line.has_visible_history()); + assert_eq!(line.visible_history_count(), 0); + // Pinned step prompt still shows at 0. + line.set_step_prompt(Some("Specify point:".to_string())); + assert!(line.has_visible_history()); + } + #[test] fn info_entries_carry_prefix_marker() { let mut line = CommandLine::new();