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
This commit is contained in:
Hakan Seven 2026-08-01 18:24:05 +03:00
commit 796d7d91d0
11 changed files with 352 additions and 220 deletions

View file

@ -515,9 +515,12 @@ pub(super) struct OpenCADStudio {
/// Layer Manager Name column width in px, adjusted by the divider drag. /// Layer Manager Name column width in px, adjusted by the divider drag.
layer_name_col_w: f32, layer_name_col_w: f32,
/// How far the user has dragged the modal's corner resize grip from the /// 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. /// with `modal_offset` so every dialog opens at its own size.
modal_resize: iced::Vector, 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<iced::Size>,
/// True while the modal's corner resize grip is held. /// True while the modal's corner resize grip is held.
modal_resizing: bool, modal_resizing: bool,
// ── Attribute editor dialog (ATTEDIT / double-click a block) ─────────── // ── Attribute editor dialog (ATTEDIT / double-click a block) ───────────
@ -2293,6 +2296,8 @@ pub enum Message {
MTextApply, MTextApply,
/// Grab the resizable modal's corner grip (a drag resizes it). /// Grab the resizable modal's corner grip (a drag resizes it).
ModalResizeGrab, ModalResizeGrab,
/// The shared modal body finished layout at this size.
ModalContentResized(iced::Size),
/// Discard the editor without creating / changing the entity. /// Discard the editor without creating / changing the entity.
MTextCancel, MTextCancel,
// ── In-place single-line TEXT editor ──────────────────────────────── // ── In-place single-line TEXT editor ────────────────────────────────
@ -2725,6 +2730,7 @@ impl OpenCADStudio {
layer_col_dragging: false, layer_col_dragging: false,
layer_name_col_w: 130.0, layer_name_col_w: 130.0,
modal_resize: iced::Vector::ZERO, modal_resize: iced::Vector::ZERO,
modal_content_size: None,
modal_resizing: false, modal_resizing: false,
attr_editor_handle: None, attr_editor_handle: None,
attr_editor_block: String::new(), attr_editor_block: String::new(),

View file

@ -594,8 +594,7 @@ impl super::OpenCADStudio {
// Open centred at the editor's natural size: it renders through the // Open centred at the editor's natural size: it renders through the
// shared modal frame, which `modal_offset` positions and `modal_resize` // shared modal frame, which `modal_offset` positions and `modal_resize`
// grows (both draggable, so reset on open). // grows (both draggable, so reset on open).
self.modal_offset = iced::Vector::ZERO; self.reset_modal_geometry();
self.modal_resize = iced::Vector::ZERO;
self.rebuild_mtext_preview(); self.rebuild_mtext_preview();
// The preview uses a fixed on-screen text size. Therefore the initial // The preview uses a fixed on-screen text size. Therefore the initial
// wrap width is the drawing-unit span that exactly reaches the right // 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. /// Discard the editor without changing the drawing.
pub(super) fn mtext_cancel(&mut self) { pub(super) fn mtext_cancel(&mut self) {
self.mtext_editor = None; self.mtext_editor = None;
self.reset_modal_geometry();
} }
} }

View file

@ -580,6 +580,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
// Cancel layout rename first, then fall through. // Cancel layout rename first, then fall through.
let i_e = self.active_tab; let i_e = self.active_tab;
if self.qselect.take().is_some() { if self.qselect.take().is_some() {
self.reset_modal_geometry();
return Task::none(); return Task::none();
} }
{ {
@ -779,8 +780,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
pub(super) fn on_layer_delete_confirm(&mut self) -> Task<Message> { pub(super) fn on_layer_delete_confirm(&mut self) -> Task<Message> {
let i = self.active_tab; let i = self.active_tab;
self.active_modal = None; self.active_modal = None;
self.modal_offset = iced::Vector::ZERO; self.reset_modal_geometry();
self.modal_resize = iced::Vector::ZERO;
let Some((names, _)) = self.layer_delete_pending.take() else { let Some((names, _)) = self.layer_delete_pending.take() else {
return Task::none(); return Task::none();
}; };
@ -1224,6 +1224,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
value: String::new(), value: String::new(),
append: false, append: false,
}); });
self.reset_modal_geometry();
Task::none() Task::none()
} }
@ -2041,8 +2042,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
pub(super) fn cancel_attr_editor(&mut self) { pub(super) fn cancel_attr_editor(&mut self) {
if self.active_modal == Some(crate::app::ModalKind::AttributeEditor) { if self.active_modal == Some(crate::app::ModalKind::AttributeEditor) {
self.active_modal = None; self.active_modal = None;
self.modal_offset = iced::Vector::ZERO; self.reset_modal_geometry();
self.modal_resize = iced::Vector::ZERO;
} }
self.attr_editor_handle = None; self.attr_editor_handle = None;
self.attr_editor_block.clear(); self.attr_editor_block.clear();

View file

@ -65,6 +65,7 @@ impl OpenCADStudio {
self.aec_drop_acknowledged = false; self.aec_drop_acknowledged = false;
if self.active_modal == Some(crate::app::ModalKind::SaveDialog) { if self.active_modal == Some(crate::app::ModalKind::SaveDialog) {
self.active_modal = None; self.active_modal = None;
self.reset_modal_geometry();
} }
Task::none() Task::none()
} }
@ -94,6 +95,7 @@ impl OpenCADStudio {
pub(in crate::app) fn close_unsaved_dialog_window(&mut self) -> Task<Message> { pub(in crate::app) fn close_unsaved_dialog_window(&mut self) -> Task<Message> {
if self.active_modal == Some(crate::app::ModalKind::Unsaved) { if self.active_modal == Some(crate::app::ModalKind::Unsaved) {
self.active_modal = None; self.active_modal = None;
self.reset_modal_geometry();
} }
Task::none() Task::none()
} }

View file

@ -399,6 +399,7 @@ impl OpenCADStudio {
/// Check if a suspended command exists on the active tab and resume it /// Check if a suspended command exists on the active tab and resume it
/// with the outcome of the text editor. /// with the outcome of the text editor.
pub(in crate::app) fn post_editor_closed(&mut self, committed: bool) -> Task<Message> { pub(in crate::app) fn post_editor_closed(&mut self, committed: bool) -> Task<Message> {
self.reset_modal_geometry();
let i = self.active_tab; let i = self.active_tab;
if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() { if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() {
let res = cmd.on_editor_closed(committed); let res = cmd.on_editor_closed(committed);
@ -3328,6 +3329,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
// the runtime paper/scale choices still drive this one plot operation. // the runtime paper/scale choices still drive this one plot operation.
self.sync_dialog_plot_runtime(); self.sync_dialog_plot_runtime();
self.active_modal = None; self.active_modal = None;
self.reset_modal_geometry();
let plot_style = self.dialog_plot_style(&d); let plot_style = self.dialog_plot_style(&d);
// Extents, Window and Display use one plot path in both spaces. Only // Extents, Window and Display use one plot path in both spaces. Only

View file

@ -93,6 +93,15 @@ mod util;
mod viewport; mod viewport;
impl OpenCADStudio { 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) { fn sync_open_command_history(&mut self) {
if !self.command_line.history_open { if !self.command_line.history_open {
return; return;
@ -167,10 +176,7 @@ impl OpenCADStudio {
} }
self.active_modal = None; self.active_modal = None;
// Recentre / reset the size of the next dialog and drop any drag. // Recentre / reset the size of the next dialog and drop any drag.
self.modal_offset = iced::Vector::ZERO; self.reset_modal_geometry();
self.modal_resize = iced::Vector::ZERO;
self.modal_drag_last = None;
self.modal_dragging = false;
} }
pub fn update(&mut self, msg: Message) -> Task<Message> { pub fn update(&mut self, msg: Message) -> Task<Message> {
@ -1423,6 +1429,7 @@ impl OpenCADStudio {
if self.active_modal == Some(super::ModalKind::Layers) { if self.active_modal == Some(super::ModalKind::Layers) {
self.ribbon.deactivate_tool_if("LAYERS"); self.ribbon.deactivate_tool_if("LAYERS");
self.active_modal = None; self.active_modal = None;
self.reset_modal_geometry();
} else { } else {
self.sync_ribbon_layers(); self.sync_ribbon_layers();
self.active_modal = Some(super::ModalKind::Layers); self.active_modal = Some(super::ModalKind::Layers);
@ -3335,6 +3342,7 @@ impl OpenCADStudio {
Message::QSelectClose => { Message::QSelectClose => {
self.qselect = None; self.qselect = None;
self.reset_modal_geometry();
Task::none() Task::none()
} }
@ -3390,6 +3398,7 @@ impl OpenCADStudio {
let Some(state) = self.qselect.take() else { let Some(state) = self.qselect.take() else {
return Task::none(); return Task::none();
}; };
self.reset_modal_geometry();
let i = self.active_tab; let i = self.active_tab;
let matched = self.tabs[i].scene.qselect( let matched = self.tabs[i].scene.qselect(
state.type_filter.as_deref(), state.type_filter.as_deref(),
@ -4402,6 +4411,32 @@ impl OpenCADStudio {
self.modal_drag_last = None; self.modal_drag_last = None;
Task::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) => { Message::RibbonLayerFilterChanged(f) => {
self.ribbon.layer_filter = f; self.ribbon.layer_filter = f;
Task::none() Task::none()
@ -4444,8 +4479,11 @@ impl OpenCADStudio {
// of being squeezed (the off-centre padding shrinks the // of being squeezed (the off-centre padding shrinks the
// dialog once it overlaps a border). // dialog once it overlaps a border).
if let Some((cw, ch)) = self.modal_outer_size() { if let Some((cw, ch)) = self.modal_outer_size() {
let ww = self.vp_size.0 + 440.0; let (ww, wh) = if self.mtext_editor.is_some() {
let wh = self.vp_size.1; self.vp_size
} else {
self.win_size
};
let max_x = ((ww - cw) * 0.5).max(0.0); let max_x = ((ww - cw) * 0.5).max(0.0);
let max_y = ((wh - ch) * 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); 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.file_assoc_enabled = true;
self.mark_assoc_prompted(); self.mark_assoc_prompted();
self.active_modal = None; self.active_modal = None;
self.reset_modal_geometry();
// set_default_app registers the handler first, then makes us the // set_default_app registers the handler first, then makes us the
// default — boot no longer does this automatically. // default — boot no longer does this automatically.
Task::perform( Task::perform(
@ -5067,6 +5106,7 @@ impl OpenCADStudio {
self.file_assoc_enabled = false; self.file_assoc_enabled = false;
self.mark_assoc_prompted(); self.mark_assoc_prompted();
self.active_modal = None; self.active_modal = None;
self.reset_modal_geometry();
Task::none() Task::none()
} }
Message::AssocResult(result) => { Message::AssocResult(result) => {

View file

@ -1330,9 +1330,9 @@ impl OpenCADStudio {
viewport_stack = viewport_stack.push(mtext_editor_overlay( viewport_stack = viewport_stack.push(mtext_editor_overlay(
ed, ed,
styles, styles,
canvas,
self.modal_offset, self.modal_offset,
self.modal_resize, self.modal_resize,
self.modal_content_size,
)); ));
} }
if let Some(ed) = &self.text_inline { 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 qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect {
let types = tab.scene.entity_type_names_in_layout(); let types = tab.scene.entity_type_names_in_layout();
let properties = tab.scene.qselect_properties(state.type_filter.as_deref()); 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 { } else {
iced::widget::Space::new().width(0).height(0).into() 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 /// Measured outer pixel bounds for whichever shared modal is visible, used
/// so it cannot be pushed off-screen. Mirrors the maximum dimensions in /// to keep its frame on-screen while dragging.
/// [`Self::modal_content`]; content-sized dialogs may render smaller.
/// `None` has no active modal. About uses a safe estimate.
pub(crate) fn modal_outer_size(&self) -> Option<(f32, f32)> { pub(crate) fn modal_outer_size(&self) -> Option<(f32, f32)> {
use super::ModalKind::*; let size = self.modal_content_size?;
// Title bar (~26) + spacing (6) + frame padding (10·2) → ~52 vertical; // Frame padding is 10 px per side; title + body spacing is 30 px.
// frame padding → ~20 horizontal. Some((size.width + 20.0, size.height + 50.0))
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,
))
} }
} }

View file

@ -1,13 +1,8 @@
use super::super::{Message, OpenCADStudio}; use super::super::{Message, OpenCADStudio};
use iced::widget::{button, column, container, row, text, Space}; use iced::widget::{button, column, container, row, text, Space};
use iced::{Background, Element, Fit, Theme}; use iced::{Background, Element, Fill, Fit, Theme};
impl OpenCADStudio { 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 /// Title shown in the active modal's title bar. Keep in sync with the
/// [`Self::modal_content`] dispatch. /// [`Self::modal_content`] dispatch.
pub(super) fn modal_title(&self) -> &'static str { 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`. /// 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 dialogs start at their natural
/// Iced 0.15 measures the content first, so smaller dialogs stop at their /// size and overflowing regions become scrollable where a cap is supplied.
/// intrinsic size while overflowing regions become scrollable at the cap.
pub(super) fn modal_content<'s>(&'s self) -> Option<Element<'s, Message>> { pub(super) fn modal_content<'s>(&'s self) -> Option<Element<'s, Message>> {
let ex = self.modal_resize; 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? { Some(match self.active_modal? {
super::super::ModalKind::About => { super::super::ModalKind::About => automatic_flow(ex, |flow| {
sized(crate::ui::window::about::view_window(), 440, 360) container(crate::ui::window::about::view_window())
} .width(flow.width)
.height(flow.height)
.into()
}),
super::super::ModalKind::Shortcuts => { super::super::ModalKind::Shortcuts => {
sized_flow( sized_flow(
ex, 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( crate::ui::window::find_replace::view_window(
&self.find_replace.search, &self.find_replace.search,
&self.find_replace.replacement, &self.find_replace.replacement,
&self.find_replace.status, &self.find_replace.status,
), flow,
560, )
190, }),
),
super::super::ModalKind::PluginManager => { super::super::ModalKind::PluginManager => {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
@ -149,11 +139,12 @@ impl OpenCADStudio {
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
sized( automatic_flow(ex, |flow| {
crate::ui::window::plugin_manager::view_web_notice(), container(crate::ui::window::plugin_manager::view_web_notice())
self.web_plugin_notice_width(), .width(flow.width)
230, .height(flow.height)
) .into()
})
} }
} }
super::super::ModalKind::UpdateNotice => { super::super::ModalKind::UpdateNotice => {
@ -228,13 +219,13 @@ impl OpenCADStudio {
}, },
) )
} else { } else {
sized( automatic_flow(ex, |flow| {
container(text("The selected layer state is no longer available.")) container(text("The selected layer state is no longer available."))
.padding(16) .padding(16)
.into(), .width(flow.width)
520, .height(flow.height)
180, .into()
) })
} }
} }
super::super::ModalKind::Plot => { 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 => { super::super::ModalKind::AecDropWarning => {
let src_label = self let src_label = self
.tabs .tabs
@ -788,15 +781,14 @@ impl OpenCADStudio {
crate::io::format_for_version(version, is_dxf) crate::io::format_for_version(version, is_dxf)
}) })
.unwrap_or_else(|| "DWG".to_string()); .unwrap_or_else(|| "DWG".to_string());
sized( automatic_flow(ex, |flow| {
aec_drop_dialog_window( aec_drop_dialog_window(
self.aec_drop_count, self.aec_drop_count,
&self.save_dialog_format, &self.save_dialog_format,
&src_label, &src_label,
), flow,
480, )
230, })
)
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
super::super::ModalKind::FileInUse => { super::super::ModalKind::FileInUse => {
@ -810,7 +802,7 @@ impl OpenCADStudio {
) )
}) })
.unwrap_or_default(); .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"))] #[cfg(not(target_arch = "wasm32"))]
super::super::ModalKind::ExternalChange => { super::super::ModalKind::ExternalChange => {
@ -819,14 +811,14 @@ impl OpenCADStudio {
.as_ref() .as_ref()
.map(|conflict| conflict.path.display().to_string()) .map(|conflict| conflict.path.display().to_string())
.unwrap_or_default(); .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 => { super::super::ModalKind::LayerDeleteWarning => {
let (names, count) = self let (names, count) = self
.layer_delete_pending .layer_delete_pending
.clone() .clone()
.unwrap_or_else(|| (Vec::new(), 0)); .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 => { super::super::ModalKind::Unsaved => {
let tab_name = match &self.pending_close { let tab_name = match &self.pending_close {
@ -843,7 +835,7 @@ impl OpenCADStudio {
.unwrap_or_default(), .unwrap_or_default(),
None => String::new(), 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( super::super::ModalKind::PointStyle => sized_flow(
ex, ex,
@ -897,17 +889,13 @@ impl OpenCADStudio {
) )
} }
super::super::ModalKind::SaveDialog => { super::super::ModalKind::SaveDialog => {
// Native omits the file-name field (the OS dialog collects it), automatic_flow(ex, |flow| {
// so it stands shorter than the web build. save_as_dialog_window(
#[cfg(target_arch = "wasm32")] &self.save_dialog_filename,
let h = 200; &self.save_dialog_format,
#[cfg(not(target_arch = "wasm32"))] flow,
let h = 150; )
sized( })
save_as_dialog_window(&self.save_dialog_filename, &self.save_dialog_format),
420,
h,
)
} }
}) })
} }
@ -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( fn dialog_button(
label: &'static str, label: &'static str,
message: Message, 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 /// Compact Save-As options dialog: pick the format/version and a default file
/// name. The destination folder and overwrite confirmation come from the /// name. The destination folder and overwrite confirmation come from the
/// native OS save dialog (native) or the browser download (web) that follows. /// 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 let sel_fmt = crate::io::SAVE_FORMAT_OPTIONS
.iter() .iter()
.copied() .copied()
.find(|&s| s == format); .find(|&s| s == format);
let label = |s: &'static str| text(s).size(11).style(dialog_muted_text_style); 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<Element<'a, Message>> = Vec::new(); let mut items: Vec<Element<'a, Message>> = Vec::new();
items.push(text("Save Drawing As").size(14).into()); 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) .on_input(Message::SaveDialogFilenameChanged)
.size(13) .size(13)
.padding([5, 8]) .padding([5, 8])
.width(Fit), .width(field_width),
] ]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.spacing(6) .spacing(6)
.width(sizing.width)
.into(), .into(),
); );
items.push(Space::new().height(8).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(), |value| value.to_string(),
) )
.on_select(|s: &str| Message::SaveDialogFormatChanged(s.to_string())) .on_select(|s: &str| Message::SaveDialogFormatChanged(s.to_string()))
.width(Fit), .width(field_width),
] ]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.spacing(6) .spacing(6)
.width(sizing.width)
.into(), .into(),
); );
items.push(Space::new().height(16).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(), .into(),
); );
let body = column(items).spacing(0); let body = column(items)
.spacing(0)
.width(sizing.width)
.height(sizing.height);
container(body) container(body)
.style(dialog_body_style) .style(dialog_body_style)
.padding([14, 16]) .padding([14, 16])
.width(Fit) .width(sizing.width)
.height(Fit) .height(sizing.height)
.into() .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); let body_text = format!("Do you want to save changes to \"{}\"?", name);
container( container(
@ -1044,13 +1061,18 @@ fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> {
.spacing(0), .spacing(0),
) )
.style(dialog_body_style) .style(dialog_body_style)
.center(Fit) .center_x(sizing.width)
.center_y(sizing.height)
.padding([24, 28]) .padding([24, 28])
.into() .into()
} }
#[cfg(not(target_arch = "wasm32"))] #[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) let file_name = std::path::Path::new(path)
.file_name() .file_name()
.map(|name| name.to_string_lossy().into_owned()) .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) .style(dialog_body_style)
.padding([18, 20]) .padding([18, 20])
.width(Fit) .width(sizing.width)
.height(Fit) .height(sizing.height)
.into() .into()
} }
#[cfg(not(target_arch = "wasm32"))] #[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) let file_name = std::path::Path::new(path)
.file_name() .file_name()
.map(|name| name.to_string_lossy().into_owned()) .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) .style(dialog_body_style)
.padding([18, 20]) .padding([18, 20])
.width(Fit) .width(sizing.width)
.height(Fit) .height(sizing.height)
.into() .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 /// (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 /// 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). /// 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!( let body_text = format!(
"This drawing contains {count} AEC/Civil objects that \"{target}\" \ "This drawing contains {count} AEC/Civil objects that \"{target}\" \
cannot store, so they will not be saved.\n\n\ 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), .spacing(0),
) )
.style(dialog_body_style) .style(dialog_body_style)
.center(Fit) .center_x(sizing.width)
.center_y(sizing.height)
.padding([24, 28]) .padding([24, 28])
.into() .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. /// target folder. "Replace" overwrites; "Cancel" returns to the Save dialog.
/// Confirm deleting layer(s) that still have objects on them. "Delete Objects" /// Confirm deleting layer(s) that still have objects on them. "Delete Objects"
/// erases them and removes the layers; "Cancel" leaves everything. /// 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 obj = if count == 1 { "object" } else { "objects" };
let subject = if names.len() == 1 { let subject = if names.len() == 1 {
format!("Layer \"{}\"", names[0]) format!("Layer \"{}\"", names[0])
@ -1229,7 +1264,8 @@ fn layer_delete_warning_window(names: &[String], count: usize) -> Element<'stati
.spacing(0), .spacing(0),
) )
.style(dialog_body_style) .style(dialog_body_style)
.center(Fit) .center_x(sizing.width)
.center_y(sizing.height)
.padding([24, 28]) .padding([24, 28])
.into() .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" /// handler for .dwg / .dxf. "Yes" runs the platform association call; "Not now"
/// just dismisses. Either answer flips the persisted `default_assoc_prompted` /// just dismisses. Either answer flips the persisted `default_assoc_prompted`
/// flag so the dialog never reappears. /// 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( container(
column![ column![
text("Make Open CAD Studio your default CAD app?") text("Make Open CAD Studio your default CAD app?")
@ -1263,7 +1301,8 @@ fn default_assoc_dialog_window() -> Element<'static, Message> {
.spacing(0), .spacing(0),
) )
.style(dialog_body_style) .style(dialog_body_style)
.center(Fit) .center_x(sizing.width)
.center_y(sizing.height)
.padding([24, 28]) .padding([24, 28])
.into() .into()
} }

View file

@ -81,9 +81,10 @@ const MTEXT_FONTS: [&str; 10] = [
/// world XY plane; the program fits + vertically flips them into the box. /// 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_PAD: f32 = 12.0;
pub(in crate::app) const MTEXT_PREVIEW_EM_PX: f32 = 15.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 = 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 { struct MTextPreview {
/// Disconnected polylines as (x, y) world points + colour (NaN-split done). /// 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>( pub(super) fn mtext_editor_overlay<'a>(
ed: &'a super::super::mtext_editor::MTextEditorState, ed: &'a super::super::mtext_editor::MTextEditorState,
styles: Vec<String>, styles: Vec<String>,
canvas_size: (f32, f32),
modal_offset: iced::Vector, modal_offset: iced::Vector,
modal_resize: iced::Vector, modal_resize: iced::Vector,
modal_content_size: Option<iced::Size>,
) -> 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> { ) -> Element<'a, Message> {
let sizing = crate::ui::modal::ModalSizing::from_resize(modal_resize);
let width = sizing.width; let width = sizing.width;
let height = sizing.height; 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 super::super::mtext_editor::{JustifyChoice, MTextFmt, ParaAlign};
use iced::widget::canvas; use iced::widget::canvas;
@ -387,7 +434,7 @@ pub(super) fn mtext_editor_overlay<'a>(
let style_opts: Vec<String> = if styles.is_empty() { let style_opts: Vec<String> = if styles.is_empty() {
vec!["Standard".to_string()] vec!["Standard".to_string()]
} else { } else {
styles styles.to_vec()
}; };
let style_pl = iced::widget::pick_list( let style_pl = iced::widget::pick_list(
Some(ed.style.clone()), Some(ed.style.clone()),
@ -524,8 +571,6 @@ pub(super) fn mtext_editor_overlay<'a>(
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.width(width); .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 preview_scale = ed.preview_scale();
let slider_min = 1e-6_f64; let slider_min = 1e-6_f64;
let slider_max = let slider_max =
@ -533,10 +578,10 @@ pub(super) fn mtext_editor_overlay<'a>(
let width_slider = column![ let width_slider = column![
row![ row![
text(format!("Width: {:.3}", ed.rect_width)).size(11), 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), text(format!("{:.0}%", ed.rect_width / slider_max * 100.0)).size(11),
] ]
.width(Fill), .width(width),
container( container(
iced::widget::slider( iced::widget::slider(
slider_min..=slider_max, slider_min..=slider_max,
@ -544,10 +589,10 @@ pub(super) fn mtext_editor_overlay<'a>(
Message::MTextRectWidth, Message::MTextRectWidth,
) )
.step((slider_max * 0.01).max(1e-6)) .step((slider_max * 0.01).max(1e-6))
.width(Fill), .width(width),
) )
.padding([0.0, MTEXT_PREVIEW_PAD]) .padding([0.0, MTEXT_PREVIEW_PAD])
.width(Fill), .width(width),
] ]
.spacing(2) .spacing(2)
.width(width); .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 // ── 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 // space left by the toolbars, so the resizable modal's extra height flows
// into the text area. ───────────────────────────────────────────────── // 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 segments = mtext_preview_segments(ed);
let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for (seg, _, _) in &segments { for (seg, _, _) in &segments {
@ -616,7 +683,7 @@ pub(super) fn mtext_editor_overlay<'a>(
horizontal: Scrollbar::default(), horizontal: Scrollbar::default(),
}) })
.width(width) .width(width)
.height(height), .height(preview_height),
) )
.style(move |theme: &Theme| { .style(move |theme: &Theme| {
let palette = theme.palette(); let palette = theme.palette();
@ -632,7 +699,7 @@ pub(super) fn mtext_editor_overlay<'a>(
}) })
.padding(2) .padding(2)
.width(width) .width(width)
.height(height) .height(preview_height)
.into() .into()
}; };
@ -655,30 +722,15 @@ pub(super) fn mtext_editor_overlay<'a>(
.width(width) .width(width)
.padding([5, 8]); .padding([5, 8]);
// The shared modal frame supplies the panel background, drag title bar, container(
// ✕ (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(
column![action_bar, row1, row2, width_slider, body] column![action_bar, row1, row2, width_slider, body]
.spacing(6) .spacing(6)
.width(width) .width(width)
.height(height), .height(height),
) )
.width(iced::Length::Fit.max(MTEXT_EDITOR_BASE_WIDTH + modal_resize.x)) .width(width)
.height(iced::Length::Fit.max(480.0 + modal_resize.y)); .height(height)
.into()
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,
)
} }
// ── Viewport right-click context menu ────────────────────────────────────── // ── Viewport right-click context menu ──────────────────────────────────────
@ -944,6 +996,43 @@ pub(super) fn qselect_overlay<'a>(
state: &'a crate::app::QSelectState, state: &'a crate::app::QSelectState,
types: &[String], types: &[String],
properties: &[(String, 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> { ) -> Element<'a, Message> {
use iced::widget::{checkbox}; use iced::widget::{checkbox};
let mut type_options: Vec<String> = vec![QSELECT_ANY_TYPE.to_string()]; let mut type_options: Vec<String> = 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. // operator is "*Any value" — both of those skip the value test.
let value_enabled = let value_enabled =
state.property.is_some() && !matches!(state.operator, crate::app::QSelectOp::Any); 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| { let label = |s: &'static str| {
text(s) text(s)
@ -1021,14 +1115,14 @@ pub(super) fn qselect_overlay<'a>(
.padding([4, 14]) .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 { if value_enabled {
value_input = value_input.on_input(Message::QSelectSetValue); value_input = value_input.on_input(Message::QSelectSetValue);
} }
let panel_body = column![ let panel_body = column![
text("Quick Select").size(14),
Space::new().height(10),
row![ row![
label("Object type:"), label("Object type:"),
iced::widget::pick_list( iced::widget::pick_list(
@ -1043,10 +1137,11 @@ pub(super) fn qselect_overlay<'a>(
Message::QSelectSetType(Some(s)) Message::QSelectSetType(Some(s))
} }
}) })
.width(Fill), .width(field_width),
] ]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.spacing(8), .spacing(8)
.width(sizing.width),
Space::new().height(6), Space::new().height(6),
row![ row![
label("Property:"), label("Property:"),
@ -1062,10 +1157,11 @@ pub(super) fn qselect_overlay<'a>(
Message::QSelectSetProperty(Some(p)) Message::QSelectSetProperty(Some(p))
} }
}) })
.width(Fill), .width(field_width),
] ]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.spacing(8), .spacing(8)
.width(sizing.width),
Space::new().height(6), Space::new().height(6),
row![ row![
label("Operator:"), label("Operator:"),
@ -1075,14 +1171,16 @@ pub(super) fn qselect_overlay<'a>(
|value| value.to_string(), |value| value.to_string(),
) )
.on_select(Message::QSelectSetOperator) .on_select(Message::QSelectSetOperator)
.width(Fill), .width(field_width),
] ]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.spacing(8), .spacing(8)
.width(sizing.width),
Space::new().height(6), Space::new().height(6),
row![label("Value:"), value_input,] row![label("Value:"), value_input,]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.spacing(8), .spacing(8)
.width(sizing.width),
Space::new().height(10), Space::new().height(10),
row![ row![
checkbox(state.append) checkbox(state.append)
@ -1094,18 +1192,21 @@ pub(super) fn qselect_overlay<'a>(
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
Space::new().height(14), Space::new().height(14),
row![ row![
Space::new().width(Fill), Space::new().width(field_width),
btn("Cancel", Message::QSelectClose, false), btn("Cancel", Message::QSelectClose, false),
Space::new().width(8), Space::new().width(8),
btn("Apply", Message::QSelectApply, true), btn("Apply", Message::QSelectApply, true),
] ]
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
] ]
.spacing(0); .spacing(0)
.width(sizing.width)
.height(sizing.height);
let panel = container(panel_body) let panel = container(panel_body)
.padding(16) .padding(16)
.width(iced::Length::Fixed(400.0)) .width(sizing.width)
.height(sizing.height)
.style(|theme: &Theme| { .style(|theme: &Theme| {
let palette = theme.palette(); let palette = theme.palette();
container::Style { container::Style {
@ -1119,18 +1220,5 @@ pub(super) fn qselect_overlay<'a>(
} }
}); });
// Outside-click catcher — fills the whole screen, sits below the panel.into()
// 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()
} }

