1use gpui::{
2 App, Bounds, ClipboardItem, Component, Context, ElementId, Entity, FocusHandle, Focusable,
3 FontStyle, FontWeight, GlobalElementId, Hsla, IntoElement, LayoutId, MouseButton,
4 MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, Render, RenderOnce,
5 Rgba, ShapedLine, SharedString, Style, StyledText, TextRun, TextStyle, UnderlineStyle,
6 WhiteSpace, Window, actions, div, fill, point, prelude::*, px, relative, size,
7};
8use liora_core::{Config, stable_unique_id};
9use liora_icons::Icon;
10use liora_icons_lucide::IconName;
11use std::{
12 collections::{HashMap, VecDeque},
13 hash::{Hash, Hasher},
14 ops::Range,
15 sync::{Arc, Mutex, MutexGuard, OnceLock},
16 time::{Duration, Instant},
17};
18use syntect::{
19 easy::HighlightLines,
20 highlighting::{FontStyle as SyntectFontStyle, Style as SyntectStyle, Theme},
21 parsing::SyntaxSet,
22 util::LinesWithEndings,
23};
24use two_face::theme::{EmbeddedLazyThemeSet, EmbeddedThemeName};
25
26actions!(code_block_actions, [CodeSelectAll, CodeCopy]);
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum CodeLanguage {
30 PlainText,
31 Rust,
32 Toml,
33 Json,
34 Markdown,
35 Shell,
36 TypeScript,
37 JavaScript,
38}
39
40impl CodeLanguage {
41 pub fn label(self) -> &'static str {
42 match self {
43 Self::PlainText => "text",
44 Self::Rust => "rust",
45 Self::Toml => "toml",
46 Self::Json => "json",
47 Self::Markdown => "markdown",
48 Self::Shell => "shell",
49 Self::TypeScript => "typescript",
50 Self::JavaScript => "javascript",
51 }
52 }
53
54 fn syntect_token(self) -> &'static str {
55 match self {
56 Self::PlainText => "txt",
57 Self::Rust => "rs",
58 Self::Toml => "toml",
59 Self::Json => "json",
60 Self::Markdown => "md",
61 Self::Shell => "sh",
62 Self::TypeScript => "ts",
63 Self::JavaScript => "js",
64 }
65 }
66
67 pub fn from_label(label: &str) -> Self {
68 match label.trim().to_ascii_lowercase().as_str() {
69 "rs" | "rust" => Self::Rust,
70 "toml" => Self::Toml,
71 "json" => Self::Json,
72 "md" | "markdown" => Self::Markdown,
73 "sh" | "bash" | "shell" | "zsh" => Self::Shell,
74 "ts" | "tsx" | "typescript" => Self::TypeScript,
75 "js" | "jsx" | "javascript" => Self::JavaScript,
76 _ => Self::PlainText,
77 }
78 }
79}
80
81impl From<&str> for CodeLanguage {
82 fn from(value: &str) -> Self {
83 Self::from_label(value)
84 }
85}
86
87impl From<String> for CodeLanguage {
88 fn from(value: String) -> Self {
89 Self::from_label(&value)
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum CodeFormat {
95 Block,
96 Inline,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub enum CodeHighlighter {
101 Syntect,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
105pub enum CodeTheme {
106 Auto,
107 Light,
108 Dark,
109 LioraLight,
110 LioraDark,
111 GitHubLight,
112 GitHubDark,
113 OneDark,
114 Nord,
115 Dracula,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119enum CodeThemeMode {
120 Light,
121 Dark,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125struct ResolvedCodeTheme {
126 theme: CodeTheme,
127 mode: CodeThemeMode,
128}
129
130impl CodeTheme {
131 pub fn label(self) -> &'static str {
132 match self {
133 Self::Auto => "auto",
134 Self::Light | Self::LioraLight => "liora-light",
135 Self::Dark | Self::LioraDark => "liora-dark",
136 Self::GitHubLight => "github-light",
137 Self::GitHubDark => "github-dark",
138 Self::OneDark => "one-dark",
139 Self::Nord => "nord",
140 Self::Dracula => "dracula",
141 }
142 }
143
144 fn mode(self) -> CodeThemeMode {
145 match self {
146 Self::Auto | Self::Light | Self::LioraLight | Self::GitHubLight => CodeThemeMode::Light,
147 Self::Dark
148 | Self::LioraDark
149 | Self::GitHubDark
150 | Self::OneDark
151 | Self::Nord
152 | Self::Dracula => CodeThemeMode::Dark,
153 }
154 }
155
156 fn embedded_theme(self) -> EmbeddedThemeName {
157 match self {
158 Self::Auto | Self::Light | Self::LioraLight => EmbeddedThemeName::CatppuccinLatte,
159 Self::Dark | Self::LioraDark => EmbeddedThemeName::CatppuccinMocha,
160 Self::GitHubLight => EmbeddedThemeName::Github,
161 Self::GitHubDark => EmbeddedThemeName::OneHalfDark,
162 Self::OneDark => EmbeddedThemeName::TwoDark,
163 Self::Nord => EmbeddedThemeName::Nord,
164 Self::Dracula => EmbeddedThemeName::Dracula,
165 }
166 }
167}
168
169pub struct CodeBlock {
170 code: SharedString,
171 language: CodeLanguage,
172 format: CodeFormat,
173 highlighter: CodeHighlighter,
174 theme: CodeTheme,
175 copyable: bool,
176 selectable: bool,
177 id: Option<ElementId>,
178 on_copy: Option<Arc<CodeCopyCallback>>,
179}
180
181type CodeCopyCallback = dyn Fn(&str, &mut Window, &mut App) + 'static;
182
183impl CodeBlock {
184 pub fn new(code: impl Into<SharedString>) -> Self {
185 Self {
186 code: code.into(),
187 language: CodeLanguage::PlainText,
188 format: CodeFormat::Block,
189 highlighter: CodeHighlighter::Syntect,
190 theme: CodeTheme::Auto,
191 copyable: true,
192 selectable: true,
193 id: None,
194 on_copy: None,
195 }
196 }
197
198 pub fn language(mut self, language: impl Into<CodeLanguage>) -> Self {
199 self.language = language.into();
200 self
201 }
202
203 pub fn rust(self) -> Self {
204 self.language(CodeLanguage::Rust)
205 }
206
207 pub fn toml(self) -> Self {
208 self.language(CodeLanguage::Toml)
209 }
210
211 pub fn json(self) -> Self {
212 self.language(CodeLanguage::Json)
213 }
214
215 pub fn markdown(self) -> Self {
216 self.language(CodeLanguage::Markdown)
217 }
218
219 pub fn shell(self) -> Self {
220 self.language(CodeLanguage::Shell)
221 }
222
223 pub fn typescript(self) -> Self {
224 self.language(CodeLanguage::TypeScript)
225 }
226
227 pub fn javascript(self) -> Self {
228 self.language(CodeLanguage::JavaScript)
229 }
230
231 pub fn format(mut self, format: CodeFormat) -> Self {
232 self.format = format;
233 self
234 }
235
236 pub fn inline(mut self) -> Self {
237 self.format = CodeFormat::Inline;
238 self.copyable = false;
239 self
240 }
241
242 pub fn highlighter(mut self, highlighter: CodeHighlighter) -> Self {
243 self.highlighter = highlighter;
244 self
245 }
246
247 pub fn syntect(self) -> Self {
248 self.highlighter(CodeHighlighter::Syntect)
249 }
250
251 pub fn theme(mut self, theme: CodeTheme) -> Self {
252 self.theme = theme;
253 self
254 }
255
256 pub fn auto_theme(self) -> Self {
257 self.theme(CodeTheme::Auto)
258 }
259
260 pub fn light_theme(self) -> Self {
261 self.theme(CodeTheme::Light)
262 }
263
264 pub fn dark_theme(self) -> Self {
265 self.theme(CodeTheme::Dark)
266 }
267
268 pub fn liora_light_theme(self) -> Self {
269 self.theme(CodeTheme::LioraLight)
270 }
271
272 pub fn liora_dark_theme(self) -> Self {
273 self.theme(CodeTheme::LioraDark)
274 }
275
276 pub fn github_light_theme(self) -> Self {
277 self.theme(CodeTheme::GitHubLight)
278 }
279
280 pub fn github_dark_theme(self) -> Self {
281 self.theme(CodeTheme::GitHubDark)
282 }
283
284 pub fn one_dark_theme(self) -> Self {
285 self.theme(CodeTheme::OneDark)
286 }
287
288 pub fn nord_theme(self) -> Self {
289 self.theme(CodeTheme::Nord)
290 }
291
292 pub fn dracula_theme(self) -> Self {
293 self.theme(CodeTheme::Dracula)
294 }
295
296 pub fn copyable(mut self, copyable: bool) -> Self {
297 self.copyable = copyable;
298 self
299 }
300
301 pub fn on_copy(mut self, callback: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
302 self.on_copy = Some(Arc::new(callback));
303 self
304 }
305
306 pub fn selectable(mut self, selectable: bool) -> Self {
307 self.selectable = selectable;
308 self
309 }
310
311 pub fn register_key_bindings(cx: &mut App) {
312 cx.bind_keys([
313 gpui::KeyBinding::new("cmd-a", CodeSelectAll, Some("CodeBlock")),
314 gpui::KeyBinding::new("ctrl-a", CodeSelectAll, Some("CodeBlock")),
315 gpui::KeyBinding::new("cmd-c", CodeCopy, Some("CodeBlock")),
316 gpui::KeyBinding::new("ctrl-c", CodeCopy, Some("CodeBlock")),
317 ]);
318 Self::prewarm_highlighter();
319 }
320
321 pub fn prewarm_highlighter() {
322 let _ = syntax_set();
323 let themes = theme_set();
324 for theme in [
325 CodeTheme::LioraLight,
326 CodeTheme::LioraDark,
327 CodeTheme::GitHubLight,
328 CodeTheme::GitHubDark,
329 CodeTheme::OneDark,
330 CodeTheme::Nord,
331 CodeTheme::Dracula,
332 ] {
333 let _ = themes.get(theme.embedded_theme());
334 }
335 }
336
337 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
338 self.id = Some(id.into());
339 self
340 }
341}
342
343impl RenderOnce for CodeBlock {
344 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
345 let theme = cx.global::<Config>().theme.clone();
346 let id = self.id.clone().unwrap_or_else(|| {
347 stable_unique_id(
348 format!(
349 "liora-code-block:{}:{:016x}:{}:{:?}:{:?}:{:?}:copyable={}:selectable={}",
350 self.language.label(),
351 hash_code_text(self.code.as_ref()),
352 self.code.len(),
353 self.format,
354 self.highlighter,
355 self.theme,
356 self.copyable,
357 self.selectable
358 ),
359 "liora-code-block",
360 window,
361 cx,
362 )
363 .into()
364 });
365
366 match self.format {
367 CodeFormat::Inline => render_inline_code(
368 self.code,
369 self.language,
370 self.highlighter,
371 self.theme,
372 &theme,
373 ),
374 CodeFormat::Block => render_block_code(
375 id,
376 self.code,
377 self.language,
378 self.copyable,
379 self.selectable,
380 self.highlighter,
381 self.theme,
382 &theme,
383 self.on_copy,
384 window,
385 cx,
386 ),
387 }
388 }
389}
390
391impl IntoElement for CodeBlock {
392 type Element = Component<Self>;
393
394 fn into_element(self) -> Self::Element {
395 Component::new(self)
396 }
397}
398
399fn render_inline_code(
400 code: SharedString,
401 language: CodeLanguage,
402 highlighter: CodeHighlighter,
403 code_theme: CodeTheme,
404 theme: &liora_theme::Theme,
405) -> gpui::AnyElement {
406 let resolved_theme = resolve_code_theme(code_theme, theme);
407 div()
408 .rounded(px(theme.radius.sm))
409 .bg(code_surface(resolved_theme).opacity(0.72))
410 .border_1()
411 .border_color(code_border(resolved_theme))
412 .px_1()
413 .py_0p5()
414 .child(render_highlighted_text(
415 code,
416 language,
417 highlighter,
418 resolved_theme,
419 theme,
420 false,
421 ))
422 .into_any_element()
423}
424
425fn render_block_code(
426 id: ElementId,
427 code: SharedString,
428 language: CodeLanguage,
429 copyable: bool,
430 selectable: bool,
431 highlighter: CodeHighlighter,
432 code_theme: CodeTheme,
433 theme: &liora_theme::Theme,
434 on_copy: Option<Arc<CodeCopyCallback>>,
435 window: &mut Window,
436 cx: &mut App,
437) -> gpui::AnyElement {
438 let resolved_theme = resolve_code_theme(code_theme, theme);
439 let copied_code = code.to_string();
440 let scroll_id = format!("{id}-scroll");
441 let should_render_code = should_render_code_now(
442 id.clone(),
443 code.as_ref(),
444 language,
445 highlighter,
446 resolved_theme,
447 theme,
448 window,
449 cx,
450 );
451
452 let mut header = div()
453 .flex()
454 .items_center()
455 .justify_between()
456 .gap_2()
457 .px_4()
458 .py_2()
459 .border_b_1()
460 .border_color(code_border(resolved_theme))
461 .bg(code_header_surface(resolved_theme))
462 .child(
463 div()
464 .flex()
465 .items_center()
466 .gap_2()
467 .text_color(code_muted_text(resolved_theme))
468 .text_xs()
469 .font_weight(FontWeight::BOLD)
470 .child(
471 Icon::new(IconName::FileCode)
472 .size(px(14.0))
473 .color(code_muted_text(resolved_theme)),
474 )
475 .child(language.label()),
476 );
477
478 if copyable {
479 header = header.child(
480 div()
481 .id(format!("{id}-copy"))
482 .flex()
483 .items_center()
484 .gap_1()
485 .px_2()
486 .py_1()
487 .rounded(px(theme.radius.sm))
488 .text_xs()
489 .text_color(code_muted_text(resolved_theme))
490 .cursor_pointer()
491 .hover(|style| {
492 style
493 .bg(code_hover_surface(resolved_theme))
494 .text_color(code_accent(theme, resolved_theme))
495 })
496 .on_click(move |_, window, cx| {
497 cx.write_to_clipboard(ClipboardItem::new_string(copied_code.clone()));
498 if let Some(on_copy) = on_copy.as_ref() {
499 on_copy(copied_code.as_str(), window, cx);
500 }
501 })
502 .child(
503 Icon::new(IconName::Copy)
504 .size(px(12.0))
505 .color(code_muted_text(resolved_theme)),
506 )
507 .child("Copy"),
508 );
509 }
510
511 div()
512 .id(id.clone())
513 .w_full()
514 .rounded(px(theme.radius.lg))
515 .border_1()
516 .border_color(code_border(resolved_theme))
517 .bg(code_surface(resolved_theme))
518 .overflow_hidden()
519 .child(header)
520 .child(
521 div()
522 .id(scroll_id)
523 .overflow_x_scroll()
524 .p_4()
525 .bg(code_surface(resolved_theme))
526 .cursor_text()
527 .child(if should_render_code {
528 render_code_content(
529 id,
530 code,
531 language,
532 highlighter,
533 resolved_theme,
534 selectable,
535 theme,
536 window,
537 cx,
538 )
539 } else {
540 render_code_placeholder(code, resolved_theme, theme)
541 }),
542 )
543 .into_any_element()
544}
545
546fn should_render_code_now(
547 id: ElementId,
548 code: &str,
549 language: CodeLanguage,
550 highlighter: CodeHighlighter,
551 code_theme: ResolvedCodeTheme,
552 theme: &liora_theme::Theme,
553 window: &mut Window,
554 cx: &mut App,
555) -> bool {
556 let cache_key = HighlightCacheKey::new(code, language, highlighter, code_theme, true, theme);
557 if lock_highlight_cache().runs.contains_key(&cache_key) {
558 return true;
559 }
560
561 let state_key = ElementId::NamedChild(Arc::new(id), SharedString::from("deferred-code-ready"));
562 let ready = window.use_keyed_state(state_key, cx, |_, _| false);
563
564 if !*ready.read(cx) {
565 ready.update(cx, |ready, cx| {
566 *ready = true;
567 cx.notify();
568 });
569 return false;
570 }
571
572 if take_deferred_highlight_slot() {
573 true
574 } else {
575 ready.update(cx, |_, cx| {
576 cx.notify();
577 });
578 false
579 }
580}
581
582#[derive(Debug)]
583struct DeferredHighlightBudget {
584 window_start: Instant,
585 remaining: usize,
586}
587
588fn take_deferred_highlight_slot() -> bool {
589 static BUDGET: OnceLock<Mutex<DeferredHighlightBudget>> = OnceLock::new();
590 const FRAME_BUDGET: usize = 1;
591 const FRAME_WINDOW: Duration = Duration::from_millis(12);
592
593 let mut budget = BUDGET
594 .get_or_init(|| {
595 Mutex::new(DeferredHighlightBudget {
596 window_start: Instant::now(),
597 remaining: FRAME_BUDGET,
598 })
599 })
600 .lock()
601 .unwrap_or_else(|poisoned| poisoned.into_inner());
602
603 let now = Instant::now();
604 if now.duration_since(budget.window_start) >= FRAME_WINDOW {
605 budget.window_start = now;
606 budget.remaining = FRAME_BUDGET;
607 }
608
609 if budget.remaining > 0 {
610 budget.remaining -= 1;
611 true
612 } else {
613 false
614 }
615}
616
617fn render_code_placeholder(
618 code: SharedString,
619 code_theme: ResolvedCodeTheme,
620 theme: &liora_theme::Theme,
621) -> gpui::AnyElement {
622 let line_count = code.as_ref().lines().count().max(1).min(6);
623 div()
624 .flex()
625 .flex_col()
626 .gap_2()
627 .children((0..line_count).map(|index| {
628 let width = match index % 4 {
629 0 => px(520.0),
630 1 => px(380.0),
631 2 => px(460.0),
632 _ => px(300.0),
633 };
634 div()
635 .h(px(theme.font_size.sm * 1.15))
636 .w(width)
637 .rounded(px(theme.radius.sm))
638 .bg(code_muted_text(code_theme).opacity(0.16))
639 }))
640 .into_any_element()
641}
642
643fn render_highlighted_text(
644 code: SharedString,
645 language: CodeLanguage,
646 highlighter: CodeHighlighter,
647 code_theme: ResolvedCodeTheme,
648 theme: &liora_theme::Theme,
649 block: bool,
650) -> StyledText {
651 let runs = cached_highlight_runs(
652 code.as_ref(),
653 language,
654 highlighter,
655 code_theme,
656 theme,
657 block,
658 );
659 StyledText::new(code).with_runs(runs.to_vec())
660}
661
662fn render_code_content(
663 id: ElementId,
664 code: SharedString,
665 language: CodeLanguage,
666 highlighter: CodeHighlighter,
667 code_theme: ResolvedCodeTheme,
668 selectable: bool,
669 theme: &liora_theme::Theme,
670 window: &mut Window,
671 cx: &mut App,
672) -> gpui::AnyElement {
673 let (highlight_key, runs) = cached_highlight_runs_with_key(
674 code.as_ref(),
675 language,
676 highlighter,
677 code_theme,
678 theme,
679 true,
680 );
681
682 if selectable {
683 let state_key = ElementId::NamedChild(
684 Arc::new(id.clone()),
685 SharedString::from("selectable-code-text"),
686 );
687 let initial_id = id.clone();
688 let initial_code = code.clone();
689 let initial_runs = runs.clone();
690 let initial_theme = theme.clone();
691 let initial_highlight_key = highlight_key.clone();
692 let input = window.use_keyed_state(state_key, cx, move |_, cx| {
693 SelectableCodeText::new(
694 cx,
695 initial_id,
696 initial_code,
697 initial_runs,
698 initial_highlight_key,
699 &initial_theme,
700 )
701 });
702 input.update(cx, |text, cx| {
703 text.update_content(id, code, runs, highlight_key, theme, cx);
704 });
705 SelectableCodeTextView { input }.into_any_element()
706 } else {
707 let state_key = ElementId::NamedChild(
708 Arc::new(id.clone()),
709 SharedString::from("read-only-code-text"),
710 );
711 let initial_code = code.clone();
712 let initial_runs = runs.clone();
713 let initial_theme = theme.clone();
714 let initial_highlight_key = highlight_key.clone();
715 let input = window.use_keyed_state(state_key, cx, move |_, _| {
716 ReadOnlyCodeText::new(
717 initial_code,
718 initial_runs,
719 initial_highlight_key,
720 &initial_theme,
721 )
722 });
723 input.update(cx, |text, cx| {
724 text.update_content(code, runs, highlight_key, theme, cx);
725 });
726 ReadOnlyCodeTextView { input }.into_any_element()
727 }
728}
729
730struct SelectableCodeTextView {
731 input: Entity<SelectableCodeText>,
732}
733
734impl IntoElement for SelectableCodeTextView {
735 type Element = Component<Self>;
736
737 fn into_element(self) -> Self::Element {
738 Component::new(self)
739 }
740}
741
742impl RenderOnce for SelectableCodeTextView {
743 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
744 self.input.into_any_element()
745 }
746}
747
748struct ReadOnlyCodeTextView {
749 input: Entity<ReadOnlyCodeText>,
750}
751
752impl IntoElement for ReadOnlyCodeTextView {
753 type Element = Component<Self>;
754
755 fn into_element(self) -> Self::Element {
756 Component::new(self)
757 }
758}
759
760impl RenderOnce for ReadOnlyCodeTextView {
761 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
762 self.input.into_any_element()
763 }
764}
765
766fn cached_highlight_runs(
767 text: &str,
768 language: CodeLanguage,
769 highlighter: CodeHighlighter,
770 code_theme: ResolvedCodeTheme,
771 theme: &liora_theme::Theme,
772 block: bool,
773) -> Vec<TextRun> {
774 cached_highlight_runs_with_key(text, language, highlighter, code_theme, theme, block)
775 .1
776 .to_vec()
777}
778
779fn cached_highlight_runs_with_key(
780 text: &str,
781 language: CodeLanguage,
782 highlighter: CodeHighlighter,
783 code_theme: ResolvedCodeTheme,
784 theme: &liora_theme::Theme,
785 block: bool,
786) -> (HighlightCacheKey, HighlightRuns) {
787 let key = HighlightCacheKey::new(text, language, highlighter, code_theme, block, theme);
788 if let Some(runs) = lock_highlight_cache().runs.get(&key).cloned() {
789 return (key, runs);
790 }
791
792 let runs = HighlightRuns::from(highlight_runs(
793 text,
794 language,
795 highlighter,
796 code_theme,
797 theme,
798 block,
799 ));
800 let mut cache = lock_highlight_cache();
801 cache.insert(key.clone(), runs.clone());
802 (key, runs)
803}
804
805#[derive(Clone, Debug, PartialEq, Eq, Hash)]
806struct HighlightCacheKey {
807 text_hash: u64,
808 text_len: usize,
809 language: CodeLanguage,
810 highlighter: CodeHighlighter,
811 theme: CodeTheme,
812 block: bool,
813 font_size_bits: u32,
814}
815
816impl HighlightCacheKey {
817 fn new(
818 text: &str,
819 language: CodeLanguage,
820 highlighter: CodeHighlighter,
821 code_theme: ResolvedCodeTheme,
822 block: bool,
823 theme: &liora_theme::Theme,
824 ) -> Self {
825 let mut hasher = std::collections::hash_map::DefaultHasher::new();
826 text.hash(&mut hasher);
827 Self {
828 text_hash: hasher.finish(),
829 text_len: text.len(),
830 language,
831 highlighter,
832 theme: code_theme.theme,
833 block,
834 font_size_bits: if block {
835 theme.font_size.sm
836 } else {
837 theme.font_size.md
838 }
839 .to_bits(),
840 }
841 }
842}
843
844const HIGHLIGHT_CACHE_CAPACITY: usize = 256;
845type HighlightRuns = Arc<[TextRun]>;
846
847#[derive(Default)]
848struct HighlightCache {
849 runs: HashMap<HighlightCacheKey, HighlightRuns>,
850 order: VecDeque<HighlightCacheKey>,
851}
852
853impl HighlightCache {
854 fn insert(&mut self, key: HighlightCacheKey, runs: HighlightRuns) {
855 if self.runs.contains_key(&key) {
856 self.runs.insert(key, runs);
857 return;
858 }
859
860 self.runs.insert(key.clone(), runs);
861 self.order.push_back(key);
862 self.evict_over_capacity();
863 }
864
865 fn evict_over_capacity(&mut self) {
866 while self.runs.len() > HIGHLIGHT_CACHE_CAPACITY {
867 let Some(oldest) = self.order.pop_front() else {
868 break;
869 };
870 self.runs.remove(&oldest);
871 }
872 }
873}
874
875fn highlight_cache() -> &'static Mutex<HighlightCache> {
876 static CACHE: OnceLock<Mutex<HighlightCache>> = OnceLock::new();
877 CACHE.get_or_init(|| Mutex::new(HighlightCache::default()))
878}
879
880fn lock_highlight_cache() -> MutexGuard<'static, HighlightCache> {
881 highlight_cache()
882 .lock()
883 .unwrap_or_else(|poisoned| poisoned.into_inner())
884}
885
886fn highlight_runs(
887 text: &str,
888 language: CodeLanguage,
889 highlighter: CodeHighlighter,
890 code_theme: ResolvedCodeTheme,
891 theme: &liora_theme::Theme,
892 block: bool,
893) -> Vec<TextRun> {
894 match highlighter {
895 CodeHighlighter::Syntect => syntect_runs(text, language, code_theme, theme, block),
896 }
897}
898
899fn syntect_runs(
900 text: &str,
901 language: CodeLanguage,
902 code_theme: ResolvedCodeTheme,
903 theme: &liora_theme::Theme,
904 block: bool,
905) -> Vec<TextRun> {
906 if text.is_empty() {
907 return vec![base_style(theme, code_theme, block).to_run(0)];
908 }
909
910 let syntax_set = syntax_set();
911 let syntax = syntax_set
912 .find_syntax_by_token(language.syntect_token())
913 .or_else(|| syntax_set.find_syntax_by_extension(language.syntect_token()))
914 .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
915 let syntect_theme = syntect_theme(code_theme);
916 let mut highlighter = HighlightLines::new(syntax, syntect_theme);
917 let mut runs = Vec::new();
918
919 for line in LinesWithEndings::from(text) {
920 match highlighter.highlight_line(line, syntax_set) {
921 Ok(regions) => {
922 for (style, slice) in regions {
923 if !slice.is_empty() {
924 push_run(
925 &mut runs,
926 syntect_style_run(slice.len(), style, theme, code_theme, block),
927 );
928 }
929 }
930 }
931 Err(_) => push_run(
932 &mut runs,
933 base_style(theme, code_theme, block).to_run(line.len()),
934 ),
935 }
936 }
937
938 if runs.is_empty() {
939 runs.push(base_style(theme, code_theme, block).to_run(text.len()));
940 }
941
942 runs
943}
944
945fn push_run(runs: &mut Vec<TextRun>, run: TextRun) {
946 if run.len == 0 {
947 return;
948 }
949
950 if let Some(last) = runs.last_mut() {
951 if last.font == run.font
952 && last.color == run.color
953 && last.background_color == run.background_color
954 && last.underline == run.underline
955 && last.strikethrough == run.strikethrough
956 {
957 last.len += run.len;
958 return;
959 }
960 }
961
962 runs.push(run);
963}
964
965fn syntect_style_run(
966 len: usize,
967 syntect_style: SyntectStyle,
968 theme: &liora_theme::Theme,
969 code_theme: ResolvedCodeTheme,
970 block: bool,
971) -> TextRun {
972 let mut style = base_style(theme, code_theme, block);
973 style.color = syntect_color(syntect_style.foreground);
974
975 if syntect_style.font_style.contains(SyntectFontStyle::BOLD) {
976 style.font_weight = FontWeight::BOLD;
977 }
978
979 if syntect_style.font_style.contains(SyntectFontStyle::ITALIC) {
980 style.font_style = FontStyle::Italic;
981 }
982
983 if syntect_style
984 .font_style
985 .contains(SyntectFontStyle::UNDERLINE)
986 {
987 style.underline = Some(UnderlineStyle {
988 thickness: px(1.0),
989 color: Some(style.color),
990 ..Default::default()
991 });
992 }
993
994 style.to_run(len)
995}
996
997fn base_style(theme: &liora_theme::Theme, code_theme: ResolvedCodeTheme, block: bool) -> TextStyle {
998 let mut style = TextStyle::default();
999 style.font_family = "Monospace".into();
1000 style.font_size = px(if block {
1001 theme.font_size.sm
1002 } else {
1003 theme.font_size.md
1004 })
1005 .into();
1006 style.line_height = px(theme.font_size.md * 1.7).into();
1007 style.white_space = WhiteSpace::Nowrap;
1008 style.color = code_text(code_theme);
1009 style
1010}
1011
1012fn syntax_set() -> &'static SyntaxSet {
1013 static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
1014 SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines)
1015}
1016
1017fn resolve_code_theme(code_theme: CodeTheme, theme: &liora_theme::Theme) -> ResolvedCodeTheme {
1018 let resolved = match code_theme {
1019 CodeTheme::Auto if theme.name.eq_ignore_ascii_case("dark") => CodeTheme::LioraDark,
1020 CodeTheme::Auto => CodeTheme::LioraLight,
1021 CodeTheme::Light => CodeTheme::LioraLight,
1022 CodeTheme::Dark => CodeTheme::LioraDark,
1023 theme => theme,
1024 };
1025
1026 ResolvedCodeTheme {
1027 theme: resolved,
1028 mode: resolved.mode(),
1029 }
1030}
1031
1032fn theme_set() -> &'static EmbeddedLazyThemeSet {
1033 static THEME_SET: OnceLock<EmbeddedLazyThemeSet> = OnceLock::new();
1034 THEME_SET.get_or_init(two_face::theme::extra)
1035}
1036
1037fn syntect_theme(code_theme: ResolvedCodeTheme) -> &'static Theme {
1038 theme_set().get(code_theme.theme.embedded_theme())
1039}
1040
1041fn syntect_color(color: syntect::highlighting::Color) -> Hsla {
1042 Rgba {
1043 r: color.r as f32 / 255.0,
1044 g: color.g as f32 / 255.0,
1045 b: color.b as f32 / 255.0,
1046 a: color.a as f32 / 255.0,
1047 }
1048 .into()
1049}
1050
1051fn code_surface(code_theme: ResolvedCodeTheme) -> Hsla {
1052 match code_theme.mode {
1053 CodeThemeMode::Light => rgb(0xf7f8fa),
1054 CodeThemeMode::Dark => rgb(0x1b2b34),
1055 }
1056}
1057
1058fn code_header_surface(code_theme: ResolvedCodeTheme) -> Hsla {
1059 match code_theme.mode {
1060 CodeThemeMode::Light => rgb(0xf0f2f5),
1061 CodeThemeMode::Dark => rgb(0x16242c),
1062 }
1063}
1064
1065fn code_hover_surface(code_theme: ResolvedCodeTheme) -> Hsla {
1066 match code_theme.mode {
1067 CodeThemeMode::Light => rgb(0xe8edf3),
1068 CodeThemeMode::Dark => rgb(0x253c49),
1069 }
1070}
1071
1072fn code_border(code_theme: ResolvedCodeTheme) -> Hsla {
1073 match code_theme.mode {
1074 CodeThemeMode::Light => rgb(0xd8dee8),
1075 CodeThemeMode::Dark => rgb(0x334d5c),
1076 }
1077}
1078
1079fn code_text(code_theme: ResolvedCodeTheme) -> Hsla {
1080 match code_theme.mode {
1081 CodeThemeMode::Light => rgb(0x2b303b),
1082 CodeThemeMode::Dark => rgb(0xc0c5ce),
1083 }
1084}
1085
1086fn code_muted_text(code_theme: ResolvedCodeTheme) -> Hsla {
1087 match code_theme.mode {
1088 CodeThemeMode::Light => rgb(0x65737e),
1089 CodeThemeMode::Dark => rgb(0xa7adba),
1090 }
1091}
1092
1093fn code_accent(theme: &liora_theme::Theme, code_theme: ResolvedCodeTheme) -> Hsla {
1094 match code_theme.mode {
1095 CodeThemeMode::Light => theme.info.base,
1096 CodeThemeMode::Dark => rgb(0x96b5b4),
1097 }
1098}
1099
1100struct ReadOnlyCodeText {
1101 code: SharedString,
1102 runs: HighlightRuns,
1103 highlight_key: HighlightCacheKey,
1104 theme: liora_theme::Theme,
1105 layout: Option<Arc<SelectableCodeLayout>>,
1106}
1107
1108impl ReadOnlyCodeText {
1109 fn new(
1110 code: SharedString,
1111 runs: HighlightRuns,
1112 highlight_key: HighlightCacheKey,
1113 theme: &liora_theme::Theme,
1114 ) -> Self {
1115 Self {
1116 code,
1117 runs,
1118 highlight_key,
1119 theme: theme.clone(),
1120 layout: None,
1121 }
1122 }
1123
1124 fn update_content(
1125 &mut self,
1126 code: SharedString,
1127 runs: HighlightRuns,
1128 highlight_key: HighlightCacheKey,
1129 theme: &liora_theme::Theme,
1130 cx: &mut Context<Self>,
1131 ) {
1132 let changed = self.highlight_key != highlight_key
1133 || self.theme.name != theme.name
1134 || self.theme.font_size.sm != theme.font_size.sm
1135 || self.theme.font_size.md != theme.font_size.md
1136 || self.theme.primary.base != theme.primary.base;
1137 if !changed {
1138 return;
1139 }
1140
1141 self.code = code;
1142 self.runs = runs;
1143 self.highlight_key = highlight_key;
1144 self.theme = theme.clone();
1145 self.layout = None;
1146 cx.notify();
1147 }
1148
1149 fn font_size(&self) -> Pixels {
1150 px(self.theme.font_size.md)
1151 }
1152
1153 fn line_height(&self) -> Pixels {
1154 px(self.theme.font_size.md * 1.7)
1155 }
1156
1157 fn ensure_layout(&mut self, window: &mut Window) -> Arc<SelectableCodeLayout> {
1158 if let Some(layout) = self.layout.as_ref() {
1159 return layout.clone();
1160 }
1161
1162 let layout = Arc::new(build_code_layout(
1163 self.code.as_ref(),
1164 &self.runs,
1165 self.font_size(),
1166 self.line_height(),
1167 window,
1168 ));
1169 self.layout = Some(layout.clone());
1170 layout
1171 }
1172}
1173
1174impl Render for ReadOnlyCodeText {
1175 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1176 div()
1177 .id("read-only-code-text")
1178 .child(ReadOnlyCodeElement { input: cx.entity() })
1179 }
1180}
1181
1182struct ReadOnlyCodeElement {
1183 input: Entity<ReadOnlyCodeText>,
1184}
1185
1186struct ReadOnlyCodePrepaint {
1187 layout: Arc<SelectableCodeLayout>,
1188}
1189
1190impl IntoElement for ReadOnlyCodeElement {
1191 type Element = Self;
1192
1193 fn into_element(self) -> Self::Element {
1194 self
1195 }
1196}
1197
1198impl Element for ReadOnlyCodeElement {
1199 type RequestLayoutState = Arc<SelectableCodeLayout>;
1200 type PrepaintState = ReadOnlyCodePrepaint;
1201
1202 fn id(&self) -> Option<ElementId> {
1203 None
1204 }
1205
1206 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
1207 None
1208 }
1209
1210 fn request_layout(
1211 &mut self,
1212 _: Option<&GlobalElementId>,
1213 _: Option<&gpui::InspectorElementId>,
1214 window: &mut Window,
1215 cx: &mut App,
1216 ) -> (LayoutId, Arc<SelectableCodeLayout>) {
1217 let layout = self
1218 .input
1219 .update(cx, |input, _| input.ensure_layout(window));
1220 let mut style = Style::default();
1221 style.size.width = layout.width.into();
1222 style.min_size.width = relative(1.).into();
1223 style.size.height = layout.height.into();
1224 (window.request_layout(style, [], cx), layout)
1225 }
1226
1227 fn prepaint(
1228 &mut self,
1229 _: Option<&GlobalElementId>,
1230 _: Option<&gpui::InspectorElementId>,
1231 _bounds: Bounds<Pixels>,
1232 layout: &mut Arc<SelectableCodeLayout>,
1233 _window: &mut Window,
1234 _cx: &mut App,
1235 ) -> ReadOnlyCodePrepaint {
1236 ReadOnlyCodePrepaint {
1237 layout: layout.clone(),
1238 }
1239 }
1240
1241 fn paint(
1242 &mut self,
1243 _: Option<&GlobalElementId>,
1244 _: Option<&gpui::InspectorElementId>,
1245 bounds: Bounds<Pixels>,
1246 _: &mut Arc<SelectableCodeLayout>,
1247 prepaint: &mut ReadOnlyCodePrepaint,
1248 window: &mut Window,
1249 cx: &mut App,
1250 ) {
1251 let line_height = self.input.read(cx).line_height();
1252 for line in &prepaint.layout.lines {
1253 let _ = line.shaped.paint(
1254 point(bounds.left(), bounds.top() + line.y),
1255 line_height,
1256 gpui::TextAlign::Left,
1257 None,
1258 window,
1259 cx,
1260 );
1261 }
1262 }
1263}
1264
1265#[derive(Clone)]
1266struct SelectableCodeState {
1267 selected_range: Range<usize>,
1268 selection_reversed: bool,
1269 selecting: bool,
1270 lines: Vec<(ShapedLine, Pixels, usize)>,
1271 bounds: Option<Bounds<Pixels>>,
1272}
1273
1274impl Default for SelectableCodeState {
1275 fn default() -> Self {
1276 Self {
1277 selected_range: 0..0,
1278 selection_reversed: false,
1279 selecting: false,
1280 lines: Vec::new(),
1281 bounds: None,
1282 }
1283 }
1284}
1285
1286#[derive(Clone)]
1287struct SelectableCodeLayout {
1288 lines: Vec<SelectableCodeLine>,
1289 width: Pixels,
1290 height: Pixels,
1291}
1292
1293#[derive(Clone)]
1294struct SelectableCodeLine {
1295 shaped: ShapedLine,
1296 start: usize,
1297 y: Pixels,
1298}
1299
1300fn selectable_state_map() -> &'static Mutex<HashMap<String, SelectableCodeState>> {
1301 static STATES: OnceLock<Mutex<HashMap<String, SelectableCodeState>>> = OnceLock::new();
1302 STATES.get_or_init(|| Mutex::new(HashMap::new()))
1303}
1304
1305fn lock_selectable_state_map() -> MutexGuard<'static, HashMap<String, SelectableCodeState>> {
1306 selectable_state_map()
1307 .lock()
1308 .unwrap_or_else(|poisoned| poisoned.into_inner())
1309}
1310
1311fn set_selectable_layout_state(
1312 id: &ElementId,
1313 lines: Vec<(ShapedLine, Pixels, usize)>,
1314 bounds: Bounds<Pixels>,
1315) {
1316 with_selectable_state(id, |state| {
1317 state.lines = lines;
1318 state.bounds = Some(bounds);
1319 });
1320}
1321
1322fn selectable_key(id: &ElementId) -> String {
1323 id.to_string()
1324}
1325
1326fn with_selectable_state<R>(id: &ElementId, f: impl FnOnce(&mut SelectableCodeState) -> R) -> R {
1327 let mut states = lock_selectable_state_map();
1328 f(states.entry(selectable_key(id)).or_default())
1329}
1330
1331fn selectable_state_snapshot(id: &ElementId) -> SelectableCodeState {
1332 lock_selectable_state_map()
1333 .get(&selectable_key(id))
1334 .cloned()
1335 .unwrap_or_default()
1336}
1337
1338struct SelectableCodeText {
1339 id: ElementId,
1340 focus_handle: FocusHandle,
1341 code: SharedString,
1342 runs: HighlightRuns,
1343 highlight_key: HighlightCacheKey,
1344 theme: liora_theme::Theme,
1345 layout: Option<Arc<SelectableCodeLayout>>,
1346}
1347
1348impl SelectableCodeText {
1349 fn new(
1350 cx: &mut Context<Self>,
1351 id: ElementId,
1352 code: SharedString,
1353 runs: HighlightRuns,
1354 highlight_key: HighlightCacheKey,
1355 theme: &liora_theme::Theme,
1356 ) -> Self {
1357 Self {
1358 id,
1359 focus_handle: cx.focus_handle(),
1360 code,
1361 runs,
1362 highlight_key,
1363 theme: theme.clone(),
1364 layout: None,
1365 }
1366 }
1367
1368 fn update_content(
1369 &mut self,
1370 id: ElementId,
1371 code: SharedString,
1372 runs: HighlightRuns,
1373 highlight_key: HighlightCacheKey,
1374 theme: &liora_theme::Theme,
1375 cx: &mut Context<Self>,
1376 ) {
1377 let changed = self.id != id
1378 || self.highlight_key != highlight_key
1379 || self.theme.name != theme.name
1380 || self.theme.font_size.sm != theme.font_size.sm
1381 || self.theme.font_size.md != theme.font_size.md
1382 || self.theme.primary.base != theme.primary.base;
1383 if !changed {
1384 return;
1385 }
1386
1387 let old_id = self.id.clone();
1388 self.id = id;
1389 self.code = code;
1390 self.runs = runs;
1391 self.highlight_key = highlight_key;
1392 self.theme = theme.clone();
1393 self.layout = None;
1394
1395 if old_id != self.id {
1396 let old_state = selectable_state_snapshot(&old_id);
1397 with_selectable_state(&self.id, |state| *state = old_state);
1398 }
1399
1400 with_selectable_state(&self.id, |state| {
1401 state.selected_range.start = self.clamp_boundary(state.selected_range.start);
1402 state.selected_range.end = self.clamp_boundary(state.selected_range.end);
1403 if state.selected_range.end < state.selected_range.start {
1404 state.selected_range = state.selected_range.end..state.selected_range.start;
1405 state.selection_reversed = !state.selection_reversed;
1406 }
1407 });
1408 cx.notify();
1409 }
1410
1411 fn move_to(&self, state: &mut SelectableCodeState, offset: usize) -> bool {
1412 let offset = self.clamp_boundary(offset);
1413 if state.selected_range == (offset..offset) && !state.selection_reversed {
1414 return false;
1415 }
1416 state.selected_range = offset..offset;
1417 state.selection_reversed = false;
1418 true
1419 }
1420
1421 fn select_to(&self, state: &mut SelectableCodeState, offset: usize) -> bool {
1422 let offset = self.clamp_boundary(offset);
1423 let previous_range = state.selected_range.clone();
1424 let previous_reversed = state.selection_reversed;
1425 if state.selection_reversed {
1426 state.selected_range.start = offset;
1427 } else {
1428 state.selected_range.end = offset;
1429 }
1430 if state.selected_range.end < state.selected_range.start {
1431 state.selection_reversed = !state.selection_reversed;
1432 state.selected_range = state.selected_range.end..state.selected_range.start;
1433 }
1434 if state.selected_range == previous_range && state.selection_reversed == previous_reversed {
1435 return false;
1436 }
1437 true
1438 }
1439
1440 fn clamp_boundary(&self, mut offset: usize) -> usize {
1441 offset = offset.min(self.code.len());
1442 while offset > 0 && !self.code.is_char_boundary(offset) {
1443 offset -= 1;
1444 }
1445 offset
1446 }
1447
1448 fn index_for_point(&self, pt: Point<Pixels>) -> usize {
1449 let state = selectable_state_snapshot(&self.id);
1450 let Some(bounds) = state.bounds.as_ref() else {
1451 return self.code.len();
1452 };
1453 if state.lines.is_empty() {
1454 return 0;
1455 }
1456
1457 let mut chosen = 0;
1458 for (ix, (_line, y, _start)) in state.lines.iter().enumerate() {
1459 let line_height = self.line_height();
1460 if pt.y >= *y && pt.y < *y + line_height {
1461 chosen = ix;
1462 break;
1463 }
1464 if pt.y >= *y {
1465 chosen = ix;
1466 }
1467 }
1468
1469 let (line, _y, start) = &state.lines[chosen];
1470 let x = pt.x - bounds.left();
1471 let line_index = line.index_for_x(x).unwrap_or(line.len());
1472 self.clamp_boundary(*start + line_index)
1473 }
1474
1475 fn select_all(&mut self, _: &CodeSelectAll, _: &mut Window, cx: &mut Context<Self>) {
1476 let changed = with_selectable_state(&self.id, |state| {
1477 let changed = state.selected_range != (0..self.code.len()) || state.selection_reversed;
1478 state.selected_range = 0..self.code.len();
1479 state.selection_reversed = false;
1480 changed
1481 });
1482 if changed {
1483 cx.notify();
1484 }
1485 }
1486
1487 fn copy(&mut self, _: &CodeCopy, _: &mut Window, cx: &mut Context<Self>) {
1488 let selected_range = selectable_state_snapshot(&self.id).selected_range;
1489 if !selected_range.is_empty() {
1490 cx.write_to_clipboard(ClipboardItem::new_string(
1491 self.code[selected_range].to_string(),
1492 ));
1493 }
1494 }
1495
1496 fn on_mouse_down(
1497 &mut self,
1498 event: &MouseDownEvent,
1499 window: &mut Window,
1500 cx: &mut Context<Self>,
1501 ) {
1502 window.focus(&self.focus_handle, cx);
1503 let idx = self.index_for_point(event.position);
1504 let changed = with_selectable_state(&self.id, |state| {
1505 let was_selecting = state.selecting;
1506 state.selecting = true;
1507 if event.modifiers.shift {
1508 self.select_to(state, idx) || !was_selecting
1509 } else if event.click_count >= 3 {
1510 let changed = state.selected_range != (0..self.code.len())
1511 || state.selection_reversed
1512 || !was_selecting;
1513 state.selected_range = 0..self.code.len();
1514 state.selection_reversed = false;
1515 changed
1516 } else if event.click_count == 2 {
1517 let range = self.word_range_at(idx);
1518 let changed =
1519 state.selected_range != range || state.selection_reversed || !was_selecting;
1520 state.selected_range = range;
1521 state.selection_reversed = false;
1522 changed
1523 } else {
1524 self.move_to(state, idx) || !was_selecting
1525 }
1526 });
1527 if changed {
1528 cx.notify();
1529 }
1530 }
1531
1532 fn on_mouse_move(&mut self, event: &MouseMoveEvent, cx: &mut Context<Self>) {
1533 let dragging = event.pressed_button == Some(MouseButton::Left);
1534 let idx = dragging.then(|| self.index_for_point(event.position));
1535 let changed = with_selectable_state(&self.id, |state| {
1536 if !dragging {
1537 let changed = state.selecting;
1538 state.selecting = false;
1539 changed
1540 } else if state.selecting {
1541 self.select_to(state, idx.unwrap_or(self.code.len()))
1542 } else {
1543 false
1544 }
1545 });
1546 if changed {
1547 cx.notify();
1548 }
1549 }
1550
1551 fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, cx: &mut Context<Self>) {
1552 let changed = with_selectable_state(&self.id, |state| {
1553 let changed = state.selecting;
1554 state.selecting = false;
1555 changed
1556 });
1557 if changed {
1558 cx.notify();
1559 }
1560 }
1561
1562 fn word_range_at(&self, idx: usize) -> Range<usize> {
1563 let text = self.code.as_ref();
1564 if text.is_empty() {
1565 return 0..0;
1566 }
1567 let idx = self.clamp_boundary(idx);
1568 let mut start = idx;
1569 while start > 0 {
1570 let prev = self.prev_char(start);
1571 let ch = text[prev..start].chars().next().unwrap_or(' ');
1572 if !ch.is_alphanumeric() && ch != '_' {
1573 break;
1574 }
1575 start = prev;
1576 }
1577 let mut end = idx;
1578 while end < text.len() {
1579 let next = self.next_char(end);
1580 let ch = text[end..next].chars().next().unwrap_or(' ');
1581 if !ch.is_alphanumeric() && ch != '_' {
1582 break;
1583 }
1584 end = next;
1585 }
1586 start..end
1587 }
1588
1589 fn prev_char(&self, offset: usize) -> usize {
1590 if offset == 0 {
1591 return 0;
1592 }
1593 let mut prev = offset - 1;
1594 while prev > 0 && !self.code.is_char_boundary(prev) {
1595 prev -= 1;
1596 }
1597 prev
1598 }
1599
1600 fn next_char(&self, offset: usize) -> usize {
1601 if offset >= self.code.len() {
1602 return self.code.len();
1603 }
1604 let mut next = offset + 1;
1605 while next < self.code.len() && !self.code.is_char_boundary(next) {
1606 next += 1;
1607 }
1608 next
1609 }
1610
1611 fn font_size(&self) -> Pixels {
1612 px(self.theme.font_size.md)
1613 }
1614
1615 fn line_height(&self) -> Pixels {
1616 px(self.theme.font_size.md * 1.7)
1617 }
1618
1619 fn ensure_layout(&mut self, window: &mut Window) -> Arc<SelectableCodeLayout> {
1620 if let Some(layout) = self.layout.as_ref() {
1621 return layout.clone();
1622 }
1623
1624 let layout = Arc::new(build_code_layout(
1625 self.code.as_ref(),
1626 &self.runs,
1627 self.font_size(),
1628 self.line_height(),
1629 window,
1630 ));
1631 self.layout = Some(layout.clone());
1632 layout
1633 }
1634}
1635
1636impl Focusable for SelectableCodeText {
1637 fn focus_handle(&self, _cx: &App) -> FocusHandle {
1638 self.focus_handle.clone()
1639 }
1640}
1641
1642struct SelectableCodeElement {
1643 id: ElementId,
1644 input: Entity<SelectableCodeText>,
1645}
1646
1647struct SelectableCodePrepaint {
1648 layout: Arc<SelectableCodeLayout>,
1649 selection: Vec<PaintQuad>,
1650 hitbox: gpui::Hitbox,
1651}
1652
1653impl IntoElement for SelectableCodeElement {
1654 type Element = Self;
1655
1656 fn into_element(self) -> Self::Element {
1657 self
1658 }
1659}
1660
1661impl Element for SelectableCodeElement {
1662 type RequestLayoutState = Arc<SelectableCodeLayout>;
1663 type PrepaintState = SelectableCodePrepaint;
1664
1665 fn id(&self) -> Option<ElementId> {
1666 Some(self.id.clone())
1667 }
1668
1669 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
1670 None
1671 }
1672
1673 fn request_layout(
1674 &mut self,
1675 _: Option<&GlobalElementId>,
1676 _: Option<&gpui::InspectorElementId>,
1677 window: &mut Window,
1678 cx: &mut App,
1679 ) -> (LayoutId, Arc<SelectableCodeLayout>) {
1680 let layout = self
1681 .input
1682 .update(cx, |input, _| input.ensure_layout(window));
1683 let mut style = Style::default();
1684 style.size.width = layout.width.into();
1685 style.min_size.width = relative(1.).into();
1686 style.size.height = layout.height.into();
1687 (window.request_layout(style, [], cx), layout)
1688 }
1689
1690 fn prepaint(
1691 &mut self,
1692 _: Option<&GlobalElementId>,
1693 _: Option<&gpui::InspectorElementId>,
1694 bounds: Bounds<Pixels>,
1695 layout: &mut Arc<SelectableCodeLayout>,
1696 window: &mut Window,
1697 cx: &mut App,
1698 ) -> SelectableCodePrepaint {
1699 let input = self.input.read(cx);
1700 let line_height = input.line_height();
1701 let mut state_lines = Vec::new();
1702 let mut selection_quads = Vec::new();
1703 let selected_range = selectable_state_snapshot(&input.id).selected_range;
1704
1705 for line in &layout.lines {
1706 let y = bounds.top() + line.y;
1707 if !selected_range.is_empty() {
1708 let line_end = line.start + line.shaped.len();
1709 let start = selected_range.start.max(line.start);
1710 let end = selected_range.end.min(line_end);
1711 if start < end {
1712 let x_start = line.shaped.x_for_index(start - line.start);
1713 let x_end = line.shaped.x_for_index(end - line.start);
1714 selection_quads.push(fill(
1715 Bounds::new(
1716 point(bounds.left() + x_start, y),
1717 size(x_end - x_start, line_height),
1718 ),
1719 input.theme.primary.base.opacity(0.28),
1720 ));
1721 }
1722 }
1723
1724 state_lines.push((line.shaped.clone(), y, line.start));
1725 }
1726
1727 let hitbox = window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal);
1728 set_selectable_layout_state(&input.id, state_lines, bounds);
1729
1730 SelectableCodePrepaint {
1731 layout: layout.clone(),
1732 selection: selection_quads,
1733 hitbox,
1734 }
1735 }
1736
1737 fn paint(
1738 &mut self,
1739 _: Option<&GlobalElementId>,
1740 _: Option<&gpui::InspectorElementId>,
1741 bounds: Bounds<Pixels>,
1742 _: &mut Arc<SelectableCodeLayout>,
1743 prepaint: &mut SelectableCodePrepaint,
1744 window: &mut Window,
1745 cx: &mut App,
1746 ) {
1747 let focus_handle = self.input.read(cx).focus_handle.clone();
1748 window.set_cursor_style(gpui::CursorStyle::IBeam, &prepaint.hitbox);
1749
1750 let input = self.input.clone();
1751 let focus_handle_for_down = focus_handle.clone();
1752 let hitbox = prepaint.hitbox.clone();
1753 window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
1754 if phase.bubble() && event.button == MouseButton::Left && hitbox.is_hovered(window) {
1755 window.capture_pointer(hitbox.id);
1756 window.focus(&focus_handle_for_down, cx);
1757 input.update(cx, |input, cx| input.on_mouse_down(event, window, cx));
1758 cx.stop_propagation();
1759 }
1760 });
1761
1762 let input = self.input.clone();
1763 window.on_mouse_event(move |event: &MouseMoveEvent, phase, _window, cx| {
1764 if phase.capture() {
1765 input.update(cx, |input, cx| input.on_mouse_move(event, cx));
1766 }
1767 });
1768
1769 let input = self.input.clone();
1770 window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| {
1771 if phase.capture() && event.button == MouseButton::Left {
1772 input.update(cx, |input, cx| input.on_mouse_up(event, window, cx));
1773 }
1774 });
1775
1776 for selection in prepaint.selection.drain(..) {
1777 window.paint_quad(selection);
1778 }
1779
1780 for line in &prepaint.layout.lines {
1781 let _ = line.shaped.paint(
1782 point(bounds.left(), bounds.top() + line.y),
1783 self.input.read(cx).line_height(),
1784 gpui::TextAlign::Left,
1785 None,
1786 window,
1787 cx,
1788 );
1789 }
1790 }
1791}
1792
1793impl Render for SelectableCodeText {
1794 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1795 div()
1796 .id(format!("{}-selectable", self.id))
1797 .key_context("CodeBlock")
1798 .track_focus(&self.focus_handle(cx))
1799 .cursor_text()
1800 .on_action(cx.listener(Self::select_all))
1801 .on_action(cx.listener(Self::copy))
1802 .child(SelectableCodeElement {
1803 id: format!("{}-text", self.id).into(),
1804 input: cx.entity(),
1805 })
1806 }
1807}
1808
1809fn build_code_layout(
1810 code: &str,
1811 runs: &[TextRun],
1812 font_size: Pixels,
1813 line_height: Pixels,
1814 window: &mut Window,
1815) -> SelectableCodeLayout {
1816 let mut max_width = px(1.0);
1817 let mut offset = 0;
1818 let mut y = px(0.0);
1819 let mut lines = Vec::new();
1820 for line in code_lines(code) {
1821 let line_len = line.len();
1822 let line_runs = slice_runs(runs, offset, offset + line_len);
1823 let shaped = window.text_system().shape_line(
1824 SharedString::from(line.to_string()),
1825 font_size,
1826 &line_runs,
1827 None,
1828 );
1829 max_width = max_width.max(shaped.width());
1830 lines.push(SelectableCodeLine {
1831 shaped,
1832 start: offset,
1833 y,
1834 });
1835 offset += line_len + 1;
1836 y += line_height;
1837 }
1838
1839 SelectableCodeLayout {
1840 height: line_height * lines.len() as f32,
1841 lines,
1842 width: max_width,
1843 }
1844}
1845
1846fn hash_code_text(text: &str) -> u64 {
1847 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1848 text.hash(&mut hasher);
1849 hasher.finish()
1850}
1851
1852fn code_lines(text: &str) -> impl Iterator<Item = &str> {
1853 text.strip_suffix('\n').unwrap_or(text).split('\n')
1854}
1855
1856fn slice_runs(runs: &[TextRun], start: usize, end: usize) -> Vec<TextRun> {
1857 let mut sliced = Vec::new();
1858 let mut offset = 0;
1859 for run in runs {
1860 let run_start = offset;
1861 let run_end = offset + run.len;
1862 let overlap_start = start.max(run_start);
1863 let overlap_end = end.min(run_end);
1864 if overlap_start < overlap_end {
1865 sliced.push(TextRun {
1866 len: overlap_end - overlap_start,
1867 ..run.clone()
1868 });
1869 }
1870 offset = run_end;
1871 if offset >= end {
1872 break;
1873 }
1874 }
1875 if sliced.is_empty() && start == end {
1876 return sliced;
1877 }
1878 sliced
1879}
1880
1881fn rgb(hex: u32) -> Hsla {
1882 Rgba {
1883 r: ((hex >> 16) & 0xff) as f32 / 255.0,
1884 g: ((hex >> 8) & 0xff) as f32 / 255.0,
1885 b: (hex & 0xff) as f32 / 255.0,
1886 a: 1.0,
1887 }
1888 .into()
1889}
1890
1891#[cfg(test)]
1892mod tests {
1893 use super::*;
1894
1895 #[test]
1896 fn language_labels_parse_common_aliases() {
1897 assert_eq!(CodeLanguage::from_label("rs"), CodeLanguage::Rust);
1898 assert_eq!(CodeLanguage::from_label("bash"), CodeLanguage::Shell);
1899 assert_eq!(CodeLanguage::from_label("tsx"), CodeLanguage::TypeScript);
1900 assert_eq!(CodeLanguage::from_label("unknown"), CodeLanguage::PlainText);
1901 }
1902
1903 #[test]
1904 fn syntect_highlighter_generates_multiple_styled_runs_for_rust() {
1905 let theme = liora_theme::Theme::light();
1906 let code_theme = resolve_code_theme(CodeTheme::Auto, &theme);
1907 let runs = syntect_runs(
1908 "fn main() { let n = 42; // ok\n println!(\"hi\"); }",
1909 CodeLanguage::Rust,
1910 code_theme,
1911 &theme,
1912 true,
1913 );
1914
1915 assert!(runs.len() > 3);
1916 assert_eq!(runs.iter().map(|run| run.len).sum::<usize>(), 48);
1917 assert!(runs.iter().any(|run| run.color != code_text(code_theme)));
1918 }
1919
1920 #[test]
1921 fn themes_resolve_to_distinct_syntect_and_surface_palettes() {
1922 let light = liora_theme::Theme::light();
1923 let dark = liora_theme::Theme::dark();
1924
1925 assert_eq!(
1926 resolve_code_theme(CodeTheme::Auto, &light),
1927 ResolvedCodeTheme {
1928 theme: CodeTheme::LioraLight,
1929 mode: CodeThemeMode::Light
1930 }
1931 );
1932 assert_eq!(
1933 resolve_code_theme(CodeTheme::Auto, &dark),
1934 ResolvedCodeTheme {
1935 theme: CodeTheme::LioraDark,
1936 mode: CodeThemeMode::Dark
1937 }
1938 );
1939 assert_ne!(
1940 syntect_theme(ResolvedCodeTheme {
1941 theme: CodeTheme::LioraLight,
1942 mode: CodeThemeMode::Light,
1943 })
1944 .settings
1945 .background,
1946 syntect_theme(ResolvedCodeTheme {
1947 theme: CodeTheme::LioraDark,
1948 mode: CodeThemeMode::Dark,
1949 })
1950 .settings
1951 .background
1952 );
1953 assert_ne!(
1954 code_surface(ResolvedCodeTheme {
1955 theme: CodeTheme::LioraLight,
1956 mode: CodeThemeMode::Light,
1957 }),
1958 code_surface(ResolvedCodeTheme {
1959 theme: CodeTheme::LioraDark,
1960 mode: CodeThemeMode::Dark,
1961 })
1962 );
1963 }
1964
1965 #[test]
1966 fn cached_highlight_runs_reuses_render_runs_for_same_code_and_theme() {
1967 let theme = liora_theme::Theme::light();
1968 let code_theme = resolve_code_theme(CodeTheme::Auto, &theme);
1969 let first = cached_highlight_runs(
1970 "let cached = true;",
1971 CodeLanguage::Rust,
1972 CodeHighlighter::Syntect,
1973 code_theme,
1974 &theme,
1975 true,
1976 );
1977 let second = cached_highlight_runs(
1978 "let cached = true;",
1979 CodeLanguage::Rust,
1980 CodeHighlighter::Syntect,
1981 code_theme,
1982 &theme,
1983 true,
1984 );
1985
1986 assert_eq!(first, second);
1987 }
1988
1989 #[test]
1990 fn cached_highlight_runs_share_arc_storage_for_block_layouts() {
1991 let theme = liora_theme::Theme::light();
1992 let code_theme = resolve_code_theme(CodeTheme::Auto, &theme);
1993 let (_, first) = cached_highlight_runs_with_key(
1994 "fn shared_runs() { println!(\"cache\"); }",
1995 CodeLanguage::Rust,
1996 CodeHighlighter::Syntect,
1997 code_theme,
1998 &theme,
1999 true,
2000 );
2001 let (_, second) = cached_highlight_runs_with_key(
2002 "fn shared_runs() { println!(\"cache\"); }",
2003 CodeLanguage::Rust,
2004 CodeHighlighter::Syntect,
2005 code_theme,
2006 &theme,
2007 true,
2008 );
2009
2010 assert!(Arc::ptr_eq(&first, &second));
2011 }
2012
2013 #[test]
2014 fn highlight_cache_evicts_incrementally_without_clearing_all_runs() {
2015 let mut cache = HighlightCache::default();
2016 let theme = liora_theme::Theme::light();
2017 let code_theme = resolve_code_theme(CodeTheme::Auto, &theme);
2018 let first_key = HighlightCacheKey::new(
2019 "let item_0 = 0;",
2020 CodeLanguage::Rust,
2021 CodeHighlighter::Syntect,
2022 code_theme,
2023 true,
2024 &theme,
2025 );
2026 let last_key = HighlightCacheKey::new(
2027 "let item_256 = 256;",
2028 CodeLanguage::Rust,
2029 CodeHighlighter::Syntect,
2030 code_theme,
2031 true,
2032 &theme,
2033 );
2034
2035 for index in 0..=HIGHLIGHT_CACHE_CAPACITY {
2036 let text = format!("let item_{index} = {index};");
2037 let key = HighlightCacheKey::new(
2038 &text,
2039 CodeLanguage::Rust,
2040 CodeHighlighter::Syntect,
2041 code_theme,
2042 true,
2043 &theme,
2044 );
2045 cache.insert(
2046 key,
2047 HighlightRuns::from(vec![
2048 base_style(&theme, code_theme, true).to_run(text.len()),
2049 ]),
2050 );
2051 }
2052
2053 assert_eq!(cache.runs.len(), HIGHLIGHT_CACHE_CAPACITY);
2054 assert!(!cache.runs.contains_key(&first_key));
2055 assert!(cache.runs.contains_key(&last_key));
2056 }
2057
2058 #[test]
2059 fn block_code_defers_expensive_rendering_for_first_frame() {
2060 let source = include_str!("code_block.rs");
2061
2062 assert!(source.contains("should_render_code_now"));
2063 assert!(source.contains("deferred-code-ready"));
2064 assert!(source.contains("take_deferred_highlight_slot"));
2065 assert!(source.contains("FRAME_BUDGET"));
2066 assert!(source.contains("render_code_placeholder"));
2067 assert!(source.contains("cx.notify()"));
2068 }
2069
2070 #[test]
2071 fn component_uses_syntect_and_supports_copyable_block_and_inline_format() {
2072 let source = include_str!("code_block.rs");
2073
2074 assert!(source.contains("HighlightLines"));
2075 assert!(source.contains("SyntaxSet::load_defaults_newlines"));
2076 assert!(source.contains("ThemeSet::load_defaults"));
2077 assert!(source.contains("ClipboardItem::new_string"));
2078 assert!(source.contains("on_copy"));
2079 assert!(source.contains("CodeCopyCallback"));
2080 assert!(source.contains("CodeFormat::Inline"));
2081 assert!(source.contains("selectable"));
2082 assert!(source.contains("SelectableCodeText"));
2083 assert!(source.contains("SelectableCodeState"));
2084 assert!(source.contains("selectable_state_map"));
2085 assert!(source.contains("lines: Vec<(ShapedLine"));
2086 assert!(source.contains("bounds: Option<Bounds"));
2087 assert!(source.contains("with_selectable_state(&self.id"));
2088 assert!(source.contains("prewarm_highlighter"));
2089 assert!(source.contains("SelectableCodeLayout"));
2090 assert!(source.contains("ReadOnlyCodeText"));
2091 assert!(source.contains("build_code_layout"));
2092 assert!(source.contains("set_selectable_layout_state"));
2093 assert!(source.contains("fn id(&self) -> Option<ElementId>"));
2094 assert!(source.contains("fn font_size(&self) -> Pixels"));
2095 assert!(source.contains("theme.font_size.md"));
2096 assert!(source.contains("cached_highlight_runs"));
2097 assert!(source.contains("HighlightCacheKey"));
2098 assert!(source.contains("CodeHighlighter::Syntect"));
2099 assert!(source.contains("CodeTheme::Auto"));
2100 assert!(source.contains("light_theme"));
2101 assert!(source.contains("dark_theme"));
2102 assert!(source.contains("github_dark_theme"));
2103 assert!(source.contains("two_face::syntax::extra_newlines"));
2104 assert!(source.contains("StyledText::new"));
2105 assert!(source.contains("with_runs"));
2106 }
2107}