Skip to main content

fc_dioxus/components/
terminal.rs

1//! A terminal transcript from terminal.css: styled output lines, a prompt, a status
2//! bar with the exit code, and a cursor.
3//!
4//! It parses no escape sequences. Output arrives as styled runs; turning raw ANSI into
5//! runs is `fc-ansi`, a model crate requested upstream (see COMPONENTS.md, "Waiting on
6//! upstream"), so the parser lands once, with a TypeScript twin, rather than here.
7use crate::{
8    core::{
9        assets,
10        attrs::{component_attributes, named_state_attribute},
11    },
12    prelude::*,
13    theme::use_stylesheets,
14};
15use dioxus::html::geometry::PixelsVector2D;
16use std::rc::Rc;
17const BEM: Bem = Bem::new("fc-terminal");
18
19/// The sixteen ANSI colours, drawn from themed tokens rather than fixed values.
20#[non_exhaustive]
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22#[allow(missing_docs)]
23pub enum AnsiColor {
24    Black,
25    Red,
26    Green,
27    Yellow,
28    Blue,
29    Magenta,
30    Cyan,
31    White,
32    BrightBlack,
33    BrightRed,
34    BrightGreen,
35    BrightYellow,
36    BrightBlue,
37    BrightMagenta,
38    BrightCyan,
39    BrightWhite,
40}
41impl AnsiColor {
42    /// Every colour, in SGR order (30 to 37, then 90 to 97).
43    pub const ALL: [Self; 16] = [
44        Self::Black,
45        Self::Red,
46        Self::Green,
47        Self::Yellow,
48        Self::Blue,
49        Self::Magenta,
50        Self::Cyan,
51        Self::White,
52        Self::BrightBlack,
53        Self::BrightRed,
54        Self::BrightGreen,
55        Self::BrightYellow,
56        Self::BrightBlue,
57        Self::BrightMagenta,
58        Self::BrightCyan,
59        Self::BrightWhite,
60    ];
61    /// The colour's class, a block of its own in terminal.css.
62    #[must_use]
63    pub const fn bem(self) -> Bem {
64        match self {
65            Self::Black => Bem::new("fc-term-ansi-black"),
66            Self::Red => Bem::new("fc-term-ansi-red"),
67            Self::Green => Bem::new("fc-term-ansi-green"),
68            Self::Yellow => Bem::new("fc-term-ansi-yellow"),
69            Self::Blue => Bem::new("fc-term-ansi-blue"),
70            Self::Magenta => Bem::new("fc-term-ansi-magenta"),
71            Self::Cyan => Bem::new("fc-term-ansi-cyan"),
72            Self::White => Bem::new("fc-term-ansi-white"),
73            Self::BrightBlack => Bem::new("fc-term-ansi-bright-black"),
74            Self::BrightRed => Bem::new("fc-term-ansi-bright-red"),
75            Self::BrightGreen => Bem::new("fc-term-ansi-bright-green"),
76            Self::BrightYellow => Bem::new("fc-term-ansi-bright-yellow"),
77            Self::BrightBlue => Bem::new("fc-term-ansi-bright-blue"),
78            Self::BrightMagenta => Bem::new("fc-term-ansi-bright-magenta"),
79            Self::BrightCyan => Bem::new("fc-term-ansi-bright-cyan"),
80            Self::BrightWhite => Bem::new("fc-term-ansi-bright-white"),
81        }
82    }
83}
84
85/// A text attribute a run can carry, by its SGR code.
86#[non_exhaustive]
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum Sgr {
89    /// SGR 1.
90    Bold,
91    /// SGR 2.
92    Dim,
93    /// SGR 3.
94    Italic,
95    /// SGR 4.
96    Underline,
97    /// SGR 7: swaps foreground and background.
98    Inverse,
99    /// SGR 9.
100    Strike,
101}
102impl Sgr {
103    /// Every attribute.
104    pub const ALL: [Self; 6] = [
105        Self::Bold,
106        Self::Dim,
107        Self::Italic,
108        Self::Underline,
109        Self::Inverse,
110        Self::Strike,
111    ];
112    const fn bit(self) -> u8 {
113        match self {
114            Self::Bold => 1,
115            Self::Dim => 2,
116            Self::Italic => 4,
117            Self::Underline => 8,
118            Self::Inverse => 16,
119            Self::Strike => 32,
120        }
121    }
122    const fn element(self) -> &'static str {
123        match self {
124            Self::Bold => "bold",
125            Self::Dim => "dim",
126            Self::Italic => "italic",
127            Self::Underline => "underline",
128            Self::Inverse => "inverse",
129            Self::Strike => "strike",
130        }
131    }
132}
133
134/// A set of [`Sgr`] attributes.
135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
136pub struct SgrSet(u8);
137impl SgrSet {
138    /// Whether the set holds `attribute`.
139    #[must_use]
140    pub const fn contains(self, attribute: Sgr) -> bool {
141        self.0 & attribute.bit() != 0
142    }
143    /// The set with `attribute` added.
144    #[must_use]
145    pub const fn with(self, attribute: Sgr) -> Self {
146        Self(self.0 | attribute.bit())
147    }
148    /// Whether the set is empty.
149    #[must_use]
150    pub const fn is_empty(self) -> bool {
151        self.0 == 0
152    }
153}
154
155/// One run of identically styled text.
156#[derive(Debug, Clone, Default, PartialEq, Eq)]
157pub struct TermRun {
158    /// The text.
159    pub text: String,
160    /// Foreground colour; the terminal's own when `None`.
161    pub fg: Option<AnsiColor>,
162    /// Bold, dim, italic, and the rest.
163    pub attributes: SgrSet,
164}
165impl TermRun {
166    /// Unstyled text.
167    #[must_use]
168    pub fn new(text: impl Into<String>) -> Self {
169        Self {
170            text: text.into(),
171            ..Self::default()
172        }
173    }
174    /// With a foreground colour.
175    #[must_use]
176    pub const fn fg(mut self, color: AnsiColor) -> Self {
177        self.fg = Some(color);
178        self
179    }
180    /// With a text attribute.
181    #[must_use]
182    pub const fn with(mut self, attribute: Sgr) -> Self {
183        self.attributes = self.attributes.with(attribute);
184        self
185    }
186    fn styled(&self) -> bool {
187        self.fg.is_some() || !self.attributes.is_empty()
188    }
189    fn classes(&self) -> String {
190        Sgr::ALL
191            .into_iter()
192            .filter(|a| self.attributes.contains(*a))
193            .fold(ClassList::default(), |list, a| {
194                list.with(BEM.element(a.element()))
195            })
196            .with(self.fg.map_or("", |c| c.bem().block()))
197            .to_string()
198    }
199}
200impl<T: Into<String>> From<T> for TermRun {
201    fn from(text: T) -> Self {
202        Self::new(text)
203    }
204}
205
206/// One line of output.
207#[derive(Debug, Clone, Default, PartialEq, Eq)]
208pub struct TermLine(pub Vec<TermRun>);
209impl TermLine {
210    /// A line of unstyled text.
211    #[must_use]
212    pub fn plain(text: impl Into<String>) -> Self {
213        Self(vec![TermRun::new(text)])
214    }
215}
216impl From<Vec<TermRun>> for TermLine {
217    fn from(runs: Vec<TermRun>) -> Self {
218        Self(runs)
219    }
220}
221
222/// Whether the session behind the terminal is live.
223#[non_exhaustive]
224#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
225pub enum TermState {
226    /// Accepting input.
227    #[default]
228    Connected,
229    /// The session ended or dropped: input is disabled and dimmed.
230    Disconnected,
231}
232
233/// How the cursor is drawn at the end of the output.
234#[non_exhaustive]
235#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
236pub enum CursorStyle {
237    /// No cursor.
238    #[default]
239    None,
240    /// A solid block.
241    Steady,
242    /// A blinking block; steady under reduced motion, which the CSS handles.
243    Blink,
244}
245
246/// A terminal transcript. The output is a polite `log`, so new lines are announced
247/// without interrupting, and it stays pinned to the newest line unless the reader has
248/// scrolled up. With `onsubmit`, an input row sends commands; Up and Down recall
249/// earlier ones.
250#[component]
251pub fn Terminal(
252    output: ReadSignal<Vec<TermLine>>,
253    /// Names the output region; "Terminal output" when omitted.
254    #[props(default)]
255    label: Option<AccessibleName>,
256    #[props(default)] state: TermState,
257    /// The prompt drawn before the input.
258    #[props(default = "$".to_owned())]
259    prompt: String,
260    /// The shell or session name at the start of the status bar.
261    #[props(default)]
262    session: Option<String>,
263    /// The last command's exit code; zero is success.
264    #[props(default)]
265    exit: Option<i32>,
266    #[props(default)] cursor: CursorStyle,
267    #[props(default)] onsubmit: Option<EventHandler<String>>,
268    #[props(default)] class: Option<String>,
269    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
270) -> Element {
271    let mut draft = use_signal(String::new);
272    let mut history = use_signal(Vec::<String>::new);
273    let mut recall = use_signal(|| None::<usize>);
274    let mut pinned = use_signal(|| true);
275    let mut node = use_signal(|| None::<Rc<MountedData>>);
276    let disconnected = state == TermState::Disconnected;
277    let label = label.map_or_else(|| "Terminal output".to_owned(), |l| l.to_string());
278
279    // Follow new output only while the reader is at the end; scrolling up to read
280    // must not be undone by the next line.
281    use_effect(move || {
282        let _ = output.read().len();
283        if *pinned.peek() {
284            if let Some(node) = node.peek().clone() {
285                spawn(async move {
286                    // Measured, not a huge constant: browsers do not all clamp an
287                    // out-of-range offset to the end.
288                    if let Ok(size) = node.get_scroll_size().await {
289                        let _ = node
290                            .scroll(
291                                PixelsVector2D::new(0.0, size.height),
292                                ScrollBehavior::Instant,
293                            )
294                            .await;
295                    }
296                });
297            }
298        }
299    });
300
301    let mut attributes = component_attributes(
302        attributes,
303        ClassList::new(BEM.block()).extend(class.as_deref()),
304        &[],
305    );
306    attributes.push(named_state_attribute(if disconnected {
307        "disconnected"
308    } else {
309        "connected"
310    }));
311    // Borrowed, not cloned: a long session would otherwise copy its whole transcript on
312    // every appended line.
313    let lines = output.read();
314    let last = lines.len().saturating_sub(1);
315    let show_cursor = cursor != CursorStyle::None && !disconnected;
316    let cursor_class = ClassList::new(&BEM.element("cursor"))
317        .with_if(cursor == CursorStyle::Blink, BEM.element("cursor--blink"))
318        .to_string();
319
320    rsx! {
321        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_TERMINAL_CSS } }
322        // Terminal output is left-to-right in every locale; columns line up by position.
323        div { dir: "ltr", ..attributes,
324            div { class: BEM.element("output"), tabindex: "0", role: "log", aria_live: "polite", aria_label: "{label}",
325                onmounted: move |event: MountedEvent| node.set(Some(event.data())),
326                onscroll: move |event: ScrollEvent| {
327                    let end = f64::from(event.scroll_height() - event.client_height());
328                    // A pixel of slack: fractional scroll positions never quite reach the end.
329                    pinned.set(event.scroll_top() >= end - 1.0);
330                },
331                for (index, line) in lines.iter().enumerate() {
332                    div { key: "{index}", class: BEM.element("line"),
333                        for run in line.0.iter() {
334                            if run.styled() {
335                                span { class: run.classes(), "{run.text}" }
336                            } else {
337                                "{run.text}"
338                            }
339                        }
340                        if show_cursor && index == last {
341                            span { class: "{cursor_class}", aria_hidden: "true", "\u{a0}" }
342                        }
343                    }
344                }
345                // A cleared session still shows where typing will appear.
346                if show_cursor && lines.is_empty() {
347                    div { class: BEM.element("line"),
348                        span { class: "{cursor_class}", aria_hidden: "true", "\u{a0}" }
349                    }
350                }
351            }
352            if let Some(onsubmit) = onsubmit {
353                div { class: BEM.element("input-row"),
354                    span { class: BEM.element("prompt"), aria_hidden: "true", "{prompt}" }
355                    input { class: BEM.element("input"), r#type: "text", aria_label: "Terminal input",
356                        autocomplete: "off", spellcheck: "false", disabled: disconnected, value: "{draft}",
357                        oninput: move |event| { draft.set(event.value()); recall.set(None); },
358                        onkeydown: move |event: KeyboardEvent| {
359                            let key = event.key();
360                            // Enter that confirms an IME candidate is composition, not a command.
361                            if key == Key::Enter && !event.is_composing() {
362                                event.prevent_default();
363                                let command = draft();
364                                if !command.trim().is_empty() {
365                                    history.write().push(command.clone());
366                                }
367                                draft.set(String::new());
368                                recall.set(None);
369                                pinned.set(true);
370                                onsubmit.call(command);
371                                // Back to the end now, even if the command prints nothing yet.
372                                if let Some(node) = node.peek().clone() {
373                                    spawn(async move {
374                                        if let Ok(size) = node.get_scroll_size().await {
375                                            let _ = node.scroll(PixelsVector2D::new(0.0, size.height), ScrollBehavior::Instant).await;
376                                        }
377                                    });
378                                }
379                            } else if key == Key::ArrowUp || key == Key::ArrowDown {
380                                let past = history.peek().len();
381                                if past == 0 { return; }
382                                event.prevent_default();
383                                let next = match (recall(), key == Key::ArrowUp) {
384                                    (None, true) => Some(past - 1),
385                                    (None, false) => None,
386                                    (Some(i), true) => Some(i.saturating_sub(1)),
387                                    (Some(i), false) => (i + 1 < past).then_some(i + 1),
388                                };
389                                recall.set(next);
390                                draft.set(next.and_then(|i| history.peek().get(i).cloned()).unwrap_or_default());
391                            }
392                        },
393                    }
394                }
395            }
396            if session.is_some() || exit.is_some() {
397                div { class: BEM.element("status"),
398                    span { "{session.clone().unwrap_or_default()}" }
399                    span { class: BEM.element("status-end"),
400                        if let Some(code) = exit {
401                            span { class: BEM.element("exit"), ..crate::spread![named_state_attribute(if code == 0 { "complete" } else { "error" })],
402                                "exit {code}"
403                            }
404                        }
405                    }
406                }
407            }
408        }
409    }
410}