Skip to content

Commit 9ee202e

Browse files
committed
Rewrite code editor on TextEdit with gutter, autocomplete, scroll fix
Replaces egui_code_editor with a custom implementation built on egui::TextEdit::multiline + a custom layouter, unlocking programmatic cursor access via TextEditState. New modules: - highlight: keyword-based tokenizer producing a colored LayoutJob for lua/rhai/rust/wgsl/python/shell/sql/json/toml. Handles line + block + Lua long comments, strings with escapes, numbers with exponents and Rust-style type suffixes, keywords, types, and call-site detection. - autocomplete: registry of ~60 Lua/Rhai scripting API symbols across Transform, Input, Audio, Physics, Timers, Debug, Rendering, Animation, Cursor, Camera, ECS, Scene, Environment, Reflection, Actions, UI, and lifecycle hooks. Prefix extraction + case-insensitive matching. - actions: selection-aware line-comment toggle, bracket matcher, byte <-> char index helpers. Features: - Syntax highlighting via custom layouter (egui_code_editor dep removed). - Line-number gutter with current-line emphasis. - Auto-scroll fires only when the cursor index actually changes AND the cursor rect is outside the visible clip rect, fixing the bug where scroll fought the user's manual scrolling every focused frame. - Ctrl+/ toggles line comments across the selection, language-aware, preserves indentation, removes both '// ' and '//' variants. - Ctrl+G opens a goto-line bar that places the cursor via TextEditState::set_char_range and scrolls to it. - Ctrl+Space opens an autocomplete popup filtered by prefix; arrow keys navigate, Enter/Tab/click inserts, Esc closes. Footer shows signature + category + doc. - Bracket matching highlights the matching () [] {} under the cursor. - Find 'Next' actually selects the match in the editor.
1 parent d5cf079 commit 9ee202e

7 files changed

Lines changed: 1594 additions & 280 deletions

File tree