View file

@ -9,7 +9,7 @@ use crate::app::Message;
use iced::advanced::layout::{self, Layout}; use iced::advanced::layout::{self, Layout};
use iced::advanced::widget::{self, Widget}; use iced::advanced::widget::{self, Widget};
use iced::advanced::{mouse, overlay, renderer, Shell}; 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::{ use iced::{
Background, Border, Element, Event, Length, Padding, Rectangle, Renderer, Size, Theme, Vector, Background, Border, Element, Event, Length, Padding, Rectangle, Renderer, Size, Theme, Vector,
}; };
@ -42,10 +42,6 @@ impl ModalSizing {
width: Length::Fill, width: Length::Fill,
height: 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 /// 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 // 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 // room for the title, while the actual title bar and resize grip overlay it
// without influencing the modal dimensions. // without influencing the modal dimensions.
let measured_content = sensor(content).on_resize(Message::ModalContentResized);
let body_base = column![ let body_base = column![
Space::new().height(Length::Fixed(24.0)), Space::new().height(Length::Fixed(24.0)),
content.into(), measured_content,
] ]
.spacing(6); .spacing(6);
let mut body = stack![body_base, title_bar]; let mut body = stack![body_base, title_bar];

View file

@ -1,6 +1,6 @@
use crate::app::Message; use crate::app::Message;
use iced::widget::{button, column, row, text, text_input, Space}; 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"; pub const FIND_INPUT_ID: &str = "find-replace-search";
@ -8,7 +8,13 @@ pub fn view_window<'a>(
search: &'a str, search: &'a str,
replacement: &'a str, replacement: &'a str,
status: &'a str, status: &'a str,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let field_width = if matches!(sizing.width, Length::Fill) {
Fill
} else {
Shrink
};
let find_input = text_input("Text to find", search) let find_input = text_input("Text to find", search)
.id(iced::widget::Id::new(FIND_INPUT_ID)) .id(iced::widget::Id::new(FIND_INPUT_ID))
.on_input(Message::FindReplaceSearchChanged) .on_input(Message::FindReplaceSearchChanged)
@ -33,13 +39,13 @@ pub fn view_window<'a>(
column![ column![
row![ row![
text("Find:").size(12).width(90), text("Find:").size(12).width(90),
find_input.width(Fill), find_input.width(field_width),
] ]
.spacing(8) .spacing(8)
.align_y(iced::Center), .align_y(iced::Center),
row![ row![
text("Replace with:").size(12).width(90), text("Replace with:").size(12).width(90),
replacement_input.width(Fill), replacement_input.width(field_width),
] ]
.spacing(8) .spacing(8)
.align_y(iced::Center), .align_y(iced::Center),
@ -47,7 +53,7 @@ pub fn view_window<'a>(
.size(11), .size(11),
text(status).size(11), text(status).size(11),
row![ row![
Space::new().width(Fill), Space::new().width(field_width),
button(text("Close").size(12)) button(text("Close").size(12))
.on_press(Message::CloseModal) .on_press(Message::CloseModal)
.padding([6, 12]) .padding([6, 12])
@ -61,6 +67,7 @@ pub fn view_window<'a>(
] ]
.spacing(10) .spacing(10)
.padding(12) .padding(12)
.width(Fill) .width(sizing.width)
.height(sizing.height)
.into() .into()
} }