1use std::fmt::Debug;
2use std::hash::Hash;
3use std::ops::Range;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use egui::text::{CCursor, CCursorRange, LayoutJob, TextFormat};
8use egui::text_edit::TextEditState;
9use egui::{
10 Align, Align2, Frame, Galley, Id, InputState, Key, KeyboardShortcut, Label, Layout, Margin,
11 Modifiers, Pos2, Rect, RichText, ScrollArea, Sense, Shape, Stroke, TextBuffer, TextEdit, Ui,
12 Vec2, pos2,
13};
14use pfx_editor_doc::{Changed, Disk, File, Files, GAP, History, TextSplice, toggle_comment};
15use pfx_editor_style::Theme;
16use pfx_editor_style::widgets::KeyCap;
17
18use crate::compare::{Basis, Compare};
19use crate::diag::{self, Check, Diagnostic, Severity};
20use crate::diff::Diff;
21use crate::host::{Git, Head, NO_EDITOR, Opener, Process};
22use crate::job::JobCache;
23use crate::lex::Lexed;
24use crate::table::{Table, TableCache};
25
26pub const DEBOUNCE: f64 = 0.15;
27pub const PROBLEM_ROWS: usize = 5;
28pub const UNDERNEATH: &str = "the file changed underneath";
29pub const THEIRS: &str = "take theirs";
30pub const MINE: &str = "keep mine";
31pub const RELOAD: &str = "reload";
32const TOLERANCE: f64 = 1e-9;
33const GUTTER_PAD: f32 = 8.0;
34const WAVE: f32 = 2.0;
35const PROBLEM_GAP: f32 = 12.0;
36
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub enum Notice {
39 Underneath,
40 Refused(String),
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44enum Chord {
45 Undo,
46 Redo,
47 Comment,
48 Open,
49}
50
51pub struct Source {
52 id: Id,
53 file: PathBuf,
54 text: String,
55 base: String,
56 seen: Option<u64>,
57 editable: bool,
58 cache: JobCache,
59 edited: Option<f64>,
60 started: Option<Range<usize>>,
61 selection: Range<usize>,
62 chars: Option<(usize, usize)>,
63 place: bool,
64 notice: Option<Notice>,
65 table: Option<Table>,
66 tables: TableCache,
67 reported: Option<usize>,
68 problems: Vec<Diagnostic>,
69 spans: Vec<Option<Range<usize>>>,
70 mark: Option<Range<usize>>,
71 scroll_to: Option<usize>,
72 jump: Option<usize>,
73 rows: Vec<Rect>,
74 underlines: Vec<Rect>,
75 choices: Vec<Rect>,
76 compare: Compare,
77 head: Box<dyn Head>,
78 opener: Box<dyn Opener>,
79 open_key: Option<KeyboardShortcut>,
80 toolbar: bool,
81 bar: Vec<Rect>,
82}
83
84fn floor(text: &str, byte: usize) -> usize {
85 let mut byte = byte.min(text.len());
86 while !text.is_char_boundary(byte) {
87 byte -= 1;
88 }
89 byte
90}
91
92fn char_index(text: &str, byte: usize) -> usize {
93 text[..floor(text, byte)].chars().count()
94}
95
96fn byte_index(text: &str, char: usize) -> usize {
97 text.char_indices()
98 .nth(char)
99 .map_or(text.len(), |(at, _)| at)
100}
101
102fn shifted(before: &str, after: &str, selection: Range<usize>) -> Range<usize> {
103 let Some(splice) = TextSplice::between("", before, after) else {
104 return floor(after, selection.start)..floor(after, selection.end);
105 };
106 let (start, end, new) = (splice.range.start, splice.range.end, splice.new.len());
107 let shift = |byte: usize| {
108 let moved = if byte <= start {
109 byte
110 } else if byte >= end {
111 byte - end + start + new
112 } else {
113 start + (byte - start).min(new)
114 };
115 floor(after, moved)
116 };
117 shift(selection.start)..shift(selection.end)
118}
119
120fn chord(input: &mut InputState, open: Option<KeyboardShortcut>) -> Option<Chord> {
121 if let Some(open) = open
122 && input.consume_shortcut(&open)
123 {
124 return Some(Chord::Open);
125 }
126 let redo = KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
127 if input.consume_shortcut(&redo) || input.consume_key(Modifiers::COMMAND, Key::Y) {
128 Some(Chord::Redo)
129 } else if input.consume_key(Modifiers::COMMAND, Key::Z) {
130 Some(Chord::Undo)
131 } else if input.consume_key(Modifiers::COMMAND, Key::Slash) {
132 Some(Chord::Comment)
133 } else {
134 None
135 }
136}
137
138fn wave(from: Pos2, to: f32, color: egui::Color32) -> Shape {
139 let mut points = vec![from];
140 let mut x = from.x;
141 let mut up = true;
142 while x < to {
143 x = (x + WAVE).min(to);
144 points.push(pos2(x, from.y + if up { -WAVE / 2.0 } else { 0.0 }));
145 up = !up;
146 }
147 Shape::line(points, Stroke::new(1.0_f32, color))
148}
149
150struct Drawn<'a> {
151 galley: &'a Arc<Galley>,
152 at: Vec2,
153}
154
155impl Drawn<'_> {
156 fn cursor(&self, text: &str, byte: usize) -> (usize, f32) {
157 let cursor = CCursor::new(char_index(text, byte));
158 let row = self.galley.layout_from_cursor(cursor).row;
159 (row, self.galley.pos_from_cursor(cursor).min.x + self.at.x)
160 }
161
162 fn row(&self, row: usize) -> Rect {
163 self.galley.rows[row].rect().translate(self.at)
164 }
165
166 fn spans(&self, text: &str, span: &Range<usize>) -> Vec<Rect> {
167 let (first, left) = self.cursor(text, span.start);
168 let (last, right) = self.cursor(text, span.end);
169 (first..=last.min(self.galley.rows.len().saturating_sub(1)))
170 .map(|row| {
171 let rect = self.row(row);
172 let x0 = if row == first { left } else { rect.min.x };
173 let x1 = if row == last { right } else { rect.max.x };
174 Rect::from_x_y_ranges(x0..=x1.max(x0), rect.y_range())
175 })
176 .collect()
177 }
178}
179
180fn shown_path(file: &Path, own: &Path) -> String {
181 let folder = own.parent().unwrap_or(Path::new(""));
182 match file.strip_prefix(folder) {
183 Ok(relative) if !folder.as_os_str().is_empty() => relative.display().to_string(),
184 _ => file
185 .file_name()
186 .map(|name| name.to_string_lossy().into_owned())
187 .unwrap_or_else(|| file.display().to_string()),
188 }
189}
190
191impl Source {
192 pub fn new(id_salt: impl Hash + Debug, file: impl Into<PathBuf>) -> Source {
193 Source {
194 id: Id::new(id_salt),
195 file: file.into(),
196 text: String::new(),
197 base: String::new(),
198 seen: None,
199 editable: true,
200 cache: JobCache::default(),
201 edited: None,
202 started: None,
203 selection: 0..0,
204 chars: None,
205 place: false,
206 notice: None,
207 table: None,
208 tables: TableCache::default(),
209 reported: None,
210 problems: Vec::new(),
211 spans: Vec::new(),
212 mark: None,
213 scroll_to: None,
214 jump: None,
215 rows: Vec::new(),
216 underlines: Vec::new(),
217 choices: Vec::new(),
218 compare: Compare::default(),
219 head: Box::new(Git),
220 opener: Box::new(Process),
221 open_key: None,
222 toolbar: true,
223 bar: Vec::new(),
224 }
225 }
226
227 pub fn with_head(mut self, head: impl Head + 'static) -> Source {
228 self.head = Box::new(head);
229 self
230 }
231
232 pub fn with_opener(mut self, opener: impl Opener + 'static) -> Source {
233 self.opener = Box::new(opener);
234 self
235 }
236
237 pub fn open_shortcut(mut self, shortcut: Option<KeyboardShortcut>) -> Source {
238 self.open_key = shortcut;
239 self
240 }
241
242 pub fn toolbar(mut self, toolbar: bool) -> Source {
243 self.toolbar = toolbar;
244 self
245 }
246
247 pub fn editable(mut self, editable: bool) -> Source {
248 self.editable = editable;
249 self
250 }
251
252 pub fn set_editable(&mut self, editable: bool) {
253 self.editable = editable;
254 }
255
256 pub fn id(&self) -> Id {
257 self.id.with("text")
258 }
259
260 pub fn file(&self) -> &Path {
261 &self.file
262 }
263
264 pub fn text(&self) -> &str {
265 &self.text
266 }
267
268 pub fn base(&self) -> &str {
269 &self.base
270 }
271
272 pub fn unapplied(&self) -> bool {
273 self.text != self.base
274 }
275
276 pub fn problems(&self) -> &[Diagnostic] {
277 &self.problems
278 }
279
280 pub fn pending(&self) -> bool {
281 self.edited.is_some()
282 }
283
284 pub fn notice(&self) -> Option<&Notice> {
285 self.notice.as_ref()
286 }
287
288 pub fn cache(&self) -> &JobCache {
289 &self.cache
290 }
291
292 pub fn mark(&self) -> Option<Range<usize>> {
293 self.mark.clone()
294 }
295
296 pub fn selection(&self) -> Range<usize> {
297 self.selection.clone()
298 }
299
300 pub fn caret(&self) -> usize {
301 self.selection.end
302 }
303
304 pub fn choices(&self) -> &[Rect] {
305 &self.choices
306 }
307
308 pub fn table(&self) -> Option<&Table> {
309 self.table.as_ref()
310 }
311
312 pub fn bar(&self) -> &[Rect] {
313 &self.bar
314 }
315
316 pub fn diff_on(&self) -> bool {
317 self.compare.on
318 }
319
320 pub fn basis(&self) -> Basis {
321 self.compare.basis
322 }
323
324 pub fn diffs(&self) -> usize {
325 self.compare.computed()
326 }
327
328 pub fn reverts(&self) -> Vec<Rect> {
329 self.compare.reverts.iter().map(|(_, rect)| *rect).collect()
330 }
331
332 pub fn head_note(&self) -> Option<String> {
333 self.compare.head_note()
334 }
335
336 pub fn set_diff(&mut self, on: bool) {
337 self.compare.on = on;
338 if on && !self.compare.probed() {
339 self.refresh_head();
340 }
341 }
342
343 pub fn set_basis(&mut self, basis: Basis) {
344 self.compare.basis = basis;
345 if basis == Basis::Head && !self.compare.probed() {
346 self.refresh_head();
347 }
348 }
349
350 pub fn refresh_head(&mut self) {
351 self.compare.probe(self.head.as_ref(), &self.file);
352 }
353
354 pub fn diff(&mut self) -> Option<&Diff> {
355 self.compare.refresh(&self.text)
356 }
357
358 pub fn editor_command(&self) -> Option<String> {
359 self.opener.command()
360 }
361
362 pub fn open_note(&self) -> Option<&'static str> {
363 self.opener.command().is_none().then_some(NO_EDITOR)
364 }
365
366 pub fn open_in_editor(&mut self) -> bool {
367 let Some(command) = self.opener.command() else {
368 self.refused(NO_EDITOR);
369 return false;
370 };
371 match self.opener.open(&command, &self.file) {
372 Ok(()) => true,
373 Err(error) => {
374 self.refused(format!("cannot start {command}: {error}"));
375 false
376 }
377 }
378 }
379
380 pub fn select(&mut self, selection: Range<usize>) {
381 self.selection = floor(&self.text, selection.start)..floor(&self.text, selection.end);
382 self.place = true;
383 }
384
385 fn set_text(&mut self, text: String) {
386 self.text = text;
387 self.chars = None;
388 self.selection =
389 floor(&self.text, self.selection.start)..floor(&self.text, self.selection.end);
390 }
391
392 fn respan(&mut self) {
393 self.spans = self
394 .problems
395 .iter()
396 .map(|problem| (problem.file == self.file).then(|| problem.span(&self.text)))
397 .collect();
398 }
399
400 fn recheck(&mut self, file: &File, check: &dyn Check) {
401 self.problems = match (file.doc(), file.error()) {
402 (Some(doc), _) => check.check(&self.file, file.text(), doc),
403 (None, Some(error)) => vec![Diagnostic::parse(&self.file, file.text(), error)],
404 (None, None) => Vec::new(),
405 };
406 self.respan();
407 }
408
409 fn adopt(&mut self, file: &File, check: &dyn Check) {
410 if self.text != file.text() {
411 let selection = shifted(&self.text, file.text(), self.selection.clone());
412 self.set_text(file.text().to_string());
413 self.selection = selection;
414 self.place = true;
415 }
416 self.base.clone_from(&self.text);
417 self.seen = Some(file.generation());
418 self.edited = None;
419 self.started = None;
420 if self.notice == Some(Notice::Underneath) {
421 self.notice = None;
422 }
423 self.recheck(file, check);
424 }
425
426 pub fn sync(&mut self, files: &Files, check: &dyn Check) -> bool {
427 let Some(file) = files.get(&self.file) else {
428 return false;
429 };
430 if self.seen != Some(file.generation()) || self.compare.on {
431 self.compare.note_saved(file, &self.file);
432 }
433 if self.seen == Some(file.generation()) {
434 return false;
435 }
436 if self.seen.is_some() && self.unapplied() && self.text != file.text() {
437 self.notice = Some(Notice::Underneath);
438 return false;
439 }
440 self.adopt(file, check);
441 true
442 }
443
444 pub fn take_theirs(&mut self, files: &Files, check: &dyn Check) {
445 if let Some(file) = files.get(&self.file) {
446 self.adopt(file, check);
447 }
448 self.notice = None;
449 }
450
451 pub fn keep_mine(&mut self, files: &Files, now: f64) {
452 if let Some(file) = files.get(&self.file) {
453 self.base = file.text().to_string();
454 self.seen = Some(file.generation());
455 if self.unapplied() {
456 self.edited = Some(now);
457 }
458 }
459 self.notice = None;
460 }
461
462 pub fn edit(&mut self, text: impl Into<String>, now: f64) {
463 let text = text.into();
464 if text != self.text {
465 self.started.get_or_insert(self.selection.clone());
466 self.set_text(text);
467 self.edited = Some(now);
468 }
469 }
470
471 pub fn tick(
472 &mut self,
473 now: f64,
474 files: &mut Files,
475 history: &mut History,
476 check: &dyn Check,
477 ) -> Option<Changed> {
478 let at = self.edited?;
479 if now - at + TOLERANCE < DEBOUNCE {
480 return None;
481 }
482 self.edited = None;
483 self.commit(now, files, history, check)
484 }
485
486 fn refused(&mut self, error: impl ToString) {
487 self.notice = Some(Notice::Refused(error.to_string()));
488 }
489
490 fn commit(
491 &mut self,
492 now: f64,
493 files: &mut Files,
494 history: &mut History,
495 check: &dyn Check,
496 ) -> Option<Changed> {
497 let (problems, clean) = match diag::parse(&self.file, &self.text) {
498 Err(error) => (vec![*error], false),
499 Ok(doc) => {
500 let problems = check.check(&self.file, &self.text, &doc);
501 let clean = problems
502 .iter()
503 .all(|problem| problem.severity != Severity::Error);
504 (problems, clean)
505 }
506 };
507 self.problems = problems;
508 self.respan();
509 if !clean || !self.unapplied() || self.notice == Some(Notice::Underneath) {
510 return None;
511 }
512 if files.generation_of(&self.file) != self.seen {
513 self.notice = Some(Notice::Underneath);
514 return None;
515 }
516 let before = self.started.clone().unwrap_or(self.selection.clone());
517 let splice = TextSplice::between(&self.file, &self.base, &self.text)?
518 .with_carets(before, self.selection.clone());
519 match history.typed(files, splice, now, GAP) {
520 Ok(changed) => {
521 self.base.clone_from(&self.text);
522 self.seen = files.generation_of(&self.file);
523 self.started = None;
524 self.notice = None;
525 Some(changed)
526 }
527 Err(error) => {
528 self.refused(error);
529 None
530 }
531 }
532 }
533
534 fn follow(&mut self, files: &Files, check: &dyn Check, changed: &Changed) {
535 if let Some(file) = files.get(&self.file) {
536 self.adopt(file, check);
537 }
538 if let Some(caret) = &changed.caret
539 && caret.file == self.file
540 {
541 self.select(caret.selection.clone());
542 }
543 if matches!(self.notice, Some(Notice::Refused(_))) {
544 self.notice = None;
545 }
546 }
547
548 fn revert(&mut self, files: &Files, check: &dyn Check) {
549 let back = self.started.take();
550 if let Some(file) = files.get(&self.file) {
551 self.adopt(file, check);
552 }
553 if let Some(back) = back {
554 self.select(back);
555 }
556 }
557
558 pub fn undo(
559 &mut self,
560 now: f64,
561 files: &mut Files,
562 history: &mut History,
563 check: &dyn Check,
564 ) -> Option<Changed> {
565 self.step(true, now, files, history, check)
566 }
567
568 pub fn redo(
569 &mut self,
570 now: f64,
571 files: &mut Files,
572 history: &mut History,
573 check: &dyn Check,
574 ) -> Option<Changed> {
575 self.step(false, now, files, history, check)
576 }
577
578 fn step(
579 &mut self,
580 undo: bool,
581 now: f64,
582 files: &mut Files,
583 history: &mut History,
584 check: &dyn Check,
585 ) -> Option<Changed> {
586 if self.notice == Some(Notice::Underneath) {
587 return None;
588 }
589 self.edited = None;
590 if self.unapplied() && self.commit(now, files, history, check).is_none() {
591 if undo && self.notice != Some(Notice::Underneath) {
592 self.revert(files, check);
593 }
594 return None;
595 }
596 let stepped = if undo {
597 history.undo(files)
598 } else {
599 history.redo(files)
600 };
601 match stepped {
602 Ok(Some(changed)) => {
603 self.follow(files, check, &changed);
604 Some(changed)
605 }
606 Ok(None) => None,
607 Err(error) => {
608 self.refused(error);
609 None
610 }
611 }
612 }
613
614 pub fn toggle_comment(
615 &mut self,
616 now: f64,
617 files: &mut Files,
618 history: &mut History,
619 check: &dyn Check,
620 ) -> Option<Changed> {
621 if self.notice == Some(Notice::Underneath) {
622 return None;
623 }
624 self.edited = None;
625 if self.unapplied() {
626 self.commit(now, files, history, check);
627 if self.notice == Some(Notice::Underneath) {
628 return None;
629 }
630 }
631 let splice = toggle_comment(&self.file, &self.text, self.selection.clone())?;
632 if self.unapplied() {
633 let mut text = self.text.clone();
634 text.replace_range(splice.range.clone(), &splice.new);
635 self.started.get_or_insert(self.selection.clone());
636 self.set_text(text);
637 self.select(splice.caret_after);
638 self.edited = Some(now);
639 return None;
640 }
641 match history.apply(files, splice) {
642 Ok(changed) => {
643 self.follow(files, check, &changed);
644 Some(changed)
645 }
646 Err(error) => {
647 self.refused(error);
648 None
649 }
650 }
651 }
652
653 pub fn revert_hunk(
654 &mut self,
655 index: usize,
656 now: f64,
657 files: &mut Files,
658 history: &mut History,
659 check: &dyn Check,
660 ) -> Option<Changed> {
661 if self.notice == Some(Notice::Underneath) {
662 return None;
663 }
664 self.edited = None;
665 if self.unapplied() {
666 self.commit(now, files, history, check);
667 if self.notice == Some(Notice::Underneath) {
668 return None;
669 }
670 }
671 self.compare.refresh(&self.text)?;
672 let hunk = self.compare.hunk(index)?;
673 let base = self.compare.base_of()?;
674 let splice = hunk.revert(&self.file, base, &self.text);
675 if self.unapplied() {
676 let mut text = self.text.clone();
677 text.replace_range(splice.range.clone(), &splice.new);
678 self.started.get_or_insert(self.selection.clone());
679 self.set_text(text);
680 self.select(splice.caret_after);
681 self.edited = Some(now);
682 return None;
683 }
684 match history.apply(files, splice) {
685 Ok(changed) => {
686 self.follow(files, check, &changed);
687 Some(changed)
688 }
689 Err(error) => {
690 self.refused(error);
691 None
692 }
693 }
694 }
695
696 pub fn reload(
697 &mut self,
698 files: &mut Files,
699 history: &mut History,
700 check: &dyn Check,
701 ) -> Option<Changed> {
702 let Ok(Disk::Outside(disk)) = files.disk(&self.file) else {
703 return None;
704 };
705 let current = files.text(&self.file)?;
706 let changed = match TextSplice::between(&self.file, current, &disk) {
707 Some(splice) => match history.apply(files, splice.labelled(RELOAD)) {
708 Ok(changed) => Some(changed),
709 Err(error) => {
710 self.refused(error);
711 return None;
712 }
713 },
714 None => None,
715 };
716 if let Err(error) = files.reload(&self.file, disk) {
717 self.refused(error);
718 }
719 self.sync(files, check);
720 changed
721 }
722
723 pub fn reveal(&mut self, range: Range<usize>) {
724 let start = floor(&self.text, range.start);
725 let end = floor(&self.text, range.end).max(start);
726 self.mark = Some(start..end);
727 self.scroll_to = Some(start);
728 }
729
730 pub fn clear_mark(&mut self) {
731 self.mark = None;
732 }
733
734 pub fn jump(&mut self, byte: usize) {
735 self.jump = Some(byte);
736 }
737
738 pub fn show(
739 &mut self,
740 ui: &mut Ui,
741 files: &mut Files,
742 history: &mut History,
743 check: &dyn Check,
744 select: &mut dyn FnMut(usize, &Table),
745 ) -> Option<Changed> {
746 let now = ui.input(|input| input.time);
747 let theme = Theme::of(ui.ctx());
748 self.sync(files, check);
749 let mut changed = self.tick(now, files, history, check);
750 let live = ui.memory(|memory| memory.has_focus(self.id()))
751 || (self.compare.on && ui.ui_contains_pointer());
752 if live {
753 let open = self.open_key;
754 let editable = self.editable;
755 let done = match ui.input_mut(|input| chord(input, open)) {
756 Some(Chord::Undo) if editable => self.undo(now, files, history, check),
757 Some(Chord::Redo) if editable => self.redo(now, files, history, check),
758 Some(Chord::Comment) if editable => self.toggle_comment(now, files, history, check),
759 Some(Chord::Open) => {
760 self.open_in_editor();
761 None
762 }
763 _ => None,
764 };
765 changed = done.or(changed);
766 }
767 self.choices.clear();
768 if self.toolbar {
769 self.tools(ui, &theme);
770 }
771 if self.notice.is_some() {
772 self.notice_row(ui, &theme, files, check, now);
773 }
774 let listed = self.problems.len().min(PROBLEM_ROWS);
775 let list_height = if listed == 0 {
776 0.0
777 } else {
778 listed as f32 * theme.row_height + theme.item_spacing
779 };
780 let text_height = (ui.available_height() - list_height).max(theme.row_height * 3.0);
781 let background = theme.well;
782 let before = self.selection.clone();
783 let mut reverted = None;
784 let typed = egui::Frame::new()
785 .fill(background)
786 .show(ui, |ui| {
787 if self.compare.on {
788 reverted = self.compare.paint(ui, &theme, &self.text, text_height);
789 return false;
790 }
791 ScrollArea::both()
792 .id_salt(self.id.with("scroll"))
793 .auto_shrink([false, false])
794 .max_height(text_height)
795 .show(ui, |ui| self.editor(ui, &theme))
796 .inner
797 })
798 .inner;
799 if let Some(hunk) = reverted {
800 let done = self.revert_hunk(hunk, now, files, history, check);
801 changed = done.or(changed);
802 ui.ctx().request_repaint();
803 }
804 if typed {
805 self.started.get_or_insert(before);
806 self.edited = Some(now);
807 }
808 let focused = ui.memory(|memory| memory.has_focus(self.id()));
809 self.report(focused, select);
810 if let Some(at) = self.edited {
811 ui.ctx()
812 .request_repaint_after_secs((at + DEBOUNCE - now).max(0.0) as f32);
813 }
814 self.rows.clear();
815 if listed > 0 {
816 self.list(ui, list_height, &theme);
817 }
818 changed
819 }
820
821 fn tools(&mut self, ui: &mut Ui, theme: &Theme) {
822 let mut rects = Vec::new();
823 let (mut toggle, mut saved, mut head, mut open) = (false, false, false, false);
824 let has_head = self.compare.has_head();
825 let reason = self.compare.head_note();
826 let note = self.open_note();
827 ui.horizontal(|ui| {
828 let button = ui
829 .add(KeyCap::new("diff").button().lit(self.compare.on))
830 .on_hover_text("show the buffer against the saved file or git HEAD");
831 rects.push(button.rect);
832 toggle = button.clicked();
833 if self.compare.on {
834 let button = ui.add(
835 KeyCap::new("saved")
836 .button()
837 .lit(self.compare.basis == Basis::Saved),
838 );
839 rects.push(button.rect);
840 saved = button.clicked();
841 let label = if self.compare.probed() && !has_head {
842 "no HEAD"
843 } else {
844 "HEAD"
845 };
846 let button = ui
847 .add_enabled(
848 has_head,
849 KeyCap::new(label)
850 .button()
851 .lit(self.compare.basis == Basis::Head),
852 )
853 .on_disabled_hover_text(reason.unwrap_or_default());
854 rects.push(button.rect);
855 head = button.clicked();
856 }
857 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
858 let button = ui
859 .add_enabled(note.is_none(), KeyCap::new("open in editor").button())
860 .on_hover_text("open the file in $VISUAL, else $EDITOR")
861 .on_disabled_hover_text(note.unwrap_or_default());
862 rects.push(button.rect);
863 open = button.clicked();
864 });
865 });
866 ui.add_space(theme.item_spacing);
867 self.bar = rects;
868 if toggle {
869 self.set_diff(!self.compare.on);
870 }
871 if saved {
872 self.set_basis(Basis::Saved);
873 }
874 if head {
875 self.set_basis(Basis::Head);
876 }
877 if open {
878 self.open_in_editor();
879 }
880 if toggle || saved || head {
881 ui.ctx().request_repaint();
882 }
883 }
884
885 fn report(&mut self, focused: bool, select: &mut dyn FnMut(usize, &Table)) {
886 let caret = self.caret();
887 if self.reported == Some(caret) {
888 return;
889 }
890 let fresh;
891 let lexed = if self.cache.lexed().text() == self.text {
892 self.cache.lexed()
893 } else {
894 fresh = Lexed::new(&self.text);
895 &fresh
896 };
897 let table = self.tables.at(lexed, caret);
898 let moved = match (&self.table, &table) {
899 (Some(was), Some(now)) => !was.same(now),
900 (None, None) => false,
901 _ => true,
902 };
903 if moved
904 && focused
905 && self.reported.is_some()
906 && let Some(table) = &table
907 {
908 select(caret, table);
909 }
910 self.reported = Some(caret);
911 self.table = table;
912 }
913
914 fn notice_row(
915 &mut self,
916 ui: &mut Ui,
917 theme: &Theme,
918 files: &Files,
919 check: &dyn Check,
920 now: f64,
921 ) {
922 let Some(notice) = self.notice.clone() else {
923 return;
924 };
925 let code = theme.font(theme.size_code);
926 let (message, color, choose) = match ¬ice {
927 Notice::Underneath => (UNDERNEATH, theme.warning, true),
928 Notice::Refused(message) => (message.as_str(), theme.error, false),
929 };
930 let mut choice = None;
931 let mut choices = Vec::new();
932 ui.horizontal(|ui| {
933 ui.add(Label::new(
934 RichText::new(message).font(code.clone()).color(color),
935 ));
936 if choose {
937 ui.add_space(PROBLEM_GAP);
938 let theirs = ui.add(KeyCap::new(THEIRS).button());
939 ui.add_space(theme.item_spacing);
940 let mine = ui.add(KeyCap::new(MINE).button());
941 choices = vec![theirs.rect, mine.rect];
942 if theirs.clicked() {
943 choice = Some(true);
944 } else if mine.clicked() {
945 choice = Some(false);
946 }
947 }
948 });
949 ui.add_space(theme.item_spacing);
950 self.choices = choices;
951 match choice {
952 Some(true) => self.take_theirs(files, check),
953 Some(false) => self.keep_mine(files, now),
954 None => return,
955 }
956 ui.ctx().request_repaint();
957 }
958
959 fn editor(&mut self, ui: &mut Ui, theme: &Theme) -> bool {
960 let code = theme.font(theme.size_code);
961 let text_id = self.id();
962 let ctx = ui.ctx().clone();
963 if let Some(byte) = self.jump.take() {
964 let byte = floor(&self.text, byte);
965 self.selection = byte..byte;
966 self.place = true;
967 ctx.memory_mut(|memory| memory.request_focus(text_id));
968 self.scroll_to = Some(byte);
969 }
970 if std::mem::take(&mut self.place) {
971 let mut state = TextEditState::load(&ctx, text_id).unwrap_or_default();
972 let anchor = CCursor::new(char_index(&self.text, self.selection.start));
973 let cursor = CCursor::new(char_index(&self.text, self.selection.end));
974 state
975 .cursor
976 .set_char_range(Some(CCursorRange::two(anchor, cursor)));
977 state.store(&ctx, text_id);
978 self.chars = None;
979 }
980 let lines = self.cache.lexed().lines().len().max(1);
981 let digits = lines.to_string().len().max(2);
982 let digit = ui.fonts_mut(|fonts| fonts.glyph_width(&code, '0'));
983 let gutter = digits as f32 * digit + 2.0 * GUTTER_PAD;
984 ui.horizontal_top(|ui| {
985 ui.spacing_mut().item_spacing = Vec2::ZERO;
986 let left = ui.cursor().min.x;
987 ui.add_space(gutter);
988 let behind = ui.painter().add(Shape::Noop);
989 ui.visuals_mut().selection.bg_fill = theme.accent;
990 let Source {
991 text,
992 cache,
993 editable,
994 ..
995 } = self;
996 let mut layouter = |ui: &Ui, buffer: &dyn TextBuffer, _: f32| {
997 let pixels_per_point = ui.ctx().pixels_per_point();
998 ui.fonts_mut(|fonts| {
999 cache.galley(fonts, pixels_per_point, buffer.as_str(), theme, &code)
1000 })
1001 };
1002 let mut view;
1003 let buffer: &mut dyn TextBuffer = if *editable {
1004 text
1005 } else {
1006 view = text.as_str();
1007 &mut view
1008 };
1009 let output = TextEdit::multiline(buffer)
1010 .id(text_id)
1011 .code_editor()
1012 .frame(Frame::NONE)
1013 .desired_width(f32::INFINITY)
1014 .desired_rows(1)
1015 .margin(Margin::symmetric(4, 0))
1016 .layouter(&mut layouter)
1017 .show(ui);
1018 let typed = *editable && output.response.changed();
1019 if *editable {
1020 let mut state = output.state.clone();
1021 state.clear_undoer();
1022 state.store(&ctx, text_id);
1023 }
1024 if typed {
1025 self.chars = None;
1026 }
1027 if let Some(range) = output.cursor_range {
1028 let chars = (range.secondary.index.0, range.primary.index.0);
1029 if self.chars != Some(chars) {
1030 self.selection =
1031 byte_index(&self.text, chars.0)..byte_index(&self.text, chars.1);
1032 self.chars = Some(chars);
1033 }
1034 }
1035 let drawn = Drawn {
1036 galley: &output.galley,
1037 at: output.galley_pos.to_vec2(),
1038 };
1039 let clip = ui.clip_rect();
1040 if let Some(mark) = &self.mark {
1041 let rects = drawn.spans(&self.text, mark);
1042 if let (Some(first), Some(last)) = (rects.first(), rects.last()) {
1043 let rect = Rect::from_x_y_ranges(
1044 drawn.at.x..=clip.max.x.max(first.max.x),
1045 first.min.y..=last.max.y,
1046 );
1047 ui.painter()
1048 .set(behind, Shape::rect_filled(rect, 0.0, theme.accent_deep));
1049 }
1050 }
1051 self.underlines.clear();
1052 let hover = output.response.hover_pos();
1053 let mut hovered = Vec::new();
1054 for (problem, span) in self.problems.iter().zip(&self.spans) {
1055 let Some(span) = span else {
1056 continue;
1057 };
1058 if span.end > self.text.len() || !self.text.is_char_boundary(span.end) {
1059 continue;
1060 }
1061 let color = match problem.severity {
1062 Severity::Error => theme.error,
1063 Severity::Warning => theme.warning,
1064 };
1065 for rect in drawn.spans(&self.text, span) {
1066 let y = rect.max.y - 1.0;
1067 ui.painter().add(wave(
1068 pos2(rect.min.x, y),
1069 rect.max.x.max(rect.min.x + WAVE * 2.0),
1070 color,
1071 ));
1072 if hover.is_some_and(|at| rect.expand(1.0).contains(at)) {
1073 hovered.push(problem.message.clone());
1074 }
1075 self.underlines.push(rect);
1076 }
1077 }
1078 if !hovered.is_empty() {
1079 hovered.dedup();
1080 output
1081 .response
1082 .response
1083 .clone()
1084 .on_hover_ui_at_pointer(|ui| {
1085 for message in &hovered {
1086 ui.label(message);
1087 }
1088 });
1089 }
1090 let current = output
1091 .cursor_range
1092 .map(|range| output.galley.layout_from_cursor(range.primary).row);
1093 let rows = &output.galley.rows;
1094 let first = rows.partition_point(|row| row.rect().max.y + drawn.at.y < clip.min.y);
1095 for (index, _) in rows.iter().enumerate().skip(first) {
1096 let rect = drawn.row(index);
1097 if rect.min.y > clip.max.y {
1098 break;
1099 }
1100 let color = if Some(index) == current {
1101 theme.text2
1102 } else {
1103 theme.dim
1104 };
1105 ui.painter().text(
1106 pos2(left + gutter - GUTTER_PAD, rect.center().y),
1107 Align2::RIGHT_CENTER,
1108 (index + 1).to_string(),
1109 code.clone(),
1110 color,
1111 );
1112 }
1113 if let Some(byte) = self.scroll_to.take() {
1114 let (row, x) = drawn.cursor(&self.text, byte);
1115 let rect = drawn.row(row.min(rows.len().saturating_sub(1)));
1116 let target = Rect::from_x_y_ranges(left..=x.max(left), rect.y_range());
1117 ui.scroll_to_rect(target, Some(Align::Center));
1118 }
1119 typed
1120 })
1121 .inner
1122 }
1123
1124 fn list(&mut self, ui: &mut Ui, height: f32, theme: &Theme) {
1125 let code = theme.font(theme.size_code);
1126 ui.add_space(theme.item_spacing);
1127 let mut clicked = None;
1128 ScrollArea::vertical()
1129 .id_salt(self.id.with("problems"))
1130 .max_height(height - theme.item_spacing)
1131 .auto_shrink([false, true])
1132 .show(ui, |ui| {
1133 for (index, problem) in self.problems.iter().enumerate() {
1134 let color = match problem.severity {
1135 Severity::Error => theme.error,
1136 Severity::Warning => theme.warning,
1137 };
1138 let mut job = LayoutJob::default();
1139 job.append(
1140 &format!(
1141 "{}:{}:{}",
1142 shown_path(&problem.file, &self.file),
1143 problem.line,
1144 problem.column
1145 ),
1146 0.0,
1147 TextFormat::simple(code.clone(), color),
1148 );
1149 job.append(
1150 &problem.message,
1151 PROBLEM_GAP,
1152 TextFormat::simple(code.clone(), theme.syntax.plain),
1153 );
1154 let response = ui.add(Label::new(job).sense(Sense::click()).truncate());
1155 if response.clicked() {
1156 clicked = Some(index);
1157 }
1158 self.rows.push(response.rect);
1159 }
1160 });
1161 if let Some(index) = clicked {
1162 let problem = &self.problems[index];
1163 if problem.file == self.file {
1164 self.jump = Some(problem.start(&self.text));
1165 ui.ctx().request_repaint();
1166 }
1167 }
1168 }
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173 use super::*;
1174
1175 #[test]
1176 fn a_problem_names_its_file_from_the_panels_folder_never_the_machines_path() {
1177 let own = Path::new("/srv/work/scenes/stand.scene.toml");
1178 assert_eq!(
1179 shown_path(Path::new("/srv/work/scenes/stand.scene.toml"), own),
1180 "stand.scene.toml"
1181 );
1182 assert_eq!(
1183 shown_path(Path::new("/srv/work/scenes/lib/materials.toml"), own),
1184 "lib/materials.toml"
1185 );
1186 assert_eq!(
1187 shown_path(Path::new("/elsewhere/rig.toml"), own),
1188 "rig.toml"
1189 );
1190 }
1191 use crate::diag::NoCheck;
1192 use egui::Context;
1193 use pfx_editor_doc::TYPING;
1194 use pfx_editor_shell::context;
1195 use pfx_editor_shell::{Script, drive};
1196 use pfx_editor_style::fixture;
1197 use toml_edit::DocumentMut;
1198
1199 const FILE: &str = "scene.toml";
1200 const GOOD: &str = "[object.lamp]\nname = \"lamp\"\nat = [0.0, 1.5, -2.0]\n";
1201
1202 struct Open {
1203 files: Files,
1204 history: History,
1205 source: Source,
1206 }
1207
1208 impl Open {
1209 fn new(text: &str, check: &dyn Check) -> Open {
1210 let mut files = Files::new();
1211 files.open(FILE, text);
1212 let mut source = Source::new("source", FILE);
1213 assert!(source.sync(&files, check));
1214 Open {
1215 files,
1216 history: History::default(),
1217 source,
1218 }
1219 }
1220
1221 fn tick(&mut self, now: f64, check: &dyn Check) -> Option<Changed> {
1222 self.source
1223 .tick(now, &mut self.files, &mut self.history, check)
1224 }
1225
1226 fn text(&self) -> &str {
1227 self.files.text(Path::new(FILE)).unwrap()
1228 }
1229
1230 fn frame(&mut self, ctx: &Context, script: &Script) -> Vec<Option<Changed>> {
1231 let mut changed = Vec::new();
1232 let Open {
1233 files,
1234 history,
1235 source,
1236 } = self;
1237 drive(ctx, [480, 320], script, |ui| {
1238 egui::CentralPanel::default().show(ui, |ui| {
1239 changed.push(source.show(ui, files, history, &check_lamps, &mut |_, _| {}));
1240 });
1241 });
1242 changed
1243 }
1244 }
1245
1246 fn check_lamps(file: &Path, text: &str, doc: &DocumentMut) -> Vec<Diagnostic> {
1247 let Some(name) = doc
1248 .get("object")
1249 .and_then(|objects| objects.get("lamp"))
1250 .and_then(|lamp| lamp.get("name"))
1251 else {
1252 return Vec::new();
1253 };
1254 if name.as_str() == Some("lamp") {
1255 return Vec::new();
1256 }
1257 let at = text.find("name").unwrap();
1258 let mut found = Diagnostic::at(
1259 file,
1260 text,
1261 at..at + 4,
1262 Severity::Error,
1263 "bad-name",
1264 "a lamp is named lamp",
1265 );
1266 found.key = "object.lamp.name".into();
1267 vec![found]
1268 }
1269
1270 #[test]
1271 fn an_edit_applies_only_after_the_debounce_with_no_other_edit() {
1272 let mut open = Open::new(GOOD, &NoCheck);
1273 open.source.edit(GOOD.replace("1.5", "2.5"), 1.0);
1274 assert!(open.source.pending());
1275 assert!(open.tick(1.10, &NoCheck).is_none());
1276 open.source.edit(GOOD.replace("1.5", "3.5"), 1.12);
1277 assert!(open.tick(1.20, &NoCheck).is_none());
1278 assert!(open.tick(1.26, &NoCheck).is_none());
1279 assert_eq!(open.text(), GOOD);
1280 let changed = open.tick(1.27, &NoCheck).unwrap();
1281 assert_eq!(changed.label, TYPING);
1282 assert_eq!(open.text(), GOOD.replace("1.5", "3.5"));
1283 assert!(!open.source.pending());
1284 assert!(!open.source.unapplied());
1285 assert_eq!(open.history.undo_len(), 1);
1286 assert!(open.tick(5.0, &NoCheck).is_none());
1287 }
1288
1289 #[test]
1290 fn an_unchanged_edit_schedules_nothing() {
1291 let mut open = Open::new(GOOD, &NoCheck);
1292 open.source.edit(GOOD, 1.0);
1293 assert!(!open.source.pending());
1294 }
1295
1296 #[test]
1297 fn a_broken_buffer_keeps_its_text_and_reports_the_parse_error_first() {
1298 let mut open = Open::new(GOOD, &check_lamps);
1299 let broken = GOOD.replace("name = \"lamp\"", "name = = \"lamp\"");
1300 open.source.edit(broken.clone(), 0.0);
1301 assert!(open.tick(0.2, &check_lamps).is_none());
1302 assert_eq!(open.source.text(), broken);
1303 assert_eq!(open.text(), GOOD);
1304 assert!(open.source.unapplied());
1305 let problems = open.source.problems();
1306 assert_eq!(problems.len(), 1);
1307 assert_eq!(problems[0].code, diag::PARSE);
1308 assert_eq!((problems[0].line, problems[0].column), (2, 8));
1309 open.source.edit(GOOD.replace("\"lamp\"", "\"bulb\""), 1.0);
1310 assert!(open.tick(1.2, &check_lamps).is_none());
1311 assert_eq!(open.source.problems()[0].code, "bad-name");
1312 assert_eq!(open.source.spans[0], Some(14..18));
1313 open.source.edit(GOOD, 2.0);
1314 assert!(open.tick(2.2, &check_lamps).is_none());
1315 assert!(open.source.problems().is_empty());
1316 assert!(!open.source.unapplied());
1317 assert_eq!(open.history.undo_len(), 0);
1318 }
1319
1320 #[test]
1321 fn warnings_still_apply() {
1322 let warn = |file: &Path, text: &str, _: &DocumentMut| {
1323 vec![Diagnostic::at(
1324 file,
1325 text,
1326 0..1,
1327 Severity::Warning,
1328 "w",
1329 "a warning",
1330 )]
1331 };
1332 let mut open = Open::new(GOOD, &warn);
1333 assert_eq!(open.source.problems().len(), 1);
1334 open.source.edit(GOOD.replace("1.5", "2.5"), 0.0);
1335 assert!(open.tick(0.2, &warn).is_some());
1336 assert_eq!(open.source.problems().len(), 1);
1337 }
1338
1339 #[test]
1340 fn diagnostics_of_other_files_are_listed_but_not_underlined() {
1341 let elsewhere = |_: &Path, text: &str, _: &DocumentMut| {
1342 vec![Diagnostic::at(
1343 "other.toml",
1344 text,
1345 0..1,
1346 Severity::Error,
1347 "e",
1348 "held twice",
1349 )]
1350 };
1351 let mut open = Open::new(GOOD, &elsewhere);
1352 open.source.edit(GOOD.replace("1.5", "2.5"), 0.0);
1353 assert!(open.tick(0.2, &elsewhere).is_none());
1354 assert_eq!(open.source.spans, vec![None]);
1355 }
1356
1357 #[test]
1358 fn an_undo_over_unapplied_broken_text_puts_back_the_applied_text() {
1359 let mut open = Open::new(GOOD, &NoCheck);
1360 open.source.select(3..3);
1361 open.source.edit(GOOD.replace("[object", "[[object"), 0.0);
1362 let Open {
1363 files,
1364 history,
1365 source,
1366 } = &mut open;
1367 assert!(source.undo(0.05, files, history, &NoCheck).is_none());
1368 assert_eq!(source.text(), GOOD);
1369 assert_eq!(source.selection(), 3..3);
1370 assert!(!source.unapplied() && !source.pending());
1371 source.edit(GOOD.replace("1.5", "2.5"), 1.0);
1372 assert!(source.undo(1.05, files, history, &NoCheck).is_some());
1373 assert_eq!(source.text(), GOOD);
1374 assert_eq!(history.redo_len(), 1);
1375 assert!(source.redo(1.1, files, history, &NoCheck).is_some());
1376 assert_eq!(source.text(), GOOD.replace("1.5", "2.5"));
1377 }
1378
1379 #[test]
1380 fn a_selection_follows_an_outside_splice() {
1381 let text = "abcdef";
1382 assert_eq!(shifted(text, "abXXcdef", 3..5), 5..7);
1383 assert_eq!(shifted(text, "abXXcdef", 0..2), 0..2);
1384 assert_eq!(shifted(text, "aZf", 1..5), 1..2);
1385 assert_eq!(shifted(text, text, 4..9), 4..6);
1386 assert_eq!(shifted("é = 1", "é", 0..5), 0..2);
1387 }
1388
1389 #[test]
1390 fn the_panel_underlines_lists_and_jumps_to_a_problem() {
1391 let ctx = context(&fixture::theme());
1392 let broken = format!("{GOOD}\n[object.box]\nsize = [1, 2\n");
1393 let mut open = Open::new(&broken, &check_lamps);
1394 open.frame(&ctx, &Script::default());
1395 assert_eq!(open.source.rows.len(), 1);
1396 assert!(!open.source.underlines.is_empty());
1397 let row = open.source.rows[0].center();
1398 let script = Script::parse(&format!("click {} {}\nframe\nframe", row.x, row.y)).unwrap();
1399 open.frame(&ctx, &script);
1400 let start = open.source.problems()[0].start(&broken);
1401 assert_eq!(open.source.caret(), start);
1402 assert_eq!(open.source.text(), broken);
1403 }
1404
1405 #[test]
1406 fn typing_into_the_panel_applies_after_the_debounce() {
1407 let ctx = context(&fixture::theme());
1408 let mut open = Open::new(GOOD, &check_lamps);
1409 open.source.jump(GOOD.find("lamp\"").unwrap());
1410 open.frame(&ctx, &Script::default());
1411 let mut steps = String::from("text x\n");
1412 for _ in 0..12 {
1413 steps.push_str("frame\n");
1414 }
1415 let changed = open.frame(&ctx, &Script::parse(&steps).unwrap());
1416 assert_eq!(open.source.text(), GOOD.replace("\"lamp\"", "\"xlamp\""));
1417 assert_eq!(changed.iter().filter(|one| one.is_some()).count(), 0);
1418 assert_eq!(open.source.problems()[0].code, "bad-name");
1419 assert_eq!(open.text(), GOOD);
1420 assert!(!open.source.pending());
1421 assert!(open.source.unapplied());
1422 }
1423
1424 #[test]
1425 fn a_read_only_panel_keeps_its_text() {
1426 let ctx = context(&fixture::theme());
1427 let mut open = Open::new(GOOD, &NoCheck);
1428 open.source.set_editable(false);
1429 open.source.jump(3);
1430 open.frame(&ctx, &Script::default());
1431 open.frame(
1432 &ctx,
1433 &Script::parse("text typed\nkey ctrl+/\nframe").unwrap(),
1434 );
1435 assert_eq!(open.source.text(), GOOD);
1436 assert_eq!(open.text(), GOOD);
1437 assert!(!open.source.pending());
1438 }
1439
1440 #[test]
1441 fn a_frame_with_no_change_builds_no_job() {
1442 let ctx = context(&fixture::theme());
1443 let mut open = Open::new(GOOD, &NoCheck);
1444 open.frame(&ctx, &Script::default());
1445 let misses = open.source.cache().misses();
1446 open.frame(&ctx, &Script::parse("frame\nframe\nframe").unwrap());
1447 assert_eq!(open.source.cache().misses(), misses);
1448 assert!(open.source.cache().hits() >= 3);
1449 }
1450
1451 #[test]
1452 fn reveal_marks_a_span() {
1453 let mut open = Open::new(GOOD, &NoCheck);
1454 assert_eq!(open.source.text(), GOOD);
1455 open.source.reveal(14..GOOD.len() + 10);
1456 assert_eq!(open.source.mark(), Some(14..GOOD.len()));
1457 let ctx = context(&fixture::theme());
1458 open.frame(&ctx, &Script::default());
1459 open.source.clear_mark();
1460 assert_eq!(open.source.mark(), None);
1461 }
1462}