Skip to main content

nightshade_api/web/
log_view.rs

1//! A scrolling, auto-tailing list of categorized log entries with optional selection and clearing.
2
3use leptos::html;
4use leptos::prelude::*;
5
6/// The severity or category of a `LogEntry`, used to pick its color and short tag.
7#[derive(Clone, Copy, PartialEq, Eq)]
8pub enum LogKind {
9    /// An informational message.
10    Info,
11    /// A command that was issued.
12    Command,
13    /// A notable event.
14    Event,
15    /// A warning.
16    Warn,
17    /// An error.
18    Error,
19}
20
21impl LogKind {
22    fn class(self) -> &'static str {
23        match self {
24            LogKind::Info => "info",
25            LogKind::Command => "command",
26            LogKind::Event => "event",
27            LogKind::Warn => "warn",
28            LogKind::Error => "error",
29        }
30    }
31
32    fn tag(self) -> &'static str {
33        match self {
34            LogKind::Info => "info",
35            LogKind::Command => "cmd",
36            LogKind::Event => "evt",
37            LogKind::Warn => "warn",
38            LogKind::Error => "err",
39        }
40    }
41}
42
43/// One row in a `LogView`: a stable `id`, a `kind`, a primary `label`, optional
44/// `detail` text, and a repeat `count` shown as a multiplier badge.
45#[derive(Clone)]
46pub struct LogEntry {
47    /// Stable identifier passed back through the selection callback.
48    pub id: usize,
49    /// Category controlling the row's color and tag.
50    pub kind: LogKind,
51    /// The primary text of the entry.
52    pub label: String,
53    /// Secondary detail text shown after the label when non-empty.
54    pub detail: String,
55    /// Repeat count; rendered as an `xN` badge when greater than one.
56    pub count: usize,
57}
58
59impl LogEntry {
60    /// Creates an entry with the given id, kind, and label, empty detail, and a count of one.
61    pub fn new(id: usize, kind: LogKind, label: impl Into<String>) -> Self {
62        Self {
63            id,
64            kind,
65            label: label.into(),
66            detail: String::new(),
67            count: 1,
68        }
69    }
70
71    /// Returns the entry with its detail text set.
72    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
73        self.detail = detail.into();
74        self
75    }
76
77    /// Returns the entry with its repeat count set.
78    pub fn with_count(mut self, count: usize) -> Self {
79        self.count = count;
80        self
81    }
82}
83
84/// A scrolling log panel driven by the `entries` signal. It shows an entry count
85/// header, an optional "Clear" button (`on_clear`), auto-scrolls to the newest row
86/// when `autoscroll` is set, and calls `on_select` with an entry's id when a row is
87/// clicked. Shows the `empty` placeholder when there are no entries.
88#[component]
89pub fn LogView(
90    #[prop(into)] entries: Signal<Vec<LogEntry>>,
91    #[prop(optional)] on_select: Option<Callback<usize>>,
92    #[prop(optional)] on_clear: Option<Callback<()>>,
93    #[prop(default = true)] autoscroll: bool,
94    #[prop(into, optional)] empty: String,
95) -> impl IntoView {
96    let body_ref = NodeRef::<html::Div>::new();
97    let empty = if empty.is_empty() {
98        "No entries".to_string()
99    } else {
100        empty
101    };
102
103    Effect::new(move |_| {
104        let _ = entries.get();
105        if autoscroll && let Some(body) = body_ref.get() {
106            body.set_scroll_top(body.scroll_height());
107        }
108    });
109
110    view! {
111        <div class="nightshade-log">
112            <div class="nightshade-log-head">
113                <span class="nightshade-log-title">
114                    {move || format!("{} entries", entries.get().len())}
115                </span>
116                {on_clear
117                    .map(|callback| {
118                        view! {
119                            <button
120                                class="nightshade-log-clear"
121                                on:click=move |_| callback.run(())
122                            >
123                                "Clear"
124                            </button>
125                        }
126                    })}
127            </div>
128            <div class="nightshade-log-body" node_ref=body_ref>
129                {move || {
130                    let rows = entries.get();
131                    if rows.is_empty() {
132                        return view! { <div class="nightshade-log-empty">{empty.clone()}</div> }
133                            .into_any();
134                    }
135                    rows.into_iter()
136                        .map(|entry| {
137                            let id = entry.id;
138                            let selectable = on_select.is_some();
139                            let on_row = move |_| {
140                                if let Some(callback) = on_select {
141                                    callback.run(id);
142                                }
143                            };
144                            let detail = entry.detail.clone();
145                            view! {
146                                <div
147                                    class=format!("nightshade-log-row {}", entry.kind.class())
148                                    class:selectable=selectable
149                                    on:click=on_row
150                                >
151                                    <span class="nightshade-log-tag">{entry.kind.tag()}</span>
152                                    <span class="nightshade-log-label">{entry.label}</span>
153                                    {(!detail.is_empty())
154                                        .then(|| {
155                                            view! { <span class="nightshade-log-detail">{detail}</span> }
156                                        })}
157                                    {(entry.count > 1)
158                                        .then(|| {
159                                            view! {
160                                                <span class="nightshade-log-count">
161                                                    {format!("x{}", entry.count)}
162                                                </span>
163                                            }
164                                        })}
165                                </div>
166                            }
167                        })
168                        .collect_view()
169                        .into_any()
170                }}
171            </div>
172        </div>
173    }
174}