Skip to main content

escriba_render/
gpu.rs

1//! GPU renderer — implements [`madori::RenderCallback`] backed by garasu's
2//! glyphon-wrapped text renderer. Each frame:
3//!
4//!   1. Locks the shared `EditorState`.
5//!   2. Collects visible buffer lines into a single string.
6//!   3. Builds a glyphon `Buffer` (re-created each frame — phase 1.B; phase 2
7//!      will diff + reuse).
8//!   4. Prepares + renders through `madori::RenderContext::text`.
9//!
10//! Colors are the **Vellum** fleet theme (warm aged-paper Nord-matte),
11//! sourced from `escriba_ui::chrome::ChromePalette` so the GPU chrome matches
12//! the rest of the fleet (mado, tear, frostmourne, …) and escriba's TUI
13//! backend. Text is rendered in `snow1` (#E2DBC8, warm cream foreground)
14//! over a `night0` (#16140E, parchment ground) background. The status
15//! line is rendered in `ice_cyan` (#94BBB8, the matte accent).
16
17use std::sync::{Arc, Mutex};
18
19use escriba_core::{EditGen, Mode};
20use escriba_runtime::EditorState;
21use escriba_ui::chrome::ChromePalette;
22use glyphon::{Attrs, Buffer, Color as GlyphColor, Family, Metrics, Shaping, TextArea, TextBounds};
23use ishou_tokens::{EscribaSignals, Rgb, SignalMode, Srgb};
24use madori::{RenderCallback, RenderContext};
25// hikari (光) — the fleet syntax-highlighting spine. path→Box<dyn Highlighter>,
26// coverage-complete HlClass span partition. HlClass→Rgb resolves through
27// `escriba_ui::syntax::ChromeSyntax`, NOT hikari's hardcoded `NordTheme`:
28// this face used to hold one by value, so picking Vellum recoloured the frame
29// and left the code Nord.
30use escriba_ui::syntax::ChromeSyntax;
31use hikari_core::{Ecosystem, Language, Rgb as HlRgb, Theme};
32
33/// Shared handle to the editor state — both the GPU renderer (reads) and
34/// the madori `on_event` callback (writes) hold one.
35pub type SharedState = Arc<Mutex<EditorState>>;
36
37/// The GPU render callback.
38///
39/// Holds a shared reference to the editor state. `render()` reads it under
40/// lock, computes a frame, releases the lock before touching the GPU to
41/// minimise contention with the event loop.
42pub struct GpuRenderer {
43    state: SharedState,
44    font_size: f32,
45    line_height: f32,
46    /// Cached font metrics — rebuilt if font_size changes.
47    metrics: Metrics,
48    /// hikari highlight registry (built once — resolves path→Highlighter).
49    eco: Ecosystem,
50    /// The refresh generation of the currently-cached text buffer — the seal
51    /// (`theory/ESCRIBA.md` §Refresh-Seal). When `EditorState::edit_gen()`
52    /// still equals this, the cached shaped buffer is reused verbatim: no
53    /// re-highlight, no re-shape. Init `u64::MAX` so the first frame always
54    /// paints.
55    last_gen: EditGen,
56    /// The shaped main-text glyphon buffer, cached across frames while the
57    /// generation is unchanged. `None` before the first paint.
58    cached_text: Option<Buffer>,
59    /// The shaped gutter and the pixel width it occupies, cached under the
60    /// SAME generation as `cached_text`. One gate for both, so a frame can
61    /// never show this scroll position's line numbers beside the previous
62    /// one's text.
63    ///
64    /// The width travels WITH the buffer rather than being recomputed at
65    /// draw time. It depends on the buffer's line count, so a frame that
66    /// reuses a cached gutter must offset its text by the width that gutter
67    /// was actually shaped at — recomputing from a line count that has since
68    /// changed is exactly how text lands on top of line numbers for one
69    /// frame after a file grows.
70    cached_gutter: Option<(Buffer, f32)>,
71    /// The incremental highlighter for the active buffer's language (M2). Held
72    /// across frames so a re-highlight re-lexes only the lines that changed
73    /// (hikari's `LineState` fixpoint, `theory/ESCRIBA.md` §X) instead of the
74    /// whole visible window. Keyed by path so a language switch rebuilds it;
75    /// `None` before the first paint.
76    highlighter: Option<(String, Box<dyn hikari_core::IncrementalHighlighter>)>,
77}
78
79impl GpuRenderer {
80    #[must_use]
81    pub fn new(state: SharedState) -> Self {
82        let font_size = 14.0;
83        let line_height = 20.0;
84        Self {
85            state,
86            font_size,
87            line_height,
88            metrics: Metrics::new(font_size, line_height),
89            eco: build_ecosystem(),
90            last_gen: EditGen(u64::MAX),
91            cached_text: None,
92            cached_gutter: None,
93            highlighter: None,
94        }
95    }
96
97    /// Point the editor at a theme — the wiring that makes
98    /// `(deftheme :preset …)` real.
99    ///
100    /// Writes THROUGH to the shared `EditorState`, which is the single
101    /// owner of the theme. A renderer-local copy would be a second answer
102    /// to "what colour is this editor", and the TUI face would not see it.
103    pub fn set_theme(&mut self, theme: ishou_tokens::FleetTheme) {
104        self.state
105            .lock()
106            .unwrap_or_else(std::sync::PoisonError::into_inner)
107            .set_theme(theme);
108    }
109
110    /// The palette currently painted with — read from the editor.
111    #[must_use]
112    pub fn chrome(&self) -> ChromePalette {
113        self.state
114            .lock()
115            .unwrap_or_else(std::sync::PoisonError::into_inner)
116            .chrome()
117    }
118
119    /// Builder form of [`Self::set_theme`].
120    #[must_use]
121    pub fn with_theme(mut self, theme: ishou_tokens::FleetTheme) -> Self {
122        self.set_theme(theme);
123        self
124    }
125
126    #[must_use]
127    pub fn with_font_size(mut self, font_size: f32, line_height: f32) -> Self {
128        self.font_size = font_size;
129        self.line_height = line_height;
130        self.metrics = Metrics::new(font_size, line_height);
131        self
132    }
133}
134
135impl RenderCallback for GpuRenderer {
136    fn render(&mut self, ctx: &mut RenderContext<'_>) {
137        // ── 1. Read state under lock. The visible text is built ONLY when a
138        //    rebuild is due (the refresh-generation gate): an idle frame reads
139        //    just mode/cursor for the status line and reuses the cached shaped
140        //    buffer below — zero re-highlight, zero re-shape. `rebuild_input`
141        //    is Some((text, path)) exactly when the generation moved.
142        let (rebuild_input, gutter_rows, splash_chunks, mode, status_core, cur_gen, palette) = {
143            let s = self
144                .state
145                .lock()
146                .unwrap_or_else(std::sync::PoisonError::into_inner);
147            let Some(buf) = s.buffers.get(s.active) else {
148                return clear_frame(ctx);
149            };
150            let cur_gen = s.edit_gen();
151            let rebuild = cur_gen != self.last_gen || self.cached_text.is_none();
152            // The start screen replaces the buffer text entirely, so when
153            // one is up the (expensive) highlight+slice pass below is not
154            // merely wasted, it is wrong — it would paint the scratch
155            // buffer underneath. Laid out in CELLS, from the same estimate
156            // `resize` uses, so the screen centres on what is really there.
157            let splash_chunks = (rebuild)
158                .then(|| s.splash())
159                .flatten()
160                .map(|sp| {
161                    let grid = cell_grid(ctx.width, ctx.height, self.font_size, self.line_height);
162                    sp.screen_chunks(grid.cols, grid.rows)
163                })
164                .filter(|c| !c.is_empty());
165            let rebuild = rebuild && splash_chunks.is_none();
166            // (rendered text, path, search-match byte ranges INTO that text).
167            // The match ranges ride along with the text they index so the two
168            // cannot be computed against different frames.
169            let rebuild_input: Option<(String, String, Vec<(usize, usize)>)> = if rebuild {
170                // The open file's path drives hikari language resolution.
171                let path = buf
172                    .path
173                    .as_ref()
174                    .map(|p| p.to_string_lossy().into_owned())
175                    .unwrap_or_default();
176                let win = s.layout.active_window().cloned();
177                let top_line = win.as_ref().map_or(0, |w| w.viewport.top_line);
178                let left_column = win.as_ref().map_or(0, |w| w.viewport.left_column) as usize;
179                let visible_lines = win
180                    .as_ref()
181                    .map_or(40, |w| w.viewport.visible_lines.max(20));
182                let visible_columns = win
183                    .as_ref()
184                    .map_or(usize::MAX, |w| w.viewport.visible_columns as usize);
185                let mut out = String::new();
186                // Search matches are DOCUMENT char offsets; `out` is a
187                // RECONSTRUCTED string (each row trimmed of \r\n, char-sliced
188                // to the horizontal window, then \n-joined). There is
189                // therefore NO single base offset relating the two — the map
190                // has to be built per row, while we still know what each row
191                // corresponds to. Converting here, at the one place both
192                // coordinate systems are in scope, is what keeps byte/char
193                // confusion out of the painting code below.
194                let mut match_bytes: Vec<(usize, usize)> = Vec::new();
195                let hl = s.search.highlights();
196                for row in 0..visible_lines {
197                    let ln = top_line + row;
198                    if ln >= buf.line_count() {
199                        break;
200                    }
201                    if let Some(line) = buf.line(ln) {
202                        let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
203                        // Slice to the visible horizontal window
204                        // `[left_column, left_column + visible_columns)`.
205                        // Char-based so multibyte text stays aligned; long
206                        // lines clip to the window, no glyphon wrap.
207                        let sliced: String = trimmed
208                            .chars()
209                            .skip(left_column)
210                            .take(visible_columns)
211                            .collect();
212                        if !hl.is_empty() {
213                            let seg_byte0 = out.len();
214                            // Document char span this rendered segment covers.
215                            let doc0 = buf
216                                .position_to_char(escriba_core::Position::new(ln, 0))
217                                .unwrap_or(0)
218                                + left_column;
219                            let seg_chars = sliced.chars().count();
220                            // char index -> byte index within this segment.
221                            let bytes: Vec<usize> = sliced
222                                .char_indices()
223                                .map(|(b, _)| b)
224                                .chain(std::iter::once(sliced.len()))
225                                .collect();
226                            for m in hl {
227                                let a = m.start.max(doc0);
228                                let b = m.end.min(doc0 + seg_chars);
229                                if a < b {
230                                    match_bytes.push((
231                                        seg_byte0 + bytes[a - doc0],
232                                        seg_byte0 + bytes[b - doc0],
233                                    ));
234                                }
235                            }
236                        }
237                        out.push_str(&sliced);
238                        out.push('\n');
239                    }
240                }
241                Some((out, path, match_bytes))
242            } else {
243                None
244            };
245            // The gutter's rows, gathered under the SAME lock and the same
246            // rebuild gate as the text they sit beside. Computing them in a
247            // second pass would let the two disagree about which lines are on
248            // screen — a mark one row off its finding is worse than no mark.
249            #[allow(clippy::type_complexity)]
250            let gutter_rows: Option<(
251                u32,
252                Vec<(u32, Option<escriba_shirube::Severity>)>,
253            )> = rebuild_input.is_some().then(|| {
254                let win = s.layout.active_window().cloned();
255                let top_line = win.as_ref().map_or(0, |w| w.viewport.top_line);
256                let visible_lines = win
257                    .as_ref()
258                    .map_or(40, |w| w.viewport.visible_lines.max(20));
259                let world = s.world();
260                let rows = (0..visible_lines)
261                    .map(|row| top_line + row)
262                    .take_while(|ln| *ln < buf.line_count())
263                    .map(|ln| (ln, s.results.worst_on_line(&world, s.active, ln)))
264                    .collect();
265                (buf.line_count(), rows)
266            });
267            (
268                rebuild_input,
269                gutter_rows,
270                splash_chunks,
271                s.modal.mode(),
272                s.status_model().render(),
273                cur_gen,
274                s.chrome(),
275            )
276        };
277
278        // ── 2. Rebuild the shaped main-text buffer ONLY on a generation
279        //    change; otherwise reuse the cached one. This is the seal
280        //    (theory/ESCRIBA.md §Refresh-Seal): highlight + set_rich_text +
281        //    shape — the frame's dominant cost — run once per edit, never
282        //    per vsync.
283        let fg = chrome_glyph(palette.text);
284        let width = ctx.width as f32;
285        let height = ctx.height as f32 - self.line_height; // reserve bottom row for status
286        if let Some(chunks) = splash_chunks {
287            // The start screen: same laid-out stream the ANSI face
288            // consumes, roles turned into glyphon attrs instead of SGR.
289            let base = Attrs::new().family(Family::Monospace);
290            let mut buffer = Buffer::new(&mut ctx.text.font_system, self.metrics);
291            buffer.set_size(&mut ctx.text.font_system, Some(width), Some(height));
292            let runs: Vec<(&str, Attrs)> = splash_runs(&chunks, &palette)
293                .into_iter()
294                .map(|(text, color)| (text, base.clone().color(color)))
295                .collect();
296            buffer.set_rich_text(
297                &mut ctx.text.font_system,
298                runs,
299                &base,
300                Shaping::Advanced,
301                None,
302            );
303            buffer.shape_until_scroll(&mut ctx.text.font_system, false);
304            self.cached_text = Some(buffer);
305            // The start screen has no gutter — it is not a view of a file.
306            // Dropping the cached one matters: without this, dismissing a file
307            // and returning to the splash would leave the last file's line
308            // numbers painted down its left edge.
309            self.cached_gutter = None;
310            self.last_gen = cur_gen;
311        } else if let Some((text, path, match_bytes)) = rebuild_input {
312            let mut buffer = Buffer::new(&mut ctx.text.font_system, self.metrics);
313            buffer.set_size(&mut ctx.text.font_system, Some(width), Some(height));
314            // hikari: resolve the language, highlight the visible text, paint
315            // each span its Nord color. The span vec is a coverage-complete,
316            // non-overlapping, sorted partition of `text` (the SpanSink
317            // invariant), so each (slice, color) run is a valid set_rich_text
318            // item. Offsets are self-consistent (highlight == render string).
319            let base = Attrs::new().family(Family::Monospace);
320            // hikari incremental (M2): reuse the per-path LineCache and re-lex
321            // only the lines that changed since the last frame (the LineState
322            // fixpoint). A language switch (path change) rebuilds the cache; a
323            // scroll re-lexes the newly-visible window (graceful degrade). This
324            // is byte-identical to the one-shot highlighter it replaces.
325            if self.highlighter.as_ref().is_none_or(|(p, _)| p != &path) {
326                self.highlighter = Some((
327                    path.clone(),
328                    self.eco.incremental_highlighter_for_path(&path),
329                ));
330            }
331            let hl = &mut self
332                .highlighter
333                .as_mut()
334                .expect("highlighter set immediately above")
335                .1;
336            let spans = hl.highlight(&text);
337            // Overlay search matches on the syntax partition. Each syntax
338            // span is cut at any match boundary crossing it and the matched
339            // piece is recoloured; the result is still coverage-complete,
340            // non-overlapping and sorted, which is what set_rich_text
341            // requires — splitting a partition preserves that, replacing it
342            // would not.
343            let search_color = chrome_glyph(palette.warning);
344            // The code's colours come from the SAME palette as the chrome's,
345            // so a `(deftheme :preset …)` recolours both together. Built here
346            // rather than held on the renderer: a stored copy would be one
347            // more thing to remember to update on a theme change, and the
348            // last one that was stored is exactly why code stayed Nord.
349            let syntax_theme = ChromeSyntax::new(palette);
350            let runs: Vec<(&str, Attrs)> = spans
351                .iter()
352                .flat_map(|sp| {
353                    let syntax = base
354                        .clone()
355                        .color(hl_to_glyph(syntax_theme.color(sp.class)));
356                    split_on_matches(sp.span.range(), &match_bytes)
357                        .into_iter()
358                        .filter_map(|(r, is_match)| {
359                            text.get(r).map(|slice| {
360                                (
361                                    slice,
362                                    if is_match {
363                                        base.clone().color(search_color)
364                                    } else {
365                                        syntax.clone()
366                                    },
367                                )
368                            })
369                        })
370                        .collect::<Vec<_>>()
371                })
372                .collect();
373            buffer.set_rich_text(
374                &mut ctx.text.font_system,
375                runs,
376                &base,
377                Shaping::Advanced,
378                None,
379            );
380            buffer.shape_until_scroll(&mut ctx.text.font_system, false);
381            self.cached_text = Some(buffer);
382            self.last_gen = cur_gen;
383        }
384        // ── 2b. The gutter, shaped as its OWN glyphon buffer.
385        //
386        // Separate rather than prefixed into the text, and this is the load-
387        // bearing reason: the syntax spans and the search-match ranges are
388        // BYTE offsets into `out`. Prefixing each line with `"  12 │ "` would
389        // shift every one of those offsets, so the highlighter would paint the
390        // wrong spans and search would box the wrong characters. Two areas
391        // keeps one coordinate system per buffer.
392        if let Some((line_count, rows)) = gutter_rows {
393            let base = Attrs::new().family(Family::Monospace);
394            let muted = chrome_glyph(palette.text_dim);
395            let gutter_w = gutter_px(self.font_size, line_count);
396            let mut gutter_buf = Buffer::new(&mut ctx.text.font_system, self.metrics);
397            gutter_buf.set_size(&mut ctx.text.font_system, Some(gutter_w), Some(height));
398            // Owned strings first: `set_rich_text` borrows its slices, so the
399            // runs cannot reference temporaries created inside the same call.
400            let mut owned: Vec<(String, GlyphColor)> = Vec::with_capacity(rows.len() * 5);
401            for (ln, mark) in &rows {
402                for cell in escriba_ui::gutter::gutter_cells(*ln, *mark, line_count) {
403                    let color = match cell.role {
404                        escriba_ui::gutter::GutterRole::Mark(sev) => {
405                            chrome_glyph(escriba_ui::chrome::severity_color(&palette, sev))
406                        }
407                        _ => muted,
408                    };
409                    owned.push((cell.text, color));
410                }
411                owned.push(("\n".to_string(), muted));
412            }
413            let runs: Vec<(&str, Attrs)> = owned
414                .iter()
415                .map(|(t, c)| (t.as_str(), base.clone().color(*c)))
416                .collect();
417            gutter_buf.set_rich_text(
418                &mut ctx.text.font_system,
419                runs,
420                &base,
421                Shaping::Advanced,
422                None,
423            );
424            gutter_buf.shape_until_scroll(&mut ctx.text.font_system, false);
425            self.cached_gutter = Some((gutter_buf, gutter_w));
426        }
427
428        let buffer = self
429            .cached_text
430            .as_ref()
431            .expect("cached_text is built on the first frame (last_gen inits to u64::MAX)");
432
433        // Status line — rendered as its own glyphon buffer. The mode is the
434        // BORN fleet mode glyph (`ishou_tokens::EscribaSignals`) + escriba's
435        // canonical uppercase mode label.
436        let signals = EscribaSignals::prescribed();
437        // Built from `EditorState::status_model()` — the ONE model the
438        // ratatui face renders too, so the two can differ only in styling.
439        // This replaces a fixed `format!()` that carried mode/line/col/version
440        // and read neither the prompt nor any message: typing `/foo` on this
441        // face moved the cursor with nothing on screen to show for it, which
442        // is why search looked absent on escriba's default renderer.
443        //
444        // `push_str`, not `format!` — ★★ TYPED EMISSION.
445        let mut status = String::with_capacity(status_core.len() + 24);
446        status.push(' ');
447        status.push_str(mode_glyph(&signals, mode).render(SignalMode::Glyph));
448        status.push(' ');
449        status.push_str(&status_core);
450        status.push_str("  escriba v");
451        status.push_str(env!("CARGO_PKG_VERSION"));
452        status.push(' ');
453        let mut status_buf = Buffer::new(&mut ctx.text.font_system, self.metrics);
454        status_buf.set_size(
455            &mut ctx.text.font_system,
456            Some(width),
457            Some(self.line_height * 2.0),
458        );
459        status_buf.set_text(
460            &mut ctx.text.font_system,
461            &status,
462            &Attrs::new().family(Family::Monospace),
463            Shaping::Advanced,
464        );
465        status_buf.shape_until_scroll(&mut ctx.text.font_system, false);
466
467        let status_color = chrome_glyph(palette.info);
468
469        // The text starts AFTER the gutter when there is one, and at the left
470        // margin when there is not (the start screen). Deriving the offset
471        // from `cached_gutter` rather than from a flag keeps the two from
472        // disagreeing — an indented text column with no gutter beside it would
473        // just look like a broken margin.
474        let text_left = 8.0 + self.cached_gutter.as_ref().map_or(0.0, |(_, w)| *w);
475        let mut text_areas = vec![
476            TextArea {
477                buffer,
478                left: text_left,
479                top: 8.0,
480                scale: 1.0,
481                bounds: TextBounds {
482                    left: text_left as i32,
483                    top: 0,
484                    right: ctx.width as i32,
485                    bottom: (height as i32).max(0),
486                },
487                default_color: fg,
488                custom_glyphs: &[],
489            },
490            TextArea {
491                buffer: &status_buf,
492                left: 8.0,
493                top: (ctx.height as f32 - self.line_height - 4.0).max(0.0),
494                scale: 1.0,
495                bounds: TextBounds {
496                    left: 0,
497                    top: (ctx.height as i32 - self.line_height as i32 - 4).max(0),
498                    right: ctx.width as i32,
499                    bottom: ctx.height as i32,
500                },
501                default_color: status_color,
502                custom_glyphs: &[],
503            },
504        ];
505        if let Some((g, gutter_w)) = self.cached_gutter.as_ref() {
506            text_areas.push(TextArea {
507                buffer: g,
508                left: 8.0,
509                top: 8.0,
510                scale: 1.0,
511                // Bounded to its own columns. Without this a line number
512                // wider than the field would spill into the text column and
513                // overprint the first characters of the file.
514                bounds: TextBounds {
515                    left: 0,
516                    top: 0,
517                    right: (8.0 + gutter_w) as i32,
518                    bottom: (height as i32).max(0),
519                },
520                default_color: chrome_glyph(palette.text_dim),
521                custom_glyphs: &[],
522            });
523        }
524
525        if let Err(e) = ctx.text.prepare(
526            &ctx.gpu.device,
527            &ctx.gpu.queue,
528            ctx.width,
529            ctx.height,
530            text_areas,
531        ) {
532            tracing::warn!(error = %e, "glyphon prepare failed");
533            return clear_frame(ctx);
534        }
535
536        // ── 3. Encode frame. ───────────────────────────────────────────
537        let mut encoder = ctx
538            .gpu
539            .device
540            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
541                label: Some("escriba frame"),
542            });
543        {
544            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
545                label: Some("escriba main pass"),
546                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
547                    view: ctx.surface_view,
548                    resolve_target: None,
549                    ops: wgpu::Operations {
550                        load: wgpu::LoadOp::Clear(ground_bg(&palette)),
551                        store: wgpu::StoreOp::Store,
552                    },
553                })],
554                depth_stencil_attachment: None,
555                timestamp_writes: None,
556                occlusion_query_set: None,
557            });
558            if let Err(e) = ctx.text.render(&mut pass) {
559                tracing::warn!(error = %e, "glyphon render failed");
560            }
561        }
562        ctx.gpu.queue.submit(std::iter::once(encoder.finish()));
563    }
564
565    fn resize(&mut self, width: u32, height: u32) {
566        if let Ok(mut s) = self.state.lock() {
567            // The SAME grid the start screen is laid out on — one estimate,
568            // one status-row reservation, one place to fix either.
569            let grid = cell_grid(width, height, self.font_size, self.line_height);
570            for w in &mut s.layout.windows {
571                w.viewport.visible_lines = u32::from(grid.rows);
572                // The full grid, NOT minus the gutter. The gutter's width
573                // depends on the buffer's line count, which `resize` has no
574                // business knowing; the subtraction happens in `render`,
575                // where the buffer is in scope. Reserving a guessed width
576                // here would be wrong for every file but one.
577                w.viewport.visible_columns = u32::from(grid.cols);
578            }
579        }
580    }
581}
582
583/// The highlight registry escriba renders through: **tree-sitter grammars
584/// (hikari-ts) take precedence** for the languages they cover, and the zero-dep
585/// table backend fills every other language. So `.rs` gets real tree-sitter
586/// highlighting while `.py` / `.lisp` / `.json` / … get the batteries-included
587/// table lexer — and both flow through the same coverage-complete `HlClass`
588/// partition. Registration order is load-bearing: `Ecosystem::resolve` returns
589/// the first matching plugin, so tree-sitter (registered first) wins for its
590/// languages; the table backend is skipped for any language tree-sitter already
591/// covers (no duplicate). If the tree-sitter host fails to build, the table
592/// backend covers everything — never a panic, never an empty registry.
593///
594/// A third tier registers last: [`crate::langs::escriba_local`], the languages
595/// escriba serves that the fleet spine does not ship yet (today: blue). Last
596/// means an upstream hikari backend for the same language always wins, so a
597/// local table retires itself the day hikari grows one — no edit here, and no
598/// window where the two disagree.
599///
600/// Public because the registry IS escriba's language surface: a test that asks
601/// "does the editor know this language?" must be able to ask the same object
602/// the renderer holds, not a reconstruction of it.
603#[must_use]
604pub fn build_ecosystem() -> Ecosystem {
605    let mut eco = Ecosystem::new();
606    let mut covered: Vec<Language> = Vec::new();
607    if let Ok(host) = hikari_ts::TreeSitterHost::builtin() {
608        for p in host.plugins() {
609            covered.push(p.language());
610            eco.register(p);
611        }
612    }
613    for p in hikari_core::langs::builtins() {
614        if !covered.contains(&p.language()) {
615            covered.push(p.language());
616            eco.register(p);
617        }
618    }
619    for p in crate::langs::escriba_local() {
620        if !covered.contains(&p.language()) {
621            eco.register(p);
622        }
623    }
624    eco
625}
626
627/// Pair each start-screen chunk with the colour its ROLE resolves to under
628/// `palette` — the GPU face's half of the role→paint mapping, extracted so
629/// it can be tested without a device.
630///
631/// This is the piece of the splash path that can be wrong in a way glyphon
632/// would not notice: a mis-mapped role paints the menu keys as body text and
633/// renders perfectly. The plumbing either side (buffer sizing, shaping) is
634/// upstream's contract; this is ours.
635///
636/// Borrows from `chunks`, so the returned slices concatenate to exactly the
637/// screen — the coverage-complete partition `set_rich_text` requires.
638///
639/// Public so `tests/gpu_logic.rs` can assert on the REAL mapping rather
640/// than on a reconstruction of it; a test that rebuilt this from
641/// `screen_chunks` would pass even if the renderer stopped calling it.
642#[must_use]
643pub fn splash_runs<'a>(
644    chunks: &'a [escriba_ui::splash::SplashSpan],
645    palette: &ChromePalette,
646) -> Vec<(&'a str, GlyphColor)> {
647    chunks
648        .iter()
649        .map(|c| (c.text.as_str(), chrome_glyph(c.role.color(palette))))
650        .collect()
651}
652
653/// The gutter's width in PIXELS for a buffer of `line_count` lines.
654///
655/// Uses the same `MONO_ADVANCE_RATIO` estimate `cell_grid` does — so the
656/// gutter and the text agree about how wide a column is, and the text starts
657/// exactly where the gutter stops. The column count comes from
658/// `escriba_ui::gutter::gutter_width`, never restated here: the number of
659/// columns this face RESERVES and the number the shared model PAINTS have to
660/// be the same number, and a second definition is how they stop being.
661#[must_use]
662pub fn gutter_px(font_size: f32, line_count: u32) -> f32 {
663    (font_size * MONO_ADVANCE_RATIO).max(1.0) * escriba_ui::gutter::gutter_width(line_count) as f32
664}
665
666/// The character grid a pixel surface maps to.
667///
668/// Both the viewport (how many buffer lines and columns fit) and the start
669/// screen (what canvas to centre on) need this, and they used to compute it
670/// separately: `resize` divided height by line-height and subtracted a row,
671/// `render` subtracted a line-height and then divided. Same intent, two
672/// spellings, two places to get the status-row reservation wrong.
673///
674/// Pure and total — no GPU, no state — which is what makes the one piece of
675/// arithmetic in the GPU face that can actually be WRONG testable without a
676/// device. The `0.6` is glyphon's monospace advance ratio for
677/// `Family::Monospace`: an estimate, and the honest reason the start screen
678/// centres approximately rather than exactly.
679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
680pub struct CellGrid {
681    pub cols: u16,
682    pub rows: u16,
683}
684
685/// Advance-to-font-size ratio for glyphon's monospace face.
686const MONO_ADVANCE_RATIO: f32 = 0.6;
687
688#[must_use]
689pub fn cell_grid(width_px: u32, height_px: u32, font_size: f32, line_height: f32) -> CellGrid {
690    let cell_w = (font_size * MONO_ADVANCE_RATIO).max(1.0);
691    let cell_h = line_height.max(1.0);
692    let cols = (width_px as f32 / cell_w).floor().max(1.0);
693    // One row is reserved for the status line, which is drawn as its own
694    // text area below the main pane. Reserved ONCE, here, so no caller can
695    // forget it or subtract it twice.
696    let rows = (height_px as f32 / cell_h).floor().max(2.0) - 1.0;
697    CellGrid {
698        cols: cols.min(f32::from(u16::MAX)) as u16,
699        rows: rows.min(f32::from(u16::MAX)) as u16,
700    }
701}
702
703/// Utility — clear the frame to the ground colour. Used on error paths.
704///
705/// This one legitimately paints the FLEET-PRESCRIBED ground rather than the
706/// operator's: it runs when the editor state could not be read (no active
707/// buffer, a failed glyphon prepare), which is exactly when the operator's
708/// theme is unknowable. A dark frame in the default theme beats a panic or
709/// an undefined surface.
710fn clear_frame(ctx: &mut RenderContext<'_>) {
711    let palette = ChromePalette::prescribed();
712    let mut encoder = ctx
713        .gpu
714        .device
715        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
716            label: Some("escriba clear"),
717        });
718    {
719        let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
720            label: Some("escriba clear pass"),
721            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
722                view: ctx.surface_view,
723                resolve_target: None,
724                ops: wgpu::Operations {
725                    load: wgpu::LoadOp::Clear(ground_bg(&palette)),
726                    store: wgpu::StoreOp::Store,
727                },
728            })],
729            depth_stencil_attachment: None,
730            timestamp_writes: None,
731            occlusion_query_set: None,
732        });
733    }
734    ctx.gpu.queue.submit(std::iter::once(encoder.finish()));
735}
736
737/// The editor ground as `wgpu::Color`, resolved from the fleet-prescribed
738/// theme's `background` role. Gamma-correct: the sRGB token is promoted
739/// through `ishou_tokens`' typed sRGB→linear path so it composites
740/// correctly on the linear-storage surface.
741fn ground_bg(c: &ChromePalette) -> wgpu::Color {
742    Srgb::from(c.background).to_linear().with_alpha(1.0).into()
743}
744
745/// ishou `Rgb` → glyphon `Color` (sRGB u8 RGBA, opaque). Theme-agnostic —
746/// was `vellum_glyph`, back when the paint path was hardwired to Vellum.
747/// Cut `range` wherever a match in `matches` starts or ends inside it.
748///
749/// Returns `(sub_range, is_match)` pieces that are contiguous, in order, and
750/// exactly cover `range` — the property `set_rich_text` depends on. `matches`
751/// are byte ranges into the SAME string `range` indexes.
752///
753/// Splitting the existing syntax partition (rather than building a second one)
754/// is what keeps the two colour sources composable: a match inside a string
755/// literal recolours only the matched bytes and the literal keeps its colour
756/// either side.
757fn split_on_matches(
758    range: std::ops::Range<usize>,
759    matches: &[(usize, usize)],
760) -> Vec<(std::ops::Range<usize>, bool)> {
761    let mut cuts: Vec<usize> = vec![range.start, range.end];
762    for &(a, b) in matches {
763        if a > range.start && a < range.end {
764            cuts.push(a);
765        }
766        if b > range.start && b < range.end {
767            cuts.push(b);
768        }
769    }
770    if cuts.len() == 2 {
771        // No boundary crosses this span — the common case, so avoid the
772        // sort/dedup entirely.
773        let hit = matches
774            .iter()
775            .any(|&(a, b)| a <= range.start && b >= range.end);
776        return vec![(range, hit)];
777    }
778    cuts.sort_unstable();
779    cuts.dedup();
780    cuts.windows(2)
781        .map(|w| {
782            let (a, b) = (w[0], w[1]);
783            let hit = matches.iter().any(|&(ms, me)| ms <= a && me >= b);
784            (a..b, hit)
785        })
786        .collect()
787}
788
789fn chrome_glyph(c: Rgb) -> GlyphColor {
790    GlyphColor::rgba(c.r, c.g, c.b, 0xFF)
791}
792
793/// hikari `Rgb` (sRGB u8) → glyphon `Color` (opaque) — the syntax-span paint.
794fn hl_to_glyph(c: HlRgb) -> GlyphColor {
795    GlyphColor::rgba(c.r, c.g, c.b, 0xFF)
796}
797
798/// Mode indicator color — used by higher-layer rendering paths that want a
799/// glance-readable color. Named by ROLE so the hue follows the active theme:
800/// Normal info, Insert success, Visual accent, Command warning.
801#[must_use]
802pub fn mode_color(c: &ChromePalette, mode: Mode) -> Rgb {
803    match mode {
804        Mode::Insert => c.success,
805        Mode::Command => c.warning,
806        Mode::Visual | Mode::VisualLine => c.accent,
807        Mode::Normal => c.info,
808    }
809}
810
811/// The [`CursorShape`](escriba_core::CursorShape) the GPU backend should
812/// draw for `mode`. Derived from the single typed `Mode::cursor_shape`
813/// mapping shared with the TUI backend — so the GPU cursor (once it gains a
814/// dedicated glyph; today the buffer text carries the caret) renders the
815/// same shape the TUI does for any given mode. Exposed now so the shape is
816/// a typed value at the GPU layer, not a renderer-local literal later.
817#[must_use]
818pub fn cursor_shape(mode: Mode) -> escriba_core::CursorShape {
819    mode.cursor_shape()
820}
821
822/// Map an editor [`Mode`] to its fleet [`Signal`](ishou_tokens::Signal)
823/// from [`EscribaSignals`].
824///
825/// `VisualLine` shares `mode_visual` with `Visual` — the fleet signal set
826/// has one visual signal, matching how [`mode_color`] groups the two.
827#[must_use]
828pub fn mode_glyph(sig: &EscribaSignals, mode: Mode) -> &ishou_tokens::Signal {
829    match mode {
830        Mode::Normal => &sig.mode_normal,
831        Mode::Insert => &sig.mode_insert,
832        Mode::Visual | Mode::VisualLine => &sig.mode_visual,
833        Mode::Command => &sig.mode_command,
834    }
835}
836
837#[cfg(test)]
838mod tests {
839
840    // ── search-highlight overlay ──────────────────────────────────────
841    //
842    // set_rich_text requires a coverage-complete, non-overlapping, sorted
843    // partition. Splitting the syntax partition preserves that; these pin it,
844    // because a violation shows up as garbled text rather than a panic.
845
846    /// The invariant, asserted directly: pieces are contiguous, ordered, and
847    /// exactly cover the input range.
848    fn assert_partition(range: std::ops::Range<usize>, out: &[(std::ops::Range<usize>, bool)]) {
849        assert!(!out.is_empty(), "a range must yield at least one piece");
850        assert_eq!(out[0].0.start, range.start, "starts at the range start");
851        assert_eq!(out[out.len() - 1].0.end, range.end, "ends at the range end");
852        for w in out.windows(2) {
853            assert_eq!(
854                w[0].0.end, w[1].0.start,
855                "pieces are contiguous, no gap or overlap"
856            );
857        }
858    }
859
860    #[test]
861    fn a_span_with_no_match_is_returned_whole() {
862        let out = split_on_matches(0..10, &[]);
863        assert_eq!(out.len(), 1, "no needless splitting");
864        assert!(!out[0].1);
865        assert_partition(0..10, &out);
866    }
867
868    #[test]
869    fn a_match_covering_the_whole_span_marks_it_without_splitting() {
870        let out = split_on_matches(4..8, &[(0, 20)]);
871        assert_eq!(out.len(), 1);
872        assert!(out[0].1, "fully covered span is a match");
873        assert_partition(4..8, &out);
874    }
875
876    #[test]
877    fn a_match_starting_mid_span_splits_it_in_two() {
878        // Syntax span 0..10, match 5..10 -> [0..5 plain][5..10 match]
879        let out = split_on_matches(0..10, &[(5, 10)]);
880        assert_eq!(out.len(), 2);
881        assert_eq!(out[0], (0..5, false));
882        assert_eq!(out[1], (5..10, true));
883        assert_partition(0..10, &out);
884    }
885
886    #[test]
887    fn a_match_inside_a_span_splits_it_in_three() {
888        // This is the case that matters: a match inside a string literal must
889        // recolour only the matched bytes, leaving the literal coloured
890        // either side.
891        let out = split_on_matches(0..10, &[(3, 6)]);
892        assert_eq!(out.len(), 3);
893        assert_eq!(out[0], (0..3, false));
894        assert_eq!(out[1], (3..6, true));
895        assert_eq!(out[2], (6..10, false));
896        assert_partition(0..10, &out);
897    }
898
899    #[test]
900    fn two_matches_in_one_span_both_split() {
901        let out = split_on_matches(0..20, &[(2, 4), (10, 12)]);
902        assert_partition(0..20, &out);
903        let hits: Vec<_> = out
904            .iter()
905            .filter(|(_, m)| *m)
906            .map(|(r, _)| r.clone())
907            .collect();
908        assert_eq!(hits, vec![2..4, 10..12]);
909    }
910
911    #[test]
912    fn a_match_entirely_outside_the_span_changes_nothing() {
913        let out = split_on_matches(10..20, &[(0, 5)]);
914        assert_eq!(out.len(), 1);
915        assert!(!out[0].1);
916        assert_partition(10..20, &out);
917    }
918
919    #[test]
920    fn a_match_touching_the_span_edge_does_not_create_an_empty_piece() {
921        // Boundary exactly at the edge must not emit a zero-width run.
922        for m in [(0usize, 10usize), (10, 20)] {
923            let out = split_on_matches(10..20, &[m]);
924            assert_partition(10..20, &out);
925            assert!(
926                out.iter().all(|(r, _)| r.start < r.end),
927                "no empty piece for {m:?}"
928            );
929        }
930    }
931
932    #[test]
933    fn adjacent_matches_do_not_produce_duplicate_cuts() {
934        // Two matches meeting at 5 must yield one cut there, not two.
935        let out = split_on_matches(0..10, &[(0, 5), (5, 10)]);
936        assert_partition(0..10, &out);
937        assert!(out.iter().all(|(r, _)| r.start < r.end));
938        assert!(out.iter().all(|(_, m)| *m), "both halves are matches");
939    }
940    use super::*;
941    use escriba_buffer::BufferSet;
942
943    #[test]
944    fn ground_is_the_prescribed_theme_promoted_to_linear() {
945        let bg = ground_bg(&ChromePalette::prescribed());
946        // Was pinned to Vellum's warm parchment (night0 #16140E, r >= g >= b).
947        // The prescribed theme is now Nord, whose ground is COOL (b >= r), so
948        // the old warmth assertion was theme-specific and had to go. What is
949        // actually invariant — and worth asserting — is that the ground is a
950        // dark, opaque, gamma-correct promotion of the theme's own
951        // background role.
952        let want = Srgb::from(ChromePalette::prescribed().background)
953            .to_linear()
954            .with_alpha(1.0);
955        let want: wgpu::Color = want.into();
956        assert!((bg.r - want.r).abs() < 1e-6, "r {} != {}", bg.r, want.r);
957        assert!((bg.g - want.g).abs() < 1e-6, "g {} != {}", bg.g, want.g);
958        assert!((bg.b - want.b).abs() < 1e-6, "b {} != {}", bg.b, want.b);
959        assert_eq!(bg.a, 1.0);
960        // Dark ground: an editor background must stay well below mid-grey in
961        // linear space whatever the theme.
962        assert!(
963            bg.r < 0.1 && bg.g < 0.1 && bg.b < 0.1,
964            "ground is not dark: {bg:?}"
965        );
966    }
967
968    #[test]
969    fn renderer_construction_is_cheap() {
970        let mut bufs = BufferSet::new();
971        let id = bufs.scratch("hello\n");
972        let state = Arc::new(Mutex::new(EditorState::new_with_buffer(bufs, id)));
973        let _r = GpuRenderer::new(state);
974    }
975
976    /// Phase 4: the render Ecosystem serves `.rs` from the **tree-sitter**
977    /// backend (hikari-ts) and other languages from the table backend — both a
978    /// coverage-complete `HlClass` partition. Proves real tree-sitter
979    /// highlighting is wired into the live render path (not just the table lexer).
980    #[test]
981    fn ecosystem_uses_tree_sitter_for_rust_and_table_for_the_rest() {
982        use hikari_core::{HlClass, Language};
983        let eco = build_ecosystem();
984        // .rs resolves to a grammar and produces real (non-Plain) classification.
985        assert_eq!(eco.resolve("src/main.rs"), Language("rust"));
986        let rs = eco
987            .highlighter_for_path("src/main.rs")
988            .highlight("fn main() { let x = 42; }");
989        assert!(
990            rs.iter().any(|s| s.class != HlClass::Plain),
991            "rust must be really highlighted (tree-sitter or table)",
992        );
993        // Python is also served (tree-sitter, once hikari-ts ships that grammar;
994        // the table backend covers it otherwise) — either way it classifies.
995        assert_eq!(eco.resolve("app.py"), Language("python"));
996        // A tree-sitter-uncovered language still resolves via the table backend.
997        assert_eq!(eco.resolve("init.lisp"), Language("lisp"));
998        // An unknown extension is still total (plain text, never a panic).
999        assert_eq!(eco.resolve("notes.xyz"), hikari_core::PLAIN_TEXT);
1000    }
1001
1002    #[test]
1003    fn mode_colors_differ_by_mode() {
1004        let n = mode_color(&ChromePalette::prescribed(), Mode::Normal);
1005        let i = mode_color(&ChromePalette::prescribed(), Mode::Insert);
1006        let v = mode_color(&ChromePalette::prescribed(), Mode::Visual);
1007        assert_ne!((n.r, n.g, n.b), (i.r, i.g, i.b));
1008        assert_ne!((n.r, n.g, n.b), (v.r, v.g, v.b));
1009    }
1010
1011    #[test]
1012    fn cursor_shape_tracks_mode() {
1013        use escriba_core::CursorShape;
1014        assert_eq!(cursor_shape(Mode::Normal), CursorShape::Block);
1015        assert_eq!(cursor_shape(Mode::Command), CursorShape::Block);
1016        assert_eq!(cursor_shape(Mode::Insert), CursorShape::Bar);
1017        assert_eq!(cursor_shape(Mode::Visual), CursorShape::Underline);
1018        assert_eq!(cursor_shape(Mode::VisualLine), CursorShape::Underline);
1019    }
1020
1021    /// Mode pills map to ROLES, not to one theme's hexes. This test used to
1022    /// pin the four Vellum values (`#94BBB8` …), which is precisely why it
1023    /// went red the moment the fleet theme moved — a test asserting a
1024    /// theme's spelling has to be rewritten on every theme change, and is
1025    /// no evidence the mapping is right. Asserting role identity instead
1026    /// survives the move AND still catches a mis-wired pill.
1027    #[test]
1028    fn mode_colors_are_role_pills() {
1029        let c = ChromePalette::prescribed();
1030        assert_eq!(
1031            mode_color(&ChromePalette::prescribed(), Mode::Normal).hex(),
1032            c.info.hex(),
1033            "Normal = info"
1034        );
1035        assert_eq!(
1036            mode_color(&ChromePalette::prescribed(), Mode::Insert).hex(),
1037            c.success.hex(),
1038            "Insert = success"
1039        );
1040        assert_eq!(
1041            mode_color(&ChromePalette::prescribed(), Mode::Visual).hex(),
1042            c.accent.hex(),
1043            "Visual = accent"
1044        );
1045        assert_eq!(
1046            mode_color(&ChromePalette::prescribed(), Mode::Command).hex(),
1047            c.warning.hex(),
1048            "Command = warning"
1049        );
1050
1051        // The four pills must be mutually distinct, or the mode is not
1052        // glance-readable regardless of which theme is active.
1053        let mut seen = std::collections::BTreeSet::new();
1054        for m in [Mode::Normal, Mode::Insert, Mode::Visual, Mode::Command] {
1055            assert!(
1056                seen.insert(mode_color(&ChromePalette::prescribed(), m).hex()),
1057                "{m:?} duplicates another pill"
1058            );
1059        }
1060    }
1061
1062    /// Forcing function: the status-line mode glyphs are sourced from the
1063    /// fleet `EscribaSignals` vocabulary, not hand-picked literals. Pins
1064    /// the geometric `Glyph`-mode marks so drift in either escriba or
1065    /// ishou surfaces here.
1066    #[test]
1067    fn mode_glyphs_are_fleet_signals() {
1068        let sig = EscribaSignals::prescribed();
1069        assert_eq!(
1070            mode_glyph(&sig, Mode::Normal).render(SignalMode::Glyph),
1071            "◆"
1072        );
1073        assert_eq!(
1074            mode_glyph(&sig, Mode::Insert).render(SignalMode::Glyph),
1075            "▸"
1076        );
1077        assert_eq!(
1078            mode_glyph(&sig, Mode::Visual).render(SignalMode::Glyph),
1079            "▮"
1080        );
1081        assert_eq!(
1082            mode_glyph(&sig, Mode::VisualLine).render(SignalMode::Glyph),
1083            "▮"
1084        );
1085        assert_eq!(
1086            mode_glyph(&sig, Mode::Command).render(SignalMode::Glyph),
1087            ":"
1088        );
1089    }
1090
1091    /// Fleet convergence guard: escriba's GPU chrome paints whatever
1092    /// `ChromePalette::prescribed()` resolves, which is
1093    /// `FleetTheme::prescribed_default()` BY CONSTRUCTION — so this Guard
1094    /// cannot be satisfied by a stale hand-written constant.
1095    ///
1096    /// It previously hardcoded `FleetTheme::Vellum` to match a paint path
1097    /// hardwired to `VellumPalette::vellum()`. When the fleet moved its
1098    /// prescribed theme to PlemeDark (Nord) this went RED — correctly, since
1099    /// the GPU backend really was painting the wrong theme while the TUI
1100    /// face and the rest of the fleet (mado, tear, frostmourne, …) moved on.
1101    /// Smallest real editor state — a scratch buffer. The theming tests
1102    /// care about the palette, not the buffer, but GpuRenderer owns state.
1103    fn test_renderer() -> GpuRenderer {
1104        let mut bufs = escriba_buffer::BufferSet::new();
1105        let id = bufs.scratch("");
1106        GpuRenderer::new(Arc::new(Mutex::new(EditorState::new_with_buffer(bufs, id))))
1107    }
1108
1109    #[test]
1110    fn default_theme_is_the_fleet_prescribed_nord() {
1111        // Nord is the default because the FLEET says so — asserted against
1112        // FleetTheme::prescribed_default(), never a hand-written "nord",
1113        // so a fleet re-point cannot leave escriba silently behind.
1114        let r = test_renderer();
1115        let want = ChromePalette::for_theme(ishou_tokens::FleetTheme::prescribed_default());
1116        assert_eq!(r.chrome().hex_tuple(), want.hex_tuple());
1117    }
1118
1119    #[test]
1120    fn set_theme_actually_changes_what_is_painted() {
1121        // The wiring this exists to prove: before it, every paint site
1122        // called ChromePalette::prescribed() directly, so (deftheme :preset)
1123        // resolved to a real FleetTheme that NOTHING consumed. If set_theme
1124        // ever stops reaching the paint path, this fails.
1125        let mut r = test_renderer();
1126        let before = r.chrome().hex_tuple();
1127        r.set_theme(ishou_tokens::FleetTheme::Vellum);
1128        let after = r.chrome().hex_tuple();
1129        assert_ne!(
1130            before, after,
1131            "switching to Vellum must change the painted palette"
1132        );
1133        assert_eq!(
1134            after,
1135            ChromePalette::for_theme(ishou_tokens::FleetTheme::Vellum).hex_tuple()
1136        );
1137        // And it is reversible — a theme is a value, not a one-way latch.
1138        r.set_theme(ishou_tokens::FleetTheme::prescribed_default());
1139        assert_eq!(r.chrome().hex_tuple(), before);
1140    }
1141
1142    #[test]
1143    fn escriba_gpu_chrome_converges_with_fleet() {
1144        use ishou_tokens::{FleetTheme, convergence::Guard};
1145        let chrome_theme = FleetTheme::prescribed_default();
1146        Guard::for_app("escriba-render")
1147            .expect_theme(chrome_theme)
1148            .run();
1149    }
1150}