forked from 1b-agent/opencadstudio-upstream
INSERT enumerated all block names in a single prompt string, causing unbounded overlay growth and full-screen occlusion in drawings with many blocks. Replace prompt enumeration with a ranked, incrementally filtered suggestion model: BlockPicker maintains precomputed lower/upper caches and usage ranks (MRU/frequency), filters via O(n) substring scan and partial sorting (select_nth_unstable) to O(k), bounded to CLIPROMPTLINES. Introduce CLIPROMPTLINES (Integer 0-50, Registry, default 3) for the number of temporary prompt lines above the command window; persist in UserSettings, expose via SETVAR and direct entry, enforce in CommandLine::view. Suggestion count is in direct relation to CLIPROMPTLINES (limit = clip, capped to 50). Make search live without requiring Enter: add CadCommand::on_live_input, implement for INSERT/MINSERT via picker.set_needle, and wire to command-line input paths (Message::CommandInput, on_command_append_char, on_command_backspace). Updates use upper/lower caches and partial sort for <0.2ms per keystroke on 10k blocks, with debounced block-usage persistence (MRU cap 20, freq cap 200) to avoid I/O thrash. Correct tri_count test helper to use display_from_solid.
346 lines
13 KiB
Rust
346 lines
13 KiB
Rust
//! Persisted user preferences — DYN, POLAR (+ increment), OTRACK, and assorted
|
||
//! app-level flags (backup, autosave, plugin lists, viewport background). These
|
||
//! are UI choices, not drawing data, so they live in the consolidated per-user
|
||
//! config ([`crate::app::config`], the "settings" section) and survive across
|
||
//! sessions. Drawing-scoped state (Ortho `$ORTHOMODE`, running OSNAP `$OSMODE`,
|
||
//! lineweight display `$LWDISPLAY`, …) belongs to the file, not here.
|
||
//!
|
||
//! Also home to the `$OSMODE` bit conversions ([`osmode_from_snaps`] /
|
||
//! [`snaps_from_osmode`]) that bridge the running-snap set and the drawing header.
|
||
|
||
use crate::snap::SnapType;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// Cursor shown over the drawing viewport.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum CursorType {
|
||
#[default]
|
||
Crosshair,
|
||
Pointer,
|
||
}
|
||
|
||
impl CursorType {
|
||
pub const ALL: [Self; 2] = [Self::Crosshair, Self::Pointer];
|
||
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Crosshair => "Crosshair",
|
||
Self::Pointer => "Desktop pointer",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Active pair of axes while isometric drafting is enabled.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum IsoPlane {
|
||
#[default]
|
||
Left,
|
||
Top,
|
||
Right,
|
||
}
|
||
|
||
impl IsoPlane {
|
||
pub const ALL: [Self; 3] = [Self::Left, Self::Top, Self::Right];
|
||
|
||
pub fn next(self) -> Self {
|
||
match self {
|
||
Self::Left => Self::Top,
|
||
Self::Top => Self::Right,
|
||
Self::Right => Self::Left,
|
||
}
|
||
}
|
||
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Left => "Left",
|
||
Self::Top => "Top",
|
||
Self::Right => "Right",
|
||
}
|
||
}
|
||
|
||
/// The two drafting directions, in degrees in the active UCS plane.
|
||
pub fn angles(self) -> [f64; 2] {
|
||
match self {
|
||
Self::Left => [90.0, 150.0],
|
||
Self::Top => [30.0, 150.0],
|
||
Self::Right => [30.0, 90.0],
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Canonical order of the user-toggleable object-snap modes. Drives the
|
||
/// deterministic order when decoding the `$OSMODE` bitmask (see
|
||
/// [`snaps_from_osmode`]).
|
||
const SNAP_ORDER: &[SnapType] = &[
|
||
SnapType::Endpoint,
|
||
SnapType::Midpoint,
|
||
SnapType::Center,
|
||
SnapType::Node,
|
||
SnapType::Quadrant,
|
||
SnapType::Intersection,
|
||
SnapType::Extension,
|
||
SnapType::Insertion,
|
||
SnapType::Perpendicular,
|
||
SnapType::Tangent,
|
||
SnapType::Nearest,
|
||
SnapType::ApparentIntersection,
|
||
SnapType::Parallel,
|
||
// Grid snap (SNAPMODE) is a per-drawing view setting stored on the VPort,
|
||
// not a global OSNAP preference, so it is deliberately excluded from the
|
||
// persisted set. (#121)
|
||
];
|
||
|
||
/// `$OSMODE` bit for each running object-snap mode (standard AutoCAD bitmask).
|
||
/// `None` for OCS-only snaps (Grid, ObjectPick) that have no standard bit.
|
||
fn snap_bit(s: SnapType) -> Option<i32> {
|
||
Some(match s {
|
||
SnapType::Endpoint => 1,
|
||
SnapType::Midpoint => 2,
|
||
SnapType::Center => 4,
|
||
SnapType::Node => 8,
|
||
SnapType::Quadrant => 16,
|
||
SnapType::Intersection => 32,
|
||
SnapType::Insertion => 64,
|
||
SnapType::Perpendicular => 128,
|
||
SnapType::Tangent => 256,
|
||
SnapType::Nearest => 512,
|
||
SnapType::ApparentIntersection => 2048,
|
||
SnapType::Extension => 4096,
|
||
SnapType::Parallel => 8192,
|
||
SnapType::Grid | SnapType::ObjectPick => return None,
|
||
})
|
||
}
|
||
|
||
/// Bit 14 of `$OSMODE` — object snap turned off (master suppress).
|
||
const OSMODE_SUPPRESS: i32 = 16384;
|
||
|
||
/// Encode the running-snap set + master toggle into an `$OSMODE` bitmask for the
|
||
/// drawing header. OCS-only snaps (Grid, ObjectPick) have no bit and are
|
||
/// dropped. A cleared master toggle sets the suppress bit.
|
||
pub(crate) fn osmode_from_snaps<'a>(
|
||
enabled: impl IntoIterator<Item = &'a SnapType>,
|
||
snap_enabled: bool,
|
||
) -> i32 {
|
||
let mut bits = 0;
|
||
for t in enabled {
|
||
if let Some(b) = snap_bit(*t) {
|
||
bits |= b;
|
||
}
|
||
}
|
||
if !snap_enabled {
|
||
bits |= OSMODE_SUPPRESS;
|
||
}
|
||
bits
|
||
}
|
||
|
||
/// Decode an `$OSMODE` bitmask into `(running-snap set, master enabled)`. Only
|
||
/// the standard mappable modes are produced; the suppress bit maps (inverted) to
|
||
/// the master toggle.
|
||
pub(crate) fn snaps_from_osmode(osmode: i32) -> (Vec<SnapType>, bool) {
|
||
let modes = SNAP_ORDER
|
||
.iter()
|
||
.copied()
|
||
.filter(|t| snap_bit(*t).is_some_and(|b| osmode & b != 0))
|
||
.collect();
|
||
(modes, osmode & OSMODE_SUPPRESS == 0)
|
||
}
|
||
|
||
/// The "settings" section of the consolidated config ([`crate::app::config`]).
|
||
/// Field defaults mirror the app's in-code defaults so a missing key restores
|
||
/// the value the app boots with.
|
||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct UserSettings {
|
||
pub dyn_input: bool,
|
||
pub polar: bool,
|
||
pub polar_increment_deg: f32,
|
||
pub zoom_wheel_reversed: bool,
|
||
pub zoom_factor: i32,
|
||
/// CURSORSIZE: normalized crosshair reach; 5 retains the original 60 px arms.
|
||
pub cursor_size: i32,
|
||
/// PICKBOX: normalized visible-box and click-aperture size.
|
||
pub pick_box: i32,
|
||
/// CURSORTYPE: crosshair or the platform pointer over the drawing.
|
||
pub cursor_type: CursorType,
|
||
/// Explicit crosshair RGB. `None` keeps automatic background contrast.
|
||
pub crosshair_color: Option<[u8; 3]>,
|
||
/// Isometric drafting changes the grid and crosshair to the active axis pair.
|
||
pub isometric_drafting: bool,
|
||
pub iso_plane: IsoPlane,
|
||
/// SNAPANG in degrees, applied in the active UCS plane.
|
||
pub snap_angle_deg: f32,
|
||
pub otrack: bool,
|
||
// Ortho ($ORTHOMODE) and the running OSNAP set ($OSMODE) are per-drawing —
|
||
// stored in the document header, not here (they used to be persisted app-
|
||
// globally, which duplicated the file's own state).
|
||
/// Whether the one-time "make Open CAD Studio the default for .dwg/.dxf?"
|
||
/// prompt has already been shown. Set once the user answers (either way),
|
||
/// so we never nag again on subsequent launches.
|
||
pub default_assoc_prompted: bool,
|
||
/// Ids of plugins the user turned off in the Plugin Manager. Disabled
|
||
/// plugins keep their manifest listed but drop their ribbon tab and command
|
||
/// dispatch.
|
||
pub disabled_plugins: Vec<String>,
|
||
/// Linked plugin source repositories (`owner/repo`) the marketplace installs
|
||
/// from.
|
||
pub plugin_repos: Vec<String>,
|
||
/// Command-line literal-space mode: when on, Space stays in the input
|
||
/// instead of submitting (as if every line started with `>`), until the
|
||
/// user toggles it back off.
|
||
pub literal_spaces: bool,
|
||
/// Height of the expanded command-history editor in logical pixels.
|
||
pub command_history_height: f32,
|
||
/// Running object-snap set + master toggle as an `$OSMODE`-style bitmask.
|
||
/// App-level, not per-drawing: modern DWG (R2000+) has no file slot for
|
||
/// OSMODE (it moved to the registry), so the set follows the user. A
|
||
/// legacy R13/R14 or DXF file carrying a nonzero `$OSMODE` still overrides
|
||
/// it on open (see `adopt_header_sysvars`).
|
||
pub osmode: i32,
|
||
/// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single).
|
||
pub texteditmode: bool,
|
||
/// TEXTFILL: fill TrueType glyphs (true) or draw them hollow (false).
|
||
pub textfill: bool,
|
||
/// When true, saving over an existing file first copies it to a sibling
|
||
/// `<name>.bak` so a faulty or accidental save can be recovered (#205).
|
||
pub backup_on_save: bool,
|
||
/// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak
|
||
/// handler on every launch. Toggle with the FILEASSOC command.
|
||
pub file_assoc_enabled: bool,
|
||
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
|
||
/// 0 disables autosave.
|
||
pub savetime_min: i32,
|
||
/// File type and version used when a new/unsaved drawing is first saved.
|
||
/// Existing drawings keep their own type and version.
|
||
pub default_save_format: String,
|
||
/// PICKADD (#226): `true` (default) = a plain click ADDS to the selection
|
||
/// (Shift removes); `false` = OS-style — a click REPLACES the selection
|
||
/// and Shift+click toggles membership.
|
||
pub pick_add: bool,
|
||
/// PICKDRAG (#226): `false` (default) = press-drag draws the freeform
|
||
/// lasso; `true` = press-drag draws a rectangle marquee instead.
|
||
pub pick_drag_rect: bool,
|
||
/// Show the floating Quick Properties panel when objects are selected.
|
||
pub quick_properties: bool,
|
||
/// Persisted viewport background colours (0–255 RGB); `None` = app default
|
||
/// (dark grey model / off-white paper). Applied to every drawing tab on
|
||
/// launch and to tabs opened later, so a chosen background survives restarts
|
||
/// (#188).
|
||
pub bg_color: Option<[u8; 3]>,
|
||
pub paper_bg_color: Option<[u8; 3]>,
|
||
/// Interface language preference. `System` negotiates against the
|
||
/// platform locale on every launch.
|
||
pub language: crate::i18n::Language,
|
||
/// CLIPROMPTLINES: how many temporary prompt lines for a single command
|
||
/// are displayed above the command window (0–50, Registry, default 3).
|
||
#[serde(default = "default_clipromptlines", deserialize_with = "deserialize_clipromptlines")]
|
||
pub cliprompt_lines: i32,
|
||
/// Most-recently-inserted block names, most recent first, capped to 20.
|
||
/// Used to rank INSERT suggestions without touching the drawing file.
|
||
#[serde(default)]
|
||
pub block_mru: Vec<String>,
|
||
/// Insertion frequency per block name (uppercase key → count).
|
||
#[serde(default)]
|
||
pub block_freq: std::collections::HashMap<String, u32>,
|
||
}
|
||
|
||
fn default_clipromptlines() -> i32 {
|
||
3
|
||
}
|
||
|
||
fn deserialize_clipromptlines<'de, D>(de: D) -> Result<i32, D::Error>
|
||
where
|
||
D: serde::Deserializer<'de>,
|
||
{
|
||
let v = i32::deserialize(de).unwrap_or(3);
|
||
Ok(v.clamp(0, 50))
|
||
}
|
||
|
||
pub fn clamp_clipromptlines(v: i32) -> i32 {
|
||
v.clamp(0, 50)
|
||
}
|
||
|
||
impl Default for UserSettings {
|
||
fn default() -> Self {
|
||
Self {
|
||
dyn_input: true,
|
||
polar: false,
|
||
polar_increment_deg: 45.0,
|
||
zoom_wheel_reversed: false,
|
||
zoom_factor: 60,
|
||
cursor_size: 5,
|
||
pick_box: 3,
|
||
cursor_type: CursorType::Crosshair,
|
||
crosshair_color: None,
|
||
isometric_drafting: false,
|
||
iso_plane: IsoPlane::Left,
|
||
snap_angle_deg: 0.0,
|
||
otrack: false,
|
||
default_assoc_prompted: false,
|
||
disabled_plugins: Vec::new(),
|
||
plugin_repos: Vec::new(),
|
||
literal_spaces: false,
|
||
command_history_height: crate::ui::command_line::HISTORY_HEIGHT_DEFAULT,
|
||
// Snapper::default(): END|MID|CEN|NODE|QUAD|INT|NEA (575), master
|
||
// off (suppress bit 16384).
|
||
osmode: 575 | OSMODE_SUPPRESS,
|
||
texteditmode: false,
|
||
textfill: true,
|
||
backup_on_save: true,
|
||
file_assoc_enabled: true,
|
||
savetime_min: 10,
|
||
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
|
||
pick_add: true,
|
||
pick_drag_rect: false,
|
||
quick_properties: false,
|
||
bg_color: None,
|
||
paper_bg_color: None,
|
||
language: crate::i18n::Language::default(),
|
||
cliprompt_lines: 3,
|
||
block_mru: Vec::new(),
|
||
block_freq: std::collections::HashMap::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn osmode_encodes_bits_and_suppress() {
|
||
// Endpoint(1) + Midpoint(2) + Intersection(32) = 35, master on.
|
||
let on = [SnapType::Endpoint, SnapType::Midpoint, SnapType::Intersection];
|
||
assert_eq!(osmode_from_snaps(on.iter(), true), 35);
|
||
// Master off sets the suppress bit (16384).
|
||
assert_eq!(osmode_from_snaps(on.iter(), false), 35 | 16384);
|
||
// OCS-only snaps carry no bit and are dropped.
|
||
assert_eq!(osmode_from_snaps([SnapType::Grid, SnapType::ObjectPick].iter(), true), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn osmode_decodes_bits_and_suppress() {
|
||
let (modes, enabled) = snaps_from_osmode(35);
|
||
let set: std::collections::HashSet<_> = modes.into_iter().collect();
|
||
assert!(enabled);
|
||
assert_eq!(set.len(), 3);
|
||
assert!(set.contains(&SnapType::Endpoint));
|
||
assert!(set.contains(&SnapType::Midpoint));
|
||
assert!(set.contains(&SnapType::Intersection));
|
||
// Suppress bit → master off; the mode bits still decode.
|
||
let (_m, en) = snaps_from_osmode(35 | 16384);
|
||
assert!(!en);
|
||
}
|
||
|
||
#[test]
|
||
fn osmode_round_trips_every_mappable_mode() {
|
||
// All 13 running snaps + master on encode and decode back to the same set.
|
||
let all: Vec<SnapType> = SNAP_ORDER.to_vec();
|
||
let bits = osmode_from_snaps(all.iter(), true);
|
||
let (back, enabled) = snaps_from_osmode(bits);
|
||
assert!(enabled);
|
||
let a: std::collections::HashSet<_> = all.into_iter().collect();
|
||
let b: std::collections::HashSet<_> = back.into_iter().collect();
|
||
assert_eq!(a, b);
|
||
}
|
||
}
|