forked from 1b-agent/opencadstudio-upstream
Merge pull request #1330 from mechpanos/feat/right-click-menus
feat(ui): right-click context menus with command keywords, PLINE/ARC keyword parity, grip menu
This commit is contained in:
58 files changed
+6437
-538
No files matched your search
+59
-4
@@ -14,6 +14,9 @@ use std::ops::Range;
|
||||
|
||||
use crate::ui::style::common::accessible_accent_threshold;
|
||||
|
||||
/// Maximum entries kept for the context menu's "Recent Input" list.
|
||||
pub const RECENT_INPUT_CAP: usize = 10;
|
||||
|
||||
pub const CMD_INPUT_ID: &str = "cmd_input";
|
||||
pub const HISTORY_SCROLL_ID: &str = "command_history_scroll";
|
||||
|
||||
@@ -68,15 +71,28 @@ fn mcp_status(enabled: bool, busy: bool) -> (&'static str, Color) {
|
||||
}
|
||||
|
||||
/// Drop a prompt's "[A / B / …]" option listing — used for the pinned line
|
||||
/// when the same options render as clickable buttons beside it. Collapses the
|
||||
/// leftover double spaces and the orphaned " :" the removal leaves behind.
|
||||
fn strip_option_listing(s: &str) -> String {
|
||||
/// when the same options render as clickable buttons beside it. Only a
|
||||
/// bracket group that reads as a listing goes (several choices, or a single
|
||||
/// choice that is one of `options`); an informational group such as
|
||||
/// "[3 objects]" or "[base 1.0,2.0]" stays. Collapses the leftover double
|
||||
/// spaces and the orphaned " :" the removal leaves behind.
|
||||
fn strip_option_listing(s: &str, options: &[CmdOption]) -> String {
|
||||
let Some(a) = s.find('[') else {
|
||||
return s.to_string();
|
||||
};
|
||||
let Some(off) = s[a..].find(']') else {
|
||||
return s.to_string();
|
||||
};
|
||||
let inner = s[a + 1..a + off].trim();
|
||||
let is_listing = inner.contains('/')
|
||||
|| inner.contains('|')
|
||||
|| inner.contains('=')
|
||||
|| options.iter().any(|o| {
|
||||
o.label.eq_ignore_ascii_case(inner) || o.keyword.eq_ignore_ascii_case(inner)
|
||||
});
|
||||
if !is_listing {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = format!("{}{}", &s[..a], &s[a + off + 1..]);
|
||||
while out.contains(" ") {
|
||||
out = out.replace(" ", " ");
|
||||
@@ -120,6 +136,10 @@ pub struct CommandLine {
|
||||
/// context menu, shortcuts), newest last. Drives the right-click "Repeat"
|
||||
/// menu so it reflects every command source, not only typed ones.
|
||||
pub recent_commands: Vec<String>,
|
||||
/// Tokens typed into a running command (points, distances, keywords),
|
||||
/// newest first, de-duplicated. Drives the context menu's "Recent Input"
|
||||
/// submenu so a value can be re-fed with one click. Not persisted.
|
||||
pub recent_inputs: Vec<String>,
|
||||
/// Current position in `cmd_recall` while navigating (None = not navigating).
|
||||
recall_cursor: Option<usize>,
|
||||
/// Saved draft input before the user started navigating history.
|
||||
@@ -168,6 +188,7 @@ impl Default for CommandLine {
|
||||
last_error: None,
|
||||
cmd_recall: Vec::new(),
|
||||
recent_commands: Vec::new(),
|
||||
recent_inputs: Vec::new(),
|
||||
recall_cursor: None,
|
||||
recall_draft: String::new(),
|
||||
history_open: false,
|
||||
@@ -309,6 +330,22 @@ impl CommandLine {
|
||||
self.cancel_history_navigation();
|
||||
}
|
||||
|
||||
/// Remember a token fed to a running command for the "Recent Input"
|
||||
/// submenu: newest first, case-insensitive move-to-front de-duplication,
|
||||
/// capped at `RECENT_INPUT_CAP`. Empty, overly long and entity-handle
|
||||
/// tokens are skipped (a handle is meaningless outside the pick it
|
||||
/// answered).
|
||||
pub fn record_recent_input(&mut self, token: &str) {
|
||||
let token = token.trim();
|
||||
if token.is_empty() || token.chars().count() > 40 {
|
||||
return;
|
||||
}
|
||||
let upper = token.to_uppercase();
|
||||
self.recent_inputs.retain(|t| t.to_uppercase() != upper);
|
||||
self.recent_inputs.insert(0, token.to_string());
|
||||
self.recent_inputs.truncate(RECENT_INPUT_CAP);
|
||||
}
|
||||
|
||||
pub fn history_navigation_active(&self) -> bool {
|
||||
self.recall_cursor.is_some()
|
||||
}
|
||||
@@ -463,6 +500,24 @@ impl CommandLine {
|
||||
e.pinned || (self.fade_ms > 0 && e.created_at.elapsed().as_secs_f32() < self.fade_secs())
|
||||
}
|
||||
|
||||
/// Height of the overlaid prompt lines currently shown above the input
|
||||
/// row (CLIPROMPTLINES, minus lines that have faded out), in pixels. Lets
|
||||
/// cursor-anchored panels stay clear of the whole command area, not just
|
||||
/// the input row.
|
||||
pub fn overlay_lines_height(&self) -> f32 {
|
||||
if self.cliprompt_lines == 0 || self.history_open {
|
||||
return 0.0;
|
||||
}
|
||||
let visible = self
|
||||
.history
|
||||
.iter()
|
||||
.filter(|e| self.entry_visible(e))
|
||||
.count()
|
||||
.min(self.cliprompt_lines as usize);
|
||||
// Text size 12 (line height ≈ 15.6) plus the 1 px padding each side.
|
||||
visible as f32 * 18.0
|
||||
}
|
||||
|
||||
/// Visible overlay count respecting CLIPROMPTLINES (0–50). Used in tests.
|
||||
#[cfg(test)]
|
||||
pub fn visible_history_count(&self) -> usize {
|
||||
@@ -614,7 +669,7 @@ impl CommandLine {
|
||||
// already name every choice, so the prompt's own "[A / B / …]"
|
||||
// listing is dropped here (the history log keeps the full text).
|
||||
if entry.pinned && !self.step_options.is_empty() {
|
||||
let shown = strip_option_listing(&entry.text);
|
||||
let shown = strip_option_listing(&entry.text, &self.step_options);
|
||||
let mut r = row![entry_text(shown)].spacing(6).align_y(iced::Center);
|
||||
for opt in &self.step_options {
|
||||
let btn = button(text(opt.label.to_uppercase()).size(11))
|
||||
|
||||
@@ -44,6 +44,8 @@ static OSNAP_APPARENT: &[u8] = include_bytes!("../../assets/icons/osnap/apparent
|
||||
static OSNAP_PARALLEL: &[u8] = include_bytes!("../../assets/icons/osnap/parallel.svg");
|
||||
static OSNAP_GRID: &[u8] = include_bytes!("../../assets/icons/osnap/grid.svg");
|
||||
static OSNAP_MTP: &[u8] = include_bytes!("../../assets/icons/osnap/mtp.svg");
|
||||
static NAV_PAN: &[u8] = include_bytes!("../../assets/icons/pan.svg");
|
||||
static NAV_ZOOM: &[u8] = include_bytes!("../../assets/icons/zoom_in.svg");
|
||||
|
||||
static LAY_ON: &[u8] = include_bytes!("../../assets/icons/layers/layon.svg");
|
||||
static LAY_OFF: &[u8] = include_bytes!("../../assets/icons/layers/layoff.svg");
|
||||
@@ -643,6 +645,15 @@ pub fn mtp_icon() -> &'static [u8] {
|
||||
OSNAP_MTP
|
||||
}
|
||||
|
||||
/// Context-menu glyphs for the transparent navigation rows.
|
||||
pub fn pan_icon() -> &'static [u8] {
|
||||
NAV_PAN
|
||||
}
|
||||
|
||||
pub fn zoom_icon() -> &'static [u8] {
|
||||
NAV_ZOOM
|
||||
}
|
||||
|
||||
/// Layer visibility icon bytes (on / off).
|
||||
pub fn layer_visible(visible: bool) -> &'static [u8] {
|
||||
if visible {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
pub mod context_menu;
|
||||
pub mod cycle_popup;
|
||||
pub mod isolate_popup;
|
||||
pub mod polar_popup;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pub(crate) mod spacemouse;
|
||||
use crate::app::config::UiThemeConfig;
|
||||
use crate::app::settings::CursorType;
|
||||
use crate::app::settings::{CursorType, RightClickMode};
|
||||
use crate::app::Message;
|
||||
use iced::widget::{
|
||||
button, column, container, row, scrollable, slider, text, text_input, Space,
|
||||
@@ -73,6 +73,10 @@ pub struct AppPrefs {
|
||||
pub show_ucs_icon: bool,
|
||||
/// UCSICON ORigin: draw it at the origin rather than the corner.
|
||||
pub ucs_icon_at_origin: bool,
|
||||
/// SHORTCUTMENU: what a right-click in the drawing area does.
|
||||
pub right_click_mode: RightClickMode,
|
||||
/// SHORTCUTMENUDURATION: time-sensitive hold threshold, ms.
|
||||
pub right_click_hold_ms: i32,
|
||||
}
|
||||
|
||||
/// The fixed locations the Files page lists.
|
||||
@@ -218,6 +222,18 @@ pub fn view_window<'a>(
|
||||
.find(|choice| choice.value == cursor_type)
|
||||
.cloned();
|
||||
|
||||
let right_click_options = RightClickMode::ALL
|
||||
.into_iter()
|
||||
.map(|value: RightClickMode| Labelled {
|
||||
label: crate::t!(value.label()).into_owned(),
|
||||
value,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let selected_right_click = right_click_options
|
||||
.iter()
|
||||
.find(|choice| choice.value == prefs.right_click_mode)
|
||||
.cloned();
|
||||
|
||||
let palette = ui_theme.palette.to_iced();
|
||||
let colors = [
|
||||
(crate::tr!("options", "color-background"), palette.background),
|
||||
@@ -1221,6 +1237,43 @@ pub fn view_window<'a>(
|
||||
.size(11)
|
||||
.width(sizing.width),
|
||||
Space::new().height(24),
|
||||
text(crate::t!("Right-click Customization")).size(15),
|
||||
Space::new().height(10),
|
||||
row![
|
||||
text(crate::t!("Right-click in drawing area")).size(12).width(150),
|
||||
iced::widget::pick_list(
|
||||
selected_right_click,
|
||||
right_click_options,
|
||||
|choice| choice.label.clone(),
|
||||
)
|
||||
.on_select(|choice| Message::RightClickModeChanged(choice.value))
|
||||
.width(Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(12),
|
||||
row![
|
||||
text(crate::t!("Hold duration")).size(12).width(150),
|
||||
slider(
|
||||
100..=1000,
|
||||
prefs.right_click_hold_ms.clamp(100, 1000),
|
||||
Message::RightClickHoldMsChanged
|
||||
)
|
||||
.step(50)
|
||||
.width(Fill),
|
||||
text(format!("{} ms", prefs.right_click_hold_ms.clamp(100, 1000)))
|
||||
.size(11)
|
||||
.width(52),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(6),
|
||||
text(crate::t!(
|
||||
"Shortcut menu: right-click always opens the menu. Time-sensitive: a quick click is Enter, holding longer opens the menu (SHORTCUTMENUDURATION)."
|
||||
))
|
||||
.size(11)
|
||||
.width(sizing.width),
|
||||
Space::new().height(24),
|
||||
text(crate::t!("Block Edit")).size(15),
|
||||
Space::new().height(10),
|
||||
row![
|
||||
|
||||
Reference in new issue
Block a user