Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions dashboard.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"version": 1,
"grid": {
"rows": 3,
"cols": 3
},
"slots": [
{
"id": "Weather",
"widget": "weather_site",
"row": 0,
"col": 0,
"row_span": 1,
"col_span": 2,
"settings": {
"location": "Seattle"
}
},
{
"id": "Recents",
"widget": "recent_commands",
"row": 1,
"col": 0,
"row_span": 1,
"col_span": 1
},
{
"id": "Frequently used",
"widget": "frequent_commands",
"row": 1,
"col": 1,
"row_span": 1,
"col_span": 1
},
{
"id": "Todos",
"widget": "todo_summary",
"row": 2,
"col": 0,
"row_span": 1,
"col_span": 1
},
{
"id": "Notes",
"widget": "notes_open",
"row": 2,
"col": 1,
"row_span": 1,
"col_span": 1
}
]
}
153 changes: 153 additions & 0 deletions src/dashboard/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
use crate::dashboard::widgets::WidgetRegistry;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::{Path, PathBuf};

fn default_version() -> u32 {
1
}

fn default_rows() -> u8 {
3
}

fn default_cols() -> u8 {
3
}

fn default_span() -> u8 {
1
}

/// Grid definition for the dashboard layout.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GridConfig {
#[serde(default = "default_rows")]
pub rows: u8,
#[serde(default = "default_cols")]
pub cols: u8,
}

impl Default for GridConfig {
fn default() -> Self {
Self {
rows: default_rows(),
cols: default_cols(),
}
}
}

/// Widget slot configuration.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SlotConfig {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub widget: String,
pub row: i32,
pub col: i32,
#[serde(default = "default_span")]
pub row_span: u8,
#[serde(default = "default_span")]
pub col_span: u8,
#[serde(default)]
pub settings: serde_json::Value,
}

impl SlotConfig {
pub fn with_widget(widget: &str, row: i32, col: i32) -> Self {
Self {
id: None,
widget: widget.to_string(),
row,
col,
row_span: default_span(),
col_span: default_span(),
settings: serde_json::Value::Object(Default::default()),
}
}
}

/// Primary dashboard configuration.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DashboardConfig {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub grid: GridConfig,
#[serde(default)]
pub slots: Vec<SlotConfig>,
}

impl Default for DashboardConfig {
fn default() -> Self {
Self {
version: default_version(),
grid: GridConfig::default(),
slots: vec![
SlotConfig::with_widget("weather_site", 0, 0),
SlotConfig::with_widget("recent_commands", 1, 0),
SlotConfig::with_widget("frequent_commands", 1, 1),
SlotConfig::with_widget("todo_summary", 2, 0),
SlotConfig::with_widget("notes_open", 2, 1),
],
}
}
}

impl DashboardConfig {
/// Load a configuration from disk. Unknown widget types or invalid slots are
/// filtered out using the provided registry.
pub fn load(path: impl AsRef<Path>, registry: &WidgetRegistry) -> anyhow::Result<Self> {
let path = path.as_ref();
let content = std::fs::read_to_string(path).unwrap_or_default();
if content.trim().is_empty() {
return Ok(Self::default());
}
let mut cfg: DashboardConfig = serde_json::from_str(&content)?;
let warnings = cfg.sanitize(registry);
for w in warnings {
tracing::warn!("{w}");
}
Ok(cfg)
}

/// Save the configuration to disk.
pub fn save(&self, path: impl AsRef<Path>) -> anyhow::Result<()> {
let json = serde_json::to_string_pretty(self)?;
std::fs::write(path, json)?;
Ok(())
}

/// Remove unsupported widgets and normalize empty settings.
pub fn sanitize(&mut self, registry: &WidgetRegistry) -> Vec<String> {
let mut warnings = Vec::new();
self.slots.retain(|slot| {
if slot.widget.is_empty() {
return false;
}
if !registry.contains(&slot.widget) {
let msg = format!("unknown dashboard widget '{}' dropped", slot.widget);
tracing::warn!(widget = %slot.widget, "unknown dashboard widget dropped");
warnings.push(msg);
return false;
}
true
});
for slot in &mut self.slots {
if slot.settings.is_null() {
slot.settings = json!({});
}
}
warnings
}

pub fn path_for(base: &str) -> PathBuf {
let base = Path::new(base);
if base.is_dir() {
base.join("dashboard.json")
} else {
PathBuf::from(base)
}
}
}
152 changes: 152 additions & 0 deletions src/dashboard/dashboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
use crate::dashboard::config::DashboardConfig;
use crate::dashboard::layout::{normalize_slots, NormalizedSlot};
use crate::dashboard::widgets::{WidgetAction, WidgetRegistry};
use crate::{actions::Action, common::json_watch::JsonWatcher};
use eframe::egui;
use std::path::{Path, PathBuf};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DashboardEvent {
Reloaded,
}

