Skip to content

Commit 5d583a3

Browse files
committed
Inspector reset-to-default + component copy/paste, code editor find/replace
1 parent 3d0decb commit 5d583a3

7 files changed

Lines changed: 392 additions & 20 deletions

File tree

crates/renzora_code_editor/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ impl EditorPanel for CodeEditorPanel {
123123
open_files: local.open_files.clone(),
124124
active_tab: local.active_tab,
125125
font_size: local.font_size,
126+
find_open: local.find_open,
127+
find_text: local.find_text.clone(),
128+
replace_text: local.replace_text.clone(),
129+
find_case_sensitive: local.find_case_sensitive,
130+
find_focus_requested: local.find_focus_requested,
126131
});
127132
}
128133
}

crates/renzora_code_editor/src/render.rs

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use renzora_theme::Theme;
44

55
use std::path::PathBuf;
66

7-
use egui_phosphor::regular::{CODE, FILE_PLUS, FLOPPY_DISK, WARNING, X};
7+
use egui_phosphor::regular::{CODE, FILE_PLUS, FLOPPY_DISK, MAGNIFYING_GLASS, WARNING, X};
88

99
use crate::state::CodeEditorState;
1010

@@ -190,6 +190,17 @@ pub fn render_code_editor_content(
190190
}
191191
}
192192

193+
let find_btn = ui
194+
.button(RichText::new(MAGNIFYING_GLASS).size(12.0))
195+
.on_hover_text("Find / Replace (Ctrl+F)");
196+
if find_btn.hovered() {
197+
ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
198+
}
199+
if find_btn.clicked() {
200+
state.find_open = !state.find_open;
201+
state.find_focus_requested = state.find_open;
202+
}
203+
193204
ui.separator();
194205

195206
// Zoom controls
@@ -232,9 +243,29 @@ pub fn render_code_editor_content(
232243
if should_save {
233244
state.save_active();
234245
}
246+
247+
let (ctrl_f, ctrl_h, esc) = ui.ctx().input(|i| {
248+
(
249+
i.modifiers.ctrl && i.key_pressed(egui::Key::F),
250+
i.modifiers.ctrl && i.key_pressed(egui::Key::H),
251+
i.key_pressed(egui::Key::Escape),
252+
)
253+
});
254+
if ctrl_f || ctrl_h {
255+
state.find_open = true;
256+
state.find_focus_requested = true;
257+
}
258+
if esc && state.find_open {
259+
state.find_open = false;
260+
}
235261
});
236262
});
237263

