Skip to main content

twrite_gpui/
lib.rs

1//! GPUI rendering, canvas text-shaping, and interactive editor component for twrite.
2
3/// Canvas element handling prepaint, layout, and GPU quad rendering.
4pub mod canvas;
5/// Configuration settings for font size, line height, wrapping, and gutters.
6pub mod config;
7/// Main editor entity, keybindings, selections, and hook executions.
8pub mod editor;
9/// Frame-rate HUD for interactive performance testing.
10pub mod fps;
11/// Translation helpers from GPUI key events to normalized twrite key events.
12pub mod input;
13/// Per-version viewport cache for highlight/conceal/link inputs.
14pub mod layout_cache;
15/// Dumb renderer for the shared headless prompt (bottom bar, palette).
16pub mod prompt_bar;
17/// Color palettes, Catppuccin themes, and syntax token style resolution.
18pub mod theme;
19
20pub use canvas::{EditorCanvas, LineMetrics, RunFonts, build_line_text_runs};
21pub use config::EditorConfig;
22pub use editor::{Editor, FaceAvailability, SelectionGranularity, VisibleLineLayout, VisibleLink};
23pub use fps::{FrameStats, fps_badge};
24pub use layout_cache::{CachedInput, LayoutCache};
25pub use prompt_bar::PromptBar;
26pub use theme::{EditorTheme, ResolvedTokenStyle, SyntaxTheme};
27
28#[cfg(test)]
29mod tests {
30    use super::*;
31    use gpui::{Font, FontStyle, FontWeight, px};
32    use twrite_core::{HighlightTag, StyleSpan};
33
34    #[test]
35    fn test_line_metrics_quote_detection_when_concealed() {
36        let spans = vec![StyleSpan::tag(0..2, HighlightTag::Blockquote)];
37        let metrics = LineMetrics::for_line("> Quote", "Quote", &spans, px(16.0), px(22.0));
38        assert!(metrics.is_quote);
39        assert!(!metrics.is_code_block);
40        assert_eq!(metrics.line_height, px(22.0));
41
42        // No tag -> no quote, proving detection is tag-driven not string-driven.
43        let plain = LineMetrics::for_line("> Quote", "> Quote", &[], px(16.0), px(22.0));
44        assert!(!plain.is_quote);
45    }
46
47    #[test]
48    fn test_line_metrics_code_fence() {
49        let active_spans = vec![StyleSpan::tag(0..7, HighlightTag::Code)];
50        let metrics =
51            LineMetrics::for_line("```rust", "```rust", &active_spans, px(16.0), px(22.0));
52        assert_eq!(metrics.line_height, px(22.0));
53        assert!(metrics.is_code_block);
54    }
55
56    #[test]
57    fn test_line_metrics_code_empty_line_inside_block() {
58        let spans = vec![StyleSpan::tag(0..0, HighlightTag::Code)];
59        let metrics = LineMetrics::for_line("", "", &spans, px(16.0), px(22.0));
60        assert!(metrics.is_code_block);
61        assert_eq!(metrics.line_height, px(22.0));
62    }
63
64    #[test]
65    fn test_build_line_text_runs_code_block_disables_text_bg() {
66        let theme = EditorTheme::default();
67        let font: Font = gpui::font(".SystemUIFont");
68        let spans = vec![StyleSpan::tag(0..4, HighlightTag::Code)];
69
70        let runs_block = build_line_text_runs(
71            "test",
72            &spans,
73            None,
74            &RunFonts {
75                base: &font,
76                code: &font,
77            },
78            &theme,
79            true,
80            false,
81        );
82        assert_eq!(runs_block.len(), 1);
83        assert!(runs_block[0].background_color.is_none());
84
85        let runs_inline = build_line_text_runs(
86            "test",
87            &spans,
88            None,
89            &RunFonts {
90                base: &font,
91                code: &font,
92            },
93            &theme,
94            false,
95            false,
96        );
97        assert_eq!(runs_inline.len(), 1);
98        assert_eq!(runs_inline[0].background_color, Some(theme.syntax.code_bg));
99
100        let runs_task_checked = build_line_text_runs(
101            "test",
102            &spans,
103            None,
104            &RunFonts {
105                base: &font,
106                code: &font,
107            },
108            &theme,
109            false,
110            true,
111        );
112        assert_eq!(runs_task_checked.len(), 1);
113        assert!(runs_task_checked[0].strikethrough.is_some());
114    }
115
116    #[test]
117    fn test_line_metrics_task_state_detection() {
118        let unchecked_spans = vec![StyleSpan::tag(0..6, HighlightTag::TaskUnchecked)];
119        let unchecked =
120            LineMetrics::for_line("- [ ] Todo", "Todo", &unchecked_spans, px(16.0), px(22.0));
121        assert_eq!(unchecked.task_state, Some(false));
122
123        let checked_spans = vec![StyleSpan::tag(0..6, HighlightTag::TaskChecked)];
124        let checked =
125            LineMetrics::for_line("- [x] Done", "Done", &checked_spans, px(16.0), px(22.0));
126        assert_eq!(checked.task_state, Some(true));
127
128        let plain = LineMetrics::for_line("Plain text", "Plain text", &[], px(16.0), px(22.0));
129        assert_eq!(plain.task_state, None);
130
131        // Raw task syntax without tags must NOT be detected (custom-language proof).
132        let raw_only = LineMetrics::for_line("- [ ] Todo", "- [ ] Todo", &[], px(16.0), px(22.0));
133        assert_eq!(raw_only.task_state, None);
134    }
135
136    #[test]
137    fn test_line_metrics_thematic_break_detection() {
138        let spans = vec![StyleSpan::tag(0..3, HighlightTag::HorizontalRule)];
139        let metrics = LineMetrics::for_line("---", "---", &spans, px(16.0), px(22.0));
140        assert!(metrics.is_thematic_break);
141
142        let plain = LineMetrics::for_line("---", "---", &[], px(16.0), px(22.0));
143        assert!(!plain.is_thematic_break);
144    }
145
146    #[test]
147    fn test_visible_link_layout() {
148        let link = VisibleLink {
149            bounds: gpui::Bounds::new(
150                gpui::point(px(50.0), px(20.0)),
151                gpui::size(px(60.0), px(20.0)),
152            ),
153            url: "https://example.com".to_string(),
154        };
155        assert!(link.bounds.contains(&gpui::point(px(60.0), px(25.0))));
156        assert!(!link.bounds.contains(&gpui::point(px(120.0), px(25.0))));
157        assert_eq!(link.url, "https://example.com");
158    }
159
160    /// Returns the font of the run covering byte `idx` in `text`.
161    fn run_font_at(runs: &[gpui::TextRun], text: &str, idx: usize) -> gpui::Font {
162        let mut offset = 0;
163        for run in runs {
164            if idx < offset + run.len {
165                return run.font.clone();
166            }
167            offset += run.len;
168        }
169        panic!("idx {idx} out of runs for {text:?}");
170    }
171
172    #[test]
173    fn test_build_line_text_runs_bold_italic_fonts() {
174        // Proves the span -> run pipeline requests real faces: if bold/italic
175        // don't *paint* differently, the cause is missing OS font faces
176        // (silent font-kit fallback), not this pipeline.
177        let theme = EditorTheme::default();
178        let font: Font = gpui::font(".SystemUIFont");
179        let text = "Hi bold and ital!";
180        let spans = vec![
181            StyleSpan::tag(3..7, HighlightTag::Bold),
182            StyleSpan::tag(12..16, HighlightTag::Italic),
183        ];
184        let runs = build_line_text_runs(
185            text,
186            &spans,
187            None,
188            &RunFonts {
189                base: &font,
190                code: &font,
191            },
192            &theme,
193            false,
194            false,
195        );
196
197        let plain = run_font_at(&runs, text, 0);
198        assert_eq!(plain.weight, FontWeight::NORMAL);
199        assert_eq!(plain.style, FontStyle::Normal);
200
201        let bold = run_font_at(&runs, text, 4);
202        assert_eq!(bold.weight, FontWeight::BOLD);
203        assert_eq!(bold.style, FontStyle::Normal);
204
205        let italic = run_font_at(&runs, text, 13);
206        assert_eq!(italic.weight, FontWeight::NORMAL);
207        assert_eq!(italic.style, FontStyle::Italic);
208    }
209
210    #[test]
211    fn test_build_line_text_runs_code_spans_use_code_font() {
212        let theme = EditorTheme::default();
213        let font: Font = gpui::font(".SystemUIFont");
214        let code_font = Font {
215            family: "CodeFam".into(),
216            ..gpui::font(".SystemUIFont")
217        };
218        let text = "a `b` c";
219        let spans = vec![StyleSpan::tag(2..5, HighlightTag::Code)];
220        let runs = build_line_text_runs(
221            text,
222            &spans,
223            None,
224            &RunFonts {
225                base: &font,
226                code: &code_font,
227            },
228            &theme,
229            false,
230            false,
231        );
232
233        assert_eq!(
234            run_font_at(&runs, text, 0).family.as_ref(),
235            font.family.as_ref()
236        );
237        assert_eq!(run_font_at(&runs, text, 3).family.as_ref(), "CodeFam");
238        assert_eq!(
239            run_font_at(&runs, text, 6).family.as_ref(),
240            font.family.as_ref()
241        );
242    }
243
244    #[test]
245    fn test_line_metrics_heading_levels() {
246        let h1 = LineMetrics::for_line(
247            "# A",
248            "# A",
249            &[StyleSpan::tag(0..3, HighlightTag::Heading(1))],
250            px(16.0),
251            px(22.0),
252        );
253        assert_eq!(h1.font_size, px(16.0) * 2.0);
254
255        let h4 = LineMetrics::for_line(
256            "#### D",
257            "#### D",
258            &[StyleSpan::tag(0..6, HighlightTag::Heading(4))],
259            px(16.0),
260            px(22.0),
261        );
262        assert_eq!(h4.font_size, px(16.0) * 1.125);
263
264        // Lowest level wins when several heading tags share a line.
265        let mixed = LineMetrics::for_line(
266            "mix",
267            "mix",
268            &[
269                StyleSpan::tag(0..3, HighlightTag::Heading(3)),
270                StyleSpan::tag(0..3, HighlightTag::Heading(1)),
271            ],
272            px(16.0),
273            px(22.0),
274        );
275        assert_eq!(mixed.font_size, px(16.0) * 2.0);
276
277        // Out-of-range levels render bold at the base size.
278        let odd = LineMetrics::for_line(
279            "odd",
280            "odd",
281            &[StyleSpan::tag(0..3, HighlightTag::Heading(9))],
282            px(16.0),
283            px(22.0),
284        );
285        assert_eq!(odd.font_size, px(16.0));
286        assert_eq!(odd.line_height, px(22.0));
287    }
288
289    #[test]
290    fn test_custom_tag_colors() {
291        let mut theme = EditorTheme::default();
292        // Unregistered customs fall back to the foreground.
293        assert_eq!(
294            theme.tag_color(HighlightTag::Custom("speaker")),
295            theme.foreground
296        );
297
298        theme
299            .syntax
300            .set_custom_tag_color("speaker", gpui::rgb(0xf9e2af).into());
301        assert_eq!(
302            theme.tag_color(HighlightTag::Custom("speaker")),
303            gpui::rgb(0xf9e2af).into()
304        );
305        // Other names are unaffected.
306        assert_eq!(
307            theme.tag_color(HighlightTag::Custom("dialogue")),
308            theme.foreground
309        );
310    }
311
312    #[test]
313    fn test_heading_level_colors() {
314        let theme = EditorTheme::default();
315        assert_eq!(
316            theme.tag_color(HighlightTag::Heading(1)),
317            theme.syntax.heading1
318        );
319        assert_eq!(
320            theme.tag_color(HighlightTag::Heading(2)),
321            theme.syntax.heading2
322        );
323        assert_eq!(
324            theme.tag_color(HighlightTag::Heading(4)),
325            theme.syntax.heading3
326        );
327    }
328}