| 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 | |
| 4 | use std::collections::{HashMap, HashSet}; |
| 5 | |
| 6 | use bench51_core::syntax::tokens; |
| 7 | use std::path::PathBuf; |
| 8 | |
| 9 | use 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 | |
| 20 | use crate::state::{Action, Document, Keymap, Mode, State}; |
| 21 | use crate::theme::{self, ACCENT, BODY, DARK, LIGHT, WARNING, palette}; |
| 22 | use crate::vim::{self, VimKey}; |
| 23 | use crate::widgets::{self, PillKind}; |
| 24 | |
| 25 | const MARGIN: Vec2 = vec2(10.0, 8.0); |
| 26 | |
| 27 | pub 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. |
| 50 | pub 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. |
| 55 | fn 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. |
| 117 | enum 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). |
| 125 | fn 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. |
| 134 | fn 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. |
| 144 | fn 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 | |
| 207 | fn 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 | |
| 305 | fn 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`. |
| 347 | fn 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 | |
| 359 | fn 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 | |
| 388 | fn 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)] |
| 454 | struct Highlighter; |
| 455 | |
| 456 | impl ComputerMut<(bool, &str), LayoutJob> for Highlighter { |
| 457 | fn compute(&mut self, (dark, text): (bool, &str)) -> LayoutJob { |
| 458 | highlight(text, dark) |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | type 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. |
| 466 | pub 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 | |
| 485 | pub 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`. |
| 490 | fn 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. |
| 499 | fn 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. |
| 513 | fn 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`. |
| 537 | struct 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 | |
| 545 | fn 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`. |
| 919 | fn doc_text_chars(text: &str, b: usize) -> usize { |
| 920 | text[..b.min(text.len())].chars().count() |
| 921 | } |
| 922 | |
| 923 | #[cfg(test)] |
| 924 | mod 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 | } |