Skip to content

Commit c606fb4

Browse files
authored
Merge pull request #626 from multiplex55/codex/implement-dashboard-subsystem-and-settings-integrations
Add dashboard subsystem and settings integration
2 parents c749717 + bcb1ac3 commit c606fb4

20 files changed

Lines changed: 2309 additions & 1027 deletions

dashboard.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"version": 1,
3+
"grid": {
4+
"rows": 3,
5+
"cols": 3
6+
},
7+
"slots": [
8+
{
9+
"id": "Weather",
10+
"widget": "weather_site",
11+
"row": 0,
12+
"col": 0,
13+
"row_span": 1,
14+
"col_span": 2,
15+
"settings": {
16+
"location": "Seattle"
17+
}
18+
},
19+
{
20+
"id": "Recents",
21+
"widget": "recent_commands",
22+
"row": 1,
23+
"col": 0,
24+
"row_span": 1,
25+
"col_span": 1
26+
},
27+
{
28+
"id": "Frequently used",
29+
"widget": "frequent_commands",
30+
"row": 1,
31+
"col": 1,
32+
"row_span": 1,
33+
"col_span": 1
34+
},
35+
{
36+
"id": "Todos",
37+
"widget": "todo_summary",
38+
"row": 2,
39+
"col": 0,
40+
"row_span": 1,
41+
"col_span": 1
42+
},
43+
{
44+
"id": "Notes",
45+
"widget": "notes_open",
46+
"row": 2,
47+
"col": 1,
48+
"row_span": 1,
49+
"col_span": 1
50+
}
51+
]
52+
}

src/dashboard/config.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
use crate::dashboard::widgets::WidgetRegistry;
2+
use serde::{Deserialize, Serialize};
3+
use serde_json::json;
4+
use std::path::{Path, PathBuf};
5+
6+
fn default_version() -> u32 {
7+
1
8+
}
9+
10+
fn default_rows() -> u8 {
11+
3
12+
}
13+
14+
fn default_cols() -> u8 {
15+
3
16+
}
17+
18+
fn default_span() -> u8 {
19+
1
20+
}
21+
22+
/// Grid definition for the dashboard layout.
23+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24+
pub struct GridConfig {
25+
#[serde(default = "default_rows")]
26+
pub rows: u8,
27+
#[serde(default = "default_cols")]
28+
pub cols: u8,
29+
}
30+
31+
impl Default for GridConfig {
32+
fn default() -> Self {
33+
Self {
34+
rows: default_rows(),
35+
cols: default_cols(),
36+
}
37+
}
38+
}
39+
40+
/// Widget slot configuration.
41+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42+
pub struct SlotConfig {
43+
#[serde(default)]
44+
pub id: Option<String>,
45+
#[serde(default)]
46+
pub widget: String,
47+
pub row: i32,
48+
pub col: i32,
49+
#[serde(default = "default_span")]
50+
pub row_span: u8,
51+
#[serde(default = "default_span")]
52+
pub col_span: u8,
53+
#[serde(default)]
54+
pub settings: serde_json::Value,
55+
}
56+
57+
impl SlotConfig {
58+
pub fn with_widget(widget: &str, row: i32, col: i32) -> Self {
59+
Self {
60+
id: None,
61+
widget: widget.to_string(),
62+
row,
63+
col,
64+
row_span: default_span(),
65+
col_span: default_span(),
66+
settings: serde_json::Value::Object(Default::default()),
67+
}
68+
}
69+
}
70+
71+
/// Primary dashboard configuration.
72+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73+
pub struct DashboardConfig {
74+
#[serde(default = "default_version")]
75+
pub version: u32,
76+
#[serde(default)]
77+
pub grid: GridConfig,
78+
#[serde(default)]
79+
pub slots: Vec<SlotConfig>,
80+
}
81+
82+
impl Default for DashboardConfig {
83+
fn default() -> Self {
84+
Self {
85+
version: default_version(),
86+
grid: GridConfig::default(),
87+
slots: vec![
88+
SlotConfig::with_widget("weather_site", 0, 0),
89+
SlotConfig::with_widget("recent_commands", 1, 0),
90+
SlotConfig::with_widget("frequent_commands", 1, 1),
91+
SlotConfig::with_widget("todo_summary", 2, 0),
92+
SlotConfig::with_widget("notes_open", 2, 1),
93+
],
94+
}
95+
}
96+
}
97+
98+
impl DashboardConfig {
99+
/// Load a configuration from disk. Unknown widget types or invalid slots are
100+
/// filtered out using the provided registry.
101+
pub fn load(path: impl AsRef<Path>, registry: &WidgetRegistry) -> anyhow::Result<Self> {
102+
let path = path.as_ref();
103+
let content = std::fs::read_to_string(path).unwrap_or_default();
104+
if content.trim().is_empty() {
105+
return Ok(Self::default());
106+
}
107+
let mut cfg: DashboardConfig = serde_json::from_str(&content)?;
108+
let warnings = cfg.sanitize(registry);
109+
for w in warnings {
110+
tracing::warn!("{w}");
111+
}
112+
Ok(cfg)
113+
}
114+
115+
/// Save the configuration to disk.
116+
pub fn save(&self, path: impl AsRef<Path>) -> anyhow::Result<()> {
117+
let json = serde_json::to_string_pretty(self)?;
118+
std::fs::write(path, json)?;
119+
Ok(())
120+
}
121+
122+
/// Remove unsupported widgets and normalize empty settings.
123+
pub fn sanitize(&mut self, registry: &WidgetRegistry) -> Vec<String> {
124+
let mut warnings = Vec::new();
125+
self.slots.retain(|slot| {
126+
if slot.widget.is_empty() {
127+
return false;
128+
}
129+
if !registry.contains(&slot.widget) {
130+
let msg = format!("unknown dashboard widget '{}' dropped", slot.widget);
131+
tracing::warn!(widget = %slot.widget, "unknown dashboard widget dropped");
132+
warnings.push(msg);
133+
return false;
134+
}
135+
true
136+
});
137+
for slot in &mut self.slots {
138+
if slot.settings.is_null() {
139+
slot.settings = json!({});
140+
}
141+
}
142+
warnings
143+
}
144+
145+
pub fn path_for(base: &str) -> PathBuf {
146+
let base = Path::new(base);
147+
if base.is_dir() {
148+
base.join("dashboard.json")
149+
} else {
150+
PathBuf::from(base)
151+
}
152+
}
153+
}