264+
// --- Find/Replace bar ---
265+
if state.find_open {
266+
render_find_bar(ui, state, theme);
267+
}
268+
238269
// Handle Ctrl+scroll zoom
239270
let panel_hovered = ui.rect_contains_pointer(ui.max_rect());
240271
let zoom_delta = if panel_hovered {
@@ -390,3 +421,107 @@ pub fn render_code_editor_content(
390421
}
391422
}
392423
}
424+
425+
fn render_find_bar(ui: &mut egui::Ui, state: &mut CodeEditorState, theme: &Theme) {
426+
let surface_faint = theme.surfaces.faint.to_color32();
427+
let muted = theme.text.muted.to_color32();
428+
429+
egui::Frame::new()
430+
.fill(surface_faint)
431+
.inner_margin(egui::Margin::symmetric(8, 4))
432+
.show(ui, |ui| {
433+
ui.horizontal(|ui| {
434+
ui.label(RichText::new("Find").size(11.0).color(muted));
435+
let find_resp = ui.add(
436+
egui::TextEdit::singleline(&mut state.find_text)
437+
.desired_width(180.0)
438+
.hint_text("search..."),
439+
);
440+
if state.find_focus_requested {
441+
find_resp.request_focus();
442+
state.find_focus_requested = false;
443+
}
444+
445+
let next_clicked = ui.small_button("Next").clicked()
446+
|| (find_resp.lost_focus()
447+
&& ui.ctx().input(|i| i.key_pressed(egui::Key::Enter)));
448+
449+
ui.checkbox(&mut state.find_case_sensitive, RichText::new("Aa").size(11.0))
450+
.on_hover_text("Case sensitive");
451+
452+
ui.separator();
453+
454+
ui.label(RichText::new("Replace").size(11.0).color(muted));
455+
ui.add(
456+
egui::TextEdit::singleline(&mut state.replace_text)
457+
.desired_width(180.0)
458+
.hint_text("replace with..."),
459+
);
460+
461+
let replace_clicked = ui.small_button("Replace").clicked();
462+
let replace_all_clicked = ui.small_button("Replace All").clicked();
463+
464+
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
465+
if ui
466+
.add(egui::Button::new(RichText::new(X).size(11.0)).frame(false))
467+
.clicked()
468+
{
469+
state.find_open = false;
470+
}
471+
});
472+
473+
if next_clicked {
474+
find_next_active(state);
475+
}
476+
if replace_clicked {
477+
replace_current_active(state);
478+
}
479+
if replace_all_clicked {
480+
let n = state.replace_all_active();
481+
log::info!("Replaced {} occurrences", n);
482+
}
483+
});
484+
});
485+
}
486+
487+
fn find_next_active(state: &mut CodeEditorState) {
488+
let Some(idx) = state.active_tab else { return };
489+
let Some(file) = state.open_files.get(idx) else { return };
490+
if state.find_text.is_empty() {
491+
return;
492+
}
493+
let from = 0;
494+
if let Some(_pos) = CodeEditorState::find_next_in(
495+
&file.content,
496+
&state.find_text,
497+
from,
498+
state.find_case_sensitive,
499+
) {
500+
// Match exists; underlying CodeEditor widget doesn't expose cursor placement,
501+
// so this acts as a presence check for now.
502+
log::debug!("match found in {}", file.name);
503+
}
504+
}
505+
506+
fn replace_current_active(state: &mut CodeEditorState) {
507+
// Without cursor API from the editor widget, "Replace" behaves as
508+
// "replace first occurrence". Replace All handles the bulk case.
509+
let Some(idx) = state.active_tab else { return };
510+
let Some(file) = state.open_files.get_mut(idx) else { return };
511+
if state.find_text.is_empty() {
512+
return;
513+
}
514+
let pos = CodeEditorState::find_next_in(
515+
&file.content,
516+
&state.find_text,
517+
0,
518+
state.find_case_sensitive,
519+
);
520+
if let Some(start) = pos {
521+
let end = start + state.find_text.len();
522+
if end <= file.content.len() {
523+
file.content.replace_range(start..end, &state.replace_text);
524+
file.is_modified = true;
525+
}
526+
}
527+
}

crates/renzora_code_editor/src/state.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ pub struct CodeEditorState {
3030
pub open_files: Vec<OpenFile>,
3131
pub active_tab: Option<usize>,
3232
pub font_size: f32,
33+
pub find_open: bool,
34+
pub find_text: String,
35+
pub replace_text: String,
36+
pub find_case_sensitive: bool,
37+
pub find_focus_requested: bool,
3338
}
3439

3540
impl Default for CodeEditorState {
@@ -38,10 +43,78 @@ impl Default for CodeEditorState {
3843
open_files: Vec::new(),
3944
active_tab: None,
4045
font_size: DEFAULT_FONT_SIZE,
46+
find_open: false,
47+
find_text: String::new(),
48+
replace_text: String::new(),
49+
find_case_sensitive: false,
50+
find_focus_requested: false,
4151
}
4252
}
4353
}
4454

