feat(find): add find and replace

Closes #583
This commit is contained in:
Hakan Seven 2026-07-30 17:56:54 +03:00
commit c9aab122f0
10 changed files with 814 additions and 7 deletions

View file

@ -1078,13 +1078,7 @@ impl OpenCADStudio {
// FIND <search> REPLACE <rep> — replace first occurrence (case-insensitive)
// FINDALL <search> REPLACE <rep> — replace all occurrences
"FIND" => {
use crate::command::ValuePromptCommand;
let c = ValuePromptCommand::new(
"FIND",
"FIND text to find (add REPLACE <text> 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;

462
src/app/find_replace.rs Normal file
View file

@ -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<Message> {
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<FindMatchKey> {
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<FindMatchKey> {
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<String>,
) -> 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
}

View file

@ -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<FindMatchKey>,
}
#[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<ModalKind>,
/// 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<String>),
/// 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,

View file

@ -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".

View file

@ -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.

View file

@ -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"))]
{

View file

@ -3,6 +3,97 @@
// hit-testing lives in `scene::pick::hit_test`.)
use super::*;
fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option<glam::DVec3> {
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<String>,
) -> Option<acadrust::types::Transform> {
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

View file

@ -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()
}

View file

@ -10,3 +10,4 @@ pub mod open_progress;
pub mod options;
pub mod attribute_editor;
pub mod alias_editor;
pub mod find_replace;

View file

@ -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"),