From 3cdd86a67487d3d559b5a35c09633f67f3ddd58f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 8 Apr 2026 00:41:01 +0300 Subject: [PATCH] feat: right-click context menu + CONSTRUCTIONLINE alias Viewport right-click now shows a proper widget-based context menu: - When a command is active: Cancel / Enter - Otherwise: Repeat last commands, Delete/Move/Copy (if selected), Select All, Zoom Extents CONSTRUCTIONLINE added as an alias for XLINE (XL). Canvas-drawn placeholder removed; context menu closes on Escape, click-outside, or when any action is taken. Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 4 +- src/app/commands.rs | 2 +- src/app/mod.rs | 2 + src/app/update.rs | 24 ++++++++- src/app/view.rs | 118 ++++++++++++++++++++++++++++++++++++++++- src/ui/command_line.rs | 2 +- src/ui/overlay.rs | 43 --------------- 7 files changed, 145 insertions(+), 50 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4722587ac..f674518da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,7 +96,7 @@ Underlay (PDF/DWF/DGN) | SOLID (2D dolu dörtgen) | ✅ | | RECTANG (REC) | ✅ | | POLYGON (POL) | ✅ | -| CONSTRUCTION LINE (tam sonsuz) | ⬜ | +| CONSTRUCTION LINE (tam sonsuz) | ✅ | --- @@ -276,7 +276,7 @@ Underlay (PDF/DWF/DGN) | MATCHPROP (özellik kopyala) | ✅ | | BYLAYER hızlı atama | ✅ | | Çoklu seçim (window/crossing) | ✅ | -| Sağ tık bağlam menüsü | ⬜ | +| Sağ tık bağlam menüsü | ✅ | | Araç çubuğu özelleştirme | ⬜ | | Tema / Renk şeması seçimi | ⬜ | | Klavye kısayol düzenleyici | ⬜ | diff --git a/src/app/commands.rs b/src/app/commands.rs index 0d4819b0b..2b70e781c 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -872,7 +872,7 @@ impl H7CAD { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "XLINE"|"XL" => { + "XLINE"|"XL"|"CONSTRUCTIONLINE" => { use crate::modules::home::draw::ray::XLineCommand; let new_cmd = XLineCommand::new(); self.command_line.push_info(&new_cmd.prompt()); diff --git a/src/app/mod.rs b/src/app/mod.rs index d21b9940c..4ef97d384 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -329,6 +329,8 @@ pub enum Message { LayoutContextMenu(String), /// Close the layout context menu. LayoutContextMenuClose, + /// Close the viewport right-click context menu without performing any action. + ViewportContextMenuClose, /// A window was closed by the OS (e.g. the user clicked the title-bar ✕). OsWindowClosed(window::Id), /// No-op — used as a fallback when a TabEvent has no host mapping. diff --git a/src/app/update.rs b/src/app/update.rs index 7856fb9bc..d5b852809 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -498,7 +498,15 @@ impl H7CAD { } Message::CommandEscape => { - // Cancel layout rename / context menu first, then fall through. + // Cancel layout rename / context menus first, then fall through. + let i_e = self.active_tab; + { + let mut sel = self.tabs[i_e].scene.selection.borrow_mut(); + if sel.context_menu.is_some() { + sel.context_menu = None; + return Task::none(); + } + } if self.layout_rename_state.take().is_some() || self.layout_context_menu.take().is_some() { return Task::none(); } @@ -522,7 +530,12 @@ impl H7CAD { Task::none() } - Message::Command(cmd) => self.dispatch_command(&cmd), + Message::Command(cmd) => { + // Close viewport context menu if open. + let i = self.active_tab; + self.tabs[i].scene.selection.borrow_mut().context_menu = None; + self.dispatch_command(&cmd) + } Message::ToggleLayers => { if let Some(id) = self.layer_window.take() { @@ -1708,6 +1721,7 @@ impl H7CAD { Message::DeleteSelected => { let i = self.active_tab; + self.tabs[i].scene.selection.borrow_mut().context_menu = None; let handles: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect(); if !handles.is_empty() { self.push_undo_snapshot(i, "ERASE"); @@ -2127,6 +2141,12 @@ impl H7CAD { Task::none() } + Message::ViewportContextMenuClose => { + let i = self.active_tab; + self.tabs[i].scene.selection.borrow_mut().context_menu = None; + Task::none() + } + Message::EnterViewport(handle) => { let i = self.active_tab; // Clear paper-space selection before entering model space. diff --git a/src/app/view.rs b/src/app/view.rs index 8021092b9..ccd2413b0 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -325,7 +325,21 @@ impl H7CAD { iced::widget::Space::new().width(0).height(0).into() }; - stack![main_ui, self.app_menu.view(), snap_layer, dropdown_layer, layout_ctx_layer, page_setup_layer, textstyle_layer, tablestyle_layer, mlstyle_layer, dimstyle_layer].into() + // ── Viewport right-click context menu ───────────────────────────── + let viewport_ctx_layer: Element<'_, Message> = { + let ctx_pos = tab.scene.selection.borrow().context_menu; + if let Some(p) = ctx_pos { + let has_cmd = tab.active_cmd.is_some(); + let has_selection = !tab.scene.selected.is_empty(); + let last_cmds: Vec = self.command_line.cmd_recall + .iter().rev().take(3).cloned().collect(); + viewport_context_menu_overlay(p, has_cmd, has_selection, last_cmds) + } else { + iced::widget::Space::new().width(0).height(0).into() + } + }; + + stack![main_ui, self.app_menu.view(), snap_layer, dropdown_layer, layout_ctx_layer, page_setup_layer, textstyle_layer, tablestyle_layer, mlstyle_layer, dimstyle_layer, viewport_ctx_layer].into() } pub fn subscription(&self) -> Subscription { @@ -517,6 +531,108 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele // ── Layout context-menu overlay ──────────────────────────────────────────── +// ── Viewport right-click context menu ────────────────────────────────────── + +fn viewport_context_menu_overlay( + pos: iced::Point, + has_cmd: bool, + has_selection: bool, + last_cmds: Vec, +) -> Element<'static, Message> { + const MENU_BG: Color = Color { r: 0.17, g: 0.17, b: 0.17, a: 1.0 }; + const MENU_BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 }; + const ITEM_HOVER: Color = Color { r: 0.25, g: 0.45, b: 0.70, a: 1.0 }; + const TEXT_COL: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 }; + const SEP_COL: Color = Color { r: 0.30, g: 0.30, b: 0.30, a: 1.0 }; + + let item = |label: String, msg: Message| -> Element<'static, Message> { + button(text(label).size(12).color(TEXT_COL)) + .on_press(msg) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => ITEM_HOVER, + _ => Color::TRANSPARENT, + })), + text_color: TEXT_COL, + border: Border::default(), + shadow: iced::Shadow::default(), + snap: false, + }) + .padding([4, 12]) + .width(Fill) + .into() + }; + + let sep = || -> Element<'static, Message> { + container(iced::widget::Space::new().width(Fill).height(1)) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(SEP_COL)), + ..Default::default() + }) + .width(Fill) + .height(1) + .padding([0, 6]) + .into() + }; + + let mut items: Vec> = Vec::new(); + + if has_cmd { + items.push(item("Cancel".to_string(), Message::CommandEscape)); + items.push(item("Enter".to_string(), Message::CommandFinalize)); + } else { + if !last_cmds.is_empty() { + let last = last_cmds[0].clone(); + items.push(item( + format!("Repeat {last}"), + Message::Command(last.to_uppercase()), + )); + if last_cmds.len() > 1 { + for cmd in last_cmds.iter().skip(1) { + let c = cmd.clone(); + items.push(item(c.clone(), Message::Command(c.to_uppercase()))); + } + } + items.push(sep()); + } + if has_selection { + items.push(item("Delete".to_string(), Message::DeleteSelected)); + items.push(item("Move".to_string(), Message::Command("MOVE".to_string()))); + items.push(item("Copy".to_string(), Message::Command("COPY".to_string()))); + items.push(sep()); + } + items.push(item("Select All".to_string(), Message::Command("SELECTALL".to_string()))); + items.push(item("Zoom Extents".to_string(), Message::Command("ZOOM".to_string()))); + } + + let menu_col = column(items).spacing(0).width(180); + + let menu = container(menu_col) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(MENU_BG)), + border: Border { color: MENU_BORDER, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }) + .padding([4, 0]); + + // Click-catcher to close the menu when clicking outside. + let catcher = mouse_area( + container(Space::new()).width(Fill).height(Fill), + ) + .on_press(Message::ViewportContextMenuClose) + .on_right_press(Message::ViewportContextMenuClose); + + // Position using top/left spacing. + let positioned = column![ + Space::new().height(pos.y), + row![Space::new().width(pos.x), menu], + ] + .width(Fill) + .height(Fill); + + stack![catcher, positioned].into() +} + /// A small right-click context menu rendered above the status bar. /// The `name` is the layout tab that was right-clicked. fn layout_context_menu_overlay(name: &str) -> Element<'_, Message> { diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 0b1634672..41521bac0 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -17,7 +17,7 @@ pub struct CommandLine { pub input: String, pub history: Vec, /// Commands the user has typed (for ↑/↓ recall). - cmd_recall: Vec, + pub cmd_recall: Vec, /// Current position in `cmd_recall` while navigating (None = not navigating). recall_cursor: Option, /// Saved draft input before the user started navigating history. diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 6cb8ab77c..1982cef93 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -385,49 +385,6 @@ impl canvas::Program for SelectionCanvas { } } - if let Some(p) = self.selection.context_menu { - let w = 140.0; - let h = 72.0; - let rect = canvas::Path::rectangle(Point::new(p.x, p.y), Size::new(w, h)); - frame.fill( - &rect, - Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 0.95, - }, - ); - frame.stroke( - &rect, - canvas::Stroke { - width: 1.0, - style: canvas::Style::Solid(Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, - }), - ..Default::default() - }, - ); - let items = ["Open", "Properties", "Hide"]; - for (i, item) in items.iter().enumerate() { - frame.fill_text(canvas::Text { - content: item.to_string(), - position: Point::new(p.x + 10.0, p.y + 10.0 + i as f32 * 20.0), - color: Color::WHITE, - size: iced::Pixels(11.0), - font: iced::Font::DEFAULT, - align_x: iced::alignment::Horizontal::Left.into(), - align_y: iced::alignment::Vertical::Top.into(), - max_width: f32::INFINITY, - line_height: iced::widget::text::LineHeight::default(), - shaping: iced::widget::text::Shaping::default(), - }); - } - } - // ── Grip markers ────────────────────────────────────────────────── for grip in &self.grips { let sp = grip.pos;