/// Source of a widget activation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WidgetActivation {
Click,
Keyboard,
}

/// Context shared with widgets at render time.
pub struct DashboardContext<'a> {
pub actions: &'a [Action],
pub usage: &'a std::collections::HashMap<String, u32>,
pub plugins: &'a crate::plugin::PluginManager,
pub default_location: Option<&'a str>,
}

pub struct Dashboard {
config_path: PathBuf,
pub config: DashboardConfig,
pub slots: Vec<NormalizedSlot>,
registry: WidgetRegistry,
watcher: Option<JsonWatcher>,
pub warnings: Vec<String>,
event_cb: Option<std::sync::Arc<dyn Fn(DashboardEvent) + Send + Sync>>,
}

impl Dashboard {
pub fn new(
config_path: impl AsRef<Path>,
registry: WidgetRegistry,
event_cb: Option<std::sync::Arc<dyn Fn(DashboardEvent) + Send + Sync>>,
) -> Self {
let path = config_path.as_ref().to_path_buf();
let (config, slots, warnings) = Self::load_internal(&path, &registry);
Self {
config_path: path,
config,
slots,
registry,
watcher: None,
warnings,
event_cb,
}
}

fn load_internal(
path: &Path,
registry: &WidgetRegistry,
) -> (DashboardConfig, Vec<NormalizedSlot>, Vec<String>) {
let cfg = DashboardConfig::load(path, registry).unwrap_or_default();
let (slots, mut warnings) = normalize_slots(&cfg, registry);
if slots.is_empty() {
warnings.push("dashboard has no valid slots".into());
}
(cfg, slots, warnings)
}

pub fn reload(&mut self) {
let (cfg, slots, warnings) = Self::load_internal(&self.config_path, &self.registry);
self.config = cfg;
self.slots = slots;
self.warnings = warnings;
}

pub fn set_path(&mut self, path: impl AsRef<Path>) {
self.config_path = path.as_ref().to_path_buf();
self.reload();
self.attach_watcher();
}

pub fn attach_watcher(&mut self) {
let path = self.config_path.clone();
let tx = self.event_cb.clone();
self.watcher = crate::common::json_watch::watch_json(path.clone(), move || {
tracing::info!("dashboard config changed");
if let Some(tx) = &tx {
(tx)(DashboardEvent::Reloaded);
}
})
.ok();
}

pub fn ui(
&mut self,
ui: &mut egui::Ui,
ctx: &DashboardContext<'_>,
activation: WidgetActivation,
) -> Option<WidgetAction> {
let mut clicked = None;
let grid_cols = self.config.grid.cols.max(1) as usize;
let col_width = ui.available_width() / grid_cols.max(1) as f32;

let size = egui::vec2(ui.available_width(), ui.available_height());
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
let mut child = ui.child_ui(rect, egui::Layout::top_down(egui::Align::LEFT));

for slot in &self.slots {
let rect = egui::Rect::from_min_size(
rect.min
+ egui::vec2(
col_width * slot.col as f32,
(slot.row as f32) * 100.0, // coarse row height
),
egui::vec2(
col_width * slot.col_span as f32,
90.0 * slot.row_span as f32,
),
);
let mut slot_ui = child.child_ui(rect, egui::Layout::left_to_right(egui::Align::TOP));
if let Some(action) = self.render_slot(slot, &mut slot_ui, ctx, activation) {
clicked = Some(action);
}
}

clicked
}

fn render_slot(
&self,
slot: &NormalizedSlot,
ui: &mut egui::Ui,
ctx: &DashboardContext<'_>,
activation: WidgetActivation,
) -> Option<WidgetAction> {
ui.group(|ui| {
ui.vertical(|ui| {
let heading = slot.id.as_deref().unwrap_or(&slot.widget);
ui.heading(heading);
self.registry
.create(&slot.widget, &slot.settings)
.and_then(|mut w| w.render(ui, ctx, activation))
})
.inner
})
.inner
}

pub fn registry(&self) -> &WidgetRegistry {
&self.registry
}
}
Loading