Skip to main content

pfx_editor_toml/
compare.rs

1use std::path::Path;
2
3use egui::{Align, Align2, Layout, Rect, ScrollArea, Sense, Shape, Ui, UiBuilder, pos2, vec2};
4use pfx_editor_doc::File;
5use pfx_editor_doc::Stamp;
6use pfx_editor_style::Theme;
7use pfx_editor_style::widgets::KeyCap;
8
9use crate::diff::{Diff, Hunk, Mark, REVERT, Row};
10use crate::host::{Head, NoHead};
11
12pub const NO_SAVED: &str = "no saved text to compare with";
13const GUTTER_PAD: f32 = 8.0;
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum Basis {
17    #[default]
18    Saved,
19    Head,
20}
21
22struct Cached {
23    basis: Basis,
24    base: String,
25    text: String,
26    diff: Diff,
27}
28
29#[derive(Default)]
30pub struct Compare {
31    pub(crate) on: bool,
32    pub(crate) basis: Basis,
33    saved: Option<String>,
34    head: Option<Result<String, NoHead>>,
35    cached: Option<Cached>,
36    computed: usize,
37    pub(crate) reverts: Vec<(usize, Rect)>,
38}
39
40impl Compare {
41    pub fn note_saved(&mut self, file: &File, path: &Path) {
42        if !file.dirty() {
43            self.saved = Some(file.text().to_string());
44        } else if self.saved.is_none()
45            && let Ok(disk) = std::fs::read_to_string(path)
46            && file.disk_stamp() == Some(Stamp::of(&disk))
47        {
48            self.saved = Some(disk);
49        }
50    }
51
52    pub fn probe(&mut self, head: &dyn Head, path: &Path) {
53        self.head = Some(head.show(path));
54    }
55
56    pub fn probed(&self) -> bool {
57        self.head.is_some()
58    }
59
60    pub fn head_note(&self) -> Option<String> {
61        match &self.head {
62            Some(Err(why)) => Some(why.to_string()),
63            _ => None,
64        }
65    }
66
67    pub fn has_head(&self) -> bool {
68        matches!(self.head, Some(Ok(_)))
69    }
70
71    pub fn base(&self) -> Result<&str, String> {
72        match self.basis {
73            Basis::Saved => self.saved.as_deref().ok_or_else(|| NO_SAVED.to_string()),
74            Basis::Head => match &self.head {
75                Some(Ok(text)) => Ok(text),
76                Some(Err(why)) => Err(why.to_string()),
77                None => Err(NoHead::Failed("not read yet".into()).to_string()),
78            },
79        }
80    }
81
82    pub fn computed(&self) -> usize {
83        self.computed
84    }
85
86    pub fn refresh(&mut self, text: &str) -> Option<&Diff> {
87        let (base, basis) = match (self.base(), self.basis) {
88            (Ok(base), basis) => (base.to_string(), basis),
89            (Err(_), _) => return None,
90        };
91        let stale = !matches!(&self.cached, Some(cached)
92            if cached.basis == basis && cached.base == base && cached.text == text);
93        if stale {
94            self.computed += 1;
95            self.cached = Some(Cached {
96                basis,
97                diff: Diff::between(&base, text),
98                base,
99                text: text.to_string(),
100            });
101        }
102        self.cached.as_ref().map(|cached| &cached.diff)
103    }
104
105    pub fn hunk(&self, index: usize) -> Option<Hunk> {
106        self.cached.as_ref()?.diff.hunks().get(index).cloned()
107    }
108
109    pub fn base_of(&self) -> Option<&str> {
110        self.cached.as_ref().map(|cached| cached.base.as_str())
111    }
112
113    pub fn paint(&mut self, ui: &mut Ui, theme: &Theme, text: &str, height: f32) -> Option<usize> {
114        self.reverts.clear();
115        let code = theme.font(theme.size_code);
116        if self.refresh(text).is_none() {
117            let why = self.base().err().unwrap_or_default();
118            ui.add_space(theme.item_spacing);
119            ui.label(egui::RichText::new(why).font(code).color(theme.warning));
120            return None;
121        }
122        let cached = self.cached.as_ref()?;
123        let diff = &cached.diff;
124        let name = match self.basis {
125            Basis::Saved => "the saved file",
126            Basis::Head => "git HEAD",
127        };
128        if diff.is_empty() {
129            ui.add_space(theme.item_spacing);
130            ui.label(
131                egui::RichText::new(format!("no changes against {name}"))
132                    .font(code)
133                    .color(theme.dim),
134            );
135            return None;
136        }
137        let rows = diff.rows();
138        let row_height = theme.row_height;
139        let digits = (text.lines().count().max(1)).to_string().len().max(2);
140        let digit = ui.fonts_mut(|fonts| fonts.glyph_width(&code, '0'));
141        let gutter = digits as f32 * digit + 2.0 * GUTTER_PAD;
142        let mut reverts = Vec::new();
143        ui.spacing_mut().item_spacing.y = 0.0;
144        ScrollArea::vertical()
145            .id_salt("pfx-editor-toml-diff")
146            .auto_shrink([false, false])
147            .max_height(height)
148            .show_rows(ui, row_height, rows.len(), |ui, range| {
149                for index in range {
150                    let (rect, _) = ui.allocate_exact_size(
151                        vec2(ui.available_width(), row_height),
152                        Sense::hover(),
153                    );
154                    let mid = rect.center().y;
155                    let text_at = pos2(rect.min.x + gutter + GUTTER_PAD, mid);
156                    let marked = |hunk: usize| match diff.hunks()[hunk].mark {
157                        Mark::Add => theme.diff.add,
158                        Mark::Del => theme.diff.del,
159                        Mark::Change => theme.diff.change,
160                    };
161                    let fill = |color| {
162                        Shape::rect_filled(
163                            Rect::from_min_max(pos2(rect.min.x + gutter, rect.min.y), rect.max),
164                            0.0,
165                            color,
166                        )
167                    };
168                    let number = |ui: &Ui, line: usize| {
169                        ui.painter().text(
170                            pos2(rect.min.x + gutter - GUTTER_PAD, mid),
171                            Align2::RIGHT_CENTER,
172                            (line + 1).to_string(),
173                            code.clone(),
174                            theme.dim,
175                        );
176                    };
177                    match &rows[index] {
178                        Row::Head(hunk) => {
179                            let color = marked(*hunk);
180                            ui.painter().add(fill(color));
181                            let h = &diff.hunks()[*hunk];
182                            let label =
183                                format!("@@ -{} +{} @@", span(&h.old_lines), span(&h.new_lines));
184                            ui.painter().text(
185                                text_at,
186                                Align2::LEFT_CENTER,
187                                label,
188                                code.clone(),
189                                theme.muted,
190                            );
191                            let mut child = ui.new_child(
192                                UiBuilder::new()
193                                    .max_rect(rect.shrink2(vec2(GUTTER_PAD, 0.0)))
194                                    .layout(Layout::right_to_left(Align::Center)),
195                            );
196                            let button = child
197                                .add(KeyCap::new(REVERT).button())
198                                .on_hover_text("put the base text back over this hunk");
199                            if button.clicked() {
200                                reverts.push(*hunk);
201                            }
202                            self.reverts.push((*hunk, button.rect));
203                        }
204                        Row::Context { line, text } => {
205                            number(ui, *line);
206                            ui.painter().text(
207                                text_at,
208                                Align2::LEFT_CENTER,
209                                text,
210                                code.clone(),
211                                theme.text2,
212                            );
213                        }
214                        Row::Fold(count) => {
215                            ui.painter().text(
216                                text_at,
217                                Align2::LEFT_CENTER,
218                                format!("{count} unchanged lines"),
219                                code.clone(),
220                                theme.dim,
221                            );
222                        }
223                        Row::Del { text, .. } => {
224                            ui.painter().add(fill(theme.diff.del));
225                            ui.painter().text(
226                                text_at,
227                                Align2::LEFT_CENTER,
228                                format!("- {text}"),
229                                code.clone(),
230                                theme.error,
231                            );
232                        }
233                        Row::Add { line, text, .. } => {
234                            number(ui, *line);
235                            ui.painter().add(fill(theme.diff.add));
236                            ui.painter().text(
237                                text_at,
238                                Align2::LEFT_CENTER,
239                                format!("+ {text}"),
240                                code.clone(),
241                                theme.ok,
242                            );
243                        }
244                    }
245                }
246            });
247        reverts.first().copied()
248    }
249}
250
251fn span(lines: &std::ops::Range<usize>) -> String {
252    match lines.len() {
253        0 => format!("{},0", lines.start),
254        1 => (lines.start + 1).to_string(),
255        count => format!("{},{}", lines.start + 1, count),
256    }
257}