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}