diff --git a/assets/icons/ui/resize.svg b/assets/icons/ui/resize.svg new file mode 100644 index 000000000..c8d33e505 --- /dev/null +++ b/assets/icons/ui/resize.svg @@ -0,0 +1 @@ + diff --git a/src/app/mod.rs b/src/app/mod.rs index ec37d3623..37c105d44 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -448,6 +448,12 @@ pub(super) struct OpenCADStudio { modal_drag_last: Option, /// True while the modal title bar is held (a drag is in progress). modal_dragging: bool, + /// How far the user has dragged the modal's corner resize grip from the + /// dialog's natural size (added to each modal's default width/height). Reset + /// with `modal_offset` so every dialog opens at its own size. + modal_resize: iced::Vector, + /// True while the modal's corner resize grip is held. + modal_resizing: bool, // ── Attribute editor dialog (ATTEDIT / double-click a block) ─────────── /// INSERT whose attributes the editor modal is editing (`None` = closed). attr_editor_handle: Option, @@ -1845,6 +1851,10 @@ pub enum Message { MTextCaretBlink, /// Commit the editor: create or update the MText entity. MTextOk, + /// Apply the buffer to the entity but leave the editor open (the button). + MTextApply, + /// Grab the resizable modal's corner grip (a drag resizes it). + ModalResizeGrab, /// Discard the editor without creating / changing the entity. MTextCancel, // ── In-place single-line TEXT editor ──────────────────────────────── @@ -2227,6 +2237,8 @@ impl OpenCADStudio { modal_offset: iced::Vector::ZERO, modal_drag_last: None, modal_dragging: false, + modal_resize: iced::Vector::ZERO, + modal_resizing: false, attr_editor_handle: None, attr_editor_block: String::new(), attr_editor_rows: Vec::new(), diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index ba048a77b..a4df4a7bd 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -559,6 +559,11 @@ impl super::OpenCADStudio { } } self.mtext_editor = Some(state); + // Open centred at the editor's natural size: it renders through the + // shared modal frame, which `modal_offset` positions and `modal_resize` + // grows (both draggable, so reset on open). + self.modal_offset = iced::Vector::ZERO; + self.modal_resize = iced::Vector::ZERO; self.rebuild_mtext_preview(); // Place the caret at the end so typing works without a click first. let end = self.mtext_vis_count(); @@ -1084,6 +1089,59 @@ impl super::OpenCADStudio { true } + /// Apply the buffer to the drawing but keep the editor open — the + /// style-manager model, where **Apply** commits and the dialog stays up + /// (closing is the ✕). A brand-new MText is created on the first apply and + /// then updated in place on later applies, so repeated Apply never leaves + /// duplicate entities behind. + pub(super) fn mtext_apply(&mut self) { + let i = self.active_tab; + let (mut mt, editing, body_empty) = match self.mtext_editor.as_ref() { + Some(ed) => ( + ed.build_mtext(), + ed.editing, + ed.content.text().trim().is_empty(), + ), + None => return, + }; + if body_empty { + return; + } + if let Some(h) = editing { + self.push_undo_snapshot(i, "MTEXT"); + match self.tabs[i].scene.document.get_entity_mut(h) { + Some(EntityType::MText(t)) => { + t.value = mt.value; + t.height = mt.height; + t.attachment_point = mt.attachment_point; + t.line_spacing_factor = mt.line_spacing_factor; + t.rectangle_width = mt.rectangle_width; + } + Some(EntityType::MultiLeader(ml)) => { + ml.context.text_string = mt.value; + ml.context.text_height = mt.height; + ml.context.line_spacing_factor = mt.line_spacing_factor; + if mt.rectangle_width > 0.0 { + ml.context.text_width = mt.rectangle_width; + } + } + _ => {} + } + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } else { + mt.rotation = self.tabs[i].ucs_rotation_angle(); + self.push_undo_snapshot(i, "MTEXT"); + let handle = self.commit_entity_handle(EntityType::MText(mt)); + self.tabs[i].dirty = true; + // Bind the editor to the fresh entity so the next Apply updates it. + if let (Some(h), Some(ed)) = (handle, self.mtext_editor.as_mut()) { + ed.editing = Some(h); + } + } + self.refresh_properties(); + } + /// Discard the editor without changing the drawing. pub(super) fn mtext_cancel(&mut self) { self.mtext_editor = None; diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 731d636cf..ddc646dba 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -702,6 +702,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.layer_delete_pending = Some((names, count)); self.active_modal = Some(crate::app::ModalKind::LayerDeleteWarning); self.modal_offset = iced::Vector::ZERO; + self.modal_resize = iced::Vector::ZERO; } Task::none() } @@ -712,6 +713,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let i = self.active_tab; self.active_modal = None; self.modal_offset = iced::Vector::ZERO; + self.modal_resize = iced::Vector::ZERO; let Some((names, _)) = self.layer_delete_pending.take() else { return Task::none(); }; @@ -1668,6 +1670,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.attr_editor_handle = Some(handle); self.active_modal = Some(crate::app::ModalKind::AttributeEditor); self.modal_offset = iced::Vector::ZERO; + self.modal_resize = iced::Vector::ZERO; } Err(msg) => self.command_line.push_error(msg), } @@ -1687,6 +1690,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if self.active_modal == Some(crate::app::ModalKind::AttributeEditor) { self.active_modal = None; self.modal_offset = iced::Vector::ZERO; + self.modal_resize = iced::Vector::ZERO; } self.attr_editor_handle = None; self.attr_editor_block.clear(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index f74b74c1f..574944003 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -130,8 +130,9 @@ impl OpenCADStudio { _ => {} } self.active_modal = None; - // Recentre the next dialog and drop any in-progress drag. + // Recentre / reset the size of the next dialog and drop any drag. self.modal_offset = iced::Vector::ZERO; + self.modal_resize = iced::Vector::ZERO; self.modal_drag_last = None; self.modal_dragging = false; } @@ -2269,6 +2270,10 @@ impl OpenCADStudio { let committed = self.mtext_commit(); self.post_editor_closed(committed) } + Message::MTextApply => { + self.mtext_apply(); + Task::none() + } Message::MTextCancel => { self.mtext_cancel(); self.post_editor_closed(false) @@ -3200,11 +3205,33 @@ impl OpenCADStudio { self.modal_drag_last = None; Task::none() } + Message::ModalResizeGrab => { + // Start a resize; the first ModalDragMove seeds the reference. + self.modal_resizing = true; + self.modal_drag_last = None; + Task::none() + } Message::ModalDragMove(p) => { - if self.modal_dragging { - if let Some(last) = self.modal_drag_last { - self.modal_offset.x += p.x - last.x; - self.modal_offset.y += p.y - last.y; + if let Some(last) = self.modal_drag_last { + let (dx, dy) = (p.x - last.x, p.y - last.y); + if self.modal_resizing { + // The grip sits bottom-right, so dragging out grows the + // box. The delta is added to each dialog's natural size, + // so clamp it at zero — dragging in past the natural size + // does nothing (the natural size is the floor). + let nx = (self.modal_resize.x + dx).max(0.0); + let ny = (self.modal_resize.y + dy).max(0.0); + let (rx, ry) = (nx - self.modal_resize.x, ny - self.modal_resize.y); + self.modal_resize.x = nx; + self.modal_resize.y = ny; + // The box is centred, so shift the centre by half the + // growth to pin the top-left corner — the grip then + // tracks the cursor instead of drifting at half speed. + self.modal_offset.x += rx * 0.5; + self.modal_offset.y += ry * 0.5; + } else if self.modal_dragging { + self.modal_offset.x += dx; + self.modal_offset.y += dy; // Clamp so the dialog stops at the window edge instead // of being squeezed (the off-centre padding shrinks the // dialog once it overlaps a border). @@ -3217,12 +3244,15 @@ impl OpenCADStudio { self.modal_offset.y = self.modal_offset.y.clamp(-max_y, max_y); } } + } + if self.modal_dragging || self.modal_resizing { self.modal_drag_last = Some(p); } Task::none() } Message::ModalDragRelease => { self.modal_dragging = false; + self.modal_resizing = false; self.modal_drag_last = None; Task::none() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index caa7925ea..3c1349ff8 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1221,7 +1221,13 @@ impl OpenCADStudio { .iter() .map(|s| s.name.clone()) .collect(); - viewport_stack = viewport_stack.push(mtext_editor_overlay(ed, styles, canvas)); + viewport_stack = viewport_stack.push(mtext_editor_overlay( + ed, + styles, + canvas, + self.modal_offset, + self.modal_resize, + )); } if let Some(ed) = &self.text_inline { viewport_stack = viewport_stack.push(text_inline_overlay(ed, canvas)); @@ -1536,7 +1542,13 @@ impl OpenCADStudio { // the native (single main window) and web builds. let base: Element<'_, Message> = match self.modal_content() { Some(content) => { - crate::ui::modal::modal(composed, content, Message::CloseModal, self.modal_offset) + crate::ui::modal::modal( + composed, + content, + Message::CloseModal, + self.modal_offset, + true, + ) } None => composed.into(), }; @@ -1552,6 +1564,7 @@ impl OpenCADStudio { .height(iced::Length::Fixed(470.0)), Message::CloseColorPicker, iced::Vector::ZERO, + false, ) } else { base @@ -1596,7 +1609,12 @@ impl OpenCADStudio { ScaleManager => (520, 360), AnnoObjectScale => (360, 420), }; - Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H)) + // Include the user's corner-resize growth so the drag clamp tracks the + // dialog's actual footprint. + Some(( + w as f32 + EXTRA_W + self.modal_resize.x, + h as f32 + EXTRA_H + self.modal_resize.y, + )) } } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index ed1f1f94d..051b7a5b6 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -6,13 +6,17 @@ impl OpenCADStudio { /// Build the currently-open modal dialog's content (Plan B), or `None`. /// Each former pop-up window is constructed here and given a bounded size /// (About shrinks to its content). Rendered as an overlay by `view_main`. - pub(super) fn modal_content(&self) -> Option> { - fn sized<'a>(e: Element<'a, Message>, w: u16, h: u16) -> Element<'a, Message> { + pub(super) fn modal_content<'s>(&'s self) -> Option> { + // Grow every dialog by the shared corner-resize delta (grow-only, so the + // natural size is the floor). The delta resets to zero whenever a modal + // opens/closes, so each dialog starts at its own size. + let ex = self.modal_resize; + let sized = |e: Element<'s, Message>, w: u16, h: u16| -> Element<'s, Message> { iced::widget::container(e) - .width(iced::Length::Fixed(w as f32)) - .height(iced::Length::Fixed(h as f32)) + .width(iced::Length::Fixed(w as f32 + ex.x)) + .height(iced::Length::Fixed(h as f32 + ex.y)) .into() - } + }; Some(match self.active_modal? { super::super::ModalKind::About => crate::ui::window::about::view_window(), super::super::ModalKind::Shortcuts => { diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 0e675330d..d5f86cc3f 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -349,16 +349,12 @@ pub(super) fn mtext_editor_overlay<'a>( ed: &'a super::super::mtext_editor::MTextEditorState, styles: Vec, canvas_size: (f32, f32), + modal_offset: iced::Vector, + modal_resize: iced::Vector, ) -> Element<'a, Message> { use super::super::mtext_editor::{JustifyChoice, MTextFmt, ParaAlign}; use iced::widget::{canvas, svg}; - const PANEL_BG: Color = Color { - r: 0.16, - g: 0.16, - b: 0.16, - a: 0.98, - }; const BORDER: Color = Color { r: 0.40, g: 0.40, @@ -548,22 +544,13 @@ pub(super) fn mtext_editor_overlay<'a>( .on_press(Message::MTextLineSpacing(2.0)) .padding(3) .style(btn_style), - iced::widget::Space::new().width(Fill), - icon_btn( - include_bytes!("../../../assets/icons/mt_ok.svg"), - Message::MTextOk - ), - icon_btn( - include_bytes!("../../../assets/icons/mt_cancel.svg"), - Message::MTextCancel - ), ] .spacing(4) .align_y(iced::Alignment::Center); - // ── Segmented Edit | Preview toggle (between toolbar and body) ──────── - // ── Body: the rendered preview (the editor is preview-only) ───────── - const VIEW_H: f32 = 150.0; + // ── Body: the rendered preview (the editor is preview-only). It fills the + // space left by the toolbars, so the resizable modal's extra height flows + // into the text area. ───────────────────────────────────────────────── let body: Element<'a, Message> = { let segments = mtext_preview_segments(ed); let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); @@ -606,7 +593,9 @@ pub(super) fn mtext_editor_overlay<'a>( let cv = canvas(prog) .width(Fill) .height(iced::Length::Fixed(content_h)); - container(iced::widget::scrollable(cv).height(iced::Length::Fixed(VIEW_H))) + // The preview fills the space the toolbars leave, so the resizable + // modal's extra height goes to the text area (it scrolls if taller). + container(iced::widget::scrollable(cv).height(Fill)) .style(move |_: &Theme| container::Style { background: Some(Background::Color(FIELD_BG)), border: Border { @@ -618,33 +607,51 @@ pub(super) fn mtext_editor_overlay<'a>( }) .padding(2) .width(Fill) + .height(Fill) .into() }; - let panel = container(column![row1, row2, body].spacing(5)) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: BORDER, - width: 1.0, - radius: 5.0.into(), - }, - ..Default::default() - }) - .padding(6) - .width(iced::Length::Fixed(640.0)); + // ── Top action bar: Apply on the right, exactly like the style managers' + // toolbar strip. Closing (the modal ✕) without applying discards the + // buffer, so there is no separate Cancel. + let action_bar = container( + row![ + iced::widget::Space::new().width(Fill), + crate::ui::style::style_manager::tb_button("Apply", Message::MTextApply, true), + ] + .align_y(iced::Alignment::Center), + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(crate::ui::style::style_manager::TB)), + ..Default::default() + }) + .width(Fill) + .padding([5, 8]); - // Keep the whole panel on-screen: clamp the anchor so it never spills past - // the right/bottom edge (where its toolbar buttons would be unclickable). - // Width is fixed; height is the toolbar rows + the fixed VIEW_H body. - const PANEL_W: f32 = 640.0 + 14.0; // fixed width + padding/border - const PANEL_H: f32 = VIEW_H + 150.0; // body + toolbars/toggle/padding - let (cw, ch) = canvas_size; - let anchor = iced::Point::new( - (ed.screen_anchor.x - 10.0).clamp(0.0, (cw - PANEL_W).max(0.0)), - (ed.screen_anchor.y - 90.0).clamp(0.0, (ch - PANEL_H).max(0.0)), - ); - position_canvas_overlay(anchor, panel.into()) + // The shared modal frame supplies the panel background, drag title bar, + // ✕ (which also cancels) and the resize grip. The content is a Fill column + // wrapped here in a fixed box (natural 660×480, grown by the shared resize + // delta) so the modal frame shrinks to it and the corner grip can drag it. + let content = container( + column![action_bar, row1, row2, body] + .spacing(6) + .width(Fill) + .height(Fill), + ) + .width(iced::Length::Fixed(660.0 + modal_resize.x)) + .height(iced::Length::Fixed(480.0 + modal_resize.y)); + + let _ = canvas_size; // positioned & sized by the modal frame now + // `modal` sizes its stack from the base layer, so the base must fill the + // area (a zero-size base collapses the whole overlay to nothing). The + // transparent fill lets the dimmed viewport show through beneath. + crate::ui::modal::modal( + iced::widget::Space::new().width(Fill).height(Fill), + content, + Message::MTextCancel, + modal_offset, + true, + ) } // ── Viewport right-click context menu ────────────────────────────────────── diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 4794df91f..61c1b7ed0 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -53,6 +53,7 @@ pub const TRASH: &[u8] = include_bytes!("../../assets/icons/ui/trash.svg"); pub const COPY: &[u8] = include_bytes!("../../assets/icons/ui/copy.svg"); pub const MENU: &[u8] = include_bytes!("../../assets/icons/ui/menu.svg"); pub const MOVE: &[u8] = include_bytes!("../../assets/icons/ui/move.svg"); +pub const RESIZE: &[u8] = include_bytes!("../../assets/icons/ui/resize.svg"); pub const SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg"); pub const SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg"); pub const GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg"); diff --git a/src/ui/modal.rs b/src/ui/modal.rs index 897669529..7b75be02f 100644 --- a/src/ui/modal.rs +++ b/src/ui/modal.rs @@ -48,6 +48,7 @@ pub fn modal<'a>( content: impl Into>, on_close: Message, offset: Vector, + resizable: bool, ) -> Element<'a, Message> { let close = button(crate::ui::icons::tinted( crate::ui::icons::CLOSE, @@ -84,23 +85,38 @@ pub fn modal<'a>( let title_bar = row![grip, close].spacing(6).align_y(iced::Center); - // The column shrinks to the content's width; the title bar sits top-right - // above it, mirroring the former ✕ placement with a drag handle. - let framed = container( + let panel_style = |_: &Theme| container::Style { + background: Some(Background::Color(PANEL)), + border: Border { + color: BORDER_C, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }; + + // The dialog is always sized to its content (the caller fixes the content's + // width/height, growing it by the shared resize delta). When `resizable`, a + // corner grip is appended bottom-right; dragging it drives `ModalResizeGrab` + // + the shared drag move/release, which bumps that delta. The title bar sits + // top-right above the content either way (`align_x(Right)`). + let body = if resizable { + let resize = mouse_area( + container(crate::ui::icons::tinted(crate::ui::icons::RESIZE, 15.0, GRIP_C)) + .padding([0, 2]), + ) + .on_press(Message::ModalResizeGrab) + .interaction(iced::mouse::Interaction::Grab); + column![title_bar, content.into(), resize] + } else { column![title_bar, content.into()] - .spacing(6) - .align_x(iced::alignment::Horizontal::Right), + }; + let framed: Element<'a, Message> = container( + body.spacing(6).align_x(iced::alignment::Horizontal::Right), ) - .padding(10) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL)), - border: Border { - color: BORDER_C, - width: 1.0, - radius: 6.0.into(), - }, - ..Default::default() - }); + .padding(10) + .style(panel_style) + .into(); // Position via asymmetric padding (padding is non-negative): shifting a // centred box by `d` on an axis needs (near − far) padding = 2·d there.