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