From 796d7d91d01333ce9f4a777d378bfba1a34eb3d1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 1 Aug 2026 18:24:05 +0300 Subject: [PATCH] fix(ui): auto-size modal dialogs Measure dialog content before fill layout so fields stay visible.\nKeep the MText preview width tied to its toolbar width.\n\nRefs #608 --- src/app/mod.rs | 8 +- src/app/mtext_editor.rs | 4 +- src/app/update/command.rs | 8 +- src/app/update/dialog.rs | 2 + src/app/update/file.rs | 2 + src/app/update/mod.rs | 52 ++++++++- src/app/view/mod.rs | 75 +++--------- src/app/view/modal.rs | 187 ++++++++++++++++++------------ src/app/view/overlay.rs | 208 ++++++++++++++++++++++++---------- src/ui/modal.rs | 9 +- src/ui/window/find_replace.rs | 17 ++- 11 files changed, 352 insertions(+), 220 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index cc1b3e6e..fff02f6b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -515,9 +515,12 @@ pub(super) struct OpenCADStudio { /// Layer Manager Name column width in px, adjusted by the divider drag. layer_name_col_w: f32, /// 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 + /// dialog's natural size (added to its measured width/height). Reset /// with `modal_offset` so every dialog opens at its own size. modal_resize: iced::Vector, + /// Last body size reported by the shared modal frame. Used for drag bounds + /// and controls whose range follows the real, automatically measured width. + modal_content_size: Option, /// True while the modal's corner resize grip is held. modal_resizing: bool, // ── Attribute editor dialog (ATTEDIT / double-click a block) ─────────── @@ -2293,6 +2296,8 @@ pub enum Message { MTextApply, /// Grab the resizable modal's corner grip (a drag resizes it). ModalResizeGrab, + /// The shared modal body finished layout at this size. + ModalContentResized(iced::Size), /// Discard the editor without creating / changing the entity. MTextCancel, // ── In-place single-line TEXT editor ──────────────────────────────── @@ -2725,6 +2730,7 @@ impl OpenCADStudio { layer_col_dragging: false, layer_name_col_w: 130.0, modal_resize: iced::Vector::ZERO, + modal_content_size: None, modal_resizing: false, attr_editor_handle: None, attr_editor_block: String::new(), diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index 8e78f5a0..21c81167 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -594,8 +594,7 @@ impl super::OpenCADStudio { // 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.reset_modal_geometry(); self.rebuild_mtext_preview(); // The preview uses a fixed on-screen text size. Therefore the initial // wrap width is the drawing-unit span that exactly reaches the right @@ -1242,6 +1241,7 @@ impl super::OpenCADStudio { /// Discard the editor without changing the drawing. pub(super) fn mtext_cancel(&mut self) { self.mtext_editor = None; + self.reset_modal_geometry(); } } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index f370b0a6..3d391316 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -580,6 +580,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Cancel layout rename first, then fall through. let i_e = self.active_tab; if self.qselect.take().is_some() { + self.reset_modal_geometry(); return Task::none(); } { @@ -779,8 +780,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { pub(super) fn on_layer_delete_confirm(&mut self) -> Task { let i = self.active_tab; self.active_modal = None; - self.modal_offset = iced::Vector::ZERO; - self.modal_resize = iced::Vector::ZERO; + self.reset_modal_geometry(); let Some((names, _)) = self.layer_delete_pending.take() else { return Task::none(); }; @@ -1224,6 +1224,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { value: String::new(), append: false, }); + self.reset_modal_geometry(); Task::none() } @@ -2041,8 +2042,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { pub(super) fn cancel_attr_editor(&mut self) { 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.reset_modal_geometry(); } self.attr_editor_handle = None; self.attr_editor_block.clear(); diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 95689de5..28e8efb8 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -65,6 +65,7 @@ impl OpenCADStudio { self.aec_drop_acknowledged = false; if self.active_modal == Some(crate::app::ModalKind::SaveDialog) { self.active_modal = None; + self.reset_modal_geometry(); } Task::none() } @@ -94,6 +95,7 @@ impl OpenCADStudio { pub(in crate::app) fn close_unsaved_dialog_window(&mut self) -> Task { if self.active_modal == Some(crate::app::ModalKind::Unsaved) { self.active_modal = None; + self.reset_modal_geometry(); } Task::none() } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 7f2d0eb7..e9186c64 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -399,6 +399,7 @@ impl OpenCADStudio { /// Check if a suspended command exists on the active tab and resume it /// with the outcome of the text editor. pub(in crate::app) fn post_editor_closed(&mut self, committed: bool) -> Task { + self.reset_modal_geometry(); let i = self.active_tab; if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() { let res = cmd.on_editor_closed(committed); @@ -3328,6 +3329,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // the runtime paper/scale choices still drive this one plot operation. self.sync_dialog_plot_runtime(); self.active_modal = None; + self.reset_modal_geometry(); let plot_style = self.dialog_plot_style(&d); // Extents, Window and Display use one plot path in both spaces. Only diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 5b23adbc..75fe0917 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -93,6 +93,15 @@ mod util; mod viewport; impl OpenCADStudio { + pub(in crate::app) fn reset_modal_geometry(&mut self) { + self.modal_offset = iced::Vector::ZERO; + self.modal_resize = iced::Vector::ZERO; + self.modal_content_size = None; + self.modal_drag_last = None; + self.modal_dragging = false; + self.modal_resizing = false; + } + fn sync_open_command_history(&mut self) { if !self.command_line.history_open { return; @@ -167,10 +176,7 @@ impl OpenCADStudio { } self.active_modal = None; // 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; + self.reset_modal_geometry(); } pub fn update(&mut self, msg: Message) -> Task { @@ -1423,6 +1429,7 @@ impl OpenCADStudio { if self.active_modal == Some(super::ModalKind::Layers) { self.ribbon.deactivate_tool_if("LAYERS"); self.active_modal = None; + self.reset_modal_geometry(); } else { self.sync_ribbon_layers(); self.active_modal = Some(super::ModalKind::Layers); @@ -3335,6 +3342,7 @@ impl OpenCADStudio { Message::QSelectClose => { self.qselect = None; + self.reset_modal_geometry(); Task::none() } @@ -3390,6 +3398,7 @@ impl OpenCADStudio { let Some(state) = self.qselect.take() else { return Task::none(); }; + self.reset_modal_geometry(); let i = self.active_tab; let matched = self.tabs[i].scene.qselect( state.type_filter.as_deref(), @@ -4402,6 +4411,32 @@ impl OpenCADStudio { self.modal_drag_last = None; Task::none() } + Message::ModalContentResized(size) => { + if !size.width.is_finite() + || !size.height.is_finite() + || size.width <= 0.0 + || size.height <= 0.0 + { + return Task::none(); + } + let first_measurement = self.modal_content_size.replace(size).is_none(); + if first_measurement { + let initial_width = self.mtext_editor.as_ref().and_then(|editor| { + editor.editing.is_none().then(|| { + (size.width - 2.0 * super::view::overlay::MTEXT_PREVIEW_PAD) + .max(80.0) + / editor.preview_scale() + }) + }); + if let (Some(editor), Some(width)) = + (self.mtext_editor.as_mut(), initial_width) + { + editor.rect_width = f64::from(width.max(1e-6)); + self.rebuild_mtext_preview(); + } + } + Task::none() + } Message::RibbonLayerFilterChanged(f) => { self.ribbon.layer_filter = f; Task::none() @@ -4444,8 +4479,11 @@ impl OpenCADStudio { // of being squeezed (the off-centre padding shrinks the // dialog once it overlaps a border). if let Some((cw, ch)) = self.modal_outer_size() { - let ww = self.vp_size.0 + 440.0; - let wh = self.vp_size.1; + let (ww, wh) = if self.mtext_editor.is_some() { + self.vp_size + } else { + self.win_size + }; let max_x = ((ww - cw) * 0.5).max(0.0); let max_y = ((wh - ch) * 0.5).max(0.0); self.modal_offset.x = self.modal_offset.x.clamp(-max_x, max_x); @@ -5056,6 +5094,7 @@ impl OpenCADStudio { self.file_assoc_enabled = true; self.mark_assoc_prompted(); self.active_modal = None; + self.reset_modal_geometry(); // set_default_app registers the handler first, then makes us the // default — boot no longer does this automatically. Task::perform( @@ -5067,6 +5106,7 @@ impl OpenCADStudio { self.file_assoc_enabled = false; self.mark_assoc_prompted(); self.active_modal = None; + self.reset_modal_geometry(); Task::none() } Message::AssocResult(result) => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 5655a8cf..27565ee5 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1330,9 +1330,9 @@ impl OpenCADStudio { viewport_stack = viewport_stack.push(mtext_editor_overlay( ed, styles, - canvas, self.modal_offset, self.modal_resize, + self.modal_content_size, )); } if let Some(ed) = &self.text_inline { @@ -1548,7 +1548,13 @@ impl OpenCADStudio { let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect { let types = tab.scene.entity_type_names_in_layout(); let properties = tab.scene.qselect_properties(state.type_filter.as_deref()); - qselect_overlay(state, &types, &properties) + qselect_overlay( + state, + &types, + &properties, + self.modal_offset, + self.modal_resize, + ) } else { iced::widget::Space::new().width(0).height(0).into() }; @@ -1613,67 +1619,12 @@ impl OpenCADStudio { } } - /// Conservative outer pixel bounds for the active modal, used to clamp drag - /// so it cannot be pushed off-screen. Mirrors the maximum dimensions in - /// [`Self::modal_content`]; content-sized dialogs may render smaller. - /// `None` has no active modal. About uses a safe estimate. + /// Measured outer pixel bounds for whichever shared modal is visible, used + /// to keep its frame on-screen while dragging. pub(crate) fn modal_outer_size(&self) -> Option<(f32, f32)> { - use super::ModalKind::*; - // Title bar (~26) + spacing (6) + frame padding (10·2) → ~52 vertical; - // frame padding → ~20 horizontal. - const EXTRA_W: f32 = 20.0; - const EXTRA_H: f32 = 52.0; - let (w, h) = match self.active_modal? { - About => (440, 360), - Shortcuts => (720, 520), - Options => (520, 500), - FindReplace => (560, 190), - PluginManager => { - #[cfg(target_arch = "wasm32")] - { - (self.web_plugin_notice_width(), 230) - } - #[cfg(not(target_arch = "wasm32"))] - { - (940, 600) - } - } - UpdateNotice => (560, 460), - Layers => (900, 360), - LayerStateManager => (720, 420), - LayerStateEditor => (1180, 560), - Plot => (760, 540), - LayoutManager => (640, 320), - Plotstyle => (780, 540), - TextStyle => (860, 480), - MlStyle => (620, 420), - TableStyle => (620, 420), - MLeaderStyle => (560, 560), - DimStyle => (720, 560), - AssocPrompt => (440, 210), - Unsaved => (420, 160), - AecDropWarning => (480, 230), - #[cfg(not(target_arch = "wasm32"))] - FileInUse => (560, 250), - #[cfg(not(target_arch = "wasm32"))] - ExternalChange => (620, 250), - #[cfg(target_arch = "wasm32")] - SaveDialog => (420, 200), - #[cfg(not(target_arch = "wasm32"))] - SaveDialog => (420, 150), - PointStyle => (360, 470), - AttributeEditor => (640, 500), - LayerDeleteWarning => (440, 200), - Aliases => (480, 520), - ScaleManager => (520, 360), - AnnoObjectScale => (360, 420), - }; - // 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, - )) + let size = self.modal_content_size?; + // Frame padding is 10 px per side; title + body spacing is 30 px. + Some((size.width + 20.0, size.height + 50.0)) } } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 9e769486..a782ae89 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -1,13 +1,8 @@ use super::super::{Message, OpenCADStudio}; use iced::widget::{button, column, container, row, text, Space}; -use iced::{Background, Element, Fit, Theme}; +use iced::{Background, Element, Fill, Fit, Theme}; impl OpenCADStudio { - #[cfg(target_arch = "wasm32")] - pub(super) fn web_plugin_notice_width(&self) -> u16 { - (self.win_size.0 - 48.0).clamp(280.0, 460.0) as u16 - } - /// Title shown in the active modal's title bar. Keep in sync with the /// [`Self::modal_content`] dispatch. pub(super) fn modal_title(&self) -> &'static str { @@ -49,21 +44,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 maximum size. - /// Iced 0.15 measures the content first, so smaller dialogs stop at their - /// intrinsic size while overflowing regions become scrollable at the cap. + /// Iced 0.15 measures the content first, so dialogs start at their natural + /// size and overflowing regions become scrollable where a cap is supplied. pub(super) fn modal_content<'s>(&'s self) -> Option> { let ex = self.modal_resize; - let sized = |e: Element<'s, Message>, w: u16, h: u16| -> Element<'s, Message> { - container(e) - .width(Fit.max(w as f32 + ex.x)) - .height(Fit.max(h as f32 + ex.y)) - .into() - }; Some(match self.active_modal? { - super::super::ModalKind::About => { - sized(crate::ui::window::about::view_window(), 440, 360) - } + super::super::ModalKind::About => automatic_flow(ex, |flow| { + container(crate::ui::window::about::view_window()) + .width(flow.width) + .height(flow.height) + .into() + }), super::super::ModalKind::Shortcuts => { sized_flow( ex, @@ -103,15 +94,14 @@ impl OpenCADStudio { ) }, ), - super::super::ModalKind::FindReplace => sized( + super::super::ModalKind::FindReplace => automatic_flow(ex, |flow| { crate::ui::window::find_replace::view_window( &self.find_replace.search, &self.find_replace.replacement, &self.find_replace.status, - ), - 560, - 190, - ), + flow, + ) + }), super::super::ModalKind::PluginManager => { #[cfg(not(target_arch = "wasm32"))] { @@ -149,11 +139,12 @@ impl OpenCADStudio { } #[cfg(target_arch = "wasm32")] { - sized( - crate::ui::window::plugin_manager::view_web_notice(), - self.web_plugin_notice_width(), - 230, - ) + automatic_flow(ex, |flow| { + container(crate::ui::window::plugin_manager::view_web_notice()) + .width(flow.width) + .height(flow.height) + .into() + }) } } super::super::ModalKind::UpdateNotice => { @@ -228,13 +219,13 @@ impl OpenCADStudio { }, ) } else { - sized( + automatic_flow(ex, |flow| { container(text("The selected layer state is no longer available.")) .padding(16) - .into(), - 520, - 180, - ) + .width(flow.width) + .height(flow.height) + .into() + }) } } super::super::ModalKind::Plot => { @@ -764,7 +755,9 @@ impl OpenCADStudio { ) }) } - super::super::ModalKind::AssocPrompt => sized(default_assoc_dialog_window(), 440, 210), + super::super::ModalKind::AssocPrompt => { + automatic_flow(ex, default_assoc_dialog_window) + } super::super::ModalKind::AecDropWarning => { let src_label = self .tabs @@ -788,15 +781,14 @@ impl OpenCADStudio { crate::io::format_for_version(version, is_dxf) }) .unwrap_or_else(|| "DWG".to_string()); - sized( + automatic_flow(ex, |flow| { aec_drop_dialog_window( self.aec_drop_count, &self.save_dialog_format, &src_label, - ), - 480, - 230, - ) + flow, + ) + }) } #[cfg(not(target_arch = "wasm32"))] super::super::ModalKind::FileInUse => { @@ -810,7 +802,7 @@ impl OpenCADStudio { ) }) .unwrap_or_default(); - sized(file_in_use_dialog_window(&path, &error), 560, 250) + automatic_flow(ex, |flow| file_in_use_dialog_window(&path, &error, flow)) } #[cfg(not(target_arch = "wasm32"))] super::super::ModalKind::ExternalChange => { @@ -819,14 +811,14 @@ impl OpenCADStudio { .as_ref() .map(|conflict| conflict.path.display().to_string()) .unwrap_or_default(); - sized(external_change_dialog_window(&path), 620, 250) + automatic_flow(ex, |flow| external_change_dialog_window(&path, flow)) } super::super::ModalKind::LayerDeleteWarning => { let (names, count) = self .layer_delete_pending .clone() .unwrap_or_else(|| (Vec::new(), 0)); - sized(layer_delete_warning_window(&names, count), 440, 200) + automatic_flow(ex, |flow| layer_delete_warning_window(&names, count, flow)) } super::super::ModalKind::Unsaved => { let tab_name = match &self.pending_close { @@ -843,7 +835,7 @@ impl OpenCADStudio { .unwrap_or_default(), None => String::new(), }; - sized(unsaved_changes_dialog_window(&tab_name), 420, 160) + automatic_flow(ex, |flow| unsaved_changes_dialog_window(&tab_name, flow)) } super::super::ModalKind::PointStyle => sized_flow( ex, @@ -897,17 +889,13 @@ impl OpenCADStudio { ) } super::super::ModalKind::SaveDialog => { - // Native omits the file-name field (the OS dialog collects it), - // so it stands shorter than the web build. - #[cfg(target_arch = "wasm32")] - let h = 200; - #[cfg(not(target_arch = "wasm32"))] - let h = 150; - sized( - save_as_dialog_window(&self.save_dialog_filename, &self.save_dialog_format), - 420, - h, - ) + automatic_flow(ex, |flow| { + save_as_dialog_window( + &self.save_dialog_filename, + &self.save_dialog_format, + flow, + ) + }) } }) } @@ -927,6 +915,18 @@ fn sized_flow<'a>( ) } +fn automatic_flow<'a>( + extra: iced::Vector, + mut build: impl FnMut(crate::ui::modal::ModalSizing) -> Element<'a, Message>, +) -> Element<'a, Message> { + crate::ui::modal::intrinsic( + build(crate::ui::modal::ModalSizing::INTRINSIC), + build(crate::ui::modal::ModalSizing::FILL), + iced::Size::new(f32::INFINITY, f32::INFINITY), + extra, + ) +} + fn dialog_button( label: &'static str, message: Message, @@ -957,12 +957,21 @@ fn dialog_muted_text_style(theme: &Theme) -> iced::widget::text::Style { /// Compact Save-As options dialog: pick the format/version and a default file /// name. The destination folder and overwrite confirmation come from the /// native OS save dialog (native) or the browser download (web) that follows. -fn save_as_dialog_window<'a>(filename: &'a str, format: &'a str) -> Element<'a, Message> { +fn save_as_dialog_window<'a>( + filename: &'a str, + format: &'a str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { let sel_fmt = crate::io::SAVE_FORMAT_OPTIONS .iter() .copied() .find(|&s| s == format); let label = |s: &'static str| text(s).size(11).style(dialog_muted_text_style); + let field_width = if matches!(sizing.width, iced::Length::Fill) { + Fill + } else { + iced::Length::Shrink + }; let mut items: Vec> = Vec::new(); items.push(text("Save Drawing As").size(14).into()); @@ -979,10 +988,11 @@ fn save_as_dialog_window<'a>(filename: &'a str, format: &'a str) -> Element<'a, .on_input(Message::SaveDialogFilenameChanged) .size(13) .padding([5, 8]) - .width(Fit), + .width(field_width), ] .align_y(iced::Alignment::Center) .spacing(6) + .width(sizing.width) .into(), ); items.push(Space::new().height(8).into()); @@ -999,10 +1009,11 @@ fn save_as_dialog_window<'a>(filename: &'a str, format: &'a str) -> Element<'a, |value| value.to_string(), ) .on_select(|s: &str| Message::SaveDialogFormatChanged(s.to_string())) - .width(Fit), + .width(field_width), ] .align_y(iced::Alignment::Center) .spacing(6) + .width(sizing.width) .into(), ); items.push(Space::new().height(16).into()); @@ -1016,17 +1027,23 @@ fn save_as_dialog_window<'a>(filename: &'a str, format: &'a str) -> Element<'a, .into(), ); - let body = column(items).spacing(0); + let body = column(items) + .spacing(0) + .width(sizing.width) + .height(sizing.height); container(body) .style(dialog_body_style) .padding([14, 16]) - .width(Fit) - .height(Fit) + .width(sizing.width) + .height(sizing.height) .into() } -fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> { +fn unsaved_changes_dialog_window( + name: &str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'static, Message> { let body_text = format!("Do you want to save changes to \"{}\"?", name); container( @@ -1044,13 +1061,18 @@ fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> { .spacing(0), ) .style(dialog_body_style) - .center(Fit) + .center_x(sizing.width) + .center_y(sizing.height) .padding([24, 28]) .into() } #[cfg(not(target_arch = "wasm32"))] -fn file_in_use_dialog_window(path: &str, error: &str) -> Element<'static, Message> { +fn file_in_use_dialog_window( + path: &str, + error: &str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'static, Message> { let file_name = std::path::Path::new(path) .file_name() .map(|name| name.to_string_lossy().into_owned()) @@ -1098,13 +1120,16 @@ fn file_in_use_dialog_window(path: &str, error: &str) -> Element<'static, Messag ) .style(dialog_body_style) .padding([18, 20]) - .width(Fit) - .height(Fit) + .width(sizing.width) + .height(sizing.height) .into() } #[cfg(not(target_arch = "wasm32"))] -fn external_change_dialog_window(path: &str) -> Element<'static, Message> { +fn external_change_dialog_window( + path: &str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'static, Message> { let file_name = std::path::Path::new(path) .file_name() .map(|name| name.to_string_lossy().into_owned()) @@ -1155,8 +1180,8 @@ fn external_change_dialog_window(path: &str) -> Element<'static, Message> { ) .style(dialog_body_style) .padding([18, 20]) - .width(Fit) - .height(Fit) + .width(sizing.width) + .height(sizing.height) .into() } @@ -1164,7 +1189,12 @@ fn external_change_dialog_window(path: &str) -> Element<'static, Message> { /// (AEC / application) objects that survive only as verbatim source-version /// bytes, so saving to a different version or to DXF would drop them. Offers to /// save in the source version (keep them) or proceed (drop them). -fn aec_drop_dialog_window(count: usize, target: &str, src_version: &str) -> Element<'static, Message> { +fn aec_drop_dialog_window( + count: usize, + target: &str, + src_version: &str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'static, Message> { let body_text = format!( "This drawing contains {count} AEC/Civil objects that \"{target}\" \ cannot store, so they will not be saved.\n\n\ @@ -1190,7 +1220,8 @@ fn aec_drop_dialog_window(count: usize, target: &str, src_version: &str) -> Elem .spacing(0), ) .style(dialog_body_style) - .center(Fit) + .center_x(sizing.width) + .center_y(sizing.height) .padding([24, 28]) .into() } @@ -1199,7 +1230,11 @@ fn aec_drop_dialog_window(count: usize, target: &str, src_version: &str) -> Elem /// target folder. "Replace" overwrites; "Cancel" returns to the Save dialog. /// Confirm deleting layer(s) that still have objects on them. "Delete Objects" /// erases them and removes the layers; "Cancel" leaves everything. -fn layer_delete_warning_window(names: &[String], count: usize) -> Element<'static, Message> { +fn layer_delete_warning_window( + names: &[String], + count: usize, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'static, Message> { let obj = if count == 1 { "object" } else { "objects" }; let subject = if names.len() == 1 { format!("Layer \"{}\"", names[0]) @@ -1229,7 +1264,8 @@ fn layer_delete_warning_window(names: &[String], count: usize) -> Element<'stati .spacing(0), ) .style(dialog_body_style) - .center(Fit) + .center_x(sizing.width) + .center_y(sizing.height) .padding([24, 28]) .into() } @@ -1238,7 +1274,9 @@ fn layer_delete_warning_window(names: &[String], count: usize) -> Element<'stati /// handler for .dwg / .dxf. "Yes" runs the platform association call; "Not now" /// just dismisses. Either answer flips the persisted `default_assoc_prompted` /// flag so the dialog never reappears. -fn default_assoc_dialog_window() -> Element<'static, Message> { +fn default_assoc_dialog_window( + sizing: crate::ui::modal::ModalSizing, +) -> Element<'static, Message> { container( column![ text("Make Open CAD Studio your default CAD app?") @@ -1263,7 +1301,8 @@ fn default_assoc_dialog_window() -> Element<'static, Message> { .spacing(0), ) .style(dialog_body_style) - .center(Fit) + .center_x(sizing.width) + .center_y(sizing.height) .padding([24, 28]) .into() } diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index dc5712dd..118bdba6 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -81,9 +81,10 @@ const MTEXT_FONTS: [&str; 10] = [ /// world XY plane; the program fits + vertically flips them into the box. pub(in crate::app) const MTEXT_PREVIEW_PAD: f32 = 12.0; pub(in crate::app) const MTEXT_PREVIEW_EM_PX: f32 = 15.0; -pub(in crate::app) const MTEXT_EDITOR_BASE_WIDTH: f32 = 660.0; +// Used only until the shared modal sensor reports the first real layout width. +const MTEXT_EDITOR_FALLBACK_WIDTH: f32 = 660.0; pub(in crate::app) const MTEXT_EDITOR_WRITING_WIDTH: f32 = - MTEXT_EDITOR_BASE_WIDTH - 2.0 * MTEXT_PREVIEW_PAD; + MTEXT_EDITOR_FALLBACK_WIDTH - 2.0 * MTEXT_PREVIEW_PAD; struct MTextPreview { /// Disconnected polylines as (x, y) world points + colour (NaN-split done). @@ -334,13 +335,59 @@ fn mtext_preview_segments( 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, + modal_content_size: Option, +) -> Element<'a, Message> { + let writing_area_px = modal_content_size + .map(|size| size.width - 2.0 * MTEXT_PREVIEW_PAD) + .unwrap_or(MTEXT_EDITOR_WRITING_WIDTH) + .max(80.0); + let measurement = mtext_editor_content( + ed, + &styles, + crate::ui::modal::ModalSizing::INTRINSIC, + writing_area_px, + (writing_area_px * 0.5).max(1.0), + ); + let content = mtext_editor_content( + ed, + &styles, + crate::ui::modal::ModalSizing::FILL, + writing_area_px, + (writing_area_px * 0.5).max(1.0), + ); + let content = crate::ui::modal::intrinsic( + measurement, + content, + iced::Size::INFINITE, + modal_resize, + ); + + crate::ui::modal::modal( + iced::widget::Space::new().width(Fill).height(Fill), + "Text Editor", + content, + Message::MTextCancel, + modal_offset, + crate::ui::modal::ModalOptions::STANDARD, + ) +} + +fn mtext_editor_content<'a>( + ed: &'a super::super::mtext_editor::MTextEditorState, + styles: &[String], + sizing: crate::ui::modal::ModalSizing, + writing_area_px: f32, + intrinsic_preview_height: f32, ) -> Element<'a, Message> { - let sizing = crate::ui::modal::ModalSizing::from_resize(modal_resize); let width = sizing.width; let height = sizing.height; + let preview_height = if matches!(height, iced::Length::Fill) { + iced::Length::Fill + } else { + iced::Length::Fixed(intrinsic_preview_height) + }; use super::super::mtext_editor::{JustifyChoice, MTextFmt, ParaAlign}; use iced::widget::canvas; @@ -387,7 +434,7 @@ pub(super) fn mtext_editor_overlay<'a>( let style_opts: Vec = if styles.is_empty() { vec!["Standard".to_string()] } else { - styles + styles.to_vec() }; let style_pl = iced::widget::pick_list( Some(ed.style.clone()), @@ -524,8 +571,6 @@ pub(super) fn mtext_editor_overlay<'a>( .align_y(iced::Alignment::Center) .width(width); - let writing_area_px = - (MTEXT_EDITOR_BASE_WIDTH + modal_resize.x - 2.0 * MTEXT_PREVIEW_PAD).max(80.0); let preview_scale = ed.preview_scale(); let slider_min = 1e-6_f64; let slider_max = @@ -533,10 +578,10 @@ pub(super) fn mtext_editor_overlay<'a>( let width_slider = column![ row![ text(format!("Width: {:.3}", ed.rect_width)).size(11), - Space::new().width(Fill), + Space::new().width(width), text(format!("{:.0}%", ed.rect_width / slider_max * 100.0)).size(11), ] - .width(Fill), + .width(width), container( iced::widget::slider( slider_min..=slider_max, @@ -544,10 +589,10 @@ pub(super) fn mtext_editor_overlay<'a>( Message::MTextRectWidth, ) .step((slider_max * 0.01).max(1e-6)) - .width(Fill), + .width(width), ) .padding([0.0, MTEXT_PREVIEW_PAD]) - .width(Fill), + .width(width), ] .spacing(2) .width(width); @@ -555,7 +600,29 @@ pub(super) fn mtext_editor_overlay<'a>( // ── 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 body: Element<'a, Message> = if !matches!(height, iced::Length::Fill) { + // The preview is a flexible scroll viewport. Measuring its real canvas + // would make long/wide MText dictate the dialog size, so the intrinsic + // pass uses an empty viewport proxy. The toolbars alone determine its + // width; the preview height follows that measured writing width. + container(Space::new()) + .style(move |theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } + }) + .padding(2) + .width(width) + .height(preview_height) + .into() + } else { let segments = mtext_preview_segments(ed); let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); for (seg, _, _) in &segments { @@ -616,7 +683,7 @@ pub(super) fn mtext_editor_overlay<'a>( horizontal: Scrollbar::default(), }) .width(width) - .height(height), + .height(preview_height), ) .style(move |theme: &Theme| { let palette = theme.palette(); @@ -632,7 +699,7 @@ pub(super) fn mtext_editor_overlay<'a>( }) .padding(2) .width(width) - .height(height) + .height(preview_height) .into() }; @@ -655,30 +722,15 @@ pub(super) fn mtext_editor_overlay<'a>( .width(width) .padding([5, 8]); - // The shared modal frame supplies the panel background, drag title bar, - // ✕ (which also cancels) and the resize grip. Iced 0.15 measures this - // content first and clamps it to the user-growable maximum. - let content = container( + container( column![action_bar, row1, row2, width_slider, body] .spacing(6) .width(width) .height(height), ) - .width(iced::Length::Fit.max(MTEXT_EDITOR_BASE_WIDTH + modal_resize.x)) - .height(iced::Length::Fit.max(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), - "Text Editor", - content, - Message::MTextCancel, - modal_offset, - crate::ui::modal::ModalOptions::STANDARD, - ) + .width(width) + .height(height) + .into() } // ── Viewport right-click context menu ────────────────────────────────────── @@ -944,6 +996,43 @@ pub(super) fn qselect_overlay<'a>( state: &'a crate::app::QSelectState, types: &[String], properties: &[(String, String)], + modal_offset: iced::Vector, + modal_resize: iced::Vector, +) -> Element<'a, Message> { + let measurement = qselect_content( + state, + types, + properties, + crate::ui::modal::ModalSizing::INTRINSIC, + ); + let content = qselect_content( + state, + types, + properties, + crate::ui::modal::ModalSizing::FILL, + ); + let content = crate::ui::modal::intrinsic( + measurement, + content, + iced::Size::INFINITE, + modal_resize, + ); + + crate::ui::modal::modal( + Space::new().width(Fill).height(Fill), + "Quick Select", + content, + Message::QSelectClose, + modal_offset, + crate::ui::modal::ModalOptions::STANDARD, + ) +} + +fn qselect_content<'a>( + state: &'a crate::app::QSelectState, + types: &[String], + properties: &[(String, String)], + sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { use iced::widget::{checkbox}; let mut type_options: Vec = vec![QSELECT_ANY_TYPE.to_string()]; @@ -986,6 +1075,11 @@ pub(super) fn qselect_overlay<'a>( // operator is "*Any value" — both of those skip the value test. let value_enabled = state.property.is_some() && !matches!(state.operator, crate::app::QSelectOp::Any); + let field_width = if matches!(sizing.width, iced::Length::Fill) { + Fill + } else { + iced::Length::Shrink + }; let label = |s: &'static str| { text(s) @@ -1021,14 +1115,14 @@ pub(super) fn qselect_overlay<'a>( .padding([4, 14]) }; - let mut value_input = text_input("", &state.value).size(12); + let mut value_input = text_input("", &state.value) + .size(12) + .width(field_width); if value_enabled { value_input = value_input.on_input(Message::QSelectSetValue); } let panel_body = column![ - text("Quick Select").size(14), - Space::new().height(10), row![ label("Object type:"), iced::widget::pick_list( @@ -1043,10 +1137,11 @@ pub(super) fn qselect_overlay<'a>( Message::QSelectSetType(Some(s)) } }) - .width(Fill), + .width(field_width), ] .align_y(iced::Alignment::Center) - .spacing(8), + .spacing(8) + .width(sizing.width), Space::new().height(6), row![ label("Property:"), @@ -1062,10 +1157,11 @@ pub(super) fn qselect_overlay<'a>( Message::QSelectSetProperty(Some(p)) } }) - .width(Fill), + .width(field_width), ] .align_y(iced::Alignment::Center) - .spacing(8), + .spacing(8) + .width(sizing.width), Space::new().height(6), row![ label("Operator:"), @@ -1075,14 +1171,16 @@ pub(super) fn qselect_overlay<'a>( |value| value.to_string(), ) .on_select(Message::QSelectSetOperator) - .width(Fill), + .width(field_width), ] .align_y(iced::Alignment::Center) - .spacing(8), + .spacing(8) + .width(sizing.width), Space::new().height(6), row![label("Value:"), value_input,] .align_y(iced::Alignment::Center) - .spacing(8), + .spacing(8) + .width(sizing.width), Space::new().height(10), row![ checkbox(state.append) @@ -1094,18 +1192,21 @@ pub(super) fn qselect_overlay<'a>( .align_y(iced::Alignment::Center), Space::new().height(14), row![ - Space::new().width(Fill), + Space::new().width(field_width), btn("Cancel", Message::QSelectClose, false), Space::new().width(8), btn("Apply", Message::QSelectApply, true), ] .align_y(iced::Alignment::Center), ] - .spacing(0); + .spacing(0) + .width(sizing.width) + .height(sizing.height); let panel = container(panel_body) .padding(16) - .width(iced::Length::Fixed(400.0)) + .width(sizing.width) + .height(sizing.height) .style(|theme: &Theme| { let palette = theme.palette(); container::Style { @@ -1119,18 +1220,5 @@ pub(super) fn qselect_overlay<'a>( } }); - // Outside-click catcher — fills the whole screen, sits below the - // panel. The panel itself is rendered above and absorbs its own - // clicks via standard widget event handling. - let catcher = mouse_area( - container(iced::widget::Space::new().width(Fill).height(Fill)) - .width(Fill) - .height(Fill), - ) - .on_press(Message::QSelectClose) - .on_right_press(Message::QSelectClose); - - let centered = container(iced::widget::opaque(panel)).center(Fill); - - stack![catcher, centered].into() + panel.into() } diff --git a/src/ui/modal.rs b/src/ui/modal.rs index e0852d99..71fc3fce 100644 --- a/src/ui/modal.rs +++ b/src/ui/modal.rs @@ -9,7 +9,7 @@ use crate::app::Message; use iced::advanced::layout::{self, Layout}; use iced::advanced::widget::{self, Widget}; use iced::advanced::{mouse, overlay, renderer, Shell}; -use iced::widget::{button, column, container, mouse_area, opaque, row, stack, Space}; +use iced::widget::{button, column, container, mouse_area, opaque, row, sensor, stack, Space}; use iced::{ Background, Border, Element, Event, Length, Padding, Rectangle, Renderer, Size, Theme, Vector, }; @@ -42,10 +42,6 @@ impl ModalSizing { width: Length::Fill, height: Length::Fill, }; - - pub fn from_resize(_resize: Vector) -> Self { - Self::FILL - } } /// Measure an intrinsic copy of `content`, then lay the fill copy out in the @@ -326,9 +322,10 @@ pub fn modal<'a>( // The first stack layer dictates its intrinsic size. Its top spacer reserves // room for the title, while the actual title bar and resize grip overlay it // without influencing the modal dimensions. + let measured_content = sensor(content).on_resize(Message::ModalContentResized); let body_base = column![ Space::new().height(Length::Fixed(24.0)), - content.into(), + measured_content, ] .spacing(6); let mut body = stack![body_base, title_bar]; diff --git a/src/ui/window/find_replace.rs b/src/ui/window/find_replace.rs index bf84a703..a4c3c0ea 100644 --- a/src/ui/window/find_replace.rs +++ b/src/ui/window/find_replace.rs @@ -1,6 +1,6 @@ use crate::app::Message; use iced::widget::{button, column, row, text, text_input, Space}; -use iced::{Element, Fill}; +use iced::{Element, Fill, Length, Shrink}; pub const FIND_INPUT_ID: &str = "find-replace-search"; @@ -8,7 +8,13 @@ pub fn view_window<'a>( search: &'a str, replacement: &'a str, status: &'a str, + sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { + let field_width = if matches!(sizing.width, Length::Fill) { + Fill + } else { + Shrink + }; let find_input = text_input("Text to find", search) .id(iced::widget::Id::new(FIND_INPUT_ID)) .on_input(Message::FindReplaceSearchChanged) @@ -33,13 +39,13 @@ pub fn view_window<'a>( column![ row![ text("Find:").size(12).width(90), - find_input.width(Fill), + find_input.width(field_width), ] .spacing(8) .align_y(iced::Center), row![ text("Replace with:").size(12).width(90), - replacement_input.width(Fill), + replacement_input.width(field_width), ] .spacing(8) .align_y(iced::Center), @@ -47,7 +53,7 @@ pub fn view_window<'a>( .size(11), text(status).size(11), row![ - Space::new().width(Fill), + Space::new().width(field_width), button(text("Close").size(12)) .on_press(Message::CloseModal) .padding([6, 12]) @@ -61,6 +67,7 @@ pub fn view_window<'a>( ] .spacing(10) .padding(12) - .width(Fill) + .width(sizing.width) + .height(sizing.height) .into() }