use crate::{
library::EntryName,
model::{LeadHour, MercatorPoint, Overlay, Product, RunId, RunSelection, Viewport},
persist::{load_toml, save_toml},
};
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeSet, path::Path};
const SCHEMA: u16 = 1;
#[derive(Clone, Debug)]
pub struct Slate {
pub overlay: Overlay,
pub cycle: RunSelection,
pub lead: LeadHour,
pub active_view: Option<EntryName>,
pub closed_folders: BTreeSet<String>,
pub inspector_scroll: f32,
}
impl Default for Slate {
fn default() -> Self {
Self {
overlay: Product::default().into(),
cycle: RunSelection::default(),
lead: LeadHour::ZERO,
active_view: None,
closed_folders: BTreeSet::new(),
inspector_scroll: 0.0,
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
enum CycleMode {
#[default]
Latest,
LatestLong,
Fixed,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum SlateWire {
V1(SlateV1),
Legacy(LegacySlate),
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct SlateV1 {
schema: u16,
#[serde(alias = "product")]
overlay: Overlay,
cycle: CycleMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
fixed_run: Option<RunId>,
lead: LeadHour,
active_view: Option<EntryName>,
closed_folders: BTreeSet<String>,
#[serde(default)]
inspector_scroll: f32,
}
#[derive(Deserialize)]
#[serde(default, deny_unknown_fields)]
struct LegacySlate {
#[serde(alias = "product")]
overlay: Overlay,
cycle_tether: CycleMode,
run: Option<RunId>,
lead: LeadHour,
viewport: Viewport,
#[serde(alias = "probes")]
pins: Vec<MercatorPoint>,
active_view: Option<EntryName>,
closed_folders: BTreeSet<String>,
}
impl Default for LegacySlate {
fn default() -> Self {
Self {
overlay: Product::default().into(),
cycle_tether: CycleMode::Latest,
run: None,
lead: LeadHour::ZERO,
viewport: Viewport::default(),
pins: Vec::new(),
active_view: None,
closed_folders: BTreeSet::new(),
}
}
}
impl Slate {
pub fn load(path: &Path) -> Result<(Self, bool)> {
let Some(wire) = load_toml(path, "session state")? else {
return Ok((Self::default(), false));
};
match wire {
SlateWire::V1(wire) => Ok((Self::from_v1(wire)?, false)),
SlateWire::Legacy(wire) => Ok((Self::from_legacy(wire)?, true)),
}
}
pub fn save(&self, path: &Path) -> Result<()> {
let (cycle, fixed_run) = match self.cycle {
RunSelection::Latest => (CycleMode::Latest, None),
RunSelection::LatestLong => (CycleMode::LatestLong, None),
RunSelection::Fixed(run) => (CycleMode::Fixed, Some(run)),
};
save_toml(
&SlateV1 {
schema: SCHEMA,
overlay: self.overlay,
cycle,
fixed_run,
lead: self.lead,
active_view: self.active_view.clone(),
closed_folders: self.closed_folders.clone(),
inspector_scroll: self.inspector_scroll,
},
path,
"serialize session state",
)
}
fn from_v1(wire: SlateV1) -> Result<Self> {
if wire.schema != SCHEMA {
bail!("unsupported session-state schema {}", wire.schema);
}
let cycle = refine_cycle(wire.cycle, wire.fixed_run)?;
Ok(Self {
overlay: wire.overlay,
cycle,
lead: wire.lead,
active_view: wire.active_view,
closed_folders: wire.closed_folders,
inspector_scroll: lawful_scroll(wire.inspector_scroll),
})
}
fn from_legacy(mut wire: LegacySlate) -> Result<Self> {
wire.viewport.normalize();
let _legacy_pins = wire
.pins
.drain(..)
.filter_map(MercatorPoint::normalize)
.collect::<Vec<_>>();
let cycle = refine_cycle(wire.cycle_tether, wire.run)?;
Ok(Self {
overlay: wire.overlay,
cycle,
lead: wire.lead,
active_view: wire.active_view,
closed_folders: wire.closed_folders,
inspector_scroll: 0.0,
})
}
}
fn lawful_scroll(offset: f32) -> f32 {
if offset.is_finite() {
offset.max(0.0)
} else {
0.0
}
}
fn refine_cycle(mode: CycleMode, fixed: Option<RunId>) -> Result<RunSelection> {
match (mode, fixed) {
(CycleMode::Latest, _) => Ok(RunSelection::Latest),
(CycleMode::LatestLong, _) => Ok(RunSelection::LatestLong),
(CycleMode::Fixed, Some(run)) => Ok(RunSelection::Fixed(run)),
(CycleMode::Fixed, None) => bail!("fixed cycle selection has no cycle"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_qpf_state_rectifies_to_run_accumulation() -> Result<()> {
let wire = toml::from_str::<SlateWire>("product = \"qpf\"")?;
let SlateWire::Legacy(wire) = wire else {
bail!("legacy state parsed as current state");
};
let slate = Slate::from_legacy(wire)?;
assert_eq!(slate.overlay.active(), Some(Product::QpfRun));
Ok(())
}
#[test]
fn fixed_selection_cannot_exist_without_a_cycle() {
assert!(refine_cycle(CycleMode::Fixed, None).is_err());
}
#[test]
fn illegal_persisted_leads_are_rejected() {
let text = "\
schema = 1
overlay = \"smoke\"
cycle = \"latest\"
lead = 49
closed_folders = []
";
assert!(toml::from_str::<SlateWire>(text).is_err());
}
#[test]
fn inspector_scroll_repels_nonfinite_and_negative_state() -> Result<()> {
let mut slate = Slate::from_v1(toml::from_str::<SlateV1>(
"schema = 1\noverlay = \"smoke\"\ncycle = \"latest\"\nlead = 0\nclosed_folders = []\ninspector_scroll = nan\n",
)?)?;
assert_eq!(slate.inspector_scroll, 0.0);
slate.inspector_scroll = lawful_scroll(-8.0);
assert_eq!(slate.inspector_scroll, 0.0);
Ok(())
}
}