55+
impl CodeEditorState {
56+
/// Find the next match of `find_text` in the active file's content starting from `from`.
57+
/// Returns the byte index of the match start.
58+
pub fn find_next_in(content: &str, needle: &str, from: usize, case_sensitive: bool) -> Option<usize> {
59+
if needle.is_empty() || from > content.len() {
60+
return None;
61+
}
62+
if case_sensitive {
63+
content[from..].find(needle).map(|i| from + i)
64+
.or_else(|| content[..from.min(content.len())].find(needle))
65+
} else {
66+
let hay_lower = content.to_lowercase();
67+
let needle_lower = needle.to_lowercase();
68+
hay_lower[from..].find(&needle_lower).map(|i| from + i)
69+
.or_else(|| hay_lower[..from.min(hay_lower.len())].find(&needle_lower))
70+
}
71+
}
72+
73+
/// Replace all occurrences in active file. Returns count replaced.
74+
pub fn replace_all_active(&mut self) -> usize {
75+
let Some(idx) = self.active_tab else { return 0 };
76+
let Some(file) = self.open_files.get_mut(idx) else { return 0 };
77+
if self.find_text.is_empty() {
78+
return 0;
79+
}
80+
let (new_content, count) = if self.find_case_sensitive {
81+
let c = file.content.matches(&self.find_text).count();
82+
(file.content.replace(&self.find_text, &self.replace_text), c)
83+
} else {
84+
replace_all_case_insensitive(&file.content, &self.find_text, &self.replace_text)
85+
};
86+
if count > 0 {
87+
file.content = new_content;
88+
file.is_modified = true;
89+
}
90+
count
91+
}
92+
}
93+
94+
fn replace_all_case_insensitive(haystack: &str, needle: &str, replacement: &str) -> (String, usize) {
95+
if needle.is_empty() {
96+
return (haystack.to_string(), 0);
97+
}
98+
let hay_lower = haystack.to_lowercase();
99+
let needle_lower = needle.to_lowercase();
100+
let mut out = String::with_capacity(haystack.len());
101+
let mut i = 0;
102+
let mut count = 0;
103+
while i <= hay_lower.len() {
104+
if let Some(pos) = hay_lower[i..].find(&needle_lower) {
105+
let abs = i + pos;
106+
out.push_str(&haystack[i..abs]);
107+
out.push_str(replacement);
108+
i = abs + needle.len();
109+
count += 1;
110+
} else {
111+
out.push_str(&haystack[i..]);
112+
break;
113+
}
114+
}
115+
(out, count)
116+
}
117+
45118
impl CodeEditorState {
46119
pub fn zoom_in(&mut self) {
47120
self.font_size = (self.font_size + 1.0).min(MAX_FONT_SIZE);

crates/renzora_editor_framework/src/inspector_registry.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@ pub enum FieldValue {
1515
Asset(Option<String>),
1616
}
1717

18+
impl FieldValue {
19+
/// A type-appropriate zero / empty default, used by the inspector's
20+
/// "reset to default" action when a field doesn't provide its own default.
21+
pub fn type_default(&self) -> FieldValue {
22+
match self {
23+
FieldValue::Float(_) => FieldValue::Float(0.0),
24+
FieldValue::Vec3(_) => FieldValue::Vec3([0.0; 3]),
25+
FieldValue::Bool(_) => FieldValue::Bool(false),
26+
FieldValue::Color(_) => FieldValue::Color([1.0; 3]),
27+
FieldValue::String(_) => FieldValue::String(String::new()),
28+
FieldValue::ReadOnly(s) => FieldValue::ReadOnly(s.clone()),
29+
FieldValue::Asset(_) => FieldValue::Asset(None),
30+
}
31+
}
32+
}
33+
1834
/// Metadata about a field's type, used to select the correct widget.
1935
#[derive(Debug, Clone)]
2036
pub enum FieldType {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//! Clipboard for copying component values between entities.
2+
3+
use bevy::prelude::Resource;
4+
use renzora_editor_framework::FieldValue;
5+
6+
/// Holds a snapshot of one component's field values.
7+
#[derive(Resource, Default, Clone)]
8+
pub struct ComponentClipboard {
9+
pub type_id: Option<&'static str>,
10+
pub fields: Vec<(&'static str, FieldValue)>,
11+
}
12+
13+
impl ComponentClipboard {
14+
pub fn set(&mut self, type_id: &'static str, fields: Vec<(&'static str, FieldValue)>) {
15+
self.type_id = Some(type_id);
16+
self.fields = fields;
17+
}
18+
19+
pub fn matches(&self, type_id: &'static str) -> bool {
20+
self.type_id == Some(type_id)
21+
}
22+
23+
pub fn is_empty(&self) -> bool {
24+
self.type_id.is_none()
25+
}
26+
}

0 commit comments

Comments
 (0)