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.
This commit is contained in:
Karim Jerbi committed 2026-09-06 02:13:09 +01:00
1 parent b5eabae712
commit 0dab3ec54a
6 files changed
+150 -20

No files matched your search

+1
View File
@@ -534,6 +534,7 @@ inventory::submit!(crate::command::CommandRegistration {
"SKPOLY",
"SKTOLERANCE",
"CLIPROMPTLINES",
"COMMANDLINEFADETIME",
"COLORTHEME",
"SELECTIONAREA",
"SELECTIONAREAOPACITY",
+54 -1
View File
@@ -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) <Enter reports>:",
);
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
}
// ── SETVAR — read / write system variables ───────────────────
// SETVAR <name> → report the value
// SETVAR <name> <value> → 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::<i32>() {
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);
}
}
+3
View File
@@ -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<String>,
/// 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"))]
+24
View File
@@ -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<i32, D::Error>
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(),
}
+8
View File
@@ -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
+60 -19
View File
@@ -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();