| 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 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 | |
| 17 | use crate::state::{Action, Document, Mode, State}; |
| 18 | use crate::theme::{self, ACCENT, BODY, DARK, LIGHT, WARNING, palette}; |
| 19 | use crate::widgets::{self, PillKind}; |
| 20 | |
| 21 | const MARGIN: Vec2 = vec2(10.0, 8.0); |
| 22 | |
| 23 | pub 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 | |
| 37 | fn 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 | |
| 135 | fn 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`. |
| 176 | fn 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 | |
| 188 | fn 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 | |
| 217 | fn 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)] |
| 283 | struct Highlighter; |
| 284 | |
| 285 | impl ComputerMut<(bool, &str), LayoutJob> for Highlighter { |
| 286 | fn compute(&mut self, (dark, text): (bool, &str)) -> LayoutJob { |
| 287 | highlight(text, dark) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | type 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. |
| 295 | pub 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 | |
| 314 | pub 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`. |
| 319 | fn 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. |
| 328 | fn 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. |
| 342 | fn 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`. |
| 366 | struct 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 | |
| 374 | fn 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`. |
| 634 | fn doc_text_chars(text: &str, b: usize) -> usize { |
| 635 | text[..b.min(text.len())].chars().count() |
| 636 | } |
| 637 | |
| 638 | #[cfg(test)] |
| 639 | mod 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 | } |