text_view: Adding optional code block actions to the TextView (#1725)

## Description

This PR Adds the ability for users of the library to provide code block
actions for code blocks rendered in markdown (or anything that uses the
TextViewStyle).

I tried to match the existing Fns:
```rs
pub fn heading_font_size<F>(mut self, f: F) -> Self
where
    F: Fn(u8, Pixels) -> Pixels + Send + Sync + 'static,
{
    self.heading_font_size = Some(Arc::new(f));
    self
}
```

New code in text_view:
```rs
pub fn code_block_actions<F, E>(mut self, f: F) -> Self
where
    F: Fn(SharedString, Option<SharedString>, &mut Window, &mut App) -> E
        + Send
        + Sync
        + 'static,
    E: IntoElement,
{
    self.code_block_actions = Some(Arc::new(move |code, lang, window, cx| {
        f(code, lang, window, cx).into_any_element()
    }));
    self
}
```

Example on adding a simple copy button:

```rs
TextView::markdown("preview", content, window, cx)
    .code_block_actions(|code, _lang, _window, _cx| {
        Clipboard::new("copy").value(code)
    })
```


## Screenshot

| Before                       | After                       |
| ---------------------------- | --------------------------- |
| <img width="630" height="527" alt="Screenshot 2025-12-01 at 8 37
50 PM"
src="https://github.com/user-attachments/assets/7ac3b8d9-da85-42f8-9d36-cc74848890ea"
/> | <img width="627" height="535" alt="Screenshot 2025-12-01 at 8 38
22 PM"
src="https://github.com/user-attachments/assets/cae67b43-bba8-4834-a060-4bd63d34aefd"
/> |


## How to Test

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce.

## Checklist

- [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) document and
followed the guidelines.
- [x] Reviewed the changes in this PR and confirmed AI generated code
(If any) is accurate.
- [x] Passed `cargo run` for story tests related to the changes.
- [ ] Tested macOS, Windows and Linux platforms performance (if the
change is platform-specific)

---------

Co-authored-by: Jason Lee <huacnlee@gmail.com>
This commit is contained in:
Duane Bester 2025-12-03 04:21:47 -06:00 committed by GitHub
parent b70a0633ab
commit 31bab9dcc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 106 additions and 13 deletions

View file

@ -1,6 +1,9 @@
use gpui::*; use gpui::{prelude::FluentBuilder as _, *};
use gpui_component::{ use gpui_component::{
ActiveTheme as _, ActiveTheme as _, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
clipboard::Clipboard,
h_flex,
highlighter::Language, highlighter::Language,
input::{Input, InputEvent, InputState, TabSize}, input::{Input, InputEvent, InputState, TabSize},
resizable::{h_resizable, resizable_panel}, resizable::{h_resizable, resizable_panel},
@ -103,6 +106,30 @@ impl Render for Example {
window, window,
cx, cx,
) )
.code_block_actions(|code_block, _window, _cx| {
let code = code_block.code();
let lang = code_block.lang();
h_flex()
.gap_1()
.child(Clipboard::new("copy").value(code.clone()))
.when_some(lang, |this, lang| {
// Only show run terminal button for certain languages
if lang.as_ref() == "rust" || lang.as_ref() == "python" {
this.child(
Button::new("run-terminal")
.icon(IconName::SquareTerminal)
.ghost()
.xsmall()
.on_click(move |_, _, _cx| {
println!("Running {} code: {}", lang, code);
}),
)
} else {
this
}
})
})
.flex_none() .flex_none()
.p_5() .p_5()
.scrollable(true) .scrollable(true)

View file

@ -16,7 +16,10 @@ use ropey::Rope;
use crate::{ use crate::{
ActiveTheme as _, Icon, IconName, StyledExt, h_flex, ActiveTheme as _, Icon, IconName, StyledExt, h_flex,
highlighter::{HighlightTheme, SyntaxHighlighter}, highlighter::{HighlightTheme, SyntaxHighlighter},
text::inline::{Inline, InlineState}, text::{
CodeBlockActionsFn,
inline::{Inline, InlineState},
},
tooltip::Tooltip, tooltip::Tooltip,
v_flex, v_flex,
}; };
@ -304,7 +307,7 @@ impl Paragraph {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct CodeBlock { pub struct CodeBlock {
lang: Option<SharedString>, lang: Option<SharedString>,
styles: Vec<(Range<usize>, HighlightStyle)>, styles: Vec<(Range<usize>, HighlightStyle)>,
state: Arc<Mutex<InlineState>>, state: Arc<Mutex<InlineState>>,
@ -317,6 +320,16 @@ impl PartialEq for CodeBlock {
} }
impl CodeBlock { impl CodeBlock {
/// Get the language of the code block.
pub fn lang(&self) -> Option<SharedString> {
self.lang.clone()
}
/// Get the code content of the code block.
pub fn code(&self) -> SharedString {
self.state.lock().unwrap().text.clone()
}
pub(crate) fn new( pub(crate) fn new(
code: SharedString, code: SharedString,
lang: Option<SharedString>, lang: Option<SharedString>,
@ -340,10 +353,6 @@ impl CodeBlock {
} }
} }
fn code(&self) -> SharedString {
self.state.lock().unwrap().text.clone()
}
pub(super) fn selected_text(&self) -> String { pub(super) fn selected_text(&self) -> String {
let mut text = String::new(); let mut text = String::new();
let state = self.state.lock().unwrap(); let state = self.state.lock().unwrap();
@ -358,7 +367,7 @@ impl CodeBlock {
&self, &self,
options: &NodeRenderOptions, options: &NodeRenderOptions,
node_cx: &NodeContext, node_cx: &NodeContext,
_: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> AnyElement { ) -> AnyElement {
let style = &node_cx.style; let style = &node_cx.style;
@ -380,17 +389,29 @@ impl CodeBlock {
self.state.clone(), self.state.clone(),
vec![], vec![],
self.styles.clone(), self.styles.clone(),
)), ))
.when_some(node_cx.code_block_actions.clone(), |this, actions| {
this.child(
div()
.absolute()
.top_2()
.right_2()
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.child(actions(&self, window, cx)),
)
}),
) )
.into_any_element() .into_any_element()
} }
} }
/// A context for rendering nodes, contains link references. /// A context for rendering nodes, contains link references.
#[derive(Default, Clone, PartialEq)] #[derive(Default, Clone)]
pub(crate) struct NodeContext { pub(crate) struct NodeContext {
pub(crate) link_refs: HashMap<SharedString, LinkMark>, pub(crate) link_refs: HashMap<SharedString, LinkMark>,
pub(crate) style: TextViewStyle, pub(crate) style: TextViewStyle,
pub(crate) code_block_actions: Option<Arc<CodeBlockActionsFn>>,
} }
impl NodeContext { impl NodeContext {
@ -399,6 +420,13 @@ impl NodeContext {
} }
} }
impl PartialEq for NodeContext {
fn eq(&self, other: &Self) -> bool {
self.link_refs == other.link_refs && self.style == other.style
// Note: code_block_buttons is intentionally not compared (closures can't be compared)
}
}
/// The AST Node of the rich text. /// The AST Node of the rich text.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub(crate) enum Node { pub(crate) enum Node {

