Skip to main content

nightshade_api/web/
terminal.rs

1//! A simple scrollback terminal that renders tone-styled lines and emits
2//! submitted prompt input.
3
4use leptos::html;
5use leptos::prelude::*;
6
7/// Visual style applied to a terminal line.
8#[derive(Clone, Copy, PartialEq, Eq)]
9pub enum TerminalTone {
10    /// Default foreground text.
11    Normal,
12    /// Muted/secondary text.
13    Dim,
14    /// Success (typically green) text.
15    Success,
16    /// Warning (typically amber) text.
17    Warn,
18    /// Error (typically red) text.
19    Error,
20    /// Echoed command input.
21    Command,
22}
23
24impl TerminalTone {
25    fn class(self) -> &'static str {
26        match self {
27            TerminalTone::Normal => "normal",
28            TerminalTone::Dim => "dim",
29            TerminalTone::Success => "success",
30            TerminalTone::Warn => "warn",
31            TerminalTone::Error => "error",
32            TerminalTone::Command => "command",
33        }
34    }
35}
36
37/// One line of terminal scrollback: a stable `id` for keyed rendering, its
38/// `text`, and its display `tone`.
39#[derive(Clone)]
40pub struct TerminalLine {
41    /// Stable key used to diff the scrollback list.
42    pub id: usize,
43    /// Line contents.
44    pub text: String,
45    /// Visual style for the line.
46    pub tone: TerminalTone,
47}
48
49impl TerminalLine {
50    /// Builds a line from an `id`, `text`, and `tone`.
51    pub fn new(id: usize, text: impl Into<String>, tone: TerminalTone) -> Self {
52        Self {
53            id,
54            text: text.into(),
55            tone,
56        }
57    }
58}
59
60/// A scrollback terminal that renders `lines` (auto-scrolling to the bottom as
61/// they change) with a `prompt` sigil (defaulting to `$`) and an input row that
62/// fires `on_input` with the trimmed draft when Enter is pressed.
63#[component]
64pub fn Terminal(
65    #[prop(into)] lines: Signal<Vec<TerminalLine>>,
66    on_input: Callback<String>,
67    #[prop(into, optional)] prompt: String,
68) -> impl IntoView {
69    let draft = RwSignal::new(String::new());
70    let body_ref = NodeRef::<html::Div>::new();
71    let prompt = if prompt.is_empty() {
72        "$".to_string()
73    } else {
74        prompt
75    };
76    let prompt = StoredValue::new(prompt);
77
78    Effect::new(move |_| {
79        let _ = lines.get();
80        if let Some(body) = body_ref.get() {
81            body.set_scroll_top(body.scroll_height());
82        }
83    });
84
85    let submit = move || {
86        let text = draft.get_untracked();
87        if !text.trim().is_empty() {
88            on_input.run(text);
89            draft.set(String::new());
90        }
91    };
92
93    view! {
94        <div class="nightshade-terminal">
95            <div class="nightshade-terminal-body" node_ref=body_ref>
96                <For each=move || lines.get() key=|line| line.id let:line>
97                    <div class=format!("nightshade-terminal-line {}", line.tone.class())>
98                        {line.text.clone()}
99                    </div>
100                </For>
101            </div>
102            <div class="nightshade-terminal-prompt">
103                <span class="nightshade-terminal-sigil">{move || prompt.get_value()}</span>
104                <input
105                    class="nightshade-terminal-input"
106                    spellcheck="false"
107                    autocomplete="off"
108                    prop:value=move || draft.get()
109                    on:input=move |event| draft.set(event_target_value(&event))
110                    on:keydown=move |event| {
111                        if event.key() == "Enter" {
112                            event.prevent_default();
113                            submit();
114                        }
115                    }
116                />
117            </div>
118        </div>
119    }
120}