Krolyxon
Commit 0103561

Branches

Tags

Refs →

bench51cratesbench51-guisrceditor.rs

657 lines · 23.3 KB · Rust

1//! The code editor: document tabs, a find bar, syntax-highlighted text and
2//! a gutter with breakpoints, line numbers, addresses, errors and the PC.
3
4use std::collections::{HashMap, HashSet};
5
6use bench51_core::syntax::tokens;
7use eframe::egui::{
8 self, Align, Align2, Color32, CursorIcon, FontId, Id, Key, Layout, Modifiers, Rect, RichText,
9 Sense, Shape, Stroke, TextBuffer, TextFormat, Ui, Vec2,
10 cache::{ComputerMut, FrameCache},
11 pos2,
12 text::{CCursor, CCursorRange, LayoutJob},
13 text_edit::TextEditState,
14 vec2,
15};
16
17use crate::state::{Action, Document, Mode, State};
18use crate::theme::{self, ACCENT, BODY, DARK, LIGHT, WARNING, palette};
19use crate::widgets::{self, PillKind};
20
21const MARGIN: Vec2 = vec2(10.0, 8.0);
22
23pub fn show(ui: &mut Ui, st: &mut State) {
24 doc_tabs(ui, st);
25 if st.docs.is_empty() {
26 welcome(ui, st);
27 return;
28 }
29 if st.find.open {
30 find_bar(ui, st);
31 }
32 code(ui, st);
33}
34
35// ------------------------------------------------------------- tabs, find
36
37fn doc_tabs(ui: &mut Ui, st: &mut State) {
38 let p = palette(ui.ctx());
39 let height = 30.0;
40 let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
41 ui.painter().hline(
42 strip.x_range(),
43 strip.bottom() - 0.5,
44 Stroke::new(1.0, p.border),
45 );
46 let mut child = ui.new_child(
47 egui::UiBuilder::new()
48 .max_rect(strip.shrink2(vec2(6.0, 0.0)))
49 .layout(Layout::left_to_right(Align::Center)),
50 );
51 let ui = &mut child;
52 ui.spacing_mut().item_spacing.x = 0.0;
53 for (i, doc) in st.docs.iter().enumerate() {
54 let active = i == st.active;
55 let name = doc.name();
56 let galley =
57 ui.painter()
58 .layout_no_wrap(name, FontId::monospace(12.0), Color32::PLACEHOLDER);
59 let pad = 12.0;
60 let dirty_w = if doc.dirty() { 10.0 } else { 0.0 };
61 let w = galley.size().x + 2.0 * pad + dirty_w + 14.0;
62 let (rect, resp) = ui.allocate_exact_size(vec2(w, height), Sense::click());
63 let resp = resp.on_hover_cursor(CursorIcon::PointingHand);
64 let fg = if active {
65 p.text_display
66 } else if resp.hovered() {
67 p.text_secondary
68 } else {
69 p.text_disabled
70 };
71 let text_pos = Align2::LEFT_CENTER
72 .align_size_within_rect(galley.size(), rect.shrink2(vec2(pad, 0.0)))
73 .min;
74 let text_w = galley.size().x;
75 ui.painter().galley(text_pos, galley, fg);
76 if active {
77 ui.painter().hline(
78 text_pos.x..=text_pos.x + text_w,
79 rect.bottom() - 1.5,
80 Stroke::new(2.0, p.text_display),
81 );
82 }
83 if doc.dirty() {
84 ui.painter().circle_filled(
85 pos2(text_pos.x + text_w + 7.0, rect.center().y),
86 2.5,
87 p.text_secondary,
88 );
89 }
90 let close =
91 Rect::from_center_size(pos2(rect.right() - pad, rect.center().y), Vec2::splat(12.0));
92 let close_resp = ui
93 .interact(close, Id::unique(("doc_close", doc.id)), Sense::click())
94 .on_hover_cursor(CursorIcon::PointingHand);
95 if resp.hovered() || close_resp.hovered() || active {
96 let c = if close_resp.hovered() {
97 p.text_display
98 } else {
99 p.text_disabled
100 };
101 let r = close.shrink(3.0);
102 let s = Stroke::new(1.2, c);
103 ui.painter()
104 .line_segment([r.left_top(), r.right_bottom()], s);
105 ui.painter()
106 .line_segment([r.right_top(), r.left_bottom()], s);
107 }
108 if close_resp.clicked() || resp.clicked_by(egui::PointerButton::Middle) {
109 st.actions.push(Action::Close(i));
110 } else if resp.clicked() {
111 st.actions.push(Action::Activate(i));
112 }
113 }
114 let plus = ui
115 .add(
116 egui::Button::new(
117 RichText::new("+")
118 .font(FontId::monospace(14.0))
119 .color(p.text_disabled),
120 )
121 .frame(false),
122 )
123 .on_hover_text("New file (Ctrl+N)");
124 if plus.clicked() {
125 st.actions.push(Action::New);
126 }
127 if st.mode == Mode::Debug {
128 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
129 ui.add_space(8.0);
130 ui.label(p.label("Read-only while debugging").color(p.text_disabled));
131 });
132 }
133}
134
135fn find_bar(ui: &mut Ui, st: &mut State) {
136 let p = palette(ui.ctx());
137 let mut step = None;
138 let mut close = false;
139 ui.horizontal(|ui| {
140 ui.add_space(MARGIN.x);
141 ui.label(p.label("Find"));
142 let resp = widgets::underline_input(ui, &mut st.find.query, "text", 220.0);
143 if std::mem::take(&mut st.find.focus) {
144 resp.request_focus();
145 }
146 if resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) {
147 step = Some(!ui.input(|i| i.modifiers.shift));
148 resp.request_focus();
149 }
150 if resp.has_focus() && ui.input(|i| i.key_pressed(Key::Escape)) {
151 close = true;
152 }
153 let count = st
154 .doc()
155 .map_or(0, |d| find_all(&d.text, &st.find.query).len());
156 ui.label(p.label(format!("{count} found")).color(p.text_disabled));
157 if widgets::pill(ui, "Prev", PillKind::Ghost).clicked() {
158 step = Some(false);
159 }
160 if widgets::pill(ui, "Next", PillKind::Ghost).clicked() {
161 step = Some(true);
162 }
163 if widgets::pill(ui, "Close", PillKind::Ghost).clicked() {
164 close = true;
165 }
166 });
167 if let (Some(forward), Some(doc)) = (step, st.docs.get_mut(st.active)) {
168 find_step(doc, &st.find.query, forward);
169 }
170 if close {
171 st.find.open = false;
172 }
173}
174
175/// Byte ranges of case-insensitive matches of `query` in `text`.
176fn find_all(text: &str, query: &str) -> Vec<(usize, usize)> {
177 if query.is_empty() {
178 return Vec::new();
179 }
180 let hay = text.to_ascii_lowercase();
181 let needle = query.to_ascii_lowercase();
182 hay.match_indices(&needle)
183 .take(2000)
184 .map(|(i, m)| (i, i + m.len()))
185 .collect()
186}
187
188fn find_step(doc: &mut Document, query: &str, forward: bool) {
189 let matches = find_all(&doc.text, query);
190 if matches.is_empty() {
191 doc.find_hit = None;
192 return;
193 }
194 let here = char_to_byte(&doc.text, doc.cursor_char);
195 let hit = doc.find_hit.filter(|h| matches.contains(h));
196 let pick = if forward {
197 let from = hit.map_or(here, |h| h.0 + 1);
198 matches.iter().find(|m| m.0 >= from).or(matches.first())
199 } else {
200 let before = hit.map_or(here, |h| h.0);
201 matches
202 .iter()
203 .rev()
204 .find(|m| m.0 < before)
205 .or(matches.last())
206 };
207 if let Some(&(a, b)) = pick {
208 let ca = doc.text[..a].chars().count();
209 let cb = ca + doc.text[a..b].chars().count();
210 doc.select = Some((ca, cb));
211 doc.find_hit = Some((a, b));
212 }
213}
214
215// -------------------------------------------------------------- welcome
216
217fn welcome(ui: &mut Ui, st: &mut State) {
218 let p = palette(ui.ctx());
219 let rect = ui.available_rect_before_wrap();
220 let grid = if ui.visuals().dark_mode {
221 p.border
222 } else {
223 p.border_visible.gamma_multiply(0.5)
224 };
225 widgets::dot_grid(ui.painter(), rect, grid, 16.0);
226 let inner = rect.shrink2(vec2(56.0, 48.0));
227 ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
228 ui.add_space(24.0);
229 ui.label(p.hero("BENCH51", theme::HERO_XL));
230 ui.add_space(4.0);
231 ui.label(p.label("8051 assembler · simulator · debugger"));
232 ui.add_space(40.0);
233 ui.horizontal(|ui| {
234 if widgets::pill(ui, "New file", PillKind::Primary).clicked() {
235 st.actions.push(Action::New);
236 }
237 ui.add_space(8.0);
238 if widgets::pill(ui, "Open…", PillKind::Secondary).clicked() {
239 st.actions.push(Action::Open);
240 }
241 });
242 if !st.prefs.recent.is_empty() {
243 ui.add_space(48.0);
244 ui.label(p.label("Recent"));
245 ui.add_space(8.0);
246 for path in st.prefs.recent.clone() {
247 let name = path.file_name().map_or_else(
248 || path.display().to_string(),
249 |n| n.to_string_lossy().into(),
250 );
251 let dir = path
252 .parent()
253 .map(|d| d.display().to_string())
254 .unwrap_or_default();
255 ui.horizontal(|ui| {
256 let r = ui
257 .add(
258 egui::Label::new(
259 RichText::new(name)
260 .font(FontId::monospace(BODY))
261 .color(p.text_primary),
262 )
263 .sense(Sense::click()),
264 )
265 .on_hover_cursor(CursorIcon::PointingHand);
266 ui.label(
267 RichText::new(dir)
268 .font(FontId::monospace(12.0))
269 .color(p.text_disabled),
270 );
271 if r.clicked() {
272 st.actions.push(Action::OpenPath(path.clone()));
273 }
274 });
275 }
276 }
277 });
278}
279
280// ----------------------------------------------------------------- code
281
282#[derive(Default)]
283struct Highlighter;
284
285impl ComputerMut<(bool, &str), LayoutJob> for Highlighter {
286 fn compute(&mut self, (dark, text): (bool, &str)) -> LayoutJob {
287 highlight(text, dark)
288 }
289}
290
291type HighlightCache = FrameCache<LayoutJob, Highlighter>;
292
293/// Lays out `text` with the greyscale syntax palette. The job's text is
294/// exactly `text`, as `TextEdit` requires.
295pub fn highlight(text: &str, dark: bool) -> LayoutJob {
296 let p = if dark { &DARK } else { &LIGHT };
297 let font = FontId::monospace(BODY);
298 let bold = FontId::new(BODY, theme::mono_bold());
299 let mut job = LayoutJob::default();
300 job.wrap.max_width = f32::INFINITY;
301 for (i, line) in text.split('\n').enumerate() {
302 if i > 0 {
303 job.append("\n", 0.0, TextFormat::simple(font.clone(), p.text_primary));
304 }
305 for (r, tok) in tokens(line) {
306 let (color, strong) = p.syntax(tok);
307 let font_id = if strong { bold.clone() } else { font.clone() };
308 job.append(&line[r], 0.0, TextFormat::simple(font_id, color));
309 }
310 }
311 job
312}
313
314pub fn char_to_byte(text: &str, ci: usize) -> usize {
315 text.char_indices().nth(ci).map_or(text.len(), |(b, _)| b)
316}
317
318/// (line, column) of char index `ci`.
319fn line_col(text: &str, ci: usize) -> (usize, usize) {
320 let b = char_to_byte(text, ci);
321 let before = &text[..b];
322 let line = before.matches('\n').count();
323 let start = before.rfind('\n').map_or(0, |i| i + 1);
324 (line, before[start..].chars().count())
325}
326
327/// Char index of (line, column), clamped to the text.
328fn char_index(text: &str, line: usize, col: usize) -> usize {
329 let mut ci = 0;
330 for (i, l) in text.split('\n').enumerate() {
331 let n = l.chars().count();
332 if i == line {
333 return ci + col.min(n);
334 }
335 ci += n + 1;
336 }
337 text.chars().count()
338}
339
340/// Replaces the selection with spaces up to the next 8-column tab stop,
341/// matching the assembler listing.
342fn insert_tab(ctx: &egui::Context, id: Id, text: &mut String) {
343 let Some(mut state) = TextEditState::load(ctx, id) else {
344 return;
345 };
346 let Some(range) = state.cursor.char_range() else {
347 return;
348 };
349 let (a, b) = {
350 let (x, y) = (range.primary.index.0, range.secondary.index.0);
351 (x.min(y), x.max(y))
352 };
353 let (ba, bb) = (char_to_byte(text, a), char_to_byte(text, b));
354 let start = text[..ba].rfind('\n').map_or(0, |i| i + 1);
355 let col = text[start..ba].chars().count();
356 let n = 8 - col % 8;
357 text.replace_range(ba..bb, &" ".repeat(n));
358 state
359 .cursor
360 .set_char_range(Some(CCursorRange::one(CCursor::new(a + n))));
361 state.store(ctx, id);
362}
363
364/// What the gutter and backgrounds need, gathered before the text is
365/// borrowed by the `TextEdit`.
366struct Marks {
367 errors: HashMap<usize, String>,
368 breakpoints: HashSet<usize>,
369 pc_line: Option<usize>,
370 /// Code address of each line, shown while debugging.
371 addrs: Option<HashMap<usize, u16>>,
372}
373
374fn code(ui: &mut Ui, st: &mut State) {
375 let debug = st.mode == Mode::Debug;
376 let query = if st.find.open {
377 st.find.query.clone()
378 } else {
379 String::new()
380 };
381 let Some(doc) = st.docs.get_mut(st.active) else {
382 return;
383 };
384 let p = palette(ui.ctx());
385 let dark = ui.visuals().dark_mode;
386 let font = FontId::monospace(BODY);
387 let row_h = ui.fonts_mut(|f| f.row_height(&font));
388 let char_w = ui.fonts_mut(|f| f.glyph_width(&font, '0'));
389 let n_lines = doc.text.split('\n').count();
390 let digits = n_lines.to_string().len().max(3);
391
392 let s = &doc.session;
393 let built = s.build.is_some() && !doc.stale();
394 let marks = Marks {
395 errors: s
396 .diagnostics_for(&doc.text)
397 .iter()
398 .rev()
399 .map(|d| (d.line, d.msg.clone()))
400 .collect(),
401 breakpoints: s.breakpoints.clone(),
402 pc_line: (debug && built && (!s.running || s.speed <= 2))
403 .then(|| s.pc_line())
404 .flatten(),
405 addrs: (debug && built).then(|| {
406 let b = s.build.as_ref().unwrap();
407 (0..b.out.lines.len())
408 .filter_map(|l| Some((l, b.line_addr(l)?)))
409 .collect()
410 }),
411 };
412 let follow = std::mem::take(&mut doc.follow_pc)
413 .then_some(marks.pc_line)
414 .flatten();
415
416 let bp_w = 20.0;
417 let num_w = digits as f32 * char_w;
418 let addr_w = if marks.addrs.is_some() {
419 4.0 * char_w + 16.0
420 } else {
421 0.0
422 };
423 let gutter_w = bp_w + num_w + addr_w + 16.0;
424 let edit_id = Id::unique(("editor", doc.id));
425 let ctx = ui.ctx().clone();
426
427 if !debug
428 && ctx.memory(|m| m.has_focus(edit_id))
429 && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Tab))
430 {
431 insert_tab(&ctx, edit_id, &mut doc.text);
432 }
433
434 // Programmatic cursor moves: jump to a line (taking keyboard focus), or
435 // select a find match (leaving focus in the find bar, so Enter there
436 // can't overwrite the match).
437 let mut scroll_to_char = None;
438 let jump = doc
439 .goto
440 .take()
441 .map(|(line, col)| {
442 let c = char_index(&doc.text, line, col);
443 ((c, c), true)
444 })
445 .or(doc.select.take().map(|r| (r, false)));
446 if let Some(((a, b), focus)) = jump {
447 let mut state = TextEditState::load(&ctx, edit_id).unwrap_or_default();
448 state
449 .cursor
450 .set_char_range(Some(CCursorRange::two(CCursor::new(a), CCursor::new(b))));
451 state.store(&ctx, edit_id);
452 if focus {
453 ctx.memory_mut(|m| m.request_focus(edit_id));
454 }
455 // The TextEdit only reports its cursor while focused.
456 doc.cursor_char = a;
457 doc.cursor = line_col(&doc.text, a);
458 scroll_to_char = Some(a);
459 }
460 let hit = doc.find_hit;
461
462 let matches = find_all(&doc.text, &query);
463 let mut clicked_line = None;
464 let mut cursor = None;
465
466 egui::ScrollArea::both()
467 .id_salt(("code", doc.id))
468 .auto_shrink([false; 2])
469 .show(ui, |ui| {
470 let bg = ui.painter().add(Shape::Noop);
471 ui.horizontal_top(|ui| {
472 ui.spacing_mut().item_spacing = Vec2::ZERO;
473 let min_h = n_lines as f32 * row_h + 2.0 * MARGIN.y;
474 let (gutter, gutter_resp) = ui.allocate_exact_size(
475 vec2(gutter_w, min_h.max(ui.available_height())),
476 Sense::click(),
477 );
478
479 let mut layouter = |ui: &Ui, buf: &dyn TextBuffer, _wrap: f32| {
480 let job = ui.memory_mut(|m| {
481 m.caches
482 .cache::<HighlightCache>()
483 .get((dark, buf.as_str()))
484 .clone()
485 });
486 ui.fonts_mut(|f| f.layout_job(job))
487 };
488 let mut ro: &str;
489 let buf: &mut dyn TextBuffer = if debug {
490 ro = doc.text.as_str();
491 &mut ro
492 } else {
493 &mut doc.text
494 };
495 let out = egui::TextEdit::multiline(buf)
496 .id(edit_id)
497 .font(font.clone())
498 .frame(egui::Frame::NONE)
499 .margin(egui::Margin::symmetric(MARGIN.x as i8, MARGIN.y as i8))
500 .desired_width(f32::INFINITY)
501 .desired_rows(1)
502 .lock_focus(true)
503 .layouter(&mut layouter)
504 .show(ui);
505 cursor = out.cursor_range.map(|r| r.primary.index.0);
506
507 let galley = &out.galley;
508 let origin = out.galley_pos;
509 let content_right = ui
510 .max_rect()
511 .right()
512 .max(origin.x + galley.size().x + MARGIN.x);
513 let row_rect = |line: usize| -> Rect {
514 let y = galley
515 .rows
516 .get(line)
517 .map_or(origin.y + line as f32 * row_h, |r| origin.y + r.pos.y);
518 Rect::from_min_max(pos2(gutter.left(), y), pos2(content_right, y + row_h))
519 };
520
521 // Backgrounds: find matches and the PC row.
522 let mut shapes = Vec::new();
523 for &(a, b) in matches.iter().take(500) {
524 let ca = doc_text_chars(galley.text(), a);
525 let cb = doc_text_chars(galley.text(), b);
526 let ra = galley.pos_from_cursor(CCursor::new(ca));
527 let rb = galley.pos_from_cursor(CCursor::new(cb));
528 if (ra.top() - rb.top()).abs() < 1.0 {
529 let r = Rect::from_min_max(ra.min, pos2(rb.left(), ra.bottom()))
530 .translate(origin.to_vec2());
531 let current = hit == Some((a, b));
532 shapes.push(Shape::rect_filled(
533 r.expand(1.0),
534 2.0,
535 WARNING.gamma_multiply(if current { 0.85 } else { 0.3 }),
536 ));
537 }
538 }
539 if let Some(pc) = marks.pc_line {
540 let r = row_rect(pc);
541 shapes.push(Shape::rect_filled(r, 0.0, p.surface_raised));
542 shapes.push(Shape::rect_filled(
543 Rect::from_min_size(r.min, vec2(2.0, r.height())),
544 0.0,
545 ACCENT,
546 ));
547 }
548 ui.painter().set(bg, Shape::Vec(shapes));
549
550 // Gutter.
551 let clip = ui.clip_rect();
552 let painter = ui.painter();
553 let first = ((clip.top() - origin.y) / row_h).floor().max(0.0) as usize;
554 let last = (((clip.bottom() - origin.y) / row_h).ceil() as usize + 1).min(n_lines);
555 let cursor_line = doc.cursor.0;
556 for line in first..last {
557 let r = row_rect(line);
558 let cy = r.center().y;
559 let err = marks.errors.contains_key(&line);
560 let bp = marks.breakpoints.contains(&line);
561 let bx = gutter.left() + 4.0 + bp_w / 2.0;
562 if bp {
563 painter.circle_filled(pos2(bx, cy), 4.5, ACCENT);
564 } else if err {
565 let s = Stroke::new(1.5, ACCENT);
566 let d = 3.5;
567 painter.line_segment([pos2(bx - d, cy - d), pos2(bx + d, cy + d)], s);
568 painter.line_segment([pos2(bx + d, cy - d), pos2(bx - d, cy + d)], s);
569 }
570 let num_color = if err {
571 ACCENT
572 } else if Some(line) == marks.pc_line {
573 p.text_display
574 } else if line == cursor_line && !debug {
575 p.text_secondary
576 } else {
577 p.text_disabled
578 };
579 painter.text(
580 pos2(gutter.left() + 4.0 + bp_w + num_w, cy),
581 Align2::RIGHT_CENTER,
582 (line + 1).to_string(),
583 font.clone(),
584 num_color,
585 );
586 if let Some(addr) = marks.addrs.as_ref().and_then(|a| a.get(&line)) {
587 painter.text(
588 pos2(gutter.left() + 4.0 + bp_w + num_w + 12.0, cy),
589 Align2::LEFT_CENTER,
590 format!("{addr:04X}"),
591 font.clone(),
592 p.text_disabled,
593 );
594 }
595 }
596 let gutter_resp = gutter_resp.on_hover_cursor(CursorIcon::PointingHand);
597 if gutter_resp.clicked()
598 && let Some(pos) = gutter_resp.interact_pointer_pos()
599 {
600 let line = ((pos.y - origin.y) / row_h).floor();
601 if line >= 0.0 && (line as usize) < n_lines {
602 clicked_line = Some(line as usize);
603 }
604 }
605 let hovered_line = gutter_resp
606 .hover_pos()
607 .map(|pos| ((pos.y - origin.y) / row_h).floor() as usize);
608 if let Some(msg) = hovered_line.and_then(|l| marks.errors.get(&l)) {
609 gutter_resp.on_hover_text(msg);
610 }
611
612 if let Some(line) = follow {
613 ui.scroll_to_rect(row_rect(line), Some(Align::Center));
614 }
615 if let Some(ci) = scroll_to_char {
616 let r = galley
617 .pos_from_cursor(CCursor::new(ci))
618 .translate(origin.to_vec2());
619 ui.scroll_to_rect(r.expand2(vec2(40.0, 0.0)), Some(Align::Center));
620 }
621 });
622 });
623
624 if let Some(ci) = cursor {
625 doc.cursor_char = ci;
626 doc.cursor = line_col(&doc.text, ci);
627 }
628 if let Some(line) = clicked_line {
629 doc.session.toggle_breakpoint(line);
630 }
631}
632
633/// Char index of byte offset `b` in `text`.
634fn doc_text_chars(text: &str, b: usize) -> usize {
635 text[..b.min(text.len())].chars().count()
636}
637
638#[cfg(test)]
639mod tests {
640 use super::{char_index, highlight, line_col};
641
642 #[test]
643 fn layout_job_reproduces_the_text() {
644 let text = "main: MOV A,#1 ; µs\n\n\tDB \"x\"\n";
645 assert_eq!(highlight(text, true).text, text);
646 }
647
648 #[test]
649 fn cursor_conversions_round_trip() {
650 let text = "ab\nµcd\n\nx";
651 for ci in 0..=text.chars().count() {
652 let (l, c) = line_col(text, ci);
653 assert_eq!(char_index(text, l, c), ci);
654 }
655 assert_eq!(line_col(text, 4), (1, 1));
656 }
657}