#![allow(dead_code)]
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::layout::{BgLevel, BorderStyle, Child, Flex, Layout, TitleAlign};
use crate::render::RenderSpec;
use crate::theme::ThemeConfig;
const DEFAULT_SETTINGS: &str = include_str!("default_settings.toml");
const DEFAULT_HOME_DASHBOARD: &str = include_str!("default_home_dashboard.toml");
const DEFAULT_PROJECT_DASHBOARD: &str = include_str!("default_project_dashboard.toml");
#[derive(Debug, Clone, Default)]
pub struct Config {
pub general: General,
pub theme: ThemeConfig,
pub widgets: Vec<WidgetConfig>,
pub rows: Vec<RowConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SettingsConfig {
#[serde(default)]
pub general: General,
#[serde(default)]
pub theme: ThemeConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DashboardConfig {
#[serde(default, rename = "widget")]
pub widgets: Vec<WidgetConfig>,
#[serde(default, rename = "row")]
pub rows: Vec<RowConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct General {
#[serde(default)]
pub wait_for_fresh: bool,
#[serde(default)]
pub height: Option<u16>,
#[serde(default)]
pub padding: Option<PaddingSpec>,
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub locale: Option<String>,
#[serde(default = "default_true")]
pub auto_home: bool,
#[serde(default = "default_true")]
pub auto_on_cd: bool,
#[serde(default)]
pub flush_to_scrollback: bool,
}
fn default_true() -> bool {
true
}
impl Default for General {
fn default() -> Self {
Self {
wait_for_fresh: false,
height: None,
padding: None,
timezone: None,
locale: None,
auto_home: true,
auto_on_cd: true,
flush_to_scrollback: false,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(untagged)]
pub enum PaddingSpec {
Uniform(u16),
Axes {
#[serde(default)]
x: u16,
#[serde(default)]
y: u16,
},
}
impl PaddingSpec {
pub fn xy(&self) -> (u16, u16) {
match self {
Self::Uniform(n) => (*n, *n),
Self::Axes { x, y } => (*x, *y),
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct WidgetConfig {
pub id: String,
pub fetcher: String,
#[serde(default)]
pub render: Option<RenderSpec>,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub refresh_interval: Option<u64>,
#[serde(default)]
pub file_format: Option<String>,
#[serde(default)]
pub options: Option<toml::Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RowConfig {
#[serde(default)]
pub height: Option<SizeSpec>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub title_align: Option<TitleAlignSpec>,
#[serde(default)]
pub border: Option<BorderSpec>,
#[serde(default)]
pub flex: Option<FlexSpec>,
#[serde(default)]
pub bg: Option<BgSpec>,
#[serde(default)]
pub gap: Option<u16>,
#[serde(default, rename = "child")]
pub children: Vec<ChildConfig>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BgSpec {
Default,
Subtle,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FlexSpec {
Legacy,
Start,
Center,
End,
SpaceBetween,
SpaceAround,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChildConfig {
#[serde(default)]
pub widget: Option<String>,
#[serde(default)]
pub width: Option<SizeSpec>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub title_align: Option<TitleAlignSpec>,
#[serde(default)]
pub border: Option<BorderSpec>,
#[serde(default)]
pub bg: Option<BgSpec>,
#[serde(default, rename = "row")]
pub rows: Vec<RowConfig>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TitleAlignSpec {
Left,
Center,
Right,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SizeSpec {
Fill(u16),
Length(u16),
Min(u16),
Max(u16),
Percentage(u16),
Auto,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BorderSpec {
None,
Plain,
Rounded,
Thick,
Double,
Top,
}
impl Config {
pub fn from_parts(settings: SettingsConfig, dashboard: DashboardConfig) -> Self {
Self {
general: settings.general,
theme: settings.theme,
widgets: dashboard.widgets,
rows: dashboard.rows,
}
}
pub fn to_layout(&self) -> Layout {
Layout::rows(self.rows.iter().map(to_row_child).collect())
}
pub fn computed_height(&self) -> u16 {
let rows: u16 = self
.rows
.iter()
.map(|r| row_height_estimate(r.height))
.sum();
let pad_y = self
.general
.padding
.map(|p| p.xy().1)
.unwrap_or(0)
.saturating_mul(2);
rows.saturating_add(pad_y)
}
}
impl SettingsConfig {
pub fn default_baked() -> Self {
toml::from_str(DEFAULT_SETTINGS).expect("built-in default settings must parse")
}
pub fn parse(toml_str: &str) -> Result<Self, String> {
toml::from_str(toml_str).map_err(|e| e.to_string())
}
pub fn load_or_default(path: &Path) -> Result<Self, String> {
match std::fs::read_to_string(path) {
Ok(s) => Self::parse(&s).map_err(|e| format!("{}: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default_baked()),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
}
impl DashboardConfig {
pub fn default_home() -> Self {
toml::from_str(DEFAULT_HOME_DASHBOARD).expect("built-in home dashboard must parse")
}
pub fn default_project() -> Self {
toml::from_str(DEFAULT_PROJECT_DASHBOARD).expect("built-in project dashboard must parse")
}
pub fn parse(toml_str: &str) -> Result<Self, String> {
toml::from_str(toml_str).map_err(|e| e.to_string())
}
}
#[derive(Debug, Clone)]
pub enum DashboardSource {
Local(PathBuf),
Project,
Home,
}
pub const LOCAL_DASHBOARD_CANDIDATES: &[&str] =
&[".splashboard/dashboard.toml", ".splashboard.toml"];
pub fn resolve_dashboard_source() -> DashboardSource {
let cwd = std::env::current_dir().ok();
match cwd.as_deref() {
Some(c) => resolve_from(c, false).unwrap_or(DashboardSource::Home),
None => DashboardSource::Home,
}
}
pub fn resolve_on_cd_dashboard_source() -> Option<DashboardSource> {
let cwd = std::env::current_dir().ok()?;
resolve_from(&cwd, true)
}
pub fn resolve_local_dashboard_path() -> Option<PathBuf> {
let cwd = std::env::current_dir().ok()?;
find_local_walking_up(&cwd)
}
fn resolve_from(cwd: &Path, on_cd: bool) -> Option<DashboardSource> {
if is_home_dir(cwd) {
return Some(DashboardSource::Home);
}
if let Some(path) = find_local_at(cwd) {
return Some(DashboardSource::Local(path));
}
if is_git_repo_root(cwd) {
return Some(DashboardSource::Project);
}
if on_cd {
None
} else {
Some(DashboardSource::Home)
}
}
fn is_home_dir(path: &Path) -> bool {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(dirs::home_dir);
let Some(home) = home else {
return false;
};
canonicalize_or(path) == canonicalize_or(&home)
}
fn canonicalize_or(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn is_git_repo_root(cwd: &Path) -> bool {
let dot_git = cwd.join(".git");
dot_git.is_dir() || dot_git.is_file()
}
fn find_local_walking_up(start: &Path) -> Option<PathBuf> {
let mut current = Some(start);
while let Some(dir) = current {
if let Some(path) = find_local_at(dir) {
return Some(path);
}
current = dir.parent();
}
None
}
fn find_local_at(dir: &Path) -> Option<PathBuf> {
LOCAL_DASHBOARD_CANDIDATES
.iter()
.map(|name| dir.join(name))
.find(|p| p.is_file())
}
fn row_height_estimate(size: Option<SizeSpec>) -> u16 {
match size {
Some(SizeSpec::Length(n)) | Some(SizeSpec::Min(n)) | Some(SizeSpec::Max(n)) => n,
Some(SizeSpec::Fill(_)) => 3,
Some(SizeSpec::Percentage(_)) => 0,
Some(SizeSpec::Auto) => 3,
None => 3,
}
}
fn to_row_child(row: &RowConfig) -> Child {
let cols = intersperse_gaps(row.children.iter().map(to_col_child).collect(), row.gap);
let mut inner = Layout::cols(cols);
if let Some(f) = row.flex {
inner = inner.flexed(to_flex(f));
}
let decorated = apply_panel(inner, row.title.as_deref(), row.border, row.title_align);
let decorated = apply_bg(decorated, row.bg);
make_child(row.height, decorated)
}
fn intersperse_gaps(children: Vec<Child>, gap: Option<u16>) -> Vec<Child> {
let Some(n) = gap.filter(|n| *n > 0) else {
return children;
};
let mut out = Vec::with_capacity(children.len() * 2);
for (i, c) in children.into_iter().enumerate() {
if i > 0 {
out.push(Child::length(n, Layout::Empty));
}
out.push(c);
}
out
}
fn to_flex(f: FlexSpec) -> Flex {
match f {
FlexSpec::Legacy => Flex::Legacy,
FlexSpec::Start => Flex::Start,
FlexSpec::Center => Flex::Center,
FlexSpec::End => Flex::End,
FlexSpec::SpaceBetween => Flex::SpaceBetween,
FlexSpec::SpaceAround => Flex::SpaceAround,
}
}
fn to_col_child(c: &ChildConfig) -> Child {
let leaf = if c.rows.is_empty() {
match c.widget.as_deref() {
Some(id) => Layout::widget(id.to_string()),
None => Layout::Empty,
}
} else {
Layout::rows(c.rows.iter().map(to_row_child).collect())
};
let decorated = apply_panel(leaf, c.title.as_deref(), c.border, c.title_align);
let decorated = apply_bg(decorated, c.bg);
make_child(c.width, decorated)
}
fn apply_bg(layout: Layout, bg: Option<BgSpec>) -> Layout {
match bg {
None | Some(BgSpec::Default) => layout,
Some(BgSpec::Subtle) => layout.with_bg(BgLevel::Subtle),
}
}
fn make_child(size: Option<SizeSpec>, layout: Layout) -> Child {
match size {
Some(SizeSpec::Fill(w)) => Child::fill(w, layout),
Some(SizeSpec::Length(n)) => Child::length(n, layout),
Some(SizeSpec::Min(n)) => Child::min(n, layout),
Some(SizeSpec::Max(n)) => Child::max(n, layout),
Some(SizeSpec::Percentage(p)) => Child::percentage(p, layout),
Some(SizeSpec::Auto) => Child::auto(layout),
None => Child::fill(1, layout),
}
}
fn apply_panel(
layout: Layout,
title: Option<&str>,
border: Option<BorderSpec>,
title_align: Option<TitleAlignSpec>,
) -> Layout {
let Some(style) = border.and_then(to_border_style) else {
return layout;
};
let l = match title {
Some(t) => layout.titled(t).title_aligned(to_title_align(title_align)),
None => layout,
};
l.bordered(style)
}
fn to_title_align(spec: Option<TitleAlignSpec>) -> TitleAlign {
match spec {
Some(TitleAlignSpec::Center) => TitleAlign::Center,
Some(TitleAlignSpec::Right) => TitleAlign::Right,
Some(TitleAlignSpec::Left) | None => TitleAlign::Left,
}
}
fn to_border_style(b: BorderSpec) -> Option<BorderStyle> {
match b {
BorderSpec::None => None,
BorderSpec::Plain => Some(BorderStyle::Plain),
BorderSpec::Rounded => Some(BorderStyle::Rounded),
BorderSpec::Thick => Some(BorderStyle::Thick),
BorderSpec::Double => Some(BorderStyle::Double),
BorderSpec::Top => Some(BorderStyle::Top),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::Size;
#[test]
fn default_baked_settings_parses() {
let _ = SettingsConfig::default_baked();
}
#[test]
fn default_home_dashboard_parses() {
let _ = DashboardConfig::default_home();
}
#[test]
fn default_project_dashboard_parses() {
let _ = DashboardConfig::default_project();
}
#[test]
fn minimal_dashboard_parses() {
let toml = r#"
[[widget]]
id = "x"
fetcher = "basic_static"
render = "text_plain"
[[row]]
height = { length = 3 }
[[row.child]]
widget = "x"
"#;
let d = DashboardConfig::parse(toml).unwrap();
assert_eq!(d.widgets.len(), 1);
assert_eq!(d.rows.len(), 1);
assert_eq!(d.rows[0].children.len(), 1);
}
#[test]
fn size_spec_accepts_variants() {
let toml = r#"
[[widget]]
id = "x"
fetcher = "f"
render = "text_plain"
[[row]]
height = { fill = 2 }
[[row.child]]
widget = "x"
width = { percentage = 50 }
[[row]]
height = { min = 6 }
[[row.child]]
widget = "x"
width = { max = 30 }
"#;
let d = DashboardConfig::parse(toml).unwrap();
assert!(matches!(d.rows[0].height, Some(SizeSpec::Fill(2))));
assert!(matches!(
d.rows[0].children[0].width,
Some(SizeSpec::Percentage(50))
));
assert!(matches!(d.rows[1].height, Some(SizeSpec::Min(6))));
assert!(matches!(
d.rows[1].children[0].width,
Some(SizeSpec::Max(30))
));
}
#[test]
fn border_spec_parses_all_styles() {
for name in ["plain", "rounded", "thick", "double", "top"] {
let toml = format!(
r#"
[[widget]]
id = "x"
fetcher = "f"
render = "text_plain"
[[row]]
border = "{name}"
[[row.child]]
widget = "x"
"#
);
DashboardConfig::parse(&toml).unwrap();
}
}
#[test]
fn bg_spec_parses_on_row_and_child() {
let toml = r#"
[[widget]]
id = "x"
fetcher = "basic_static"
render = "text_plain"
[[row]]
bg = "subtle"
height = { length = 2 }
[[row.child]]
widget = "x"
bg = "default"
"#;
let d = DashboardConfig::parse(toml).unwrap();
assert_eq!(d.rows[0].bg, Some(BgSpec::Subtle));
assert_eq!(d.rows[0].children[0].bg, Some(BgSpec::Default));
}
#[test]
fn invalid_dashboard_toml_returns_error() {
let r = DashboardConfig::parse("this is not [valid toml");
assert!(r.is_err());
}
#[test]
fn missing_settings_file_falls_back_to_default() {
let path = Path::new("/does/not/exist.toml");
let s = SettingsConfig::load_or_default(path).unwrap();
assert!(s.general.height.is_none());
}
#[test]
fn settings_parses_general_and_theme() {
let toml = r#"
[general]
wait_for_fresh = true
height = 24
[theme]
preset = "nord"
"#;
let s = SettingsConfig::parse(toml).unwrap();
assert!(s.general.wait_for_fresh);
assert_eq!(s.general.height, Some(24));
assert_eq!(s.theme.preset.as_deref(), Some("nord"));
}
#[test]
fn padding_xy_and_computed_height_cover_size_fallbacks() {
assert_eq!(PaddingSpec::Uniform(3).xy(), (3, 3));
assert_eq!(PaddingSpec::Axes { x: 4, y: 2 }.xy(), (4, 2));
let row = |height| RowConfig {
height,
title: None,
title_align: None,
border: None,
flex: None,
bg: None,
gap: None,
children: vec![],
};
let config = Config {
general: General {
padding: Some(PaddingSpec::Axes { x: 9, y: 2 }),
..General::default()
},
theme: ThemeConfig::default(),
widgets: vec![],
rows: vec![
row(Some(SizeSpec::Length(4))),
row(Some(SizeSpec::Min(5))),
row(Some(SizeSpec::Max(6))),
row(Some(SizeSpec::Fill(7))),
row(Some(SizeSpec::Percentage(50))),
row(Some(SizeSpec::Auto)),
row(None),
],
};
assert_eq!(config.computed_height(), 28);
}
#[test]
fn load_or_default_prefixes_non_missing_io_errors() {
let dir = tempfile::tempdir().unwrap();
let err = SettingsConfig::load_or_default(dir.path()).unwrap_err();
assert!(err.starts_with(&format!("{}:", dir.path().display())));
}
#[test]
fn auto_display_toggles_default_to_true() {
let s = SettingsConfig::default_baked();
assert!(s.general.auto_home);
assert!(s.general.auto_on_cd);
}
#[test]
fn auto_display_toggles_parse_when_false() {
let toml = r#"
[general]
auto_home = false
auto_on_cd = false
"#;
let s = SettingsConfig::parse(toml).unwrap();
assert!(!s.general.auto_home);
assert!(!s.general.auto_on_cd);
}
#[test]
fn auto_display_toggles_partial_override_keeps_other_default() {
let toml = r#"
[general]
auto_home = false
"#;
let s = SettingsConfig::parse(toml).unwrap();
assert!(!s.general.auto_home);
assert!(s.general.auto_on_cd);
}
#[test]
fn dashboard_ignores_stray_settings_sections() {
let toml = r#"
[general]
wait_for_fresh = true
[theme]
preset = "nord"
[[widget]]
id = "x"
fetcher = "basic_static"
[[row]]
[[row.child]]
widget = "x"
"#;
let d = DashboardConfig::parse(toml).unwrap();
assert_eq!(d.widgets.len(), 1);
assert_eq!(d.rows.len(), 1);
}
#[test]
fn find_local_picks_up_flat_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join(".splashboard.toml");
std::fs::write(&file, "").unwrap();
assert_eq!(find_local_at(dir.path()).unwrap(), file);
}
#[test]
fn find_local_picks_up_directory_form() {
let dir = tempfile::tempdir().unwrap();
let subdir = dir.path().join(".splashboard");
std::fs::create_dir(&subdir).unwrap();
let file = subdir.join("dashboard.toml");
std::fs::write(&file, "").unwrap();
assert_eq!(find_local_at(dir.path()).unwrap(), file);
}
#[test]
fn find_local_prefers_directory_form_when_both_exist() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".splashboard")).unwrap();
let dir_file = dir.path().join(".splashboard/dashboard.toml");
std::fs::write(&dir_file, "").unwrap();
std::fs::write(dir.path().join(".splashboard.toml"), "").unwrap();
assert_eq!(find_local_at(dir.path()).unwrap(), dir_file);
}
#[test]
fn find_local_walks_up_to_ancestor() {
let root = tempfile::tempdir().unwrap();
let file = root.path().join(".splashboard.toml");
std::fs::write(&file, "").unwrap();
let nested = root.path().join("a").join("b").join("c");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(find_local_walking_up(&nested).unwrap(), file);
}
#[test]
fn find_local_walking_up_returns_none_when_absent() {
let dir = tempfile::tempdir().unwrap();
assert!(find_local_walking_up(dir.path()).is_none());
}
#[test]
fn find_local_at_does_not_walk_up() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join(".splashboard.toml"), "").unwrap();
let nested = root.path().join("sub");
std::fs::create_dir(&nested).unwrap();
assert!(find_local_at(&nested).is_none());
assert!(find_local_walking_up(&nested).is_some());
}
#[test]
fn resolve_prefers_local_over_project() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::write(dir.path().join(".splashboard.toml"), "").unwrap();
let src = resolve_from(dir.path(), false).unwrap();
assert!(matches!(src, DashboardSource::Local(_)));
}
#[test]
fn resolve_picks_project_when_cwd_is_repo_root() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let src = resolve_from(dir.path(), false).unwrap();
assert!(matches!(src, DashboardSource::Project));
}
#[test]
fn resolve_falls_back_to_home_outside_repo() {
let dir = tempfile::tempdir().unwrap();
let src = resolve_from(dir.path(), false).unwrap();
assert!(matches!(src, DashboardSource::Home));
}
#[test]
fn on_cd_silent_in_plain_subdir() {
let dir = tempfile::tempdir().unwrap();
assert!(resolve_from(dir.path(), true).is_none());
}
#[test]
fn on_cd_fires_home_at_home_dir() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let previous = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", tmp.path());
}
let src = resolve_from(tmp.path(), true).unwrap();
assert!(matches!(src, DashboardSource::Home));
unsafe {
match previous {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
#[test]
fn on_cd_fires_at_repo_root() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let src = resolve_from(dir.path(), true).unwrap();
assert!(matches!(src, DashboardSource::Project));
}
#[test]
fn on_cd_silent_in_repo_subdir() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join(".git")).unwrap();
let sub = root.path().join("src");
std::fs::create_dir(&sub).unwrap();
assert!(resolve_from(&sub, true).is_none());
}
#[test]
fn child_without_widget_is_layout_spacer() {
let toml = r#"
[[widget]]
id = "a"
fetcher = "basic_static"
render = "text_plain"
[[row]]
height = { length = 3 }
[[row.child]]
width = { length = 2 }
[[row.child]]
widget = "a"
"#;
let d = DashboardConfig::parse(toml).unwrap();
assert!(d.rows[0].children[0].widget.is_none());
let layout = Config::from_parts(SettingsConfig::default(), d).to_layout();
let cols = match layout {
Layout::Stack { children, .. } => match &children[0].layout {
Layout::Stack { children: cs, .. } => cs.clone(),
_ => panic!("expected inner cols stack"),
},
_ => panic!("expected rows at root"),
};
assert!(matches!(cols[0].layout, Layout::Empty));
assert!(matches!(cols[1].layout, Layout::Widget { .. }));
}
#[test]
fn row_gap_splices_spacers_between_children() {
let toml = r#"
[[widget]]
id = "a"
fetcher = "basic_static"
render = "text_plain"
[[widget]]
id = "b"
fetcher = "basic_static"
render = "text_plain"
[[row]]
height = { length = 3 }
gap = 2
[[row.child]]
widget = "a"
[[row.child]]
widget = "b"
"#;
let d = DashboardConfig::parse(toml).unwrap();
assert_eq!(d.rows[0].gap, Some(2));
let layout = Config::from_parts(SettingsConfig::default(), d).to_layout();
let cols = match layout {
Layout::Stack { children, .. } => match &children[0].layout {
Layout::Stack { children: cs, .. } => cs.clone(),
_ => panic!("expected inner cols stack"),
},
_ => panic!("expected rows at root"),
};
assert_eq!(cols.len(), 3);
assert!(matches!(cols[0].layout, Layout::Widget { .. }));
assert!(matches!(cols[1].layout, Layout::Empty));
assert!(matches!(cols[2].layout, Layout::Widget { .. }));
assert!(matches!(cols[1].size, crate::layout::Size::Length(2)));
}
#[test]
fn row_gap_zero_is_noop() {
let toml = r#"
[[widget]]
id = "a"
fetcher = "basic_static"
[[row]]
gap = 0
[[row.child]]
widget = "a"
[[row.child]]
widget = "a"
"#;
let d = DashboardConfig::parse(toml).unwrap();
let layout = Config::from_parts(SettingsConfig::default(), d).to_layout();
let cols = match layout {
Layout::Stack { children, .. } => match &children[0].layout {
Layout::Stack { children: cs, .. } => cs.clone(),
_ => panic!(),
},
_ => panic!(),
};
assert_eq!(cols.len(), 2);
}
#[test]
fn helper_mappings_cover_sizes_flex_titles_and_borders() {
assert_eq!(to_flex(FlexSpec::Legacy), Flex::Legacy);
assert_eq!(to_flex(FlexSpec::Start), Flex::Start);
assert_eq!(to_flex(FlexSpec::Center), Flex::Center);
assert_eq!(to_flex(FlexSpec::End), Flex::End);
assert_eq!(to_flex(FlexSpec::SpaceBetween), Flex::SpaceBetween);
assert_eq!(to_flex(FlexSpec::SpaceAround), Flex::SpaceAround);
assert_eq!(
to_title_align(Some(TitleAlignSpec::Center)),
TitleAlign::Center
);
assert_eq!(
to_title_align(Some(TitleAlignSpec::Right)),
TitleAlign::Right
);
assert_eq!(to_title_align(Some(TitleAlignSpec::Left)), TitleAlign::Left);
assert_eq!(to_title_align(None), TitleAlign::Left);
assert_eq!(to_border_style(BorderSpec::None), None);
assert_eq!(to_border_style(BorderSpec::Plain), Some(BorderStyle::Plain));
assert_eq!(
to_border_style(BorderSpec::Rounded),
Some(BorderStyle::Rounded)
);
assert_eq!(to_border_style(BorderSpec::Thick), Some(BorderStyle::Thick));
assert_eq!(
to_border_style(BorderSpec::Double),
Some(BorderStyle::Double)
);
assert_eq!(to_border_style(BorderSpec::Top), Some(BorderStyle::Top));
assert_eq!(
make_child(Some(SizeSpec::Fill(2)), Layout::Empty).size,
Size::Fill(2)
);
assert_eq!(
make_child(Some(SizeSpec::Length(3)), Layout::Empty).size,
Size::Length(3)
);
assert_eq!(
make_child(Some(SizeSpec::Min(4)), Layout::Empty).size,
Size::Min(4)
);
assert_eq!(
make_child(Some(SizeSpec::Max(5)), Layout::Empty).size,
Size::Max(5)
);
assert_eq!(
make_child(Some(SizeSpec::Percentage(60)), Layout::Empty).size,
Size::Percentage(60)
);
assert_eq!(
make_child(Some(SizeSpec::Auto), Layout::Empty).size,
Size::Auto
);
assert_eq!(make_child(None, Layout::Empty).size, Size::Fill(1));
}
#[test]
fn panel_and_git_root_helpers_cover_remaining_branches() {
let titled = apply_panel(
Layout::widget("hero"),
Some("Status"),
Some(BorderSpec::Top),
Some(TitleAlignSpec::Right),
);
let Layout::Widget {
panel: Some(panel), ..
} = titled
else {
panic!("expected widget with panel");
};
assert_eq!(panel.title.as_deref(), Some("Status"));
assert_eq!(panel.border, BorderStyle::Top);
assert_eq!(panel.title_align, TitleAlign::Right);
let untitled = apply_panel(
Layout::widget("chart"),
None,
Some(BorderSpec::Rounded),
Some(TitleAlignSpec::Center),
);
let Layout::Widget {
panel: Some(panel), ..
} = untitled
else {
panic!("expected rounded widget panel");
};
assert!(panel.title.is_none());
assert_eq!(panel.border, BorderStyle::Rounded);
assert_eq!(panel.title_align, TitleAlign::Left);
let chromeless = apply_panel(
Layout::widget("clock"),
Some("Ignored"),
Some(BorderSpec::None),
Some(TitleAlignSpec::Center),
);
assert!(matches!(chromeless, Layout::Widget { panel: None, .. }));
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".git"), "gitdir: ../.git/worktrees/x").unwrap();
assert!(is_git_repo_root(dir.path()));
assert_eq!(
canonicalize_or(&dir.path().join("missing")),
dir.path().join("missing")
);
}
#[test]
fn to_layout_builds_nested_rows_of_cols() {
let toml = r#"
[[widget]]
id = "a"
fetcher = "basic_static"
render = "text_plain"
[[widget]]
id = "b"
fetcher = "basic_static"
render = "text_plain"
[[row]]
height = { length = 3 }
[[row.child]]
widget = "a"
[[row.child]]
widget = "b"
"#;
let d = DashboardConfig::parse(toml).unwrap();
let config = Config::from_parts(SettingsConfig::default(), d);
let layout = config.to_layout();
match layout {
Layout::Stack { children, .. } => {
assert!(!children.is_empty());
}
_ => panic!("expected Stack at root"),
}
}
}