Skip to main content

pfx_editor_toml/
job.rs

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, &sections, 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}