src/dashboard/dashboard.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
use crate::dashboard::config::DashboardConfig;
2+
use crate::dashboard::layout::{normalize_slots, NormalizedSlot};
3+
use crate::dashboard::widgets::{WidgetAction, WidgetRegistry};
4+
use crate::{actions::Action, common::json_watch::JsonWatcher};
5+
use eframe::egui;
6+
use std::path::{Path, PathBuf};
7+
8+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9+
pub enum DashboardEvent {
10+
Reloaded,
11+
}
12+
13+
/// Source of a widget activation.
14+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15+
pub enum WidgetActivation {
16+
Click,
17+
Keyboard,
18+
}
19+
20+
/// Context shared with widgets at render time.
21+
pub struct DashboardContext<'a> {
22+
pub actions: &'a [Action],
23+
pub usage: &'a std::collections::HashMap<String, u32>,
24+
pub plugins: &'a crate::plugin::PluginManager,
25+
pub default_location: Option<&'a str>,
26+
}
27+
28+
pub struct Dashboard {
29+
config_path: PathBuf,
30+
pub config: DashboardConfig,
31+
pub slots: Vec<NormalizedSlot>,
32+
registry: WidgetRegistry,
33+
watcher: Option<JsonWatcher>,
34+
pub warnings: Vec<String>,
35+
event_cb: Option<std::sync::Arc<dyn Fn(DashboardEvent) + Send + Sync>>,
36+
}
37+
38+
impl Dashboard {
39+
pub fn new(
40+
config_path: impl AsRef<Path>,
41+
registry: WidgetRegistry,
42+
event_cb: Option<std::sync::Arc<dyn Fn(DashboardEvent) + Send + Sync>>,
43+
) -> Self {
44+
let path = config_path.as_ref().to_path_buf();
45+
let (config, slots, warnings) = Self::load_internal(&path, &registry);
46+
Self {
47+
config_path: path,
48+
config,
49+
slots,
50+
registry,
51+
watcher: None,
52+
warnings,
53+
event_cb,
54+
}
55+
}
56+
57+
fn load_internal(
58+
path: &Path,
59+
registry: &WidgetRegistry,
60+
) -> (DashboardConfig, Vec<NormalizedSlot>, Vec<String>) {
61+
let cfg = DashboardConfig::load(path, registry).unwrap_or_default();
62+
let (slots, mut warnings) = normalize_slots(&cfg, registry);
63+
if slots.is_empty() {
64+
warnings.push("dashboard has no valid slots".into());
65+
}
66+
(cfg, slots, warnings)
67+
}
68+
69+
pub fn reload(&mut self) {
70+
let (cfg, slots, warnings) = Self::load_internal(&self.config_path, &self.registry);
71+
self.config = cfg;
72+
self.slots = slots;
73+
self.warnings = warnings;
74+
}
75+
76+
pub fn set_path(&mut self, path: impl AsRef<Path>) {
77+
self.config_path = path.as_ref().to_path_buf();
78+
self.reload();
79+
self.attach_watcher();
80+
}
81+
82+
pub fn attach_watcher(&mut self) {
83+
let path = self.config_path.clone();
84+
let tx = self.event_cb.clone();
85+
self.watcher = crate::common::json_watch::watch_json(path.clone(), move || {
86+
tracing::info!("dashboard config changed");
87+
if let Some(tx) = &tx {
88+
(tx)(DashboardEvent::Reloaded);
89+
}
90+
})
91+
.ok();
92+
}
93+
94+
pub fn ui(
95+
&mut self,
96+
ui: &mut egui::Ui,
97+
ctx: &DashboardContext<'_>,
98+
activation: WidgetActivation,
99+
) -> Option<WidgetAction> {
100+
let mut clicked = None;
101+
let grid_cols = self.config.grid.cols.max(1) as usize;
102+
let col_width = ui.available_width() / grid_cols.max(1) as f32;
103+
104+
let size = egui::vec2(ui.available_width(), ui.available_height());
105+
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
106+
let mut child = ui.child_ui(rect, egui::Layout::top_down(egui::Align::LEFT));
107+
108+
for slot in &self.slots {
109+
let rect = egui::Rect::from_min_size(
110+
rect.min
111+
+ egui::vec2(
112+
col_width * slot.col as f32,
113+
(slot.row as f32) * 100.0, // coarse row height
114+
),
115+
egui::vec2(
116+
col_width * slot.col_span as f32,
117+
90.0 * slot.row_span as f32,
118+
),
119+
);
120+
let mut slot_ui = child.child_ui(rect, egui::Layout::left_to_right(egui::Align::TOP));
121+
if let Some(action) = self.render_slot(slot, &mut slot_ui, ctx, activation) {
122+
clicked = Some(action);
123+
}
124+
}
125+
126+
clicked
127+
}
128+
129+
fn render_slot(
130+
&self,
131+
slot: &NormalizedSlot,
132+
ui: &mut egui::Ui,
133+
ctx: &DashboardContext<'_>,
134+
activation: WidgetActivation,
135+
) -> Option<WidgetAction> {
136+
ui.group(|ui| {
137+
ui.vertical(|ui| {
138+
let heading = slot.id.as_deref().unwrap_or(&slot.widget);
139+
ui.heading(heading);
140+
self.registry
141+
.create(&slot.widget, &slot.settings)
142+
.and_then(|mut w| w.render(ui, ctx, activation))
143+
})
144+
.inner
145+
})
146+
.inner
147+
}
148+
149+
pub fn registry(&self) -> &WidgetRegistry {
150+
&self.registry
151+
}
152+
}

0 commit comments

Comments
 (0)