From c9aab122f0de29ff29862581d3c1fb0ece23f3ef Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 30 Jul 2026 17:56:54 +0300 Subject: [PATCH] feat(find): add find and replace Closes #583 --- src/app/commands/inquiry.rs | 8 +- src/app/find_replace.rs | 462 ++++++++++++++++++++++++++++++++++ src/app/mod.rs | 33 +++ src/app/update/mod.rs | 22 ++ src/app/view/mod.rs | 2 + src/app/view/modal.rs | 10 + src/scene/camera_ops.rs | 216 ++++++++++++++++ src/ui/window/find_replace.rs | 66 +++++ src/ui/window/mod.rs | 1 + src/ui/window/shortcuts.rs | 1 + 10 files changed, 814 insertions(+), 7 deletions(-) create mode 100644 src/app/find_replace.rs create mode 100644 src/ui/window/find_replace.rs diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 4114d0c9..ad259d4e 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -1078,13 +1078,7 @@ impl OpenCADStudio { // FIND REPLACE — replace first occurrence (case-insensitive) // FINDALL REPLACE — replace all occurrences "FIND" => { - use crate::command::ValuePromptCommand; - let c = ValuePromptCommand::new( - "FIND", - "FIND text to find (add REPLACE by typing):", - ); - self.command_line.push_info(&c.prompt()); - self.tabs[self.active_tab].active_cmd = Some(Box::new(c)); + return Some(Task::done(Message::FindReplaceOpen)); } "FINDALL" => { use crate::command::ValuePromptCommand; diff --git a/src/app/find_replace.rs b/src/app/find_replace.rs new file mode 100644 index 00000000..723b3c53 --- /dev/null +++ b/src/app/find_replace.rs @@ -0,0 +1,462 @@ +use super::{FindMatchKey, Message, OpenCADStudio}; +use crate::entities::traits::EntityTypeOps; +use acadrust::EntityType; +use iced::Task; + +impl OpenCADStudio { + pub(super) fn open_find_replace(&mut self) -> Task { + self.find_replace.current_match = None; + self.find_replace.status.clear(); + self.active_modal = Some(super::ModalKind::FindReplace); + self.modal_offset = iced::Vector::ZERO; + self.modal_resize = iced::Vector::ZERO; + iced::widget::operation::focus(iced::widget::Id::new( + crate::ui::window::find_replace::FIND_INPUT_ID, + )) + } + + pub(super) fn find_replace_search_changed(&mut self, value: String) { + self.find_replace.search = value; + self.find_replace.current_match = None; + self.find_replace.status.clear(); + } + + pub(super) fn find_replace_replacement_changed(&mut self, value: String) { + self.find_replace.replacement = value; + } + + pub(super) fn find_replace_next(&mut self) { + let i = self.active_tab; + let matches = self.find_navigation_matches(i); + if matches.is_empty() { + self.find_replace.current_match = None; + self.find_replace.status = no_match_status(&self.find_replace.search); + return; + } + + let start = self + .find_replace + .current_match + .and_then(|current| matches.iter().position(|candidate| *candidate == current)) + .map_or(0, |current| (current + 1) % matches.len()); + for offset in 0..matches.len() { + let index = (start + offset) % matches.len(); + let target = matches[index]; + let centered = match target { + FindMatchKey::Entity(handle) => { + self.tabs[i].scene.center_camera_on_entity(handle) + } + FindMatchKey::BlockEntityInInsert { entity, insert } => self.tabs[i] + .scene + .center_camera_on_block_entity(insert, entity), + FindMatchKey::InsertAttribute { insert, index } => { + self.tabs[i] + .scene + .center_camera_on_insert_attribute(insert, index) + } + }; + if !centered { + continue; + } + + self.find_replace.current_match = Some(target); + self.find_replace.status = format!( + "{} of {} — {}", + index + 1, + matches.len(), + match_label(target) + ); + self.tabs[i].scene.deselect_all(); + self.tabs[i] + .scene + .select_entity(match_owner_handle(target), false); + self.refresh_properties(); + return; + } + + self.find_replace.current_match = None; + self.find_replace.status = no_match_status(&self.find_replace.search); + } + + pub(super) fn find_replace_one(&mut self) { + let i = self.active_tab; + let matches = self.find_text_matches(i); + let Some(target) = self + .find_replace + .current_match + .filter(|current| { + matches.iter().any(|candidate| { + match_document_handle(*candidate) == match_document_handle(*current) + }) + }) + .or_else(|| matches.first().copied()) + else { + self.find_replace.current_match = None; + self.find_replace.status = no_match_status(&self.find_replace.search); + return; + }; + + let search = self.find_replace.search.clone(); + let replacement = self.find_replace.replacement.clone(); + self.push_undo_snapshot(i, "FIND/REPLACE"); + let replaced = replace_match_text( + &mut self.tabs[i].scene.document, + target, + &search, + &replacement, + false, + ); + if replaced == 0 { + self.discard_last_undo_entry(i); + self.find_replace.status = no_match_status(&search); + return; + } + + let handle = match_document_handle(target); + if self.tabs[i] + .scene + .entity_belongs_to_active_space(handle) + { + self.invalidate_property_targets(i, &[handle]); + } else { + // A block-definition edit must rebuild the definition cache; an + // entity-only delta would leave every visible INSERT showing the + // old text until some unrelated full rebuild. + self.tabs[i].scene.bump_geometry(); + } + self.tabs[i].dirty = true; + self.find_replace.current_match = None; + let remaining = self.find_text_matches(i).len(); + self.find_replace.status = format!( + "Replaced 1 occurrence in {}; {remaining} matching object(s) remain.", + match_label(target) + ); + self.command_line.push_output(&format!( + "FIND/REPLACE: replaced 1 occurrence of \"{search}\"." + )); + self.refresh_properties(); + } + + pub(super) fn find_replace_all(&mut self) { + let i = self.active_tab; + let matches = self.find_text_matches(i); + if matches.is_empty() { + self.find_replace.current_match = None; + self.find_replace.status = no_match_status(&self.find_replace.search); + return; + } + + let search = self.find_replace.search.clone(); + let replacement = self.find_replace.replacement.clone(); + self.push_undo_snapshot(i, "FIND/REPLACE ALL"); + let mut replaced = 0usize; + let mut changed = Vec::new(); + let mut changed_outside_active_space = false; + for target in matches { + let count = replace_match_text( + &mut self.tabs[i].scene.document, + target, + &search, + &replacement, + true, + ); + if count > 0 { + replaced += count; + let handle = match_document_handle(target); + changed_outside_active_space |= !self.tabs[i] + .scene + .entity_belongs_to_active_space(handle); + if !changed.contains(&handle) { + changed.push(handle); + } + } + } + if changed.is_empty() { + self.discard_last_undo_entry(i); + self.find_replace.status = no_match_status(&search); + return; + } + + if changed_outside_active_space { + self.tabs[i].scene.bump_geometry(); + } else { + self.invalidate_property_targets(i, &changed); + } + self.tabs[i].dirty = true; + self.find_replace.current_match = None; + self.find_replace.status = format!( + "Replaced {replaced} occurrence(s) in {} object(s).", + changed.len() + ); + self.command_line.push_output(&format!( + "FIND/REPLACE: replaced {replaced} occurrence(s) of \"{search}\"." + )); + self.refresh_properties(); + } + + fn find_text_matches(&self, i: usize) -> Vec { + let search = self.find_replace.search.trim(); + if search.is_empty() { + return Vec::new(); + } + let mut visible = Vec::new(); + let mut definitions = Vec::new(); + for entity in self.tabs[i].scene.document.entities() { + if let Some(text) = entity.text_content() { + if find_case_insensitive_range(&text, search, 0).is_some() { + let target = FindMatchKey::Entity(entity.common().handle); + if self.tabs[i] + .scene + .entity_belongs_to_active_space(entity.common().handle) + { + visible.push(target); + } else { + definitions.push(target); + } + } + } + if let EntityType::Insert(insert) = entity { + for (index, attribute) in insert.attributes.iter().enumerate() { + if find_case_insensitive_range(attribute.get_value(), search, 0).is_some() { + let target = FindMatchKey::InsertAttribute { + insert: insert.common.handle, + index, + }; + if self.tabs[i] + .scene + .entity_belongs_to_active_space(insert.common.handle) + { + visible.push(target); + } else { + definitions.push(target); + } + } + } + } + } + visible.extend(definitions); + visible + } + + fn find_navigation_matches(&self, i: usize) -> Vec { + let scene = &self.tabs[i].scene; + let mut matches = Vec::new(); + for target in self.find_text_matches(i) { + let owner = match_owner_handle(target); + if scene.entity_belongs_to_active_space(owner) { + matches.push(target); + continue; + } + + let FindMatchKey::Entity(entity) = target else { + continue; + }; + for candidate in scene.document.entities() { + let EntityType::Insert(insert) = candidate else { + continue; + }; + if !scene.entity_belongs_to_active_space(insert.common.handle) { + continue; + } + let mut visited = Vec::new(); + if block_contains_entity( + &scene.document, + &insert.block_name, + entity, + &mut visited, + ) { + matches.push(FindMatchKey::BlockEntityInInsert { + entity, + insert: insert.common.handle, + }); + } + } + } + matches + } +} + +fn no_match_status(search: &str) -> String { + if search.trim().is_empty() { + "Enter text to find.".to_string() + } else { + format!("\"{search}\" was not found.") + } +} + +fn replace_entity_text( + entity: &mut EntityType, + search: &str, + replacement: &str, + replace_all: bool, +) -> usize { + let Some(value) = entity.text_content() else { + return 0; + }; + let (value, count) = replace_case_insensitive(&value, search, replacement, replace_all); + if count == 0 { + return 0; + } + match entity { + EntityType::Text(text) => text.value = value, + EntityType::MText(text) => text.value = value, + EntityType::AttributeDefinition(attribute) => attribute.default_value = value, + EntityType::AttributeEntity(attribute) => attribute.set_value(value), + _ => return 0, + } + count +} + +fn replace_match_text( + document: &mut acadrust::CadDocument, + target: FindMatchKey, + search: &str, + replacement: &str, + replace_all: bool, +) -> usize { + match target { + FindMatchKey::Entity(handle) | FindMatchKey::BlockEntityInInsert { entity: handle, .. } => { + document.get_entity_mut(handle).map_or(0, |entity| { + replace_entity_text(entity, search, replacement, replace_all) + }) + } + FindMatchKey::InsertAttribute { insert, index } => { + let Some(EntityType::Insert(entity)) = document.get_entity_mut(insert) else { + return 0; + }; + let Some(attribute) = entity.attributes.get_mut(index) else { + return 0; + }; + let (value, count) = replace_case_insensitive( + attribute.get_value(), + search, + replacement, + replace_all, + ); + if count > 0 { + attribute.set_value(value); + } + count + } + } +} + +fn match_owner_handle(target: FindMatchKey) -> acadrust::Handle { + match target { + FindMatchKey::Entity(handle) => handle, + FindMatchKey::BlockEntityInInsert { insert, .. } => insert, + FindMatchKey::InsertAttribute { insert, .. } => insert, + } +} + +fn match_document_handle(target: FindMatchKey) -> acadrust::Handle { + match target { + FindMatchKey::Entity(handle) => handle, + FindMatchKey::BlockEntityInInsert { entity, .. } => entity, + FindMatchKey::InsertAttribute { insert, .. } => insert, + } +} + +fn match_label(target: FindMatchKey) -> String { + match target { + FindMatchKey::Entity(handle) => format!("handle {:X}", handle.value()), + FindMatchKey::BlockEntityInInsert { entity, insert } => { + format!( + "block {:X}, text {:X}", + insert.value(), + entity.value() + ) + } + FindMatchKey::InsertAttribute { insert, index } => { + format!("block {:X}, attribute {}", insert.value(), index + 1) + } + } +} + +fn block_contains_entity( + document: &acadrust::CadDocument, + block_name: &str, + target: acadrust::Handle, + visited: &mut Vec, +) -> bool { + if visited + .iter() + .any(|name| name.eq_ignore_ascii_case(block_name)) + { + return false; + } + let Some(record) = document + .block_records + .iter() + .find(|record| record.name.eq_ignore_ascii_case(block_name)) + else { + return false; + }; + visited.push(record.name.clone()); + let found = record.entity_handles.iter().any(|handle| { + if *handle == target { + return true; + } + matches!( + document.get_entity(*handle), + Some(EntityType::Insert(insert)) + if block_contains_entity(document, &insert.block_name, target, visited) + ) + }); + visited.pop(); + found +} + +fn replace_case_insensitive( + value: &str, + search: &str, + replacement: &str, + replace_all: bool, +) -> (String, usize) { + if search.is_empty() { + return (value.to_string(), 0); + } + + let mut result = String::with_capacity(value.len()); + let mut cursor = 0usize; + let mut count = 0usize; + while let Some((start, end)) = find_case_insensitive_range(value, search, cursor) { + result.push_str(&value[cursor..start]); + result.push_str(replacement); + cursor = end; + count += 1; + if !replace_all { + break; + } + } + if count == 0 { + return (value.to_string(), 0); + } + result.push_str(&value[cursor..]); + (result, count) +} + +fn find_case_insensitive_range(value: &str, search: &str, from: usize) -> Option<(usize, usize)> { + let needle = search.to_lowercase(); + if needle.is_empty() { + return None; + } + + for (start, _) in value.char_indices().filter(|(index, _)| *index >= from) { + let ends = value[start..] + .char_indices() + .skip(1) + .map(|(offset, _)| start + offset) + .chain(std::iter::once(value.len())); + for end in ends { + let candidate = value[start..end].to_lowercase(); + if candidate == needle { + return Some((start, end)); + } + if candidate.len() > needle.len() { + break; + } + } + } + None +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 0dcfc4ff..46b3a99a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,6 +8,7 @@ mod command_driver; pub(crate) mod commands; mod document; mod expr_eval; +mod find_replace; mod helpers; mod history; mod layers; @@ -160,6 +161,27 @@ pub struct QSelectState { pub value: String, pub append: bool, } + +#[derive(Clone, Debug, Default)] +pub(crate) struct FindReplaceState { + pub search: String, + pub replacement: String, + pub status: String, + pub current_match: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FindMatchKey { + Entity(acadrust::Handle), + BlockEntityInInsert { + entity: acadrust::Handle, + insert: acadrust::Handle, + }, + InsertAttribute { + insert: acadrust::Handle, + index: usize, + }, +} use crate::snap::Snapper; use crate::ui::{CommandLine, Ribbon, StatusBar}; use acadrust::types::{Color as AcadColor, LineWeight}; @@ -470,6 +492,8 @@ pub(super) struct OpenCADStudio { /// The open in-canvas modal dialog, if any (Plan B: shared overlay instead /// of OS windows). active_modal: Option, + /// FIND dialog inputs and current result cursor. + find_replace: FindReplaceState, /// Set once the user acknowledges the AEC-drop warning, so re-entering the /// save path proceeds instead of re-showing the warning. aec_drop_acknowledged: bool, @@ -1345,6 +1369,7 @@ pub enum ModalKind { Unsaved, SaveDialog, Options, + FindReplace, AecDropWarning, #[cfg(not(target_arch = "wasm32"))] FileInUse, @@ -1480,6 +1505,13 @@ pub enum Message { /// Ctrl/Cmd+A — select all layer rows when the Layer Manager is open, or all /// drawing objects otherwise (#236). SelectAllShortcut, + /// Open the shared Find and Replace dialog (Ctrl/Cmd+F or Ctrl/Cmd+H). + FindReplaceOpen, + FindReplaceSearchChanged(String), + FindReplaceReplacementChanged(String), + FindReplaceNext, + FindReplaceOne, + FindReplaceAll, /// System-clipboard text read for the MText editor (`None` = empty/denied). MTextPasteClip(Option), /// System-clipboard text read for the single-line TEXT editor. @@ -2685,6 +2717,7 @@ impl OpenCADStudio { main_window: None, color_pick_target: None, active_modal: None, + find_replace: FindReplaceState::default(), aec_drop_acknowledged: false, aec_drop_count: 0, layer_delete_pending: None, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 41dfa650..a9b1d336 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -35,6 +35,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool { | Message::SaveAs | Message::Undo | Message::Redo + | Message::FindReplaceOpen ) } @@ -3235,6 +3236,27 @@ impl OpenCADStudio { self.dispatch_command("SELECTALL") } } + Message::FindReplaceOpen => self.open_find_replace(), + Message::FindReplaceSearchChanged(value) => { + self.find_replace_search_changed(value); + Task::none() + } + Message::FindReplaceReplacementChanged(value) => { + self.find_replace_replacement_changed(value); + Task::none() + } + Message::FindReplaceNext => { + self.find_replace_next(); + Task::none() + } + Message::FindReplaceOne => { + self.find_replace_one(); + Task::none() + } + Message::FindReplaceAll => { + self.find_replace_all(); + Task::none() + } Message::MTextPasteClip(text) => { if let Some(text) = text.filter(|t| !t.is_empty()) { // CR/LF arrive as line breaks; MText keeps "\n", drop "\r". diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index e3d1265d..0a9a75b8 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1586,6 +1586,7 @@ impl OpenCADStudio { About => (440, 360), Shortcuts => (720, 520), Options => (520, 500), + FindReplace => (560, 190), PluginManager => { #[cfg(target_arch = "wasm32")] { @@ -1889,6 +1890,7 @@ impl OpenCADStudio { "z" if !shift => Some(Message::Undo), "z" if shift => Some(Message::Redo), "y" => Some(Message::Redo), + "f" | "h" => Some(Message::FindReplaceOpen), // Ctrl/Cmd+A: select all layer rows when the // Layer Manager is open, else all objects. The // update handler branches on the active modal. diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 4ce176da..9e769486 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -17,6 +17,7 @@ impl OpenCADStudio { Some(K::Shortcuts) => "Keyboard Shortcuts", Some(K::Aliases) => "Command Aliases", Some(K::Options) => "Options", + Some(K::FindReplace) => "Find and Replace", Some(K::PluginManager) => "Plugin Manager", Some(K::UpdateNotice) => "Update Available", Some(K::Layers) => "Layer Manager", @@ -102,6 +103,15 @@ impl OpenCADStudio { ) }, ), + super::super::ModalKind::FindReplace => sized( + crate::ui::window::find_replace::view_window( + &self.find_replace.search, + &self.find_replace.replacement, + &self.find_replace.status, + ), + 560, + 190, + ), super::super::ModalKind::PluginManager => { #[cfg(not(target_arch = "wasm32"))] { diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 0a2548d0..20963762 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -3,6 +3,97 @@ // hit-testing lives in `scene::pick::hit_test`.) use super::*; +fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option { + let mut min = glam::DVec3::splat(f64::INFINITY); + let mut max = glam::DVec3::splat(f64::NEG_INFINITY); + let mut include = |point: glam::DVec3| { + if point.is_finite() { + min = min.min(point); + max = max.max(point); + } + }; + + for wire in wires { + let mut has_precise_position = false; + for (index, &[x, y, z]) in wire.points.iter().enumerate() { + let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); + include(glam::DVec3::new( + x as f64 + low[0] as f64, + y as f64 + low[1] as f64, + z as f64 + low[2] as f64, + )); + has_precise_position = true; + } + for vertex in &wire.text_verts { + include(glam::DVec3::new( + vertex.pos[0] as f64 + vertex.pos_low[0] as f64, + vertex.pos[1] as f64 + vertex.pos_low[1] as f64, + vertex.pos[2] as f64 + vertex.pos_low[2] as f64, + )); + has_precise_position = true; + } + for &[x, y, z] in &wire.key_vertices { + include(glam::DVec3::new(x, y, z)); + has_precise_position = true; + } + for &(point, _) in &wire.snap_pts { + include(point); + has_precise_position = true; + } + + if !has_precise_position { + let [x0, y0, x1, y1] = wire.aabb; + if wire.aabb != WireModel::UNBOUNDED_AABB + && [x0, y0, x1, y1].iter().all(|value| value.is_finite()) + && x0 <= x1 + && y0 <= y1 + { + include(glam::DVec3::new(x0 as f64, y0 as f64, fallback_z)); + include(glam::DVec3::new(x1 as f64, y1 as f64, fallback_z)); + } + } + } + + if min.is_finite() && max.is_finite() { + Some((min + max) * 0.5) + } else { + None + } +} + +fn block_entity_transform( + document: &CadDocument, + block_name: &str, + target: Handle, + visited: &mut Vec, +) -> Option { + if visited + .iter() + .any(|name| name.eq_ignore_ascii_case(block_name)) + { + return None; + } + let record = document + .block_records + .iter() + .find(|record| record.name.eq_ignore_ascii_case(block_name))?; + if record.entity_handles.contains(&target) { + return Some(acadrust::types::Transform::identity()); + } + + visited.push(record.name.clone()); + let transform = record.entity_handles.iter().find_map(|handle| { + let EntityType::Insert(insert) = document.get_entity(*handle)? else { + return None; + }; + let inner = + block_entity_transform(document, &insert.block_name, target, visited)?; + Some(inner.then(&insert.get_transform())) + }); + visited.pop(); + transform +} + /// The model-space active viewport is reserved as `*Active`, but that name is /// case-insensitive in DXF/DWG — a file may store it as `*ACTIVE`. Match it /// accordingly, otherwise an uppercased record reads as a *distinct* viewport: @@ -131,6 +222,131 @@ impl Scene { self.camera_generation += 1; } + /// Centre the active camera on one rendered entity without changing zoom + /// distance, orientation, or viewport scale. + pub fn center_camera_on_entity(&mut self, handle: Handle) -> bool { + let fallback_z = self.active_camera_target().z; + let wires = self.wire_models_for(&[handle]); + let Some(center) = rendered_wire_center(&wires, fallback_z) else { + return false; + }; + self.center_active_camera_on(center) + } + + /// Centre on one concrete attribute value attached to an INSERT rather + /// than on the complete block extents. + pub fn center_camera_on_insert_attribute(&mut self, insert: Handle, index: usize) -> bool { + let (attribute, fallback) = { + let Some(EntityType::Insert(entity)) = self.document.get_entity(insert) else { + return false; + }; + let Some(attribute) = entity.attributes.get(index) else { + return false; + }; + ( + EntityType::AttributeEntity(attribute.clone()), + glam::DVec3::new( + attribute.insertion_point.x, + attribute.insertion_point.y, + attribute.insertion_point.z, + ), + ) + }; + let wires = self.tessellate_one(&attribute); + let center = rendered_wire_center(&wires, fallback.z).unwrap_or(fallback); + self.center_active_camera_on(center) + } + + /// Centre on a text entity stored in a block definition, transformed + /// through the concrete visible INSERT occurrence that FIND is visiting. + pub fn center_camera_on_block_entity(&mut self, insert: Handle, entity: Handle) -> bool { + let (source, mut transform) = { + let Some(EntityType::Insert(insert_entity)) = self.document.get_entity(insert) else { + return false; + }; + let Some(source) = self.document.get_entity(entity) else { + return false; + }; + let mut visited = Vec::new(); + let Some(inner) = block_entity_transform( + &self.document, + &insert_entity.block_name, + entity, + &mut visited, + ) else { + return false; + }; + ( + source.clone(), + inner.then(&insert_entity.get_transform()), + ) + }; + + if (self.annotation_scale - 1.0).abs() > 1e-6 { + let Some(EntityType::Insert(insert_entity)) = self.document.get_entity(insert) else { + return false; + }; + if insert_entity + .common + .extended_data + .get_record("AcAnnotativeData") + .is_some() + { + let point = insert_entity.insert_point; + let scale_about = + acadrust::types::Transform::from_translation(acadrust::types::Vector3::new( + -point.x, -point.y, -point.z, + )) + .then(&acadrust::types::Transform::from_scale( + self.annotation_scale as f64, + )) + .then(&acadrust::types::Transform::from_translation( + acadrust::types::Vector3::new(point.x, point.y, point.z), + )); + transform = transform.then(&scale_about); + } + } + + let fallback_z = self.active_camera_target().z; + let wires = self.tessellate_one(&source); + let Some(local_center) = rendered_wire_center(&wires, fallback_z) else { + return false; + }; + let world = transform.apply(acadrust::types::Vector3::new( + local_center.x, + local_center.y, + local_center.z, + )); + self.center_active_camera_on(glam::DVec3::new(world.x, world.y, world.z)) + } + + fn active_camera_target(&self) -> glam::DVec3 { + self.active_viewport + .and_then(|handle| self.camera_for_viewport(handle)) + .map_or_else(|| self.camera.borrow().target, |camera| camera.target) + } + + fn center_active_camera_on(&mut self, center: glam::DVec3) -> bool { + if !center.is_finite() { + return false; + } + if let Some(handle) = self.active_viewport { + let Some(EntityType::Viewport(viewport)) = self.document.get_entity_mut(handle) else { + return false; + }; + if viewport.status.locked { + return false; + } + viewport.view_target.x = center.x; + viewport.view_target.y = center.y; + viewport.view_target.z = center.z; + } else { + self.camera.borrow_mut().target = center; + } + self.camera_generation += 1; + true + } + /// Apply camera state from an acadrust View table entry, through the shared /// `camera_from_view` decoder so the twist round-trips like every other /// saved view. `model_space`: if true, subtracts world_offset from target diff --git a/src/ui/window/find_replace.rs b/src/ui/window/find_replace.rs new file mode 100644 index 00000000..bf84a703 --- /dev/null +++ b/src/ui/window/find_replace.rs @@ -0,0 +1,66 @@ +use crate::app::Message; +use iced::widget::{button, column, row, text, text_input, Space}; +use iced::{Element, Fill}; + +pub const FIND_INPUT_ID: &str = "find-replace-search"; + +pub fn view_window<'a>( + search: &'a str, + replacement: &'a str, + status: &'a str, +) -> Element<'a, Message> { + let find_input = text_input("Text to find", search) + .id(iced::widget::Id::new(FIND_INPUT_ID)) + .on_input(Message::FindReplaceSearchChanged) + .on_submit(Message::FindReplaceNext) + .padding([6, 8]) + .size(13); + let replacement_input = text_input("Replacement text", replacement) + .on_input(Message::FindReplaceReplacementChanged) + .padding([6, 8]) + .size(13); + + let enabled = !search.trim().is_empty(); + let action = |label: &'static str, message: Message| { + let button = button(text(label).size(12)).padding([6, 12]); + if enabled { + button.on_press(message) + } else { + button + } + }; + + column![ + row![ + text("Find:").size(12).width(90), + find_input.width(Fill), + ] + .spacing(8) + .align_y(iced::Center), + row![ + text("Replace with:").size(12).width(90), + replacement_input.width(Fill), + ] + .spacing(8) + .align_y(iced::Center), + text("Searches Text, MText, Attribute Definitions, and block attribute values.") + .size(11), + text(status).size(11), + row![ + Space::new().width(Fill), + button(text("Close").size(12)) + .on_press(Message::CloseModal) + .padding([6, 12]) + .style(button::secondary), + action("Replace", Message::FindReplaceOne).style(button::secondary), + action("Replace All", Message::FindReplaceAll).style(button::danger), + action("Find Next", Message::FindReplaceNext).style(button::primary), + ] + .spacing(8) + .align_y(iced::Center), + ] + .spacing(10) + .padding(12) + .width(Fill) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 530aff47..f4d90a04 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -10,3 +10,4 @@ pub mod open_progress; pub mod options; pub mod attribute_editor; pub mod alias_editor; +pub mod find_replace; diff --git a/src/ui/window/shortcuts.rs b/src/ui/window/shortcuts.rs index 8d02716f..c1491a83 100644 --- a/src/ui/window/shortcuts.rs +++ b/src/ui/window/shortcuts.rs @@ -109,6 +109,7 @@ pub fn view_window<'a>( shortcut_row(format!("{MOD}+Shift+S"), "Save As"), shortcut_row(format!("{MOD}+Z"), "Undo"), shortcut_row(format!("{MOD}+Shift+Z / {MOD}+Y"), "Redo"), + shortcut_row(format!("{MOD}+F / {MOD}+H"), "Find and Replace"), shortcut_row(format!("{MOD}+C"), "Copy to Clipboard"), shortcut_row(format!("{MOD}+X"), "Cut to Clipboard"), shortcut_row(format!("{MOD}+V"), "Paste from Clipboard"),