1use std::collections::HashMap;
2use std::hash::{DefaultHasher, Hash, Hasher};
3use std::sync::Arc;
4
5use egui::epaint::text::{ByteIndex, FontsView, PlacedRow, Row};
6use egui::text::{LayoutJob, LayoutSection, TextFormat, TextWrapping};
7use egui::{Align, Color32, FontId, Galley, Rect, pos2};
8
9use pfx_editor_style::Theme;
10
11use crate::lex::{Kind, Lexed};
12
13pub fn color(theme: &Theme, kind: Kind) -> Color32 {
14 let syntax = &theme.syntax;
15 match kind {
16 Kind::Key => syntax.key,
17 Kind::Header => syntax.table,
18 Kind::String => syntax.string,
19 Kind::Number => syntax.number,
20 Kind::Boolean => syntax.boolean,
21 Kind::Date => syntax.date,
22 Kind::Comment => syntax.comment,
23 Kind::Punctuation => syntax.punctuation,
24 Kind::Plain => syntax.plain,
25 Kind::Invalid => theme.error,
26 }
27}
28
29fn palette(theme: &Theme) -> [Color32; 10] {
30 [
31 Kind::Key,
32 Kind::Header,
33 Kind::String,
34 Kind::Number,
35 Kind::Boolean,
36 Kind::Date,
37 Kind::Comment,
38 Kind::Punctuation,
39 Kind::Plain,
40 Kind::Invalid,
41 ]
42 .map(|kind| color(theme, kind))
43}
44
45fn hash_of(value: &(impl Hash + ?Sized)) -> u64 {
46 let mut hasher = DefaultHasher::new();
47 value.hash(&mut hasher);
48 hasher.finish()
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52struct Key {
53 text: u64,
54 style: u64,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq)]
58struct Fonts {
59 font: u64,
60 pixels_per_point: f32,
61 fill: f32,
62}
63
64#[derive(Default)]
65pub struct JobCache {
66 key: Option<Key>,
67 lexed: Lexed,
68 job: Arc<LayoutJob>,
69 hits: u64,
70 misses: u64,
71 relexed: usize,
72 galley: Option<(Key, Arc<Galley>)>,
73 fonts: Option<Fonts>,
74 rows: HashMap<u64, Arc<Row>>,
75 laid_out: usize,
76}
77
78fn line_sections(
79 lexed: &Lexed,
80 index: usize,
81 theme: &Theme,
82) -> (usize, Vec<(usize, usize, Color32)>) {
83 let lines = lexed.lines();
84 let line = &lines[index];
85 let end = lines
86 .get(index + 1)
87 .map_or(lexed.text().len(), |next| next.start);
88 let text = &lexed.text()[line.start..end];
89 let length = text.strip_suffix('\n').map_or(text.len(), str::len);
90 let mut sections: Vec<(usize, usize, Color32)> = Vec::new();
91 for token in &line.tokens {
92 let start = token.range.start.min(length);
93 let stop = token.range.end.min(length);
94 if start == stop {
95 continue;
96 }
97 let color = color(theme, token.kind);
98 match sections.last_mut() {
99 Some(last) if last.2 == color && last.1 == start => last.1 = stop,
100 _ => sections.push((start, stop, color)),
101 }
102 }
103 if sections.is_empty() {
104 sections.push((0, 0, theme.syntax.plain));
105 }
106 (length, sections)
107}
108
109pub fn build(lexed: &Lexed, theme: &Theme, font: &FontId) -> LayoutJob {
110 let text = lexed.text();
111 let mut sections: Vec<LayoutSection> = Vec::new();
112 for token in lexed.tokens() {
113 let color = color(theme, token.kind);
114 if let Some(last) = sections.last_mut()
115 && last.format.color == color
116 && last.byte_range.end == ByteIndex(token.range.start)
117 {
118 last.byte_range.end = ByteIndex(token.range.end);
119 continue;
120 }
121 sections.push(LayoutSection {
122 leading_space: 0.0,
123 byte_range: ByteIndex(token.range.start)..ByteIndex(token.range.end),
124 format: TextFormat {
125 font_id: font.clone(),
126 color,
127 ..TextFormat::default()
128 },
129 });
130 }
131 if sections.is_empty() {
132 sections.push(LayoutSection {
133 leading_space: 0.0,
134 byte_range: ByteIndex(0)..ByteIndex(0),
135 format: TextFormat {
136 font_id: font.clone(),
137 color: theme.syntax.plain,
138 ..TextFormat::default()
139 },
140 });
141 }
142 LayoutJob {
143 text: text.to_owned(),
144 sections,
145 wrap: TextWrapping::no_max_width(),
146 break_on_newline: true,
147 halign: Align::LEFT,
148 ..LayoutJob::default()
149 }
150}
151
152impl JobCache {
153 pub fn job(&mut self, text: &str, theme: &Theme, font: &FontId) -> Arc<LayoutJob> {
154 let key = Key {
155 text: hash_of(text),
156 style: hash_of(&(palette(theme), font)),
157 };
158 if self.key == Some(key) {
159 self.hits += 1;
160 return self.job.clone();
161 }
162 self.misses += 1;
163 self.relexed = if self.key.is_some_and(|old| old.text == key.text) {
164 0
165 } else {
166 self.lexed.update(text)
167 };
168 self.key = Some(key);
169 self.job = Arc::new(build(&self.lexed, theme, font));
170 self.job.clone()
171 }
172
173 pub fn galley(
174 &mut self,
175 fonts: &mut FontsView<'_>,
176 pixels_per_point: f32,
177 text: &str,
178 theme: &Theme,
179 font: &FontId,
180 ) -> Arc<Galley> {
181 let job = self.job(text, theme, font);
182 let now = Fonts {
183 font: hash_of(font),
184 pixels_per_point,
185 fill: fonts.font_atlas_fill_ratio(),
186 };
187 let reset = self.fonts.is_none_or(|was| {
188 was.font != now.font
189 || was.pixels_per_point != now.pixels_per_point
190 || now.fill < was.fill
191 });
192 self.fonts = Some(now);
193 if reset {
194 self.rows.clear();
195 self.galley = None;
196 }
197 let key = self.key.unwrap_or(Key { text: 0, style: 0 });
198 if let Some((at, galley)) = &self.galley
199 && *at == key
200 {
201 return galley.clone();
202 }
203 let galley = self.assemble(fonts, pixels_per_point, job, theme, font);
204 self.galley = Some((key, galley.clone()));
205 galley
206 }
207
208 fn assemble(
209 &mut self,
210 fonts: &mut FontsView<'_>,
211 pixels_per_point: f32,
212 job: Arc<LayoutJob>,
213 theme: &Theme,
214 font: &FontId,
215 ) -> Arc<Galley> {
216 let count = self.lexed.lines().len();
217 let mut kept = HashMap::with_capacity(count);
218 let mut placed = Vec::with_capacity(count);
219 let mut base = None;
220 let mut rect = Rect::ZERO;
221 let mut mesh_bounds = Rect::NOTHING;
222 let mut vertices = 0;
223 let mut indices = 0;
224 self.laid_out = 0;
225 for index in 0..count {
226 let last = index + 1 == count;
227 let (length, sections) = line_sections(&self.lexed, index, theme);
228 let start = self.lexed.lines()[index].start;
229 let text = &self.lexed.text()[start..start + length];
230 let key = hash_of(&(text, §ions, last));
231 let row = match self.rows.remove(&key).or_else(|| kept.get(&key).cloned()) {
232 Some(row) => row,
233 None => {
234 self.laid_out += 1;
235 let line = fonts.layout_job(LayoutJob {
236 text: text.to_owned(),
237 sections: sections
238 .iter()
239 .map(|(from, to, color)| LayoutSection {
240 leading_space: 0.0,
241 byte_range: ByteIndex(*from)..ByteIndex(*to),
242 format: TextFormat {
243 font_id: font.clone(),
244 color: *color,
245 ..TextFormat::default()
246 },
247 })
248 .collect(),
249 wrap: TextWrapping::no_max_width(),
250 break_on_newline: false,
251 halign: Align::LEFT,
252 ..LayoutJob::default()
253 });
254 if base.is_none() {
255 base = Some(line.clone());
256 }
257 line.rows[0].row.clone()
258 }
259 };
260 let y = (rect.height() * pixels_per_point).round() / pixels_per_point;
261 let at = pos2(0.0, y);
262 mesh_bounds |= row.visuals.mesh_bounds.translate(at.to_vec2());
263 rect |= Rect::from_min_size(at, row.size);
264 vertices += row.visuals.mesh.vertices.len();
265 indices += row.visuals.mesh.indices.len();
266 placed.push(PlacedRow {
267 pos: at,
268 row: row.clone(),
269 ends_with_newline: !last,
270 });
271 kept.insert(key, row);
272 }
273 self.rows = kept;
274 let mut galley = match base {
275 Some(line) => Galley::clone(&line),
276 None => Galley::clone(&fonts.layout_job(LayoutJob::default())),
277 };
278 galley.job = job;
279 galley.rows = placed;
280 galley.elided = false;
281 galley.rect = rect;
282 galley.mesh_bounds = mesh_bounds;
283 galley.num_vertices = vertices;
284 galley.num_indices = indices;
285 galley.pixels_per_point = pixels_per_point;
286 Arc::new(galley)
287 }
288
289 pub fn laid_out(&self) -> usize {
290 self.laid_out
291 }
292
293 pub fn lexed(&self) -> &Lexed {
294 &self.lexed
295 }
296
297 pub fn hits(&self) -> u64 {
298 self.hits
299 }
300
301 pub fn misses(&self) -> u64 {
302 self.misses
303 }
304
305 pub fn relexed(&self) -> usize {
306 self.relexed
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use pfx_editor_style::fixture;
314
315 const TEXT: &str = "[a]\nb = \"c\" # d\n";
316
317 #[test]
318 fn the_cache_hits_until_the_text_or_the_theme_changes() {
319 let mut cache = JobCache::default();
320 let theme = fixture::theme();
321 let code = theme.font(theme.size_code);
322 let first = cache.job(TEXT, &theme, &code);
323 assert_eq!((cache.hits(), cache.misses()), (0, 1));
324 let again = cache.job(TEXT, &theme, &code);
325 assert!(Arc::ptr_eq(&first, &again));
326 assert_eq!((cache.hits(), cache.misses()), (1, 1));
327 let edited = cache.job("[a]\nb = \"cc\" # d\n", &theme, &code);
328 assert_eq!((cache.hits(), cache.misses()), (1, 2));
329 assert_eq!(cache.relexed(), 1);
330 assert_ne!(edited.text, first.text);
331 let mut loud = fixture::theme();
332 loud.syntax.key = egui::Color32::RED;
333 let recoloured = cache.job("[a]\nb = \"cc\" # d\n", &loud, &code);
334 assert_eq!((cache.hits(), cache.misses()), (1, 3));
335 assert_eq!(cache.relexed(), 0);
336 assert_eq!(recoloured.sections[2].format.color, egui::Color32::RED);
337 cache.job(
338 "[a]\nb = \"cc\" # d\n",
339 &loud,
340 &loud.font(loud.size_code + 1.0),
341 );
342 assert_eq!((cache.hits(), cache.misses()), (1, 4));
343 cache.job(
344 "[a]\nb = \"cc\" # d\n",
345 &loud,
346 &loud.font(loud.size_code + 1.0),
347 );
348 assert_eq!((cache.hits(), cache.misses()), (2, 4));
349 }
350
351 #[test]
352 fn a_job_colours_each_token_and_covers_the_text() {
353 let theme = fixture::theme();
354 let job = build(&Lexed::new(TEXT), &theme, &theme.font(theme.size_code));
355 assert_eq!(job.text, TEXT);
356 assert_eq!(job.sections.first().unwrap().byte_range.start, ByteIndex(0));
357 assert_eq!(
358 job.sections.last().unwrap().byte_range.end,
359 ByteIndex(TEXT.len())
360 );
361 for pair in job.sections.windows(2) {
362 assert_eq!(pair[0].byte_range.end, pair[1].byte_range.start);
363 assert_ne!(pair[0].format.color, pair[1].format.color);
364 }
365 let colour_at = |needle: &str| {
366 let at = TEXT.find(needle).unwrap();
367 job.sections
368 .iter()
369 .find(|section| section.byte_range.contains(&ByteIndex(at)))
370 .unwrap()
371 .format
372 .color
373 };
374 assert_eq!(colour_at("[a]"), color(&theme, Kind::Header));
375 assert_eq!(colour_at("b ="), color(&theme, Kind::Key));
376 assert_eq!(colour_at("\"c\""), color(&theme, Kind::String));
377 assert_eq!(colour_at("# d"), color(&theme, Kind::Comment));
378 }
379
380 fn same_rows(ours: &Galley, theirs: &Galley) {
381 assert_eq!(ours.text(), theirs.text());
382 assert_eq!(ours.rows.len(), theirs.rows.len());
383 for (one, two) in ours.rows.iter().zip(&theirs.rows) {
384 assert_eq!(one.pos, two.pos);
385 assert_eq!(one.row.size, two.row.size);
386 assert_eq!(one.ends_with_newline, two.ends_with_newline);
387 assert_eq!(one.row.glyphs.len(), two.row.glyphs.len());
388 for (a, b) in one.row.glyphs.iter().zip(&two.row.glyphs) {
389 assert_eq!((a.chr, a.pos), (b.chr, b.pos));
390 }
391 assert_eq!(one.row.visuals.mesh, two.row.visuals.mesh);
392 }
393 assert_eq!(ours.rect.size(), theirs.rect.size());
394 assert_eq!(ours.num_vertices, theirs.num_vertices);
395 }
396
397 #[test]
398 fn the_assembled_galley_matches_egui_and_lays_out_only_changed_lines() {
399 let ctx = egui::Context::default();
400 let theme = fixture::theme();
401 pfx_editor_style::theme::apply_theme(&ctx, &theme);
402 let code = theme.font(theme.size_code);
403 let mut cache = JobCache::default();
404 let texts = [
405 "[a]\nb = \"c\" # d\n\nlist = [\n 1,\n]\n",
406 "[a]\nb = \"cé\" # d\n\nlist = [\n 1,\n]\n",
407 "[a]\nb = \"cé\" # d\n\nlist = [\n 1,\n]",
408 "",
409 "\n",
410 ];
411 let mut laid_out = Vec::new();
412 for text in texts {
413 ctx.run_ui(egui::RawInput::default(), |root| {
414 let ctx = root.ctx();
415 let pixels_per_point = ctx.pixels_per_point();
416 ctx.fonts_mut(|fonts| {
417 let ours = cache.galley(fonts, pixels_per_point, text, &theme, &code);
418 let again = cache.galley(fonts, pixels_per_point, text, &theme, &code);
419 assert!(Arc::ptr_eq(&ours, &again));
420 let theirs = fonts.layout_job(build(&Lexed::new(text), &theme, &code));
421 same_rows(&ours, &theirs);
422 });
423 })
424 .textures_delta
425 .clear();
426 laid_out.push(cache.laid_out());
427 }
428 assert_eq!(laid_out[..3], [7, 1, 1]);
429 }
430}