crates/renzora_code_editor/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,5 @@ bevy_egui = { workspace = true }
1313
renzora_editor_framework = { path = "../renzora_editor_framework" }
1414
bevy = { workspace = true }
1515
renzora = { path = "../renzora", default-features = false, features = ["editor"] }
16-
egui_code_editor = "=0.2.21"
1716
log = "0.4"
1817
renzora_scripting = { path = "../renzora_scripting" }
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
//! Editor text operations: comment toggle, goto line, bracket matching.
2+
3+
use crate::highlight::Language;
4+
5+
/// Convert a 0-based line number to its starting byte offset in `content`.
6+
/// If the line is past EOF, returns `content.len()`.
7+
pub fn line_to_byte(content: &str, line: usize) -> usize {
8+
if line == 0 {
9+
return 0;
10+
}
11+
let mut seen = 0usize;
12+
for (i, b) in content.bytes().enumerate() {
13+
if b == b'\n' {
14+
seen += 1;
15+
if seen == line {
16+
return i + 1;
17+
}
18+
}
19+
}
20+
content.len()
21+
}
22+
23+
/// 0-based line that contains the given byte offset.
24+
pub fn byte_to_line(content: &str, byte_idx: usize) -> usize {
25+
content[..byte_idx.min(content.len())]
26+
.bytes()
27+
.filter(|b| *b == b'\n')
28+
.count()
29+
}
30+
31+
/// Byte length of the substring `content[..byte_idx]` in characters.
32+
/// egui's `CCursor` indexes by chars, not bytes.
33+
pub fn byte_to_char(content: &str, byte_idx: usize) -> usize {
34+
content[..byte_idx.min(content.len())].chars().count()
35+
}
36+
37+
/// Inverse of `byte_to_char`.
38+
pub fn char_to_byte(content: &str, char_idx: usize) -> usize {
39+
let mut count = 0usize;
40+
for (i, _) in content.char_indices() {
41+
if count == char_idx {
42+
return i;
43+
}
44+
count += 1;
45+
}
46+
content.len()
47+
}
48+
49+
/// Byte range of a single line (not including the trailing `\n`).
50+
pub fn line_byte_range(content: &str, line: usize) -> (usize, usize) {
51+
let start = line_to_byte(content, line);
52+
let bytes = content.as_bytes();
53+
let mut end = start;
54+
while end < bytes.len() && bytes[end] != b'\n' {
55+
end += 1;
56+
}
57+
(start, end)
58+
}
59+
60+
/// Toggle a line comment across the lines touched by `[sel_start_byte, sel_end_byte]`.
61+
///
62+
/// If every non-blank line in the range is already commented, all of them are
63+
/// uncommented. Otherwise the comment prefix is added to each non-blank line.
64+
///
65+
/// Returns the new selection byte range (or `None` if the language doesn't
66+
/// support line comments / nothing changed).
67+
pub fn toggle_line_comment(
68+
content: &mut String,
69+
sel_start_byte: usize,
70+
sel_end_byte: usize,
71+
lang: Language,
72+
) -> Option<(usize, usize)> {
73+
let prefix = lang.line_comment()?;
74+
let prefix_with_space = format!("{} ", prefix);
75+
76+
let (a, b) = if sel_start_byte <= sel_end_byte {
77+
(sel_start_byte, sel_end_byte)
78+
} else {
79+
(sel_end_byte, sel_start_byte)
80+
};
81+
82+
let first_line = byte_to_line(content, a);
83+
// If the selection ends exactly at the start of a line (after pressing
84+
// Home or selecting up to a newline), the user didn't really include that
85+
// line — back off so we don't toggle a comment on an uninvolved row.
86+
let last_line = {
87+
let raw = byte_to_line(content, b);
88+
if raw > first_line && b > 0 && content.as_bytes()[b - 1] == b'\n' {
89+
raw - 1
90+
} else {
91+
raw
92+
}
93+
};
94+
95+
// Pass 1: decide add vs remove.
96+
let mut any_non_blank = false;
97+
let mut all_commented = true;
98+
for line in first_line..=last_line {
99+
let (ls, le) = line_byte_range(content, line);
100+
let slice = &content[ls..le];
101+
let trimmed = slice.trim_start();
102+
if trimmed.is_empty() {
103+
continue;
104+
}
105+
any_non_blank = true;
106+
if !trimmed.starts_with(prefix) {
107+
all_commented = false;
108+
break;
109+
}
110+
}
111+
if !any_non_blank {
112+
return None;
113+
}
114+
115+
// Pass 2: mutate lines bottom-up so earlier edits don't shift later offsets.
116+
let mut new_sel_start = sel_start_byte;
117+
let mut new_sel_end = sel_end_byte;
118+
119+
for line in (first_line..=last_line).rev() {
120+
let (ls, le) = line_byte_range(content, line);
121+
let slice = &content[ls..le];
122+
if slice.trim_start().is_empty() {
123+
continue;
124+
}
125+
126+
let indent_len = slice
127+
.bytes()
128+
.take_while(|b| *b == b' ' || *b == b'\t')
129+
.count();
130+
let indent_end = ls + indent_len;
131+
132+
if all_commented {
133+
// Remove "<prefix> " or "<prefix>"
134+
let rest = &content[indent_end..le];
135+
let strip_len = if rest.starts_with(&prefix_with_space) {
136+
prefix_with_space.len()
137+
} else if rest.starts_with(prefix) {
138+
prefix.len()
139+
} else {
140+
0
141+
};
142+
if strip_len > 0 {
143+
content.replace_range(indent_end..indent_end + strip_len, "");
144+
// Adjust selection if the removed text was before it.
145+
if indent_end <= new_sel_start {
146+
new_sel_start = new_sel_start.saturating_sub(strip_len);
147+
}
148+
if indent_end <= new_sel_end {
149+
new_sel_end = new_sel_end.saturating_sub(strip_len);
150+
}
151+
}
152+
} else {
153+
// Insert "<prefix> " at indent_end.
154+
content.insert_str(indent_end, &prefix_with_space);
155+
let added = prefix_with_space.len();
156+
if indent_end <= new_sel_start {
157+
new_sel_start += added;
158+
}
159+
if indent_end <= new_sel_end {
160+
new_sel_end += added;
161+
}
162+
}
163+
}
164+
165+
Some((new_sel_start, new_sel_end))
166+
}
167+
168+
/// Find the matching bracket for the one immediately before or at `byte_idx`.
169+
/// Returns the other bracket's byte offset, or `None` if the cursor isn't on a
170+
/// bracket or no match was found.
171+
pub fn find_matching_bracket(content: &str, byte_idx: usize) -> Option<(usize, usize)> {
172+
let bytes = content.as_bytes();
173+
// Prefer the bracket immediately *before* the cursor (VSCode behavior when
174+
// the caret is right after a closing bracket). Fall back to the one at the
175+
// cursor.
176+
let candidates = [byte_idx.checked_sub(1), Some(byte_idx)];
177+
for cand in candidates.iter().flatten() {
178+
let i = *cand;
179+
if i >= bytes.len() {
180+
continue;
181+
}
182+
let ch = bytes[i];
183+
let (open, close, forward) = match ch {
184+
b'(' => (b'(', b')', true),
185+
b'[' => (b'[', b']', true),
186+
b'{' => (b'{', b'}', true),
187+
b')' => (b'(', b')', false),
188+
b']' => (b'[', b']', false),
189+
b'}' => (b'{', b'}', false),
190+
_ => continue,
191+
};
192+
if let Some(m) = scan_bracket(bytes, i, open, close, forward) {
193+
return Some((i, m));
194+
}
195+
}
196+
None
197+
}
198+
199+
fn scan_bracket(bytes: &[u8], start: usize, open: u8, close: u8, forward: bool) -> Option<usize> {
200+
let mut depth: i32 = 0;
201+
let mut i = start;
202+
loop {
203+
let ch = bytes[i];
204+
if ch == open {
205+
depth += if forward { 1 } else { -1 };
206+
} else if ch == close {
207+
depth += if forward { -1 } else { 1 };
208+
}
209+
if depth == 0 {
210+
return Some(i);
211+
}
212+
if forward {
213+
i += 1;
214+
if i >= bytes.len() {
215+
return None;
216+
}
217+
} else {
218+
if i == 0 {
219+
return None;
220+
}
221+
i -= 1;
222+
}
223+
}
224+
}

0 commit comments

Comments
 (0)