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