1use std::ops::Range;
27use std::sync::Arc;
28
29use ashlar_syntax::{Builtin, Highlighter, Kind, State};
30use egui::text::{ByteIndex, CCursor, LayoutJob, LayoutSection, TextWrapping};
31use egui::text_selection::CCursorRange;
32use egui::{Color32, Event, FontId, Galley, Key, Pos2, Rect, TextFormat, Ui, Visuals, pos2, vec2};
33
34use crate::automation::{self, Details, Role};
35use crate::cache::{self, hash_of};
36use crate::hscroll::HScroll;
37use crate::tokens::{self, Tokens};
38use crate::virtual_list::{self, MAX_ROWS, RowHeights, VirtualList};
39
40#[derive(Clone, Debug, PartialEq)]
42pub struct SyntaxTheme {
43 colors: [Color32; Kind::ALL.len()],
44}
45
46fn kind_index(kind: Kind) -> usize {
47 Kind::ALL.iter().position(|k| *k == kind).unwrap_or(0)
48}
49
50impl SyntaxTheme {
51 pub fn from_tokens(t: &Tokens) -> SyntaxTheme {
54 let text = t.text.primary;
55 let weak = t.text.secondary;
56 let mix = |a: Color32, b: Color32, f: f32| a.lerp_to_gamma(b, f);
57 let mut theme = SyntaxTheme { colors: [text; Kind::ALL.len()] };
58 let pairs = [
59 (Kind::Plain, text),
60 (Kind::Keyword, t.accent),
61 (Kind::Type, mix(t.status.info, t.status.success, 0.5)),
62 (Kind::Function, t.status.info),
63 (Kind::String, t.status.success),
64 (Kind::Comment, mix(weak, t.text.disabled, 0.3)),
65 (Kind::Number, t.status.warning),
66 (Kind::Constant, mix(t.status.warning, t.status.danger, 0.4)),
67 (Kind::Punctuation, mix(text, weak, 0.6)),
68 (Kind::Operator, mix(text, t.accent, 0.35)),
69 (Kind::Attribute, mix(t.status.warning, t.accent, 0.4)),
70 (Kind::Property, mix(t.status.info, text, 0.3)),
71 (Kind::Variable, mix(t.status.danger, t.accent, 0.3)),
72 (Kind::Tag, t.status.danger),
73 (Kind::Heading, t.accent),
74 (Kind::Emphasis, mix(text, t.accent, 0.2)),
75 (Kind::Link, t.status.info),
76 (Kind::Inserted, t.status.success),
77 (Kind::Deleted, t.status.danger),
78 (Kind::Meta, weak),
79 ];
80 for (kind, color) in pairs {
81 theme.set(kind, color);
82 }
83 theme
84 }
85
86 pub fn color(&self, kind: Kind) -> Color32 {
88 self.colors.get(kind_index(kind)).copied().unwrap_or(Color32::PLACEHOLDER)
89 }
90
91 pub fn set(&mut self, kind: Kind, color: Color32) {
93 if let Some(c) = self.colors.get_mut(kind_index(kind)) {
94 *c = color;
95 }
96 }
97
98 pub fn revision(&self) -> u64 {
100 hash_of(&self.colors.map(|c| c.to_array()))
101 }
102}
103
104pub fn highlight_job(lang: Option<&str>, text: &str, font: FontId, theme: &SyntaxTheme) -> LayoutJob {
107 let spans = ashlar_syntax::highlight(lang, text);
108 job_from_spans(text, &spans, font, theme)
109}
110
111fn job_from_spans(text: &str, spans: &[ashlar_syntax::Span], font: FontId, theme: &SyntaxTheme) -> LayoutJob {
112 let sections = spans
113 .iter()
114 .map(|(range, kind)| LayoutSection {
115 leading_space: 0.0,
116 byte_range: ByteIndex(range.start)..ByteIndex(range.end),
117 format: TextFormat { font_id: font.clone(), color: theme.color(*kind), ..Default::default() },
118 })
119 .collect();
120 LayoutJob { text: text.to_owned(), sections, break_on_newline: true, ..Default::default() }
121}
122
123pub struct CodeViewResponse {
125 pub response: egui::Response,
127 pub selection: Option<Range<usize>>,
129 pub copied: bool,
131 pub clicked_line: Option<usize>,
133 pub visible: Range<usize>,
135}
136
137pub struct CodeView<'a> {
139 id: &'a str,
140 text: &'a str,
141 lang: Option<&'a str>,
142 revision: u64,
143 wrap: bool,
144 line_numbers: bool,
145 theme: Option<&'a SyntaxTheme>,
146 highlighter: &'a dyn Highlighter,
147 marks: &'a [Range<usize>],
148 scroll_to_line: Option<usize>,
149 max_height: Option<f32>,
150}
151
152impl<'a> CodeView<'a> {
153 pub fn new(id: &'a str, text: &'a str) -> Self {
154 CodeView {
155 id,
156 text,
157 lang: None,
158 revision: 0,
159 wrap: false,
160 line_numbers: true,
161 theme: None,
162 highlighter: &Builtin,
163 marks: &[],
164 scroll_to_line: None,
165 max_height: None,
166 }
167 }
168
169 pub fn lang(mut self, lang: Option<&'a str>) -> Self {
172 self.lang = lang;
173 self
174 }
175
176 pub fn lang_for_path(mut self, path: &str) -> Self {
178 self.lang = ashlar_syntax::lang_for_path(path);
179 self
180 }
181
182 pub fn revision(mut self, revision: u64) -> Self {
185 self.revision = revision;
186 self
187 }
188
189 pub fn wrap(mut self, wrap: bool) -> Self {
191 self.wrap = wrap;
192 self
193 }
194
195 pub fn line_numbers(mut self, yes: bool) -> Self {
197 self.line_numbers = yes;
198 self
199 }
200
201 pub fn theme(mut self, theme: &'a SyntaxTheme) -> Self {
203 self.theme = Some(theme);
204 self
205 }
206
207 pub fn highlighter(mut self, h: &'a dyn Highlighter) -> Self {
209 self.highlighter = h;
210 self
211 }
212
213 pub fn marks(mut self, marks: &'a [Range<usize>]) -> Self {
215 self.marks = marks;
216 self
217 }
218
219 pub fn scroll_to_line(mut self, line: Option<usize>) -> Self {
221 self.scroll_to_line = line;
222 self
223 }
224
225 pub fn max_height(mut self, h: f32) -> Self {
227 self.max_height = Some(h);
228 self
229 }
230
231 pub fn show(self, ui: &mut Ui) -> CodeViewResponse {
232 show(self, ui)
233 }
234}
235
236#[derive(Clone, Default)]
238struct Doc {
239 key: u64,
240 starts: Vec<usize>,
242 states: Vec<State>,
244 longest: usize,
246}
247
248impl Doc {
249 fn build(key: u64, text: &str) -> Doc {
250 let mut starts = Vec::with_capacity(text.len() / 32 + 2);
251 starts.push(0);
252 for (i, _) in text.match_indices('\n') {
253 if starts.len() > MAX_ROWS {
254 break;
255 }
256 starts.push(i + 1);
257 }
258 starts.push(text.len());
259 let longest = starts.windows(2).enumerate().max_by_key(|(_, w)| w.get(1).zip(w.first()).map_or(0, |(b, a)| b - a)).map_or(0, |(i, _)| i);
260 Doc { key, starts, states: vec![State::START], longest }
261 }
262}
263
264fn line_count(starts: &[usize]) -> usize {
265 starts.len().saturating_sub(1).max(1)
266}
267
268fn line_range(starts: &[usize], text: &str, i: usize) -> Range<usize> {
270 let s = starts.get(i).copied().unwrap_or(text.len()).min(text.len());
271 let e = starts.get(i + 1).copied().unwrap_or(text.len()).clamp(s, text.len());
272 let line = text.get(s..e).unwrap_or("");
273 let body = line.strip_suffix('\n').unwrap_or(line);
274 let body = body.strip_suffix('\r').unwrap_or(body);
275 s..s + body.len()
276}
277
278struct Lines<'d> {
280 starts: &'d [usize],
281 states: &'d mut Vec<State>,
282 text: &'d str,
283 lang: &'d str,
284 hl: &'d dyn Highlighter,
285}
286
287impl Lines<'_> {
288 fn state(&mut self, i: usize, scratch: &mut Vec<ashlar_syntax::Span>) -> State {
290 let target = i.min(line_count(self.starts));
291 if self.states.is_empty() {
292 self.states.push(State::START);
293 }
294 while self.states.len() <= target {
295 let k = self.states.len() - 1;
296 let prev = self.states.last().copied().unwrap_or_default();
297 let r = line_range(self.starts, self.text, k);
298 scratch.clear();
299 let next = self.hl.highlight_line(self.lang, self.text.get(r).unwrap_or(""), prev, scratch);
300 self.states.push(next);
301 }
302 self.states.get(target).copied().unwrap_or_default()
303 }
304}
305
306#[derive(Clone, Copy, Default)]
308struct Sel {
309 anchor: usize,
310 head: usize,
311 dragging: bool,
312}
313
314impl Sel {
315 fn range(&self) -> Option<Range<usize>> {
316 let (a, b) = (self.anchor.min(self.head), self.anchor.max(self.head));
317 (b > a).then_some(a..b)
318 }
319}
320
321fn short(line: &str) -> &str {
323 let mut end = line.len().min(256);
324 while end > 0 && !line.is_char_boundary(end) {
325 end -= 1;
326 }
327 line.get(..end).unwrap_or("")
328}
329
330fn char_index(line: &str, byte: usize) -> usize {
332 let mut b = byte.min(line.len());
333 while b > 0 && !line.is_char_boundary(b) {
334 b -= 1;
335 }
336 line.get(..b).map_or(0, |s| s.chars().count())
337}
338
339fn byte_index(line: &str, ci: usize) -> usize {
341 line.char_indices().nth(ci).map_or(line.len(), |(b, _)| b)
342}
343
344fn show(view: CodeView<'_>, ui: &mut Ui) -> CodeViewResponse {
345 let t = tokens::current(ui.ctx());
346 let base = ui.make_persistent_id(view.id);
347 let text = view.text;
348 let lang = view.lang.unwrap_or("");
349 let font = t.mono();
350 let (line_h, char_w) = ui.fonts_mut(|f| (f.row_height(&font), f.glyph_width(&font, '0')));
351 let (line_h, char_w) = (line_h.max(1.0), char_w.max(1.0));
352 let own_theme;
353 let theme = match view.theme {
354 Some(th) => th,
355 None => {
356 own_theme = SyntaxTheme::from_tokens(&t);
357 &own_theme
358 }
359 };
360
361 let doc_id = base.with("doc");
362 let key = hash_of(&(view.revision, text.as_ptr() as usize, text.len(), lang));
363 let mut doc = ui.data_mut(|d| d.remove_temp::<Doc>(doc_id)).filter(|d| d.key == key).unwrap_or_else(|| Doc::build(key, text));
364 let mut states = std::mem::take(&mut doc.states);
365 let lines = line_count(&doc.starts);
366
367 let pad = t.space.sm;
369 let digits = lines.to_string().len().max(2) as f32;
370 let gutter_w = if view.line_numbers { digits * char_w + 2.0 * pad } else { 0.0 };
371 let text_w = (ui.available_width() - gutter_w - 3.0 * pad - ui.spacing().scroll.bar_width).max(char_w);
372 let cols = ((text_w / char_w).floor() as usize).max(1);
373 let wrap_w = if view.wrap { cols as f32 * char_w + 0.5 } else { f32::INFINITY };
374 let style_rev = hash_of(&(theme.revision(), font.size.to_bits(), wrap_w.to_bits(), t.dark));
375
376 let sel_id = base.with("sel");
378 let mut sel: Sel = ui.data(|d| d.get_temp(sel_id)).unwrap_or_default();
379 let (pressed, down, pointer, shift) =
380 ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.interact_pos(), i.modifiers.shift));
381 let mut clicked_line = None;
382
383 let height_fn = |i: usize| {
384 let r = line_range(&doc.starts, text, i);
385 let chars = text.get(r).map_or(0, |s| s.chars().count());
386 chars.div_ceil(cols).max(1) as f32 * line_h
387 };
388 let heights = if view.wrap {
389 RowHeights::Variable { revision: hash_of(&(key, cols, line_h.to_bits())), height: &height_fn }
390 } else {
391 RowHeights::Uniform(line_h)
392 };
393 let longest = text.get(line_range(&doc.starts, text, doc.longest)).map_or(0, |l| l.chars().count());
395 let area = ui.available_rect_before_wrap();
396 let mut hs = (!view.wrap).then(|| HScroll::begin(ui, base.with("hx"), area, longest as f32 * char_w + pad, text_w));
397 let hx = hs.as_ref().map_or(0.0, |h| h.offset);
398 let mut list = VirtualList::new(view.id, lines, heights).scroll_to(view.scroll_to_line);
399 if let Some(h) = view.max_height {
400 list = list.max_height(h);
401 }
402
403 let sel_range = sel.range();
404 let mut sel_visuals: Option<Visuals> = None;
405 let mut mark_visuals: Option<Visuals> = None;
406 let mut scratch = Vec::new();
407 let mut known = Lines { starts: &doc.starts, states: &mut states, text, lang, hl: view.highlighter };
408 let kind =
409 virtual_list::Kind { role: Role::Document, rows: false, node: egui::accesskit::Role::Document, item: egui::accesskit::Role::Paragraph };
410 let out = virtual_list::show_rows(ui, base, &mut list, kind, None, |ui, row| {
411 let i = row.index;
412 let range = line_range(known.starts, text, i);
413 let line = text.get(range.clone()).unwrap_or("");
414 let state = known.state(i, &mut scratch);
415 let rev = hash_of(&(key, style_rev, state.to_bits()));
416 let mut galley: Arc<Galley> = cache::galley(ui, (base, "line", i), rev, || {
417 scratch.clear();
418 let _ = view.highlighter.highlight_line(lang, line, state, &mut scratch);
419 let mut job = job_from_spans(line, &scratch, font.clone(), theme);
420 job.wrap = TextWrapping { max_width: wrap_w, break_anywhere: true, ..Default::default() };
421 job
422 });
423 let rect = row.rect;
424 let origin = pos2(rect.left() + pad + gutter_w - hx, rect.top());
425 if let Some(h) = hs.as_mut() {
426 h.see(galley.size().x + 2.0 * pad);
427 }
428
429 if let Some(p) = pointer {
431 let offset_at = |p: Pos2| {
432 let local = (p - origin).max(vec2(0.0, 0.0));
433 range.start + byte_index(line, galley.cursor_from_pos(local).index.0)
434 };
435 if pressed && rect.contains(p) {
436 let off = offset_at(p);
437 if !shift {
438 sel.anchor = off;
439 }
440 sel.head = off;
441 sel.dragging = true;
442 clicked_line = Some(i);
443 ui.memory_mut(|m| m.request_focus(base));
444 } else if sel.dragging && down && p.y >= rect.top() && p.y < rect.bottom() {
445 sel.head = offset_at(p);
446 }
447 }
448
449 let first = view.marks.partition_point(|m| m.end <= range.start);
451 for m in view.marks.iter().skip(first).take_while(|m| m.start < range.end.max(range.start + 1)).take(256) {
452 let vis = mark_visuals.get_or_insert_with(|| {
453 let mut v = ui.visuals().clone();
454 v.selection.bg_fill = t.status.warning.gamma_multiply(0.35);
455 v
456 });
457 paint_range(&mut galley, vis, line, &range, m);
458 }
459 if let Some(s) = &sel_range {
460 let vis = sel_visuals.get_or_insert_with(|| {
461 let mut v = ui.visuals().clone();
462 v.selection.bg_fill = t.selection;
463 v
464 });
465 paint_range(&mut galley, vis, line, &range, s);
466 }
467
468 let painter = ui.painter();
469 if view.line_numbers {
470 let gutter = Rect::from_min_max(pos2(rect.left(), rect.top()), pos2(rect.left() + gutter_w, rect.bottom()));
471 painter.rect_filled(gutter, 0, t.surface.sunken);
472 let n = i + 1;
473 let num = cache::galley(ui, (base, "ln", n), hash_of(&(font.size.to_bits(), t.dark)), || {
474 LayoutJob::simple_singleline(n.to_string(), font.clone(), t.text.disabled)
475 });
476 painter.galley(pos2(gutter.right() - pad - num.size().x, rect.top()), num, t.text.disabled);
477 }
478 let text_clip = Rect::from_min_max(pos2(rect.left() + gutter_w, rect.top()), rect.max).intersect(ui.clip_rect());
479 painter.with_clip_rect(text_clip).galley(origin, galley, theme.color(Kind::Plain));
480
481 let label = short(line);
482 let row_id = base.with(("row", i));
483 ui.ctx().accesskit_node_builder(row_id, |node| {
484 node.set_role(egui::accesskit::Role::Paragraph);
485 node.set_label(label);
486 });
487 automation::register_fmt(ui, rect, format_args!("{}.line.{}", view.id, i + 1), label, Role::Label, true, Details::default());
488 });
489 doc.states = states;
490 if let Some(h) = hs {
491 let r = out.response.rect;
492 h.end(ui, Rect::from_min_max(pos2(r.left() + gutter_w, r.top()), pos2(r.right() - ui.spacing().scroll.bar_width, r.bottom())), &t);
493 }
494
495 let focused = out.response.has_focus();
497 let mut copied = false;
498 if focused {
499 let (all, copy) = ui.input(|i| {
500 let cmd = i.modifiers.command;
501 (cmd && i.key_pressed(Key::A), i.events.iter().any(|e| matches!(e, Event::Copy)) || (cmd && i.key_pressed(Key::C)))
502 });
503 if all {
504 sel = Sel { anchor: 0, head: text.len(), dragging: false };
505 }
506 if copy && let Some(s) = sel.range().and_then(|r| text.get(r)) {
507 ui.ctx().copy_text(s.to_owned());
508 copied = true;
509 }
510 }
511 if !down {
512 sel.dragging = false;
513 }
514 let selection = sel.range();
515 ui.data_mut(|d| {
516 d.insert_temp(sel_id, sel);
517 d.insert_temp(doc_id, doc);
518 });
519 ui.ctx().accesskit_node_builder(out.response.id, |node| {
520 node.set_role(egui::accesskit::Role::Document);
521 node.set_read_only();
523 });
524 CodeViewResponse { response: out.response, selection, copied, clicked_line, visible: out.visible }
525}
526
527fn paint_range(galley: &mut Arc<Galley>, vis: &Visuals, line: &str, line_range: &Range<usize>, r: &Range<usize>) {
529 let from = r.start.max(line_range.start).saturating_sub(line_range.start);
530 let to = r.end.min(line_range.end).saturating_sub(line_range.start);
531 if to <= from {
532 return;
533 }
534 let range = CCursorRange::two(CCursor::new(char_index(line, from)), CCursor::new(char_index(line, to)));
535 egui::text_selection::visuals::paint_text_selection(galley, vis, &range, None);
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn line_index_and_ranges() {
544 let text = "a\r\nbc\n\nlast";
545 let d = Doc::build(1, text);
546 assert_eq!(line_count(&d.starts), 4);
547 assert_eq!(line_range(&d.starts, text, 0), 0..1);
548 assert_eq!(line_range(&d.starts, text, 1), 3..5);
549 assert_eq!(line_range(&d.starts, text, 2), 6..6);
550 assert_eq!(line_range(&d.starts, text, 3), 7..11);
551 assert_eq!(line_range(&d.starts, text, 99), 11..11);
552 assert_eq!(line_count(&Doc::build(1, "x\n").starts), 2);
553 assert_eq!(line_count(&Doc::build(1, "").starts), 1);
554 }
555
556 #[test]
557 fn char_and_byte_indices_round_trip() {
558 let s = "aé😀b";
559 assert_eq!(char_index(s, 3), 2);
560 assert_eq!(char_index(s, 4), 2); assert_eq!(byte_index(s, 3), 7);
562 assert_eq!(byte_index(s, 99), s.len());
563 }
564
565 #[test]
566 fn states_are_lazy_and_carry_comments() {
567 let text = "/* a\nb */\nfn x";
568 let mut d = Doc::build(1, text);
569 let mut scratch = Vec::new();
570 assert_eq!(d.states.len(), 1);
571 let mut lines = Lines { starts: &d.starts, states: &mut d.states, text, lang: "rust", hl: &Builtin };
572 let s1 = lines.state(1, &mut scratch);
573 assert_ne!(s1, State::START);
574 assert_eq!(lines.state(2, &mut scratch), State::START);
575 assert_eq!(d.states.len(), 3);
576 }
577
578 #[test]
579 fn theme_from_tokens_differs_by_kind() {
580 let th = SyntaxTheme::from_tokens(&Tokens::dark());
581 assert_ne!(th.color(Kind::Keyword), th.color(Kind::Plain));
582 assert_ne!(th.color(Kind::String), th.color(Kind::Comment));
583 let mut th2 = th.clone();
584 th2.set(Kind::Keyword, Color32::RED);
585 assert_eq!(th2.color(Kind::Keyword), Color32::RED);
586 assert_ne!(th.revision(), th2.revision());
587 }
588}