1use 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#[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 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 #[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#[non_exhaustive]
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum Sgr {
89 Bold,
91 Dim,
93 Italic,
95 Underline,
97 Inverse,
99 Strike,
101}
102impl Sgr {
103 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
136pub struct SgrSet(u8);
137impl SgrSet {
138 #[must_use]
140 pub const fn contains(self, attribute: Sgr) -> bool {
141 self.0 & attribute.bit() != 0
142 }
143 #[must_use]
145 pub const fn with(self, attribute: Sgr) -> Self {
146 Self(self.0 | attribute.bit())
147 }
148 #[must_use]
150 pub const fn is_empty(self) -> bool {
151 self.0 == 0
152 }
153}
154
155#[derive(Debug, Clone, Default, PartialEq, Eq)]
157pub struct TermRun {
158 pub text: String,
160 pub fg: Option<AnsiColor>,
162 pub attributes: SgrSet,
164}
165impl TermRun {
166 #[must_use]
168 pub fn new(text: impl Into<String>) -> Self {
169 Self {
170 text: text.into(),
171 ..Self::default()
172 }
173 }
174 #[must_use]
176 pub const fn fg(mut self, color: AnsiColor) -> Self {
177 self.fg = Some(color);
178 self
179 }
180 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
208pub struct TermLine(pub Vec<TermRun>);
209impl TermLine {
210 #[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#[non_exhaustive]
224#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
225pub enum TermState {
226 #[default]
228 Connected,
229 Disconnected,
231}
232
233#[non_exhaustive]
235#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
236pub enum CursorStyle {
237 #[default]
239 None,
240 Steady,
242 Blink,
244}
245
246#[component]
251pub fn Terminal(
252 output: ReadSignal<Vec<TermLine>>,
253 #[props(default)]
255 label: Option<AccessibleName>,
256 #[props(default)] state: TermState,
257 #[props(default = "$".to_owned())]
259 prompt: String,
260 #[props(default)]
262 session: Option<String>,
263 #[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 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 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 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 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 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 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 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 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}