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}