Krolyxon
Commit 2a1ff04

Branches

Tags

Refs →

bench51cratesbench51-guisrceditor.rs

942 lines · 33.9 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 std::path::PathBuf;
8
9use eframe::egui::{
10 self, Align, Align2, Color32, Context, CursorIcon, Event, EventFilter, FontId, Id, Key, Layout,
11 Modifiers, Rect, RichText, Sense, Shape, Stroke, StrokeKind, TextBuffer, TextFormat, Ui,
12 UiBuilder, Vec2, WidgetText,
13 cache::{ComputerMut, FrameCache},
14 pos2,
15 text::{CCursor, CCursorRange, LayoutJob},
16 text_edit::TextEditState,
17 vec2,
18};
19
20use crate::state::{Action, Document, Keymap, Mode, State};
21use crate::theme::{self, ACCENT, BODY, DARK, LIGHT, WARNING, palette};
22use crate::vim::{self, VimKey};
23use crate::widgets::{self, PillKind};
24
25const MARGIN: Vec2 = vec2(10.0, 8.0);
26
27pub fn show(ui: &mut Ui, st: &mut State) {
28 doc_tabs(ui, st);
29 if st.docs.is_empty() {
30 welcome(ui, st);
31 return;
32 }
33 if st.find.open {
34 find_bar(ui, st);
35 }
36 if st.prefs.keymap == Keymap::Vim {
37 let full = ui.available_rect_before_wrap();
38 let h = 26.0;
39 let code_rect = Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - h));
40 let line_rect = Rect::from_min_max(pos2(full.min.x, full.max.y - h), full.max);
41 ui.scope_builder(UiBuilder::new().max_rect(code_rect), |ui| code(ui, st));
42 mode_line(ui, line_rect, st);
43 ui.advance_cursor_after_rect(full);
44 } else {
45 code(ui, st);
46 }
47}
48
49/// Id of a document's text editor widget.
50pub fn edit_id(doc_id: u64) -> Id {
51 Id::unique(("editor", doc_id))
52}
53
54/// Vim's mode, recording state, command line or message, and pending keys.
55fn mode_line(ui: &Ui, rect: Rect, st: &State) {
56 let p = palette(ui.ctx());
57 let v = &st.vim;
58 let painter = ui.painter();
59 painter.hline(rect.x_range(), rect.top() + 0.5, Stroke::new(1.0, p.border));
60 let label = |text: &str, color: Color32| {
61 WidgetText::from(p.label(text).color(color)).into_galley(
62 ui,
63 Some(egui::TextWrapMode::Extend),
64 f32::INFINITY,
65 egui::TextStyle::Small,
66 )
67 };
68 let cy = rect.center().y;
69 let mut x = rect.left() + 12.0;
70 let put = |g: std::sync::Arc<egui::Galley>, x: &mut f32, color: Color32| {
71 let w = g.size().x;
72 painter.galley(pos2(*x, cy - g.size().y / 2.0), g, color);
73 *x += w;
74 };
75 let color = if v.mode == vim::Mode::Normal {
76 p.text_secondary
77 } else {
78 p.text_display
79 };
80 put(label(v.mode_label(), color), &mut x, color);
81 x += 18.0;
82 if let Some(r) = v.recording() {
83 painter.circle_filled(pos2(x + 3.5, cy), 3.5, ACCENT);
84 x += 12.0;
85 put(label(&format!("rec @{r}"), ACCENT), &mut x, ACCENT);
86 x += 18.0;
87 }
88 let font = FontId::monospace(13.0);
89 if let Some((prompt, input)) = &v.cmdline {
90 let r = painter.text(
91 pos2(x, cy),
92 Align2::LEFT_CENTER,
93 format!("{prompt}{input}"),
94 font.clone(),
95 p.text_display,
96 );
97 let cw = ui.fonts_mut(|f| f.glyph_width(&font, '0'));
98 painter.rect_filled(
99 Rect::from_center_size(pos2(r.right() + cw / 2.0, cy), vec2(cw, r.height())),
100 0.0,
101 p.text_display,
102 );
103 } else if let Some((msg, error)) = &v.message {
104 let color = if *error { ACCENT } else { p.text_secondary };
105 painter.text(pos2(x, cy), Align2::LEFT_CENTER, msg, font.clone(), color);
106 }
107 painter.text(
108 pos2(rect.right() - 12.0, cy),
109 Align2::RIGHT_CENTER,
110 v.pending_label(),
111 font,
112 p.text_display,
113 );
114}
115
116/// Where the view goes after Vim handled this frame's keys.
117enum VimScroll {
118 /// Just keep the cursor visible.
119 Cursor,
120 Align(vim::Align),
121 Lines(isize),
122}
123
124/// Keyboard input for the focused editor (Ctrl+Tab stays with the panes).
125fn is_keyboard(e: &Event) -> bool {
126 match e {
127 Event::Key { key, modifiers, .. } => !(modifiers.ctrl && *key == Key::Tab),
128 Event::Text(_) | Event::Paste(_) | Event::Copy | Event::Cut | Event::Ime(_) => true,
129 _ => false,
130 }
131}
132
133/// A path typed in `:w` or `:e`, relative to the document's folder.
134fn resolve(doc: &Document, path: &str) -> PathBuf {
135 let p = PathBuf::from(path);
136 match doc.path.as_ref().and_then(|d| d.parent()) {
137 Some(dir) if p.is_relative() => dir.join(p),
138 _ => p,
139 }
140}
141
142/// Takes this frame's keys away from the TextEdit, feeds them to Vim and
143/// turns its effects into app actions.
144fn drive_vim(ctx: &Context, st: &mut State, debug: bool) -> Option<VimScroll> {
145 let events: Vec<Event> = ctx.input_mut(|i| {
146 let (keys, rest) = std::mem::take(&mut i.events)
147 .into_iter()
148 .partition(is_keyboard);
149 i.events = rest;
150 keys
151 });
152 let mut keys: Vec<VimKey> = events.iter().flat_map(vim::from_egui).collect();
153 if debug && st.vim.mode == vim::Mode::Insert {
154 keys.insert(0, VimKey::Esc);
155 }
156 if keys.is_empty() {
157 return None;
158 }
159 let n_docs = st.docs.len();
160 let State {
161 docs,
162 active,
163 vim,
164 actions,
165 vim_view,
166 ..
167 } = st;
168 let active = *active;
169 let doc = docs.get_mut(active)?;
170 let mut scroll = VimScroll::Cursor;
171 for k in keys {
172 let dirty = doc.dirty();
173 let mut cx = vim::Cx {
174 text: &mut doc.text,
175 cursor: &mut doc.cursor_char,
176 history: &mut doc.history,
177 readonly: debug,
178 dirty,
179 view: *vim_view,
180 };
181 for fx in vim.key(k, &mut cx) {
182 use vim::Effect;
183 match fx {
184 Effect::Clipboard(t) => ctx.copy_text(t),
185 Effect::Save => actions.push(Action::Save),
186 Effect::SaveAs(p) => actions.push(Action::SaveTo(resolve(doc, &p))),
187 Effect::Close { force: true } => actions.push(Action::ForceClose(active)),
188 Effect::Close { force: false } => actions.push(Action::Close(active)),
189 Effect::Quit { force: true } => actions.push(Action::ForceQuit),
190 Effect::Quit { force: false } => actions.push(Action::Quit),
191 Effect::Edit(p) => actions.push(Action::OpenPath(resolve(doc, &p))),
192 Effect::Make => actions.push(Action::Build),
193 Effect::NextDoc => actions.push(Action::Activate((active + 1) % n_docs)),
194 Effect::PrevDoc => actions.push(Action::Activate((active + n_docs - 1) % n_docs)),
195 Effect::Scroll(a) => scroll = VimScroll::Align(a),
196 Effect::ScrollLines(n) => scroll = VimScroll::Lines(n),
197 }
198 }
199 }
200 doc.cursor_char = doc.cursor_char.min(doc.text.chars().count());
201 doc.cursor = line_col(&doc.text, doc.cursor_char);
202 Some(scroll)
203}
204
205// ------------------------------------------------------------- tabs, find
206
207fn doc_tabs(ui: &mut Ui, st: &mut State) {
208 let p = palette(ui.ctx());
209 let height = 30.0;
210 let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
211 ui.painter().hline(
212 strip.x_range(),
213 strip.bottom() - 0.5,
214 Stroke::new(1.0, p.border),
215 );
216 let mut child = ui.new_child(
217 egui::UiBuilder::new()
218 .max_rect(strip.shrink2(vec2(6.0, 0.0)))
219 .layout(Layout::left_to_right(Align::Center)),
220 );
221 let ui = &mut child;
222 ui.spacing_mut().item_spacing.x = 0.0;
223 for (i, doc) in st.docs.iter().enumerate() {
224 let active = i == st.active;
225 let name = doc.name();
226 let galley =
227 ui.painter()
228 .layout_no_wrap(name, FontId::monospace(12.0), Color32::PLACEHOLDER);
229 let pad = 12.0;
230 let dirty_w = if doc.dirty() { 10.0 } else { 0.0 };
231 let w = galley.size().x + 2.0 * pad + dirty_w + 14.0;
232 let (rect, resp) = ui.allocate_exact_size(vec2(w, height), Sense::click());
233 let resp = resp.on_hover_cursor(CursorIcon::PointingHand);
234 let fg = if active {
235 p.text_display
236 } else if resp.hovered() {
237 p.text_secondary
238 } else {
239 p.text_disabled
240 };
241 let text_pos = Align2::LEFT_CENTER
242 .align_size_within_rect(galley.size(), rect.shrink2(vec2(pad, 0.0)))
243 .min;
244 let text_w = galley.size().x;
245 ui.painter().galley(text_pos, galley, fg);
246 if active {
247 ui.painter().hline(
248 text_pos.x..=text_pos.x + text_w,
249 rect.bottom() - 1.5,
250 Stroke::new(2.0, p.text_display),
251 );
252 }
253 if doc.dirty() {
254 ui.painter().circle_filled(
255 pos2(text_pos.x + text_w + 7.0, rect.center().y),
256 2.5,
257 p.text_secondary,
258 );
259 }
260 let close =
261 Rect::from_center_size(pos2(rect.right() - pad, rect.center().y), Vec2::splat(12.0));
262 let close_resp = ui
263 .interact(close, Id::unique(("doc_close", doc.id)), Sense::click())
264 .on_hover_cursor(CursorIcon::PointingHand);
265 if resp.hovered() || close_resp.hovered() || active {
266 let c = if close_resp.hovered() {
267 p.text_display
268 } else {
269 p.text_disabled
270 };
271 let r = close.shrink(3.0);
272 let s = Stroke::new(1.2, c);
273 ui.painter()
274 .line_segment([r.left_top(), r.right_bottom()], s);
275 ui.painter()
276 .line_segment([r.right_top(), r.left_bottom()], s);
277 }
278 if close_resp.clicked() || resp.clicked_by(egui::PointerButton::Middle) {
279 st.actions.push(Action::Close(i));
280 } else if resp.clicked() {
281 st.actions.push(Action::Activate(i));
282 }
283 }
284 let plus = ui
285 .add(
286 egui::Button::new(
287 RichText::new("+")
288 .font(FontId::monospace(14.0))
289 .color(p.text_disabled),
290 )
291 .frame(false),
292 )
293 .on_hover_text("New file (Ctrl+N)");
294 if plus.clicked() {
295 st.actions.push(Action::New);
296 }
297 if st.mode == Mode::Debug {
298 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
299 ui.add_space(8.0);
300 ui.label(p.label("Read-only while debugging").color(p.text_disabled));
301 });
302 }
303}
304
305fn find_bar(ui: &mut Ui, st: &mut State) {
306 let p = palette(ui.ctx());
307 let mut step = None;
308 let mut close = false;
309 ui.horizontal(|ui| {
310 ui.add_space(MARGIN.x);
311 ui.label(p.label("Find"));
312 let resp = widgets::underline_input(ui, &mut st.find.query, "text", 220.0);
313 if std::mem::take(&mut st.find.focus) {
314 resp.request_focus();
315 }
316 if resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) {
317 step = Some(!ui.input(|i| i.modifiers.shift));
318 resp.request_focus();
319 }
320 if resp.has_focus() && ui.input(|i| i.key_pressed(Key::Escape)) {
321 close = true;
322 }
323 let count = st
324 .doc()
325 .map_or(0, |d| find_all(&d.text, &st.find.query).len());
326 ui.label(p.label(format!("{count} found")).color(p.text_disabled));
327 if widgets::pill(ui, "Prev", PillKind::Ghost).clicked() {
328 step = Some(false);
329 }
330 if widgets::pill(ui, "Next", PillKind::Ghost).clicked() {
331 step = Some(true);
332 }
333 if widgets::pill(ui, "Close", PillKind::Ghost).clicked() {
334 close = true;
335 }
336 });
337 if let (Some(forward), Some(doc)) = (step, st.docs.get_mut(st.active)) {
338 find_step(doc, &st.find.query, forward);
339 }
340 if close {
341 st.find.open = false;
342 st.focus_editor = true;
343 }
344}
345
346/// Byte ranges of case-insensitive matches of `query` in `text`.
347fn find_all(text: &str, query: &str) -> Vec<(usize, usize)> {
348 if query.is_empty() {
349 return Vec::new();
350 }
351 let hay = text.to_ascii_lowercase();
352 let needle = query.to_ascii_lowercase();
353 hay.match_indices(&needle)
354 .take(2000)
355 .map(|(i, m)| (i, i + m.len()))
356 .collect()
357}
358
359fn find_step(doc: &mut Document, query: &str, forward: bool) {
360 let matches = find_all(&doc.text, query);
361 if matches.is_empty() {
362 doc.find_hit = None;
363 return;
364 }
365 let here = char_to_byte(&doc.text, doc.cursor_char);
366 let hit = doc.find_hit.filter(|h| matches.contains(h));
367 let pick = if forward {
368 let from = hit.map_or(here, |h| h.0 + 1);
369 matches.iter().find(|m| m.0 >= from).or(matches.first())
370 } else {
371 let before = hit.map_or(here, |h| h.0);
372 matches
373 .iter()
374 .rev()
375 .find(|m| m.0 < before)
376 .or(matches.last())
377 };
378 if let Some(&(a, b)) = pick {
379 let ca = doc.text[..a].chars().count();
380 let cb = ca + doc.text[a..b].chars().count();
381 doc.select = Some((ca, cb));
382 doc.find_hit = Some((a, b));
383 }
384}
385
386// -------------------------------------------------------------- welcome
387
388fn welcome(ui: &mut Ui, st: &mut State) {
389 let p = palette(ui.ctx());
390 let rect = ui.available_rect_before_wrap();
391 let grid = if ui.visuals().dark_mode {
392 p.border
393 } else {
394 p.border_visible.gamma_multiply(0.5)
395 };
396 widgets::dot_grid(ui.painter(), rect, grid, 16.0);
397 let inner = rect.shrink2(vec2(56.0, 48.0));
398 ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
399 ui.add_space(24.0);
400 ui.label(p.hero("BENCH51", theme::HERO_XL));
401 ui.add_space(4.0);
402 ui.label(p.label("8051 assembler · simulator · debugger"));
403 ui.add_space(40.0);
404 ui.horizontal(|ui| {
405 if widgets::pill(ui, "New file", PillKind::Primary).clicked() {
406 st.actions.push(Action::New);
407 }
408 ui.add_space(8.0);
409 if widgets::pill(ui, "Open…", PillKind::Secondary).clicked() {
410 st.actions.push(Action::Open);
411 }
412 });
413 if !st.prefs.recent.is_empty() {
414 ui.add_space(48.0);
415 ui.label(p.label("Recent"));
416 ui.add_space(8.0);
417 for path in st.prefs.recent.clone() {
418 let name = path.file_name().map_or_else(
419 || path.display().to_string(),
420 |n| n.to_string_lossy().into(),
421 );
422 let dir = path
423 .parent()
424 .map(|d| d.display().to_string())
425 .unwrap_or_default();
426 ui.horizontal(|ui| {
427 let r = ui
428 .add(
429 egui::Label::new(
430 RichText::new(name)
431 .font(FontId::monospace(BODY))
432 .color(p.text_primary),
433 )
434 .sense(Sense::click()),
435 )
436 .on_hover_cursor(CursorIcon::PointingHand);
437 ui.label(
438 RichText::new(dir)
439 .font(FontId::monospace(12.0))
440 .color(p.text_disabled),
441 );
442 if r.clicked() {
443 st.actions.push(Action::OpenPath(path.clone()));
444 }
445 });
446 }
447 }
448 });
449}
450
451// ----------------------------------------------------------------- code
452
453#[derive(Default)]
454struct Highlighter;
455
456impl ComputerMut<(bool, &str), LayoutJob> for Highlighter {
457 fn compute(&mut self, (dark, text): (bool, &str)) -> LayoutJob {
458 highlight(text, dark)
459 }
460}
461
462type HighlightCache = FrameCache<LayoutJob, Highlighter>;
463
464/// Lays out `text` with the greyscale syntax palette. The job's text is
465/// exactly `text`, as `TextEdit` requires.
466pub fn highlight(text: &str, dark: bool) -> LayoutJob {
467 let p = if dark { &DARK } else { &LIGHT };
468 let font = FontId::monospace(BODY);
469 let bold = FontId::new(BODY, theme::mono_bold());
470 let mut job = LayoutJob::default();
471 job.wrap.max_width = f32::INFINITY;
472 for (i, line) in text.split('\n').enumerate() {
473 if i > 0 {
474 job.append("\n", 0.0, TextFormat::simple(font.clone(), p.text_primary));
475 }
476 for (r, tok) in tokens(line) {
477 let (color, strong) = p.syntax(tok);
478 let font_id = if strong { bold.clone() } else { font.clone() };
479 job.append(&line[r], 0.0, TextFormat::simple(font_id, color));
480 }
481 }
482 job
483}
484
485pub fn char_to_byte(text: &str, ci: usize) -> usize {
486 text.char_indices().nth(ci).map_or(text.len(), |(b, _)| b)
487}
488
489/// (line, column) of char index `ci`.
490fn line_col(text: &str, ci: usize) -> (usize, usize) {
491 let b = char_to_byte(text, ci);
492 let before = &text[..b];
493 let line = before.matches('\n').count();
494 let start = before.rfind('\n').map_or(0, |i| i + 1);
495 (line, before[start..].chars().count())
496}
497
498/// Char index of (line, column), clamped to the text.
499fn char_index(text: &str, line: usize, col: usize) -> usize {
500 let mut ci = 0;
501 for (i, l) in text.split('\n').enumerate() {
502 let n = l.chars().count();
503 if i == line {
504 return ci + col.min(n);
505 }
506 ci += n + 1;
507 }
508 text.chars().count()
509}
510
511/// Replaces the selection with spaces up to the next 8-column tab stop,
512/// matching the assembler listing.
513fn insert_tab(ctx: &egui::Context, id: Id, text: &mut String) {
514 let Some(mut state) = TextEditState::load(ctx, id) else {
515 return;
516 };
517 let Some(range) = state.cursor.char_range() else {
518 return;
519 };
520 let (a, b) = {
521 let (x, y) = (range.primary.index.0, range.secondary.index.0);
522 (x.min(y), x.max(y))
523 };
524 let (ba, bb) = (char_to_byte(text, a), char_to_byte(text, b));
525 let start = text[..ba].rfind('\n').map_or(0, |i| i + 1);
526 let col = text[start..ba].chars().count();
527 let n = 8 - col % 8;
528 text.replace_range(ba..bb, &" ".repeat(n));
529 state
530 .cursor
531 .set_char_range(Some(CCursorRange::one(CCursor::new(a + n))));
532 state.store(ctx, id);
533}
534
535/// What the gutter and backgrounds need, gathered before the text is
536/// borrowed by the `TextEdit`.
537struct Marks {
538 errors: HashMap<usize, String>,
539 breakpoints: HashSet<usize>,
540 pc_line: Option<usize>,
541 /// Code address of each line, shown while debugging.
542 addrs: Option<HashMap<usize, u16>>,
543}
544
545fn code(ui: &mut Ui, st: &mut State) {
546 let debug = st.mode == Mode::Debug;
547 let vim_on = st.prefs.keymap == Keymap::Vim;
548 let ctx = ui.ctx().clone();
549 let Some(doc_id) = st.doc().map(|d| d.id) else {
550 return;
551 };
552 let edit_id = edit_id(doc_id);
553 if std::mem::take(&mut st.focus_editor) && !st.find.open {
554 ctx.memory_mut(|m| m.request_focus(edit_id));
555 }
556 let focused = ctx.memory(|m| m.has_focus(edit_id));
557 let vim_scroll = if vim_on && focused {
558 drive_vim(&ctx, st, debug)
559 } else {
560 None
561 };
562 let query = if st.find.open {
563 st.find.query.clone()
564 } else {
565 String::new()
566 };
567 let State {
568 docs,
569 active,
570 vim,
571 vim_view,
572 ..
573 } = st;
574 let Some(doc) = docs.get_mut(*active) else {
575 return;
576 };
577 let p = palette(ui.ctx());
578 let dark = ui.visuals().dark_mode;
579 let font = FontId::monospace(BODY);
580 let row_h = ui.fonts_mut(|f| f.row_height(&font));
581 let char_w = ui.fonts_mut(|f| f.glyph_width(&font, '0'));
582 let n_lines = doc.text.split('\n').count();
583 let digits = n_lines.to_string().len().max(3);
584
585 let s = &doc.session;
586 let built = s.build.is_some() && !doc.stale();
587 let marks = Marks {
588 errors: s
589 .diagnostics_for(&doc.text)
590 .iter()
591 .rev()
592 .map(|d| (d.line, d.msg.clone()))
593 .collect(),
594 breakpoints: s.breakpoints.clone(),
595 pc_line: (debug && built && (!s.running || s.speed <= 2))
596 .then(|| s.pc_line())
597 .flatten(),
598 addrs: (debug && built).then(|| {
599 let b = s.build.as_ref().unwrap();
600 (0..b.out.lines.len())
601 .filter_map(|l| Some((l, b.line_addr(l)?)))
602 .collect()
603 }),
604 };
605 let follow = std::mem::take(&mut doc.follow_pc)
606 .then_some(marks.pc_line)
607 .flatten();
608
609 let bp_w = 20.0;
610 let num_w = digits as f32 * char_w;
611 let addr_w = if marks.addrs.is_some() {
612 4.0 * char_w + 16.0
613 } else {
614 0.0
615 };
616 let gutter_w = bp_w + num_w + addr_w + 16.0;
617
618 if !vim_on && !debug && focused && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Tab)) {
619 insert_tab(&ctx, edit_id, &mut doc.text);
620 }
621
622 // Programmatic cursor moves: jump to a line (taking keyboard focus), or
623 // select a find match (leaving focus in the find bar, so Enter there
624 // can't overwrite the match).
625 let mut scroll_to_char = None;
626 let jump = doc
627 .goto
628 .take()
629 .map(|(line, col)| {
630 let c = char_index(&doc.text, line, col);
631 ((c, c), true)
632 })
633 .or(doc.select.take().map(|r| (r, false)));
634 if let Some(((a, b), focus)) = jump {
635 let mut state = TextEditState::load(&ctx, edit_id).unwrap_or_default();
636 state
637 .cursor
638 .set_char_range(Some(CCursorRange::two(CCursor::new(a), CCursor::new(b))));
639 state.store(&ctx, edit_id);
640 if focus {
641 ctx.memory_mut(|m| m.request_focus(edit_id));
642 }
643 // The TextEdit only reports its cursor while focused.
644 doc.cursor_char = a;
645 doc.cursor = line_col(&doc.text, a);
646 scroll_to_char = Some(a);
647 }
648 let hit = doc.find_hit;
649
650 // Vim owns the cursor: show it, or the Visual selection, in the
651 // TextEdit, except while the mouse is dragging out a selection.
652 let block = vim_on && vim.block_cursor();
653 let dragging = ctx.dragged_id() == Some(edit_id);
654 if vim_on && !dragging {
655 let mut state = TextEditState::load(&ctx, edit_id).unwrap_or_default();
656 let range = match vim.selection(&doc.text, doc.cursor_char) {
657 Some((a, b)) => CCursorRange::two(CCursor::new(a), CCursor::new(b)),
658 None => CCursorRange::one(CCursor::new(doc.cursor_char)),
659 };
660 state.cursor.set_char_range(Some(range));
661 state.store(&ctx, edit_id);
662 }
663
664 let matches = if !query.is_empty() {
665 find_all(&doc.text, &query)
666 } else if vim_on {
667 vim.search_ranges(&doc.text)
668 } else {
669 Vec::new()
670 };
671 let mut clicked_line = None;
672 let mut cursor = None;
673 let mut mouse = None;
674
675 egui::ScrollArea::both()
676 .id_salt(("code", doc.id))
677 .auto_shrink([false; 2])
678 .show(ui, |ui| {
679 let bg = ui.painter().add(Shape::Noop);
680 ui.horizontal_top(|ui| {
681 ui.spacing_mut().item_spacing = Vec2::ZERO;
682 let min_h = n_lines as f32 * row_h + 2.0 * MARGIN.y;
683 let (gutter, gutter_resp) = ui.allocate_exact_size(
684 vec2(gutter_w, min_h.max(ui.available_height())),
685 Sense::click(),
686 );
687
688 let mut layouter = |ui: &Ui, buf: &dyn TextBuffer, _wrap: f32| {
689 let job = ui.memory_mut(|m| {
690 m.caches
691 .cache::<HighlightCache>()
692 .get((dark, buf.as_str()))
693 .clone()
694 });
695 ui.fonts_mut(|f| f.layout_job(job))
696 };
697 let mut ro: &str;
698 let buf: &mut dyn TextBuffer = if debug {
699 ro = doc.text.as_str();
700 &mut ro
701 } else {
702 &mut doc.text
703 };
704 if block {
705 ui.visuals_mut().text_cursor.stroke = Stroke::NONE;
706 }
707 let mut edit = egui::TextEdit::multiline(buf)
708 .id(edit_id)
709 .font(font.clone())
710 .frame(egui::Frame::NONE)
711 .margin(egui::Margin::symmetric(MARGIN.x as i8, MARGIN.y as i8))
712 .desired_width(f32::INFINITY)
713 .desired_rows(1)
714 .lock_focus(true)
715 .layouter(&mut layouter);
716 if vim_on {
717 // Esc, Tab and the arrows belong to Vim, not to egui's
718 // focus navigation.
719 edit = edit.event_filter(EventFilter {
720 tab: true,
721 horizontal_arrows: true,
722 vertical_arrows: true,
723 escape: true,
724 });
725 }
726 let out = edit.show(ui);
727 cursor = out.cursor_range.map(|r| r.primary.index.0);
728 let resp = &out.response.response;
729 if vim_on
730 && (resp.clicked()
731 || resp.dragged()
732 || resp.drag_stopped()
733 || resp.double_clicked())
734 {
735 mouse = out
736 .cursor_range
737 .map(|r| (r.secondary.index.0, r.primary.index.0));
738 }
739
740 let galley = &out.galley;
741 let origin = out.galley_pos;
742 let content_right = ui
743 .max_rect()
744 .right()
745 .max(origin.x + galley.size().x + MARGIN.x);
746 let row_rect = |line: usize| -> Rect {
747 let y = galley
748 .rows
749 .get(line)
750 .map_or(origin.y + line as f32 * row_h, |r| origin.y + r.pos.y);
751 Rect::from_min_max(pos2(gutter.left(), y), pos2(content_right, y + row_h))
752 };
753
754 // Backgrounds: find matches and the PC row.
755 let mut shapes = Vec::new();
756 for &(a, b) in matches.iter().take(500) {
757 let ca = doc_text_chars(galley.text(), a);
758 let cb = doc_text_chars(galley.text(), b);
759 let ra = galley.pos_from_cursor(CCursor::new(ca));
760 let rb = galley.pos_from_cursor(CCursor::new(cb));
761 if (ra.top() - rb.top()).abs() < 1.0 {
762 let r = Rect::from_min_max(ra.min, pos2(rb.left(), ra.bottom()))
763 .translate(origin.to_vec2());
764 let current = hit == Some((a, b));
765 shapes.push(Shape::rect_filled(
766 r.expand(1.0),
767 2.0,
768 WARNING.gamma_multiply(if current { 0.85 } else { 0.3 }),
769 ));
770 }
771 }
772 if let Some(pc) = marks.pc_line {
773 let r = row_rect(pc);
774 shapes.push(Shape::rect_filled(r, 0.0, p.surface_raised));
775 shapes.push(Shape::rect_filled(
776 Rect::from_min_size(r.min, vec2(2.0, r.height())),
777 0.0,
778 ACCENT,
779 ));
780 }
781 ui.painter().set(bg, Shape::Vec(shapes));
782
783 // Gutter.
784 let clip = ui.clip_rect();
785 let painter = ui.painter();
786 let first = ((clip.top() - origin.y) / row_h).floor().max(0.0) as usize;
787 let last = (((clip.bottom() - origin.y) / row_h).ceil() as usize + 1).min(n_lines);
788 let cursor_line = doc.cursor.0;
789 for line in first..last {
790 let r = row_rect(line);
791 let cy = r.center().y;
792 let err = marks.errors.contains_key(&line);
793 let bp = marks.breakpoints.contains(&line);
794 let bx = gutter.left() + 4.0 + bp_w / 2.0;
795 if bp {
796 painter.circle_filled(pos2(bx, cy), 4.5, ACCENT);
797 } else if err {
798 let s = Stroke::new(1.5, ACCENT);
799 let d = 3.5;
800 painter.line_segment([pos2(bx - d, cy - d), pos2(bx + d, cy + d)], s);
801 painter.line_segment([pos2(bx + d, cy - d), pos2(bx - d, cy + d)], s);
802 }
803 let num_color = if err {
804 ACCENT
805 } else if Some(line) == marks.pc_line {
806 p.text_display
807 } else if line == cursor_line && !debug {
808 p.text_secondary
809 } else {
810 p.text_disabled
811 };
812 painter.text(
813 pos2(gutter.left() + 4.0 + bp_w + num_w, cy),
814 Align2::RIGHT_CENTER,
815 (line + 1).to_string(),
816 font.clone(),
817 num_color,
818 );
819 if let Some(addr) = marks.addrs.as_ref().and_then(|a| a.get(&line)) {
820 painter.text(
821 pos2(gutter.left() + 4.0 + bp_w + num_w + 12.0, cy),
822 Align2::LEFT_CENTER,
823 format!("{addr:04X}"),
824 font.clone(),
825 p.text_disabled,
826 );
827 }
828 }
829 let gutter_resp = gutter_resp.on_hover_cursor(CursorIcon::PointingHand);
830 if gutter_resp.clicked()
831 && let Some(pos) = gutter_resp.interact_pointer_pos()
832 {
833 let line = ((pos.y - origin.y) / row_h).floor();
834 if line >= 0.0 && (line as usize) < n_lines {
835 clicked_line = Some(line as usize);
836 }
837 }
838 let hovered_line = gutter_resp
839 .hover_pos()
840 .map(|pos| ((pos.y - origin.y) / row_h).floor() as usize);
841 if let Some(msg) = hovered_line.and_then(|l| marks.errors.get(&l)) {
842 gutter_resp.on_hover_text(msg);
843 }
844
845 // Vim: block cursor, visible lines, scrolling.
846 if vim_on {
847 let n_chars = galley.text().chars().count();
848 let ci = doc.cursor_char.min(n_chars);
849 let at = galley
850 .pos_from_cursor(CCursor::new(ci))
851 .translate(origin.to_vec2());
852 let cell = Rect::from_min_size(at.min, vec2(char_w, row_h));
853 if block {
854 let ch = galley.text().chars().nth(ci).filter(|c| !c.is_whitespace());
855 if focused {
856 painter.rect_filled(cell, 0.0, p.text_display);
857 if let Some(ch) = ch {
858 painter.text(cell.min, Align2::LEFT_TOP, ch, font.clone(), p.black);
859 }
860 } else {
861 painter.rect_stroke(
862 cell,
863 0.0,
864 Stroke::new(1.0, p.text_secondary),
865 StrokeKind::Inside,
866 );
867 }
868 }
869 *vim_view = vim::View {
870 top: first,
871 rows: (clip.height() / row_h).floor().max(1.0) as usize,
872 };
873 match vim_scroll {
874 Some(VimScroll::Cursor) => {
875 ui.scroll_to_rect(cell.expand2(vec2(char_w * 4.0, row_h)), None)
876 }
877 Some(VimScroll::Align(a)) => ui.scroll_to_rect(
878 row_rect(doc.cursor.0),
879 Some(match a {
880 vim::Align::Top => Align::Min,
881 vim::Align::Center => Align::Center,
882 vim::Align::Bottom => Align::Max,
883 }),
884 ),
885 Some(VimScroll::Lines(n)) => {
886 ui.scroll_with_delta(vec2(0.0, -(n as f32) * row_h));
887 }
888 None => {}
889 }
890 }
891
892 if let Some(line) = follow {
893 ui.scroll_to_rect(row_rect(line), Some(Align::Center));
894 }
895 if let Some(ci) = scroll_to_char {
896 let r = galley
897 .pos_from_cursor(CCursor::new(ci))
898 .translate(origin.to_vec2());
899 ui.scroll_to_rect(r.expand2(vec2(40.0, 0.0)), Some(Align::Center));
900 }
901 });
902 });
903
904 if vim_on {
905 if let Some((tail, head)) = mouse {
906 vim.mouse(tail, head, &doc.text, &mut doc.cursor_char);
907 doc.cursor = line_col(&doc.text, doc.cursor_char);
908 }
909 } else if let Some(ci) = cursor {
910 doc.cursor_char = ci;
911 doc.cursor = line_col(&doc.text, ci);
912 }
913 if let Some(line) = clicked_line {
914 doc.session.toggle_breakpoint(line);
915 }
916}
917
918/// Char index of byte offset `b` in `text`.
919fn doc_text_chars(text: &str, b: usize) -> usize {
920 text[..b.min(text.len())].chars().count()
921}
922
923#[cfg(test)]
924mod tests {
925 use super::{char_index, highlight, line_col};
926
927 #[test]
928 fn layout_job_reproduces_the_text() {
929 let text = "main: MOV A,#1 ; µs\n\n\tDB \"x\"\n";
930 assert_eq!(highlight(text, true).text, text);
931 }
932
933 #[test]
934 fn cursor_conversions_round_trip() {
935 let text = "ab\nµcd\n\nx";
936 for ci in 0..=text.chars().count() {
937 let (l, c) = line_col(text, ci);
938 assert_eq!(char_index(text, l, c), ci);
939 }
940 assert_eq!(line_col(text, 4), (1, 1));
941 }
942}