From 31bab9dcc38fe1d85e6f7f30ae453b5b7b88bf80 Mon Sep 17 00:00:00 2001 From: Duane Bester Date: Wed, 3 Dec 2025 04:21:47 -0600 Subject: [PATCH] text_view: Adding optional code block actions to the TextView (#1725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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(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(mut self, f: F) -> Self where F: Fn(SharedString, Option, &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 | | ---------------------------- | --------------------------- | | Screenshot 2025-12-01 at 8 37
50 PM | Screenshot 2025-12-01 at 8 38
22 PM | ## 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 --- crates/story/examples/markdown.rs | 31 +++++++++++++++++++-- crates/ui/src/text/node.rs | 46 +++++++++++++++++++++++++------ crates/ui/src/text/style.rs | 2 +- crates/ui/src/text/text_view.rs | 40 ++++++++++++++++++++++++++- 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/crates/story/examples/markdown.rs b/crates/story/examples/markdown.rs index 76adbb18..32a89760 100644 --- a/crates/story/examples/markdown.rs +++ b/crates/story/examples/markdown.rs @@ -1,6 +1,9 @@ -use gpui::*; +use gpui::{prelude::FluentBuilder as _, *}; use gpui_component::{ - ActiveTheme as _, + ActiveTheme as _, IconName, Sizable as _, + button::{Button, ButtonVariants as _}, + clipboard::Clipboard, + h_flex, highlighter::Language, input::{Input, InputEvent, InputState, TabSize}, resizable::{h_resizable, resizable_panel}, @@ -103,6 +106,30 @@ impl Render for Example { window, 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() .p_5() .scrollable(true) diff --git a/crates/ui/src/text/node.rs b/crates/ui/src/text/node.rs index 926a7246..fa930d48 100644 --- a/crates/ui/src/text/node.rs +++ b/crates/ui/src/text/node.rs @@ -16,7 +16,10 @@ use ropey::Rope; use crate::{ ActiveTheme as _, Icon, IconName, StyledExt, h_flex, highlighter::{HighlightTheme, SyntaxHighlighter}, - text::inline::{Inline, InlineState}, + text::{ + CodeBlockActionsFn, + inline::{Inline, InlineState}, + }, tooltip::Tooltip, v_flex, }; @@ -304,7 +307,7 @@ impl Paragraph { } #[derive(Debug, Clone)] -pub(crate) struct CodeBlock { +pub struct CodeBlock { lang: Option, styles: Vec<(Range, HighlightStyle)>, state: Arc>, @@ -317,6 +320,16 @@ impl PartialEq for CodeBlock { } impl CodeBlock { + /// Get the language of the code block. + pub fn lang(&self) -> Option { + 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( code: SharedString, lang: Option, @@ -340,10 +353,6 @@ impl CodeBlock { } } - fn code(&self) -> SharedString { - self.state.lock().unwrap().text.clone() - } - pub(super) fn selected_text(&self) -> String { let mut text = String::new(); let state = self.state.lock().unwrap(); @@ -358,7 +367,7 @@ impl CodeBlock { &self, options: &NodeRenderOptions, node_cx: &NodeContext, - _: &mut Window, + window: &mut Window, cx: &mut App, ) -> AnyElement { let style = &node_cx.style; @@ -380,17 +389,29 @@ impl CodeBlock { self.state.clone(), vec![], 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() } } /// A context for rendering nodes, contains link references. -#[derive(Default, Clone, PartialEq)] +#[derive(Default, Clone)] pub(crate) struct NodeContext { pub(crate) link_refs: HashMap, pub(crate) style: TextViewStyle, + pub(crate) code_block_actions: Option>, } 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. #[derive(Debug, Clone, PartialEq)] pub(crate) enum Node { diff --git a/crates/ui/src/text/style.rs b/crates/ui/src/text/style.rs index fa43da57..167b7259 100644 --- a/crates/ui/src/text/style.rs +++ b/crates/ui/src/text/style.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use gpui::{px, rems, Pixels, Rems, StyleRefinement}; +use gpui::{Pixels, Rems, StyleRefinement, px, rems}; use crate::highlighter::HighlightTheme; diff --git a/crates/ui/src/text/text_view.rs b/crates/ui/src/text/text_view.rs index b35e7c98..0e0b9ec4 100644 --- a/crates/ui/src/text/text_view.rs +++ b/crates/ui/src/text/text_view.rs @@ -15,6 +15,7 @@ use smol::stream::StreamExt; use crate::highlighter::HighlightTheme; use crate::scroll::ScrollableElement; +use crate::text::node::CodeBlock; use crate::{ActiveTheme, StyledExt, v_flex}; use crate::{ 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. /// /// ## Goals @@ -91,6 +96,7 @@ pub struct TextView { style: StyleRefinement, selectable: bool, scrollable: bool, + code_block_actions: Option>, } #[derive(PartialEq)] @@ -122,9 +128,11 @@ struct UpdateFuture { rx: Pin>>, tx_result: smol::channel::Sender>, delay: Duration, + code_block_actions: Option>, } impl UpdateFuture { + #[allow(clippy::too_many_arguments)] fn new( type_: TextViewType, style: TextViewStyle, @@ -133,6 +141,7 @@ impl UpdateFuture { rx: smol::channel::Receiver, tx_result: smol::channel::Sender>, delay: Duration, + code_block_actions: Option>, ) -> Self { Self { type_, @@ -143,6 +152,7 @@ impl UpdateFuture { rx: Box::pin(rx), tx_result, delay, + code_block_actions, } } } @@ -182,6 +192,7 @@ impl Future for UpdateFuture { &self.current_text, self.current_style.clone(), &self.highlight_theme, + &self.code_block_actions.clone(), ); _ = self.tx_result.try_send(res); continue; @@ -419,6 +430,7 @@ impl TextView { state, selectable: false, scrollable: false, + code_block_actions: None, } } @@ -449,6 +461,7 @@ impl TextView { raw: html, selectable: 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())); } + + /// Set custom block actions for code blocks. + /// + /// The closure receives the [`CodeBlock`], + /// and returns an element to display. + pub fn code_block_actions(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 { @@ -548,10 +576,17 @@ impl Element for TextView { { let style = *style; let highlight_theme = highlight_theme.clone(); + let code_block_actions = self.code_block_actions.clone(); let (tx, rx) = smol::channel::unbounded::(); let (tx_result, rx_result) = smol::channel::unbounded::>(); - 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, { let tx = tx.clone(); @@ -591,6 +626,7 @@ impl Element for TextView { rx, tx_result, Duration::from_millis(200), + code_block_actions, )) .detach(); @@ -744,9 +780,11 @@ fn parse_content( text: &str, style: TextViewStyle, highlight_theme: &HighlightTheme, + code_block_actions: &Option>, ) -> Result { let mut node_cx = NodeContext { style: style.clone(), + code_block_actions: code_block_actions.clone(), ..NodeContext::default() };