View file

@ -1,6 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use gpui::{px, rems, Pixels, Rems, StyleRefinement}; use gpui::{Pixels, Rems, StyleRefinement, px, rems};
use crate::highlighter::HighlightTheme; use crate::highlighter::HighlightTheme;

View file

@ -15,6 +15,7 @@ use smol::stream::StreamExt;
use crate::highlighter::HighlightTheme; use crate::highlighter::HighlightTheme;
use crate::scroll::ScrollableElement; use crate::scroll::ScrollableElement;
use crate::text::node::CodeBlock;
use crate::{ActiveTheme, StyledExt, v_flex}; use crate::{ActiveTheme, StyledExt, v_flex};
use crate::{ use crate::{
global_state::GlobalState, global_state::GlobalState,
@ -66,6 +67,10 @@ impl RenderOnce for TextViewElement {
} }
} }
/// Type for code block actions generator function.
pub(crate) type CodeBlockActionsFn =
dyn Fn(&CodeBlock, &mut Window, &mut App) -> AnyElement + Send + Sync;
/// A text view that can render Markdown or HTML. /// A text view that can render Markdown or HTML.
/// ///
/// ## Goals /// ## Goals
@ -91,6 +96,7 @@ pub struct TextView {
style: StyleRefinement, style: StyleRefinement,
selectable: bool, selectable: bool,
scrollable: bool, scrollable: bool,
code_block_actions: Option<Arc<CodeBlockActionsFn>>,
} }
#[derive(PartialEq)] #[derive(PartialEq)]
@ -122,9 +128,11 @@ struct UpdateFuture {
rx: Pin<Box<smol::channel::Receiver<Update>>>, rx: Pin<Box<smol::channel::Receiver<Update>>>,
tx_result: smol::channel::Sender<Result<ParsedContent, SharedString>>, tx_result: smol::channel::Sender<Result<ParsedContent, SharedString>>,
delay: Duration, delay: Duration,
code_block_actions: Option<Arc<CodeBlockActionsFn>>,
} }
impl UpdateFuture { impl UpdateFuture {
#[allow(clippy::too_many_arguments)]
fn new( fn new(
type_: TextViewType, type_: TextViewType,
style: TextViewStyle, style: TextViewStyle,
@ -133,6 +141,7 @@ impl UpdateFuture {
rx: smol::channel::Receiver<Update>, rx: smol::channel::Receiver<Update>,
tx_result: smol::channel::Sender<Result<ParsedContent, SharedString>>, tx_result: smol::channel::Sender<Result<ParsedContent, SharedString>>,
delay: Duration, delay: Duration,
code_block_actions: Option<Arc<CodeBlockActionsFn>>,
) -> Self { ) -> Self {
Self { Self {
type_, type_,
@ -143,6 +152,7 @@ impl UpdateFuture {
rx: Box::pin(rx), rx: Box::pin(rx),
tx_result, tx_result,
delay, delay,
code_block_actions,
} }
} }
} }
@ -182,6 +192,7 @@ impl Future for UpdateFuture {
&self.current_text, &self.current_text,
self.current_style.clone(), self.current_style.clone(),
&self.highlight_theme, &self.highlight_theme,
&self.code_block_actions.clone(),
); );
_ = self.tx_result.try_send(res); _ = self.tx_result.try_send(res);
continue; continue;
@ -419,6 +430,7 @@ impl TextView {
state, state,
selectable: false, selectable: false,
scrollable: false, scrollable: false,
code_block_actions: None,
} }
} }
@ -449,6 +461,7 @@ impl TextView {
raw: html, raw: html,
selectable: false, selectable: false,
scrollable: false, scrollable: false,
code_block_actions: None,
} }
} }
@ -510,6 +523,21 @@ impl TextView {
cx.write_to_clipboard(ClipboardItem::new_string(selected_text.trim().to_string())); cx.write_to_clipboard(ClipboardItem::new_string(selected_text.trim().to_string()));
} }
/// Set custom block actions for code blocks.
///
/// The closure receives the [`CodeBlock`],
/// and returns an element to display.
pub fn code_block_actions<F, E>(mut self, f: F) -> Self
where
F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
E: IntoElement,
{
self.code_block_actions = Some(Arc::new(move |code_block, window, cx| {
f(&code_block, window, cx).into_any_element()
}));
self
}
} }
impl IntoElement for TextView { impl IntoElement for TextView {
@ -548,10 +576,17 @@ impl Element for TextView {
{ {
let style = *style; let style = *style;
let highlight_theme = highlight_theme.clone(); let highlight_theme = highlight_theme.clone();
let code_block_actions = self.code_block_actions.clone();
let (tx, rx) = smol::channel::unbounded::<Update>(); let (tx, rx) = smol::channel::unbounded::<Update>();
let (tx_result, rx_result) = let (tx_result, rx_result) =
smol::channel::unbounded::<Result<ParsedContent, SharedString>>(); smol::channel::unbounded::<Result<ParsedContent, SharedString>>();
let parsed_result = parse_content(type_, &text, style.clone(), &highlight_theme); let parsed_result = parse_content(
type_,
&text,
style.clone(),
&highlight_theme,
&code_block_actions,
);
self.state.update(cx, { self.state.update(cx, {
let tx = tx.clone(); let tx = tx.clone();
@ -591,6 +626,7 @@ impl Element for TextView {
rx, rx,
tx_result, tx_result,
Duration::from_millis(200), Duration::from_millis(200),
code_block_actions,
)) ))
.detach(); .detach();
@ -744,9 +780,11 @@ fn parse_content(
text: &str, text: &str,
style: TextViewStyle, style: TextViewStyle,
highlight_theme: &HighlightTheme, highlight_theme: &HighlightTheme,
code_block_actions: &Option<Arc<CodeBlockActionsFn>>,
) -> Result<ParsedContent, SharedString> { ) -> Result<ParsedContent, SharedString> {
let mut node_cx = NodeContext { let mut node_cx = NodeContext {
style: style.clone(), style: style.clone(),
code_block_actions: code_block_actions.clone(),
..NodeContext::default() ..NodeContext::default()
}; };