Skip to main content

pfx_editor_toml/
panel.rs

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 &notice {
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}