Skip to main content

gpui_ai/
code_block.rs

1//! Syntax-highlighted code blocks with streaming reveal.
2
3use crate::decoration::{DecoratedExt as _, Decoration};
4use crate::stream::{ProgressState, StreamedContent};
5use crate::surface::CardFrameExt as _;
6use crate::theme::SemanticStyledExt as _;
7use gpui::{
8    App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
9    SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
10    prelude::FluentBuilder as _,
11};
12use gpui_component::{
13    ActiveTheme as _, StyledExt as _, clipboard::Clipboard, h_flex, text::TextView, v_flex,
14};
15
16/// A code block with a language header, copy button, and syntax highlighting
17/// (via gpui-component's markdown/highlighter pipeline).
18///
19/// For streaming code, pass a [`StreamedContent`] with [`Self::streamed`]; a
20/// cursor glyph marks the insertion point while content arrives.
21///
22/// # Example
23///
24/// ```
25/// # use gpui_ai::prelude::*;
26/// CodeBlock::new("example", "fn main() {\n    println!(\"hi\");\n}")
27///     .language("rust");
28/// ```
29#[derive(IntoElement)]
30pub struct CodeBlock {
31    id: ElementId,
32    style: StyleRefinement,
33    decoration: Decoration,
34    code: SharedString,
35    language: Option<SharedString>,
36    streaming: bool,
37    failed: Option<SharedString>,
38    copyable: bool,
39}
40
41impl CodeBlock {
42    /// Layers painted into this block's frame: one under the content, one
43    /// over it, both clipped to its own shape and neither affecting layout.
44    ///
45    /// This crate ships no effects of its own — what goes in a decoration is
46    /// the application's expression.
47    pub fn decoration(mut self, decoration: Decoration) -> Self {
48        self.decoration = decoration;
49        self
50    }
51
52    /// Creates a block from complete code.
53    pub fn new(id: impl Into<ElementId>, code: impl Into<SharedString>) -> Self {
54        Self {
55            id: id.into(),
56            style: StyleRefinement::default(),
57            decoration: Decoration::default(),
58            code: code.into(),
59            language: None,
60            streaming: false,
61            failed: None,
62            copyable: true,
63        }
64    }
65
66    /// Creates a block from streamed content, deriving the streaming/failed
67    /// presentation from its state.
68    pub fn streamed(id: impl Into<ElementId>, content: &StreamedContent) -> Self {
69        let mut this = Self::new(id, content.text().to_string());
70        match content.state() {
71            ProgressState::Running => this.streaming = true,
72            ProgressState::Pending | ProgressState::Complete => {}
73            ProgressState::Failed(reason) => this.failed = Some(reason.clone()),
74        }
75        this
76    }
77
78    /// Sets the language used for the header label and syntax highlighting.
79    pub fn language(mut self, language: impl Into<SharedString>) -> Self {
80        self.language = Some(language.into());
81        self
82    }
83
84    /// Hides the copy button.
85    pub fn not_copyable(mut self) -> Self {
86        self.copyable = false;
87        self
88    }
89}
90
91impl Styled for CodeBlock {
92    fn style(&mut self) -> &mut StyleRefinement {
93        &mut self.style
94    }
95}
96
97impl RenderOnce for CodeBlock {
98    fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
99        let tokens = cx.theme().semantic_tokens();
100        // Taken once: each layer is placed at most once, and a component with
101        // no decoration adds no elements at all.
102        let mut decoration = std::mem::take(&mut self.decoration);
103        let language = self.language.unwrap_or_else(|| "text".into());
104
105        // Build a fenced markdown block, using a fence longer than any
106        // backtick run inside the code so the content cannot break out.
107        let longest_run = self
108            .code
109            .split(|c| c != '`')
110            .map(str::len)
111            .max()
112            .unwrap_or(0);
113        let fence = "`".repeat((longest_run + 1).max(3));
114        let cursor = if self.streaming { "▌" } else { "" };
115        let source = format!("{fence}{language}\n{}{cursor}\n{fence}", self.code);
116        let accessibility_label: SharedString = format!("{language} code").into();
117        let accessibility_description = self.failed.clone();
118
119        v_flex()
120            .id(self.id)
121            .role(Role::Code)
122            .aria_label(accessibility_label)
123            .when_some(accessibility_description, |this, description| {
124                this.aria_description(description)
125            })
126            .card_frame(cx)
127            .overflow_hidden()
128            .decoration_under(&mut decoration)
129            .child(
130                h_flex()
131                    .items_center()
132                    .justify_between()
133                    .px(tokens.spacing.md)
134                    .py(tokens.spacing.xs)
135                    .bg(cx.theme().muted.opacity(0.35))
136                    .border_b_1()
137                    .border_color(cx.theme().border)
138                    // The language name yields and the copy control holds:
139                    // a long language should shorten its own label rather
140                    // than push the only affordance in the header out of it.
141                    .child(
142                        div()
143                            .min_w_0()
144                            .truncate()
145                            .text_token(tokens.typography.xs)
146                            .font_family(cx.theme().mono_font_family.clone())
147                            .text_color(cx.theme().muted_foreground)
148                            .child(language.clone()),
149                    )
150                    .when(self.copyable, |this| {
151                        this.child(
152                            div()
153                                .flex_none()
154                                .child(Clipboard::new("copy").value(self.code.clone())),
155                        )
156                    }),
157            )
158            .child(
159                div()
160                    .px(tokens.spacing.md)
161                    .py(tokens.spacing.sm)
162                    .text_token(tokens.typography.sm)
163                    .child(TextView::markdown("code", source).selectable(true)),
164            )
165            .when_some(self.failed, |this, reason| {
166                this.child(
167                    div()
168                        .px(tokens.spacing.md)
169                        .py(tokens.spacing.xs)
170                        .border_t_1()
171                        .border_color(cx.theme().border)
172                        .text_token(tokens.typography.xs)
173                        .text_color(cx.theme().danger)
174                        .child(reason),
175                )
176            })
177            .decoration_over(&mut decoration)
178            .refine_style(&self.style)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::stream::Progressive;
186
187    #[test]
188    fn maps_all_shared_lifecycle_states() {
189        assert!(
190            !CodeBlock::streamed("pending", &Progressive::pending("code".to_owned())).streaming
191        );
192        assert!(CodeBlock::streamed("running", &Progressive::running("code".to_owned())).streaming);
193        assert!(
194            CodeBlock::streamed("complete", &Progressive::complete("code".to_owned()))
195                .failed
196                .is_none()
197        );
198        assert_eq!(
199            CodeBlock::streamed("failed", &Progressive::failed("code".to_owned(), "stopped"))
200                .failed,
201            Some("stopped".into())
202        );
203    }
204}