Skip to content

Commit b27b3d1

Browse files
committed
Code editor: VSCode-style editing shortcuts + inline diagnostics
Adds editing commands that fire while the TextEdit has focus, plus two new visual overlays. Shortcuts: - Tab / Shift+Tab indent and dedent (whole-line when selection spans multiple lines; 4-space insertion when there's no selection) - Enter auto-indents to match the previous line's leading whitespace - Home smart-cycles between start-of-indent and column 0 - Ctrl+Shift+D duplicates current line / selection lines - Ctrl+Shift+K deletes current line / selection lines - Alt+Up / Alt+Down move lines - Ctrl+D selects the next occurrence of the current word or selection Visuals: - Word under cursor highlights every other whole-word match - ScriptError line renders a red wavy squiggle All of this works by intercepting keys before TextEdit::show() with input_mut().consume_key() and writing the new selection back via TextEditState::set_char_range().
1 parent 9ee202e commit b27b3d1

2 files changed

Lines changed: 572 additions & 3 deletions

File tree

crates/renzora_code_editor/src/actions.rs

Lines changed: 292 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
//! Editor text operations: comment toggle, goto line, bracket matching.
1+
//! Editor text operations: comment toggle, goto line, bracket matching,
2+
//! and the VSCode-style editing commands (duplicate line, move line,
3+
//! indent/dedent selection, etc.).
24
35
use crate::highlight::Language;
46

7+
/// Number of spaces used for an indent unit.
8+
pub const TAB_SIZE: usize = 4;
9+
510
/// Convert a 0-based line number to its starting byte offset in `content`.
611
/// If the line is past EOF, returns `content.len()`.
712
pub fn line_to_byte(content: &str, line: usize) -> usize {
@@ -57,6 +62,292 @@ pub fn line_byte_range(content: &str, line: usize) -> (usize, usize) {
5762
(start, end)
5863
}
5964

65+
/// Full byte range of `line` — includes the trailing `\n` if present.
66+
pub fn line_full_range(content: &str, line: usize) -> (usize, usize) {
67+
let (s, e) = line_byte_range(content, line);
68+
let bytes = content.as_bytes();
69+
if e < bytes.len() && bytes[e] == b'\n' {
70+
(s, e + 1)
71+
} else {
72+
(s, e)
73+
}
74+
}
75+
76+
/// The first/last line numbers covered by the selection `[a, b]`.
77+
/// Mirrors the "selection ending on a newline doesn't include the next line"
78+
/// convention used elsewhere.
79+
pub fn line_span_of_selection(content: &str, a: usize, b: usize) -> (usize, usize) {
80+
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
81+
let first = byte_to_line(content, lo);
82+
let last = {
83+
let raw = byte_to_line(content, hi);
84+
if raw > first && hi > 0 && content.as_bytes()[hi - 1] == b'\n' {
85+
raw - 1
86+
} else {
87+
raw
88+
}
89+
};
90+
(first, last)
91+
}
92+
93+
/// Byte range of the identifier at `byte_idx`. Returns None if not on a word.
94+
pub fn word_range_at_byte(content: &str, byte_idx: usize) -> Option<(usize, usize)> {
95+
let bytes = content.as_bytes();
96+
let clamped = byte_idx.min(bytes.len());
97+
let mut s = clamped;
98+
while s > 0 && (bytes[s - 1].is_ascii_alphanumeric() || bytes[s - 1] == b'_') {
99+
s -= 1;
100+
}
101+
let mut e = clamped;
102+
while e < bytes.len() && (bytes[e].is_ascii_alphanumeric() || bytes[e] == b'_') {
103+
e += 1;
104+
}
105+
if s == e {
106+
None
107+
} else {
108+
Some((s, e))
109+
}
110+
}
111+
112+
/// Every identifier-delimited occurrence of `word` in `content`.
113+
pub fn find_all_occurrences(content: &str, word: &str) -> Vec<(usize, usize)> {
114+
let mut out = Vec::new();
115+
if word.is_empty() {
116+
return out;
117+
}
118+
let bytes = content.as_bytes();
119+
let wb = word.as_bytes();
120+
let mut i = 0;
121+
while i + wb.len() <= bytes.len() {
122+
if &bytes[i..i + wb.len()] == wb {
123+
let before_ok =
124+
i == 0 || !(bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_');
125+
let after_pos = i + wb.len();
126+
let after_ok = after_pos == bytes.len()
127+
|| !(bytes[after_pos].is_ascii_alphanumeric() || bytes[after_pos] == b'_');
128+
if before_ok && after_ok {
129+
out.push((i, after_pos));
130+
i = after_pos;
131+
continue;
132+
}
133+
}
134+
i += 1;
135+
}
136+
out
137+
}
138+
139+
/// Leading whitespace of the line containing `byte_idx`.
140+
pub fn leading_whitespace_of_line(content: &str, byte_idx: usize) -> String {
141+
let line = byte_to_line(content, byte_idx);
142+
let (s, e) = line_byte_range(content, line);
143+
let slice = &content[s..e];
144+
slice
145+
.chars()
146+
.take_while(|c| *c == ' ' || *c == '\t')
147+
.collect()
148+
}
149+
150+
/// Smart-Home target: first press goes to start-of-indentation, second to
151+
/// column 0.
152+
pub fn smart_home_target(content: &str, byte_idx: usize) -> usize {
153+
let line = byte_to_line(content, byte_idx);
154+
let line_start = line_to_byte(content, line);
155+
let bytes = content.as_bytes();
156+
let mut indent_end = line_start;
157+
while indent_end < bytes.len()
158+
&& (bytes[indent_end] == b' ' || bytes[indent_end] == b'\t')
159+
{
160+
indent_end += 1;
161+
}
162+
if byte_idx <= indent_end {
163+
// Already at-or-before indent start → go to column 0.
164+
line_start
165+
} else {
166+
indent_end
167+
}
168+
}
169+
170+
/// Indent or dedent the selection. Returns the new selection range.
171+
/// `dedent=false` → prepend `TAB_SIZE` spaces on each line.
172+
/// `dedent=true` → remove up to `TAB_SIZE` leading spaces (or one `\t`).
173+
pub fn indent_selection(
174+
content: &mut String,
175+
sel_start: usize,
176+
sel_end: usize,
177+
dedent: bool,
178+
) -> (usize, usize) {
179+
let indent = " ".repeat(TAB_SIZE);
180+
let (first, last) = line_span_of_selection(content, sel_start, sel_end);
181+
182+
let mut new_start = sel_start;
183+
let mut new_end = sel_end;
184+
185+
for line in (first..=last).rev() {
186+
let ls = line_to_byte(content, line);
187+
if dedent {
188+
let bytes = content.as_bytes();
189+
let mut removed = 0;
190+
while removed < TAB_SIZE
191+
&& ls + removed < bytes.len()
192+
&& bytes[ls + removed] == b' '
193+
{
194+
removed += 1;
195+
}
196+
if removed == 0 && ls < bytes.len() && bytes[ls] == b'\t' {
197+
removed = 1;
198+
}
199+
if removed > 0 {
200+
content.replace_range(ls..ls + removed, "");
201+
if ls < new_start {
202+
new_start = new_start.saturating_sub(removed);
203+
}
204+
if ls < new_end {
205+
new_end = new_end.saturating_sub(removed);
206+
}
207+
}
208+
} else {
209+
content.insert_str(ls, &indent);
210+
if ls <= new_start {
211+
new_start += TAB_SIZE;
212+
}
213+
if ls <= new_end {
214+
new_end += TAB_SIZE;
215+
}
216+
}
217+
}
218+
219+
(new_start, new_end)
220+
}
221+
222+
/// Duplicate the lines covered by `[a, b]`. The new selection tracks the copy.
223+
pub fn duplicate_lines(content: &mut String, a: usize, b: usize) -> (usize, usize) {
224+
let (first, last) = line_span_of_selection(content, a, b);
225+
let block_start = line_to_byte(content, first);
226+
let (_, block_end) = line_full_range(content, last);
227+
let mut block = content[block_start..block_end].to_string();
228+
// Ensure the copy ends with a newline so it lands on its own line.
229+
let block_had_nl = block.ends_with('\n');
230+
if !block_had_nl {
231+
block.push('\n');
232+
}
233+
content.insert_str(block_end, &block);
234+
235+
let shift = block.len();
236+
// New cursor/selection: same offsets inside the duplicated block.
237+
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
238+
let new_lo = lo + shift;
239+
let new_hi = hi + shift;
240+
(new_lo, new_hi)
241+
}
242+
243+
/// Delete the lines covered by `[a, b]`. Returns the new cursor byte.
244+
pub fn delete_lines(content: &mut String, a: usize, b: usize) -> usize {
245+
let (first, last) = line_span_of_selection(content, a, b);
246+
let block_start = line_to_byte(content, first);
247+
let (_, block_end) = line_full_range(content, last);
248+
content.replace_range(block_start..block_end, "");
249+
// Place cursor at the start of where the block was.
250+
block_start.min(content.len())
251+
}
252+
253+
/// Move the selected lines up one. Returns new selection or None if at top.
254+
pub fn move_lines_up(
255+
content: &mut String,
256+
a: usize,
257+
b: usize,
258+
) -> Option<(usize, usize)> {
259+
let (first, last) = line_span_of_selection(content, a, b);
260+
if first == 0 {
261+
return None;
262+
}
263+
264+
let lines: Vec<String> = content.split('\n').map(|s| s.to_string()).collect();
265+
let mut new_lines = lines.clone();
266+
let block: Vec<String> = new_lines.drain(first..=last).collect();
267+
new_lines.splice(first - 1..first - 1, block);
268+
*content = new_lines.join("\n");
269+
270+
let prev_len = lines[first - 1].len() + 1; // +1 for the \n that separated them
271+
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
272+
Some((lo.saturating_sub(prev_len), hi.saturating_sub(prev_len)))
273+
}
274+
275+
/// Move the selected lines down one. Returns new selection or None if at bottom.
276+
pub fn move_lines_down(
277+
content: &mut String,
278+
a: usize,
279+
b: usize,
280+
) -> Option<(usize, usize)> {
281+
let (first, last) = line_span_of_selection(content, a, b);
282+
let lines: Vec<String> = content.split('\n').map(|s| s.to_string()).collect();
283+
if last + 1 >= lines.len() {
284+
return None;
285+
}
286+
287+
let mut new_lines = lines.clone();
288+
let block: Vec<String> = new_lines.drain(first..=last).collect();
289+
// Insert after the line that now sits at `first`.
290+
let insert_at = first + 1;
291+
new_lines.splice(insert_at..insert_at, block);
292+
*content = new_lines.join("\n");
293+
294+
let next_len = lines[last + 1].len() + 1;
295+
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
296+
Some((lo + next_len, hi + next_len))
297+
}
298+
299+
/// Find the next occurrence of `needle` starting at/after `from_byte`.
300+
/// Wraps to the start on EOF.
301+
pub fn find_next_match(
302+
content: &str,
303+
needle: &str,
304+
from_byte: usize,
305+
case_sensitive: bool,
306+
) -> Option<usize> {
307+
if needle.is_empty() {
308+
return None;
309+
}
310+
let from = from_byte.min(content.len());
311+
if case_sensitive {
312+
content[from..]
313+
.find(needle)
314+
.map(|i| from + i)
315+
.or_else(|| content[..from].find(needle))
316+
} else {
317+
let hay_lower = content.to_lowercase();
318+
let needle_lower = needle.to_lowercase();
319+
hay_lower[from..]
320+
.find(&needle_lower)
321+
.map(|i| from + i)
322+
.or_else(|| hay_lower[..from.min(hay_lower.len())].find(&needle_lower))
323+
}
324+
}
325+
326+
/// Ctrl+D: pick the current selection (or the word under the cursor if no
327+
/// selection) and find its next occurrence, returning the new selection.
328+
pub fn select_next_occurrence(
329+
content: &str,
330+
a: usize,
331+
b: usize,
332+
case_sensitive: bool,
333+
) -> Option<(usize, usize)> {
334+
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
335+
let (search_start, search_end) = if lo == hi {
336+
word_range_at_byte(content, lo)?
337+
} else {
338+
(lo, hi)
339+
};
340+
let needle = &content[search_start..search_end];
341+
let from = search_end; // start searching after the current selection/word
342+
let pos = find_next_match(content, needle, from, case_sensitive)?;
343+
if pos == search_start {
344+
// Wrapped and landed on the same spot — nothing new to select.
345+
return None;
346+
}
347+
Some((pos, pos + needle.len()))
348+
}
349+
350+
60351
/// Toggle a line comment across the lines touched by `[sel_start_byte, sel_end_byte]`.
61352
///
62353
/// If every non-blank line in the range is already commented, all of them are

0 commit comments

Comments
 (0)