notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 914866e74aac0551c17724826d7a4a5374c1451f
parent 4c2173c23ac85177df1c34824f213ed5ab59b5c4
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 14 Jan 2026 12:29:09 -0800

app: initial dashboard app

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
MCargo.lock | 16+++++++++++++++-
MCargo.toml | 10+++++++---
Mcrates/notedeck/Cargo.toml | 4++--
Mcrates/notedeck_chrome/Cargo.toml | 2++
Mcrates/notedeck_chrome/src/app.rs | 13+++++++++++++
Mcrates/notedeck_chrome/src/chrome.rs | 16++++++++++++++++
Acrates/notedeck_dashboard/Cargo.toml | 13+++++++++++++
Acrates/notedeck_dashboard/README.md | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dashboard/src/chart.rs | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dashboard/src/lib.rs | 370+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dashboard/src/ui.rs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mshell.nix | 1+
12 files changed, 852 insertions(+), 6 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3845,7 +3845,7 @@ dependencies = [ [[package]] name = "nostrdb" version = "0.8.0" -source = "git+https://github.com/damus-io/nostrdb-rs?rev=8148dd3cea9bc8ff0bc510c720d0c51f327a0a1a#8148dd3cea9bc8ff0bc510c720d0c51f327a0a1a" +source = "git+https://github.com/damus-io/nostrdb-rs?rev=04702963cd9fc985976005743f06310c619428b1#04702963cd9fc985976005743f06310c619428b1" dependencies = [ "bindgen 0.69.5", "cc", @@ -3938,6 +3938,7 @@ dependencies = [ "notedeck", "notedeck_clndash", "notedeck_columns", + "notedeck_dashboard", "notedeck_dave", "notedeck_messages", "notedeck_notebook", @@ -4034,6 +4035,19 @@ dependencies = [ ] [[package]] +name = "notedeck_dashboard" +version = "0.7.1" +dependencies = [ + "crossbeam-channel", + "egui", + "nostrdb", + "notedeck", + "notedeck_ui", + "tokio", + "tracing", +] + +[[package]] name = "notedeck_dave" version = "0.7.1" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -10,13 +10,16 @@ members = [ "crates/notedeck_notebook", "crates/notedeck_ui", "crates/notedeck_clndash", - - "crates/enostr", "crates/tokenator", "crates/notedeck_dave", "crates/notedeck_ui", "crates/notedeck_clndash", + "crates/notedeck_dashboard", + "crates/tokenator", + "crates/enostr", ] [workspace.dependencies] opener = "0.8.2" chrono = "0.4.40" +crossbeam-channel = "0.5" +crossbeam = "0.8.4" base32 = "0.4.0" base64 = "0.22.1" rmpv = "1.3.0" @@ -52,11 +55,12 @@ md5 = "0.7.0" nostr = { version = "0.37.0", default-features = false, features = ["std", "nip44", "nip49"] } nwc = "0.39.0" mio = { version = "1.0.3", features = ["os-poll", "net"] } -nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "8148dd3cea9bc8ff0bc510c720d0c51f327a0a1a" } +nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "04702963cd9fc985976005743f06310c619428b1" } #nostrdb = "0.6.1" notedeck = { path = "crates/notedeck" } notedeck_chrome = { path = "crates/notedeck_chrome" } notedeck_clndash = { path = "crates/notedeck_clndash" } +notedeck_dashboard = { path = "crates/notedeck_dashboard" } notedeck_columns = { path = "crates/notedeck_columns" } notedeck_dave = { path = "crates/notedeck_dave" } notedeck_messages = { path = "crates/notedeck_messages" } diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml @@ -52,8 +52,8 @@ regex = "1" chrono = { workspace = true } indexmap = {workspace = true} rand = {workspace = true} -crossbeam-channel = "0.5" -crossbeam = "0.8.4" +crossbeam-channel = { workspace = true } +crossbeam = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } http-body-util = { workspace = true } diff --git a/crates/notedeck_chrome/Cargo.toml b/crates/notedeck_chrome/Cargo.toml @@ -21,6 +21,7 @@ notedeck_dave = { workspace = true } notedeck_messages = { workspace = true, optional = true } notedeck_notebook = { workspace = true, optional = true } notedeck_clndash = { workspace = true, optional = true } +notedeck_dashboard = { workspace = true, optional = true } notedeck = { workspace = true } nostrdb = { workspace = true } puffin = { workspace = true, optional = true } @@ -57,6 +58,7 @@ tracy = ["profiling/profile-with-tracy"] messages = ["notedeck_messages"] notebook = ["notedeck_notebook"] clndash = ["notedeck_clndash"] +dashboard = ["notedeck_dashboard"] [target.'cfg(target_os = "android")'.dependencies] tracing-logcat = "0.1.0" diff --git a/crates/notedeck_chrome/src/app.rs b/crates/notedeck_chrome/src/app.rs @@ -9,6 +9,9 @@ use notedeck_clndash::ClnDash; #[cfg(feature = "messages")] use notedeck_messages::MessagesApp; +#[cfg(feature = "dashboard")] +use notedeck_dashboard::Dashboard; + #[cfg(feature = "notebook")] use notedeck_notebook::Notebook; @@ -16,12 +19,19 @@ use notedeck_notebook::Notebook; pub enum NotedeckApp { Dave(Box<Dave>), Columns(Box<Damus>), + #[cfg(feature = "notebook")] Notebook(Box<Notebook>), + #[cfg(feature = "clndash")] ClnDash(Box<ClnDash>), + #[cfg(feature = "messages")] Messages(Box<MessagesApp>), + + #[cfg(feature = "dashboard")] + Dashboard(Box<Dashboard>), + Other(Box<dyn notedeck::App>), } @@ -41,6 +51,9 @@ impl notedeck::App for NotedeckApp { #[cfg(feature = "messages")] NotedeckApp::Messages(dms) => dms.update(ctx, ui), + #[cfg(feature = "dashboard")] + NotedeckApp::Dashboard(db) => db.update(ctx, ui), + NotedeckApp::Other(other) => other.update(ctx, ui), } } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -30,6 +30,9 @@ use notedeck_dave::{Dave, DaveAvatar}; #[cfg(feature = "messages")] use notedeck_messages::MessagesApp; +#[cfg(feature = "dashboard")] +use notedeck_dashboard::Dashboard; + #[cfg(feature = "clndash")] use notedeck_ui::expanding_button; @@ -164,6 +167,9 @@ impl Chrome { #[cfg(feature = "messages")] chrome.add_app(NotedeckApp::Messages(Box::new(MessagesApp::new()))); + #[cfg(feature = "dashboard")] + chrome.add_app(NotedeckApp::Dashboard(Box::new(Dashboard::default()))); + #[cfg(feature = "notebook")] chrome.add_app(NotedeckApp::Notebook(Box::default())); @@ -787,6 +793,11 @@ fn topdown_sidebar( tr!(loc, "Messaging", "Button to go to the messaging app") } + #[cfg(feature = "dashboard")] + NotedeckApp::Dashboard(_) => { + tr!(loc, "Dashboard", "Button to go to the dashboard app") + } + #[cfg(feature = "notebook")] NotedeckApp::Notebook(_) => { tr!(loc, "Notebook", "Button to go to the Notebook app") @@ -821,6 +832,11 @@ fn topdown_sidebar( ); } + #[cfg(feature = "dashboard")] + NotedeckApp::Dashboard(_columns_app) => { + ui.add(app_images::algo_image()); + } + #[cfg(feature = "messages")] NotedeckApp::Messages(_dms) => { ui.add(app_images::new_message_image()); diff --git a/crates/notedeck_dashboard/Cargo.toml b/crates/notedeck_dashboard/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "notedeck_dashboard" +edition = "2024" +version.workspace = true + +[dependencies] +egui = { workspace = true } +nostrdb = { workspace = true } +crossbeam-channel = { workspace = true } +notedeck = { workspace = true } +notedeck_ui = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/crates/notedeck_dashboard/README.md b/crates/notedeck_dashboard/README.md @@ -0,0 +1,102 @@ +# Notedeck Dashboard + +A minimal, real-time dashboard for **Notedeck**, built with **egui** and backed by **nostrdb**. + +This app renders live statistics from a Nostr database without blocking the UI, using a single long-lived worker thread and progressive snapshots. No loading screens, no spinners—data fades in as it’s computed. + +## What it does (today) + +* Counts **total notes** in the database +* Shows **top N event kinds** as a horizontal bar chart +* Refreshes automatically on a fixed interval +* Streams intermediate results to the UI while scanning +* Keeps previous values visible during refreshes + +## Architecture overview + +### UI thread + +* Pure egui rendering +* Never blocks on database work +* Reacts to snapshots pushed from the worker +* Requests repaints only when new data arrives + +### Worker thread + +* Single persistent thread +* Runs one scan per refresh +* Emits periodic snapshots (~30ms cadence) +* Uses a single `nostrdb::Transaction` per run +* Communicates via `crossbeam_channel` + +### Data flow + +``` +UI ── Refresh cmd ──▶ Worker +UI ◀─ Snapshot msgs ◀─ Worker +UI ◀─ Finished msg ◀─ Worker +``` + +## Code layout + +``` +src/ +├── lib.rs # App entry, worker orchestration, refresh logic +├── ui.rs # egui cards, charts, and status UI +└── chart.rs # Reusable horizontal bar chart widget +``` + +### `chart.rs` + +* Custom horizontal bar chart +* Value labels, hover tooltips, and color palette +* Designed to be generic and reusable + +### `lib.rs` + +* Implements `notedeck::App` +* Owns worker lifecycle and refresh policy +* Handles snapshot merging and UI state + +### `ui.rs` + +* Card-based layout +* Totals view + kinds bar chart +* Footer status showing freshness and timing + +## Design goals + +* **Zero UI stalls** + Database scans never block rendering. + +* **Progressive feedback** + Partial results are better than spinners. + +* **Simple concurrency** + One worker, one job at a time, explicit messaging. + +* **Low ceremony** + No async runtime, no task pools, no state machines. + +## Non-goals (for now) + +* Multiple workers +* Historical comparisons +* Persistence of dashboard state +* Configuration UI +* Fancy animations + +## Requirements + +* Rust (stable) +* `egui` +* `notedeck` +* `nostrdb` +* `crossbeam-channel` + +This crate is intended to be built and run as part of a Notedeck environment. + +## Status + +Early but functional. +The core threading, snapshotting, and rendering model is in place and intentionally conservative. Future changes should preserve the “always responsive” property above all else. diff --git a/crates/notedeck_dashboard/src/chart.rs b/crates/notedeck_dashboard/src/chart.rs @@ -0,0 +1,186 @@ +use egui::{Align2, Color32, FontId, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2}; + +pub fn palette(i: usize) -> Color32 { + const P: [Color32; 10] = [ + Color32::from_rgb(231, 76, 60), + Color32::from_rgb(52, 152, 219), + Color32::from_rgb(46, 204, 113), + Color32::from_rgb(155, 89, 182), + Color32::from_rgb(241, 196, 15), + Color32::from_rgb(230, 126, 34), + Color32::from_rgb(26, 188, 156), + Color32::from_rgb(149, 165, 166), + Color32::from_rgb(52, 73, 94), + Color32::from_rgb(233, 150, 122), + ]; + P[i % P.len()] +} + +// ---------------------- +// Bar chart (unchanged) +// ---------------------- + +#[derive(Debug, Clone)] +pub struct Bar { + pub label: String, + pub value: f32, + pub color: Color32, +} + +#[derive(Clone, Copy)] +pub struct BarChartStyle { + pub row_height: f32, + pub gap: f32, + pub rounding: f32, + pub show_values: bool, + pub value_precision: usize, +} + +impl Default for BarChartStyle { + fn default() -> Self { + Self { + row_height: 18.0, + gap: 6.0, + rounding: 3.0, + show_values: true, + value_precision: 0, + } + } +} + +/// Draws a horizontal bar chart. Returns the combined response so you can check hover/click if desired. +pub fn horizontal_bar_chart( + ui: &mut Ui, + title: Option<&str>, + bars: &[Bar], + style: BarChartStyle, +) -> Response { + if let Some(t) = title { + ui.label(t); + } + + if bars.is_empty() { + return ui.label("No data"); + } + + let max_v = bars + .iter() + .map(|b| b.value.max(0.0)) + .fold(0.0_f32, f32::max); + + if max_v <= 0.0 { + return ui.label("No data"); + } + + // Layout: label column + bar column + let label_col_w = ui + .fonts(|f| { + bars.iter() + .map(|b| { + f.layout_no_wrap( + b.label.to_owned(), + FontId::proportional(14.0), + ui.visuals().text_color(), + ) + .size() + .x + }) + .fold(0.0, f32::max) + }) + .ceil() + + 10.0; + + let avail_w = ui.available_width().max(50.0); + let bar_col_w = (avail_w - label_col_w).max(50.0); + + let total_h = + bars.len() as f32 * style.row_height + (bars.len().saturating_sub(1) as f32) * style.gap; + let (outer_rect, outer_resp) = + ui.allocate_exact_size(Vec2::new(avail_w, total_h), Sense::hover()); + let painter = ui.painter_at(outer_rect); + + // Optional: faint background + painter.rect_filled(outer_rect, 6.0, ui.visuals().faint_bg_color); + + let mut y = outer_rect.top(); + + for b in bars { + let row_rect = Rect::from_min_size( + Pos2::new(outer_rect.left(), y), + Vec2::new(avail_w, style.row_height), + ); + let row_resp = ui.interact( + row_rect, + ui.id().with(&b.label).with(y as i64), + Sense::hover(), + ); + + // Label (left) + let label_pos = Pos2::new(row_rect.left() + 6.0, row_rect.center().y); + painter.text( + label_pos, + Align2::LEFT_CENTER, + &b.label, + FontId::proportional(14.0), + ui.visuals().text_color(), + ); + + // Bar background track (right) + let track_rect = Rect::from_min_max( + Pos2::new(row_rect.left() + label_col_w, row_rect.top() + 2.0), + Pos2::new( + row_rect.left() + label_col_w + bar_col_w, + row_rect.bottom() - 2.0, + ), + ); + painter.rect_filled( + track_rect, + style.rounding, + ui.visuals().widgets.inactive.bg_fill, + ); + painter.rect_stroke( + track_rect, + style.rounding, + Stroke::new(1.0, ui.visuals().widgets.inactive.bg_stroke.color), + StrokeKind::Middle, + ); + + // Filled portion + let frac = (b.value.max(0.0) / max_v).clamp(0.0, 1.0); + let fill_w = track_rect.width() * frac; + if fill_w > 0.0 { + let fill_rect = Rect::from_min_max( + track_rect.min, + Pos2::new(track_rect.min.x + fill_w, track_rect.max.y), + ); + painter.rect_filled(fill_rect, style.rounding, b.color); + } + + // Value label (right-aligned at end of track) + if style.show_values { + let txt = if style.value_precision == 0 { + format!("{:.0}", b.value) + } else { + format!("{:.*}", style.value_precision, b.value) + }; + painter.text( + Pos2::new(track_rect.right() - 6.0, row_rect.center().y), + Align2::RIGHT_CENTER, + txt, + FontId::proportional(13.0), + ui.visuals().text_color(), + ); + } + + // Tooltip on hover + if row_resp.hovered() { + let sum = bars.iter().map(|x| x.value.max(0.0)).sum::<f32>().max(1.0); + let pct = (b.value / sum) * 100.0; + row_resp.on_hover_text(format!("{}: {:.0} ({:.1}%)", b.label, b.value, pct)); + } + + y += style.row_height + style.gap; + } + + outer_resp +} diff --git a/crates/notedeck_dashboard/src/lib.rs b/crates/notedeck_dashboard/src/lib.rs @@ -0,0 +1,370 @@ +use std::collections::HashMap; +use std::thread; +use std::time::{Duration, Instant}; + +use crossbeam_channel as chan; + +use nostrdb::{Filter, Ndb, Transaction}; +use notedeck::{AppContext, AppResponse, try_process_events_core}; + +mod chart; +mod ui; + +// ---------------------- +// Worker protocol +// ---------------------- + +#[derive(Debug)] +enum WorkerCmd { + Refresh, + //Quit, +} + +#[derive(Clone, Debug, Default)] +struct DashboardState { + total_count: usize, + top_kinds: Vec<(u32, u64)>, +} + +#[derive(Debug, Clone)] +struct Snapshot { + started_at: Instant, + snapshot_at: Instant, + state: DashboardState, +} + +#[derive(Debug)] +enum WorkerMsg { + Snapshot(Snapshot), + Finished { + started_at: Instant, + finished_at: Instant, + state: DashboardState, + }, + Failed { + started_at: Instant, + finished_at: Instant, + error: String, + }, +} + +// ---------------------- +// Dashboard (single pass, single worker) +// ---------------------- + +pub struct Dashboard { + initialized: bool, + + // Worker channels + cmd_tx: Option<chan::Sender<WorkerCmd>>, + msg_rx: Option<chan::Receiver<WorkerMsg>>, + + // Refresh policy + refresh_every: Duration, + next_tick: Instant, + + // UI state (progressively filled via snapshots) + running: bool, + last_started: Option<Instant>, + last_snapshot: Option<Instant>, + last_finished: Option<Instant>, + last_duration: Option<Duration>, + last_error: Option<String>, + + state: DashboardState, +} + +impl Default for Dashboard { + fn default() -> Self { + Self { + initialized: false, + + cmd_tx: None, + msg_rx: None, + + refresh_every: Duration::from_secs(10), + next_tick: Instant::now(), + + running: false, + last_started: None, + last_snapshot: None, + last_finished: None, + last_duration: None, + last_error: None, + + state: DashboardState::default(), + } + } +} + +impl notedeck::App for Dashboard { + fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { + try_process_events_core(ctx, ui.ctx(), |_, _| {}); + + if !self.initialized { + self.initialized = true; + self.init(ctx); + } + + self.process_worker_msgs(ui.ctx()); + self.schedule_refresh(); + + self.show(ui); + + AppResponse::none() + } +} + +impl Dashboard { + fn init(&mut self, ctx: &mut AppContext<'_>) { + // spawn single worker thread and keep it alive + let (cmd_tx, cmd_rx) = chan::unbounded::<WorkerCmd>(); + let (msg_tx, msg_rx) = chan::unbounded::<WorkerMsg>(); + + self.cmd_tx = Some(cmd_tx.clone()); + self.msg_rx = Some(msg_rx); + + // Clone the DB handle into the worker thread (Ndb is typically cheap/cloneable) + let ndb = ctx.ndb.clone(); + + spawn_worker(ndb, cmd_rx, msg_tx); + + // kick the first run immediately + let _ = cmd_tx.send(WorkerCmd::Refresh); + self.running = true; + self.last_error = None; + self.last_started = Some(Instant::now()); + self.last_snapshot = None; + self.last_finished = None; + self.last_duration = None; + self.state = DashboardState::default(); + } + + fn process_worker_msgs(&mut self, egui_ctx: &egui::Context) { + let Some(rx) = &self.msg_rx else { return }; + + let mut got_any = false; + + while let Ok(msg) = rx.try_recv() { + got_any = true; + match msg { + WorkerMsg::Snapshot(s) => { + self.running = true; + self.last_started = Some(s.started_at); + self.last_snapshot = Some(s.snapshot_at); + self.last_error = None; + + self.state = s.state; + + // Push UI updates with no "loading screen" + egui_ctx.request_repaint(); + } + WorkerMsg::Finished { + started_at, + finished_at, + state, + } => { + self.running = false; + self.last_started = Some(started_at); + self.last_snapshot = Some(finished_at); + self.last_finished = Some(finished_at); + self.last_duration = Some(finished_at.saturating_duration_since(started_at)); + self.last_error = None; + + self.state = state; + + egui_ctx.request_repaint(); + } + WorkerMsg::Failed { + started_at, + finished_at, + error, + } => { + self.running = false; + self.last_started = Some(started_at); + self.last_snapshot = Some(finished_at); + self.last_finished = Some(finished_at); + self.last_duration = Some(finished_at.saturating_duration_since(started_at)); + self.last_error = Some(error); + + egui_ctx.request_repaint(); + } + } + } + + if got_any { + // No-op; we already requested repaint on every message. + } + } + + fn schedule_refresh(&mut self) { + // throttle scheduling checks a bit + let now = Instant::now(); + if now < self.next_tick { + return; + } + self.next_tick = now + Duration::from_millis(200); + + if self.running { + return; + } + + // refresh every 30 seconds from the last finished time (or from init) + let last = self + .last_finished + .or(self.last_started) + .unwrap_or_else(Instant::now); + + if now.saturating_duration_since(last) >= self.refresh_every + && let Some(tx) = &self.cmd_tx + { + // reset UI fields for progressive load, but keep old values visible until snapshots arrive + self.running = true; + self.last_error = None; + self.last_started = Some(now); + self.last_snapshot = None; + self.last_finished = None; + self.last_duration = None; + self.state = DashboardState::default(); + + let _ = tx.send(WorkerCmd::Refresh); + } + } + + fn show(&mut self, ui: &mut egui::Ui) { + egui::Frame::new() + .inner_margin(egui::Margin::same(20)) + .show(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + self.grid(ui); + }); + }); + } + + fn grid(&mut self, ui: &mut egui::Ui) { + let cols = 2; + let min_card = 240.0; + + egui::Grid::new("dashboard_grid_single_worker") + .num_columns(cols) + .min_col_width(min_card) + .spacing(egui::vec2(8.0, 8.0)) + .show(ui, |ui| { + use crate::ui::{card_ui, kinds_ui, totals_ui}; + + // Card 1: Total notes + card_ui(ui, min_card, |ui| { + totals_ui(self, ui); + }); + + // Card 2: Kinds (top) + card_ui(ui, min_card, |ui| { + kinds_ui(self, ui); + }); + + ui.end_row(); + }); + } +} + +// ---------------------- +// Worker side (single pass, periodic snapshots) +// ---------------------- + +fn spawn_worker(ndb: Ndb, cmd_rx: chan::Receiver<WorkerCmd>, msg_tx: chan::Sender<WorkerMsg>) { + thread::Builder::new() + .name("dashboard-worker".to_owned()) + .spawn(move || { + let mut should_quit = false; + + while !should_quit { + match cmd_rx.recv() { + Ok(WorkerCmd::Refresh) => { + let started_at = Instant::now(); + + match materialize_single_pass(&ndb, &msg_tx, started_at) { + Ok(state) => { + let _ = msg_tx.send(WorkerMsg::Finished { + started_at, + finished_at: Instant::now(), + state, + }); + } + Err(e) => { + let _ = msg_tx.send(WorkerMsg::Failed { + started_at, + finished_at: Instant::now(), + error: format!("{e:?}"), + }); + } + } + } + Err(_) => { + should_quit = true; + } + } + } + }) + .expect("failed to spawn dashboard worker thread"); +} + +fn materialize_single_pass( + ndb: &Ndb, + msg_tx: &chan::Sender<WorkerMsg>, + started_at: Instant, +) -> Result<DashboardState, nostrdb::Error> { + // one transaction per refresh run + let txn = Transaction::new(ndb)?; + + // all notes + let filters = vec![Filter::new_with_capacity(1).build()]; + + struct Acc { + total_count: usize, + kinds: HashMap<u32, u64>, + last_emit: Instant, + } + + let mut acc = Acc { + total_count: 0, + kinds: HashMap::new(), + last_emit: Instant::now(), + }; + + let emit_every = Duration::from_millis(32); + + let _ = ndb.fold(&txn, &filters, &mut acc, |acc, note| { + acc.total_count += 1; + + *acc.kinds.entry(note.kind()).or_default() += 1; + + let now = Instant::now(); + if now.saturating_duration_since(acc.last_emit) >= emit_every { + acc.last_emit = now; + + let top = top_kinds(&acc.kinds, 6); + let _ = msg_tx.send(WorkerMsg::Snapshot(Snapshot { + started_at, + snapshot_at: now, + state: DashboardState { + total_count: acc.total_count, + top_kinds: top, + }, + })); + } + + acc + }); + + Ok(DashboardState { + total_count: acc.total_count, + top_kinds: top_kinds(&acc.kinds, 6), + }) +} + +fn top_kinds(hmap: &HashMap<u32, u64>, limit: usize) -> Vec<(u32, u64)> { + let mut v: Vec<(u32, u64)> = hmap.iter().map(|(k, c)| (*k, *c)).collect(); + v.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v.truncate(limit); + v +} diff --git a/crates/notedeck_dashboard/src/ui.rs b/crates/notedeck_dashboard/src/ui.rs @@ -0,0 +1,125 @@ +use egui::FontId; +use egui::RichText; + +use std::time::Duration; +use std::time::Instant; + +use crate::Dashboard; +use crate::chart::Bar; +use crate::chart::BarChartStyle; +use crate::chart::horizontal_bar_chart; +use crate::chart::palette; + +pub fn footer_status_ui( + ui: &mut egui::Ui, + running: bool, + err: Option<&str>, + last_snapshot: Option<Instant>, + last_duration: Option<Duration>, +) { + ui.add_space(8.0); + + if let Some(e) = err { + ui.label(RichText::new(e).color(ui.visuals().error_fg_color).small()); + return; + } + + let mut parts: Vec<String> = Vec::new(); + if running { + parts.push("updating…".to_owned()); + } + + if let Some(t) = last_snapshot { + parts.push(format!( + "updated {:.1?} ago", + Instant::now().duration_since(t) + )); + } + + if let Some(d) = last_duration { + let ms = d.as_secs_f64() * 1000.0; + parts.push(format!("{ms:.0} ms")); + } + + if parts.is_empty() { + parts.push("—".to_owned()); + } + + ui.label(RichText::new(parts.join(" · ")).small().weak()); +} + +fn card_header_ui(ui: &mut egui::Ui, title: &str) { + ui.horizontal(|ui| { + let weak = ui.visuals().weak_text_color(); + ui.add( + egui::Label::new(egui::RichText::new(title).small().color(weak)) + .wrap_mode(egui::TextWrapMode::Wrap), + ); + }); +} + +pub fn card_ui(ui: &mut egui::Ui, min_card: f32, content: impl FnOnce(&mut egui::Ui)) { + let visuals = ui.visuals().clone(); + + egui::Frame::group(ui.style()) + .fill(visuals.extreme_bg_color) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::same(12)) + .stroke(egui::Stroke::new( + 1.0, + visuals.widgets.noninteractive.bg_stroke.color, + )) + .show(ui, |ui| { + ui.set_min_width(min_card); + ui.set_min_height(min_card * 0.5); + ui.vertical(|ui| content(ui)); + }); +} + +pub fn kinds_ui(dashboard: &Dashboard, ui: &mut egui::Ui) { + card_header_ui(ui, "Kinds"); + ui.add_space(8.0); + + let bars = kinds_to_bars(&dashboard.state.top_kinds); + if bars.is_empty() && dashboard.state.total_count == 0 && dashboard.last_error.is_none() { + // still show something (no loading screen) + ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak()); + } else { + horizontal_bar_chart(ui, None, &bars, BarChartStyle::default()); + } + + footer_status_ui( + ui, + dashboard.running, + dashboard.last_error.as_deref(), + dashboard.last_snapshot, + dashboard.last_duration, + ); +} + +pub fn totals_ui(dashboard: &Dashboard, ui: &mut egui::Ui) { + card_header_ui(ui, "All notes"); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label( + RichText::new(dashboard.state.total_count.to_string()) + .font(FontId::proportional(34.0)) + .strong(), + ); + + ui.add_space(10.0); + }); +} + +fn kinds_to_bars(top_kinds: &[(u32, u64)]) -> Vec<Bar> { + top_kinds + .iter() + .enumerate() + .map(|(i, (k, c))| Bar { + label: format!("{k}"), + value: *c as f32, + color: palette(i), + }) + .collect() +} diff --git a/shell.nix b/shell.nix @@ -14,6 +14,7 @@ mkShell ({ #cargo-edit #cargo-watch rustup + gdb libiconv pkg-config cmake