Skip to main content

waterui_text/
code.rs

1//! A fenced code block: a header naming the language, a copy button, and the
2//! highlighted source.
3//!
4//! `Code` is a text-presentation widget, so it lives beside [`Language`],
5//! [`DefaultHighlighter`] and [`StyledStr`] rather than in the root crate. That
6//! is what lets a component crate claim a fence — install a
7//! [`Hook<CodeConfig>`] — without depending on the aggregator.
8
9use alloc::{
10    rc::Rc,
11    string::{String, ToString},
12};
13use core::error::Error;
14use core::fmt;
15
16#[cfg(target_arch = "wasm32")]
17use executor_core::spawn_local;
18use nami::SignalExt;
19use suiteki::Str;
20use waterui_core::gesture::{GestureObserver, TapGesture};
21use waterui_core::resolve::Resolvable;
22use waterui_core::view::{ConfigurableView, Hook, ViewConfiguration};
23use waterui_core::{AnyView, Environment, Metadata, View};
24use waterui_graphics::color::{
25    AccentColor, Color, CurrentColorScheme, MutedForegroundColor, SurfaceVariantColor,
26};
27use waterui_layout::{
28    background::background,
29    padding::{EdgeInsets, Padding},
30    spacer,
31    stack::{HorizontalAlignment, VStack, hstack},
32};
33
34use crate::{
35    font::{Body, Font},
36    highlight::{DefaultHighlighter, Language, highlight_text},
37    text,
38};
39
40/// Copies text to the system clipboard.
41#[cfg(all(
42    not(target_os = "android"),
43    not(target_arch = "wasm32"),
44    not(target_os = "espidf")
45))]
46fn copy_to_clipboard(text: &str) {
47    match arboard::Clipboard::new() {
48        Ok(mut clipboard) => {
49            if let Err(error) = clipboard.set_text(text) {
50                tracing::error!(%error, "Failed to copy to clipboard");
51            }
52        }
53        Err(error) => {
54            tracing::error!(%error, "Failed to access clipboard");
55        }
56    }
57}
58
59/// Embedded targets have no system clipboard; copy is a no-op.
60#[cfg(target_os = "espidf")]
61fn copy_to_clipboard(_text: &str) {}
62
63/// Copies text to the Android clipboard.
64#[cfg(target_os = "android")]
65fn copy_to_clipboard(text: &str) {
66    if let Err(error) = android_clipboard::set_text(text.to_string()) {
67        tracing::error!(%error, "Failed to copy to clipboard");
68    }
69}
70
71/// Copies text to the browser clipboard.
72#[cfg(target_arch = "wasm32")]
73fn copy_to_clipboard(text: &str) {
74    let clipboard = web_sys::window()
75        .expect("browser window is unavailable for clipboard access")
76        .navigator()
77        .clipboard();
78    let text = text.to_string();
79
80    spawn_local(async move {
81        let promise = clipboard.write_text(&text);
82        if let Err(error) = wasm_bindgen_futures::JsFuture::from(promise).await {
83            tracing::error!(?error, "Failed to write browser clipboard text");
84        }
85    })
86    .detach();
87}
88
89/// What runs after a block's text has landed on the clipboard.
90///
91/// `Code` owns the copy; what to tell the user about it is the caller's — a
92/// snackbar, a status line, nothing. The closure is erased here so
93/// [`Code::on_copied`] can take a plain generic and [`CodeConfig`] can carry
94/// it through a hook that declines the fence.
95#[derive(Clone)]
96pub struct OnCopied(Rc<dyn Fn(&Environment)>);
97
98impl OnCopied {
99    /// Runs the callback against the environment the copy happened in.
100    pub fn call(&self, env: &Environment) {
101        (self.0)(env);
102    }
103}
104
105impl fmt::Debug for OnCopied {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str("OnCopied(..)")
108    }
109}
110
111/// View that renders syntax-highlighted code snippets.
112#[derive(Debug, Clone)]
113pub struct Code {
114    info: Option<Str>,
115    language: Language,
116    content: Str,
117    on_copied: Option<OnCopied>,
118}
119
120impl Code {
121    /// Creates a new `Code` view for the provided language and content.
122    ///
123    /// # Panics
124    ///
125    /// Panics if the language cannot be converted into a supported [`Language`].
126    pub fn new(language: impl TryInto<Language, Error: Error>, content: impl Into<Str>) -> Self {
127        Self {
128            info: None,
129            language: language.try_into().expect("Invalid language"),
130            content: content.into(),
131            on_copied: None,
132        }
133    }
134
135    /// Records the fence's info token as the author wrote it.
136    ///
137    /// Markdown calls this so a realization can claim a token no [`Language`]
138    /// answers to — `mermaid`, `plantuml`, `vega` — which would otherwise be
139    /// indistinguishable from a fence carrying no info string at all.
140    #[must_use]
141    pub fn info(mut self, info: impl Into<Str>) -> Self {
142        self.info = Some(info.into());
143        self
144    }
145
146    /// Runs after the block's text has been copied to the clipboard.
147    ///
148    /// `Code` owns the copy; what to tell the user about it is the caller's —
149    /// a snackbar, a status line, nothing. Without one, copying is silent.
150    #[must_use]
151    pub fn on_copied(mut self, callback: impl Fn(&Environment) + 'static) -> Self {
152        self.on_copied = Some(OnCopied(Rc::new(callback)));
153        self
154    }
155}
156
157/// Everything a fenced code block is, before anything decides how to show it.
158///
159/// An application claims a fence by installing a [`Hook<CodeConfig>`] on its
160/// environment; a hook that does not recognise [`info`](Self::info) calls
161/// [`ViewConfiguration::render`] to get the ordinary code block back.
162#[derive(Debug, Clone)]
163pub struct CodeConfig {
164    /// The fence's info token as written (`mermaid`, `rust`, …), if it had one.
165    pub info: Option<Str>,
166    /// The language that token resolved to; [`Language::Plaintext`] when it
167    /// resolved to nothing.
168    pub language: Language,
169    /// The block's text, verbatim.
170    pub content: Str,
171    /// The caller's feedback for a copy, if it asked for any. Carried so a hook
172    /// that declines the fence and renders the default keeps it.
173    pub on_copied: Option<OnCopied>,
174}
175
176impl ViewConfiguration for CodeConfig {
177    type View = Code;
178
179    fn render(self) -> Self::View {
180        Code {
181            info: self.info,
182            language: self.language,
183            content: self.content,
184            on_copied: self.on_copied,
185        }
186    }
187}
188
189impl ConfigurableView for Code {
190    type Config = CodeConfig;
191
192    fn config(self) -> Self::Config {
193        CodeConfig {
194            info: self.info,
195            language: self.language,
196            content: self.content,
197            on_copied: self.on_copied,
198        }
199    }
200}
201
202impl View for Code {
203    fn body(self, env: &Environment) -> impl View {
204        let config = self.config();
205        // `Hook::from` removes the hook from the environment it hands to the
206        // closure, so a realization that does not recognise the info token can
207        // call `config.render()` and get this default rendering back without
208        // recursing into itself.
209        if let Some(hook) = env.get::<Hook<CodeConfig>>() {
210            AnyView::new(hook.apply(env, config))
211        } else {
212            AnyView::new(default_rendering(env, config))
213        }
214    }
215}
216
217/// What a fence looks like when nothing claims it: a header naming the
218/// language, a copy button, and the highlighted source.
219///
220/// Every colour is a theme token, so the block is a surface-variant inset on
221/// whatever the application's surface is. The highlighter's palette is the
222/// one exception a token cannot express — syntect ships a light and a dark
223/// set — so it follows the installed colour scheme and re-highlights when
224/// that flips, without the block itself being rebuilt.
225fn default_rendering(env: &Environment, config: CodeConfig) -> impl View {
226    let CodeConfig {
227        info: _,
228        language,
229        content,
230        on_copied,
231    } = config;
232    let lang_name = language.to_string();
233    let content_for_copy = content.to_string();
234
235    let highlighted = CurrentColorScheme
236        .resolve(env)
237        .map(move |scheme| {
238            highlight_text(
239                language.clone(),
240                &content,
241                &mut DefaultHighlighter::new(scheme),
242            )
243        })
244        .computed();
245
246    let block = VStack::new(
247        HorizontalAlignment::Leading,
248        8.0,
249        (
250            hstack((
251                text(lang_name)
252                    .bold()
253                    .color(Color::new(MutedForegroundColor)),
254                spacer(),
255                copy_button(content_for_copy, on_copied),
256            )),
257            text(highlighted).font(Font::from(Body).size(14.0).monospaced()),
258        ),
259    );
260    background(
261        Padding::new(EdgeInsets::all(14.0), block),
262        Color::new(SurfaceVariantColor),
263    )
264}
265
266fn copy_button(content: String, on_copied: Option<OnCopied>) -> impl View {
267    Metadata::new(
268        text("Copy").color(Color::new(AccentColor)),
269        GestureObserver::new(TapGesture::new(), move |env: Environment| {
270            copy_to_clipboard(&content);
271            if let Some(on_copied) = &on_copied {
272                on_copied.call(&env);
273            }
274        }),
275    )
276}
277
278/// Convenience constructor for creating a [`Code`] view inline.
279pub fn code(language: impl TryInto<Language, Error: Error>, content: impl Into<Str>) -> Code {
280    Code::new(language, content)
281}