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