diff --git a/src/app/mod.rs b/src/app/mod.rs index 60976d83..97696d24 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -57,6 +57,10 @@ pub(super) struct H7CAD { clipboard: Vec, /// Centroid of the clipboard entities (XZ plane, Y-up). clipboard_centroid: glam::Vec3, + /// Which layout tab has its context menu open (None = closed). + layout_context_menu: Option, + /// Inline rename state: (original_name, current_edit_value). + layout_rename_state: Option<(String, String)>, } #[derive(Debug, Clone)] @@ -210,6 +214,20 @@ pub enum Message { LayoutSwitch(String), /// Create a new paper space layout. LayoutCreate, + /// Delete the named paper space layout (Model cannot be deleted). + LayoutDelete(String), + /// Begin inline rename for the given layout tab. + LayoutRenameStart(String), + /// Live-update the rename text input buffer. + LayoutRenameEdit(String), + /// Commit the rename (Enter pressed in the text input). + LayoutRenameCommit, + /// Cancel an in-progress rename (Escape). + LayoutRenameCancel, + /// Open the right-click context menu for the given layout tab. + LayoutContextMenu(String), + /// Close the layout context menu. + LayoutContextMenuClose, /// 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. @@ -241,6 +259,8 @@ impl H7CAD { main_window: None, clipboard: Vec::new(), clipboard_centroid: glam::Vec3::ZERO, + layout_context_menu: None, + layout_rename_state: None, }; app.sync_ribbon_layers(); app diff --git a/src/app/update.rs b/src/app/update.rs index 57521cbe..c912de28 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -302,6 +302,10 @@ impl H7CAD { } Message::CommandEscape => { + // Cancel layout rename / context menu first, then fall through. + if self.layout_rename_state.take().is_some() || self.layout_context_menu.take().is_some() { + return Task::none(); + } let i = self.active_tab; if self.tabs[i].active_cmd.is_some() { let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_escape()); @@ -1427,6 +1431,9 @@ impl H7CAD { Message::LayoutSwitch(name) => { let i = self.active_tab; let going_to_paper = name != "Model"; + // Cancel any pending rename/context-menu when switching layouts. + self.layout_rename_state = None; + self.layout_context_menu = None; self.tabs[i].scene.current_layout = name; self.tabs[i].scene.deselect_all(); self.tabs[i].scene.fit_all(); @@ -1442,8 +1449,16 @@ impl H7CAD { Message::LayoutCreate => { let i = self.active_tab; - let count = self.tabs[i].scene.layout_names().len(); - let new_name = format!("Layout{}", count); + // Find a unique name (e.g. Layout2, Layout3, ...). + let existing = self.tabs[i].scene.layout_names(); + let mut idx = existing.len(); + let new_name = loop { + let candidate = format!("Layout{}", idx); + if !existing.contains(&candidate) { + break candidate; + } + idx += 1; + }; self.push_undo_snapshot(i, "LAYOUT"); match self.tabs[i].scene.document.add_layout(&new_name) { Ok(_) => { @@ -1463,6 +1478,87 @@ impl H7CAD { Task::none() } + Message::LayoutDelete(name) => { + let i = self.active_tab; + self.push_undo_snapshot(i, "LAYOUT DEL"); + if self.tabs[i].scene.delete_layout(&name) { + self.layout_context_menu = None; + self.layout_rename_state = None; + // If we fell back to Model space, update ribbon. + if self.tabs[i].scene.current_layout == "Model" + && self.ribbon.active_is_layout() + { + self.ribbon.select(0); + } + self.command_line.push_output(&format!("Layout \"{name}\" silindi")); + self.tabs[i].dirty = true; + } + Task::none() + } + + Message::LayoutRenameStart(name) => { + if name != "Model" { + self.layout_rename_state = Some((name.clone(), name)); + self.layout_context_menu = None; + } + Task::none() + } + + Message::LayoutRenameEdit(val) => { + if let Some((orig, _)) = &self.layout_rename_state { + let orig = orig.clone(); + self.layout_rename_state = Some((orig, val)); + } + Task::none() + } + + Message::LayoutRenameCommit => { + if let Some((orig, new_name)) = self.layout_rename_state.take() { + let new_name = new_name.trim().to_string(); + if !new_name.is_empty() && new_name != orig { + let i = self.active_tab; + let exists = self.tabs[i] + .scene + .layout_names() + .iter() + .any(|n| *n == new_name); + if exists { + self.command_line.push_error(&format!( + "\"{}\" adı zaten kullanımda", + new_name + )); + } else { + self.push_undo_snapshot(i, "LAYOUT RENAME"); + self.tabs[i].scene.rename_layout(&orig, &new_name); + if self.tabs[i].scene.current_layout == orig { + self.tabs[i].scene.current_layout = new_name.clone(); + } + self.tabs[i].dirty = true; + self.command_line + .push_output(&format!("Layout \"{orig}\" → \"{new_name}\"")); + } + } + } + Task::none() + } + + Message::LayoutRenameCancel => { + self.layout_rename_state = None; + Task::none() + } + + Message::LayoutContextMenu(name) => { + if name != "Model" { + self.layout_context_menu = Some(name); + } + Task::none() + } + + Message::LayoutContextMenuClose => { + self.layout_context_menu = None; + Task::none() + } + Message::Undo => { self.undo_active_tab(); Task::none() } Message::Redo => { self.redo_active_tab(); Task::none() } diff --git a/src/app/view.rs b/src/app/view.rs index 00971d51..a0688dd6 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -163,7 +163,8 @@ impl H7CAD { self.polar_mode, self.show_grid, tab.scene.layout_names(), - tab.scene.current_layout.clone() + tab.scene.current_layout.clone(), + self.layout_rename_state.as_ref(), ) ] .width(Fill) @@ -190,7 +191,14 @@ impl H7CAD { ) .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()); - stack![main_ui, self.app_menu.view(), snap_layer, dropdown_layer].into() + let layout_ctx_layer: Element<'_, Message> = + if let Some(name) = &self.layout_context_menu { + layout_context_menu_overlay(name) + } else { + iced::widget::Space::new().width(0).height(0).into() + }; + + stack![main_ui, self.app_menu.view(), snap_layer, dropdown_layer, layout_ctx_layer].into() } pub fn subscription(&self) -> Subscription { @@ -367,3 +375,70 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele .padding([0, 2]) .into() } + +// ── Layout context-menu overlay ──────────────────────────────────────────── + +/// 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> { + 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_COLOR: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 }; + + let item = |label: &'static str, msg: Message| { + button(text(label).size(12).color(TEXT_COLOR)) + .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_COLOR, + border: Border::default(), + shadow: iced::Shadow::default(), + snap: false, + }) + .padding([4, 12]) + .width(Fill) + }; + + let rename_name = name.to_string(); + let delete_name = name.to_string(); + + let menu = container( + column![ + item("Yeniden Adlandır", Message::LayoutRenameStart(rename_name)), + item("Sil", Message::LayoutDelete(delete_name)), + ] + .spacing(0) + .width(160), + ) + .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 fills the whole screen to close the menu when clicking outside. + let catcher = mouse_area( + container(iced::widget::Space::new().width(Fill).height(Fill)) + .width(Fill) + .height(Fill), + ) + .on_press(Message::LayoutContextMenuClose) + .on_right_press(Message::LayoutContextMenuClose); + + // Position the menu above the status bar at the left. + let positioned = container(menu) + .align_bottom(Fill) + .align_left(Fill) + .padding(iced::Padding { top: 0.0, right: 0.0, bottom: 30.0, left: 8.0 }); + + stack![catcher, positioned].into() +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index a0d961c7..9e9ac272 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -385,6 +385,65 @@ impl Scene { result } + // ── Layout management ───────────────────────────────────────────────── + + /// Rename a paper-space layout. Updates the Layout object name in the document. + pub fn rename_layout(&mut self, old_name: &str, new_name: &str) { + for obj in self.document.objects.values_mut() { + if let ObjectType::Layout(l) = obj { + if l.name == old_name { + l.name = new_name.to_string(); + return; + } + } + } + } + + /// Delete a paper-space layout and all entities owned by it. + /// Returns `false` if the layout was not found or is "Model". + pub fn delete_layout(&mut self, name: &str) -> bool { + if name == "Model" { + return false; + } + + let layout_info = self.document.objects.values().find_map(|obj| { + if let ObjectType::Layout(l) = obj { + if l.name == name { + return Some((l.handle, l.block_record)); + } + } + None + }); + + let (layout_handle, block_handle) = match layout_info { + Some(info) => info, + None => return false, + }; + + // Remove all entities that belong to this layout's block record. + let to_remove: Vec = self + .document + .entities() + .filter(|e| e.common().owner_handle == block_handle) + .map(|e| e.common().handle) + .collect(); + for h in &to_remove { + self.hatches.remove(h); + self.meshes.remove(h); + self.document.remove_entity(*h); + } + + // Remove the Layout object itself. + self.document.objects.remove(&layout_handle); + + // If the deleted layout was active, fall back to Model space. + if self.current_layout == name { + self.current_layout = "Model".to_string(); + } + + true + } + // ── Entity management ───────────────────────────────────────────────── pub fn add_entity(&mut self, entity: EntityType) -> Handle { diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index d9f08470..27c69e7b 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -1,7 +1,7 @@ //! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info use iced::widget::tooltip::Position as TipPos; -use iced::widget::{button, container, row, text, tooltip, Row}; +use iced::widget::{button, container, mouse_area, row, text, text_input, tooltip, Row}; use iced::{Background, Border, Color, Element, Length, Theme}; use crate::snap::Snapper; @@ -29,6 +29,8 @@ impl StatusBar { show_grid: bool, layouts: Vec, current_layout: String, + // If `Some((original, edit_value))`, the named tab shows a text input. + rename_state: Option<&'a (String, String)>, ) -> Element<'a, Message> { let menu_btn = button(text("≡").size(14).color(ICON_COLOR)) .on_press(Message::Command("MENU".into())) @@ -82,7 +84,10 @@ impl StatusBar { bar = bar.push(menu_btn); for name in layouts { let is_active = name == current_layout; - bar = bar.push(space_tab(name, is_active)); + let renaming = rename_state + .filter(|(orig, _)| *orig == name) + .map(|(_, edit)| edit.as_str()); + bar = bar.push(space_tab(name, is_active, renaming)); } bar = bar.push(add_btn); bar = bar.push(iced::widget::Space::new().width(Length::Fill)); @@ -262,40 +267,90 @@ fn osnap_btn(active: bool, snap_enabled: bool, open: bool) -> Element<'static, M // ── Helpers ─────────────────────────────────────────────────────────────── -fn space_tab(label: String, is_active: bool) -> Element<'static, Message> { - let msg = Message::LayoutSwitch(label.clone()); - button(text(label).size(11)) - .on_press(msg) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match (is_active, status) { - (true, _) => TAB_ACTIVE, - (false, button::Status::Hovered) => TAB_HOVER, - _ => Color::TRANSPARENT, - })), - text_color: if is_active { - Color::WHITE - } else { - Color { - r: 0.65, - g: 0.65, - b: 0.65, - a: 1.0, - } - }, - border: Border { - color: if is_active { - ACCENT - } else { - Color::TRANSPARENT +/// A layout tab button. +/// +/// When `rename_edit` is `Some(value)` the tab shows an inline text input +/// instead of the normal button. The tab is not renameable when it is the +/// "Model" tab (callers simply never pass `Some` for that name). +fn space_tab<'a>(label: String, is_active: bool, rename_edit: Option<&'a str>) -> Element<'a, Message> { + let bg = move |is_active: bool, hovered: bool| { + if is_active { + TAB_ACTIVE + } else if hovered { + TAB_HOVER + } else { + Color::TRANSPARENT + } + }; + + let border = Border { + color: if is_active { ACCENT } else { Color::TRANSPARENT }, + width: if is_active { 1.0 } else { 0.0 }, + radius: 2.0.into(), + }; + + let text_color = if is_active { + Color::WHITE + } else { + Color { r: 0.65, g: 0.65, b: 0.65, a: 1.0 } + }; + + if let Some(edit_val) = rename_edit { + // Inline rename text input with a cancel (✕) button. + let input = text_input("", edit_val) + .on_input(Message::LayoutRenameEdit) + .on_submit(Message::LayoutRenameCommit) + .size(11) + .style(|_: &Theme, _| text_input::Style { + background: Background::Color(TAB_ACTIVE), + border: Border { + color: ACCENT, + width: 1.0, + radius: 2.0.into(), }, - width: if is_active { 1.0 } else { 0.0 }, - radius: 2.0.into(), - }, + icon: Color::WHITE, + placeholder: Color { r: 0.5, g: 0.5, b: 0.5, a: 1.0 }, + value: Color::WHITE, + selection: Color { r: 0.20, g: 0.55, b: 0.90, a: 0.4 }, + }) + .padding([2, 6]) + .width(Length::Fixed(90.0)); + + let cancel_btn = button( + text("✕").size(10).color(Color { r: 0.65, g: 0.65, b: 0.65, a: 1.0 }), + ) + .on_press(Message::LayoutRenameCancel) + .style(|_: &Theme, _| button::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + border: Border::default(), shadow: iced::Shadow::default(), snap: false, + ..Default::default() }) - .padding([3, 10]) - .into() + .padding([2, 4]); + + row![input, cancel_btn].spacing(0).align_y(iced::Center).into() + } else { + // Normal clickable tab — left click switches, right click opens context menu. + let display = container( + text(label.clone()).size(11).color(text_color), + ) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(bg(is_active, false))), + border, + ..Default::default() + }) + .padding([3, 10]); + + let switch_msg = Message::LayoutSwitch(label.clone()); + let ctx_msg = Message::LayoutContextMenu(label.clone()); + + // Use mouse_area so we can capture right-click for the context menu. + mouse_area(display) + .on_press(switch_msg) + .on_right_press(ctx_msg) + .into() + } } fn status_pill(label: &str) -> Element<'_, Message> {