use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use arrow_array::RecordBatch;
use arrow_array::array::ArrayRef;
use arrow_array::array::StringArray;
use arrow_cast::display::ArrayFormatter;
use bevy_math::DVec2;
use chrono::DateTime;
use chrono::NaiveDateTime;
use chrono::NaiveTime;
use chrono::Utc;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::Serializer;
use serde::de::Error;
use tracing::info;
use tracing::warn;
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)]
#[serde(untagged)]
pub enum UserValue {
#[default]
#[serde(serialize_with = "serde::ser::Serializer::serialize_none")]
None,
DateTime(NaiveDateTime),
Time(NaiveTime),
Duration(Duration),
Text(String),
Number(f64),
Bool(bool),
Files(Vec<PathBuf>),
Notes(Vec<Note>),
Table(TableData),
GeoPos(GeoPos),
PlotRegions(Vec<PlotRegion>),
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct Note {
timestamp: DateTime<Utc>,
pub text: String,
}
impl Note {
pub fn new(text: impl Into<String>) -> Self {
Self::with_timestamp(Utc::now(), text.into())
}
pub fn with_timestamp(timestamp: DateTime<Utc>, text: impl Into<String>) -> Self {
Self {
timestamp,
text: text.into(),
}
}
pub fn preview(&self) -> Cow<'_, str> {
if let Some((i, _)) = self.text.char_indices().nth(8) {
Cow::Borrowed(&self.text[..i])
} else {
Cow::Owned(self.timestamp.with_timezone(&chrono::Local).to_string())
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TableData {
pub batch: RecordBatch,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct JsonTableData {
#[serde(default)]
pub columns: Vec<String>,
#[serde(deserialize_with = "deserialize_string_array", default)]
pub data: Vec<Vec<String>>,
}
impl<'w> Deserialize<'w> for TableData {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'w>,
{
let data = JsonTableData::deserialize(deserializer)?;
info!(
"Parsed table data: {} columns, {} rows",
data.columns.len(),
data.data.len()
);
if data.columns.len() != data.data.len() {
warn!(
"number of columns does not match shape of data: {} != {}",
data.columns.len(),
data.data.len()
);
}
let columns = std::iter::zip(data.data, data.columns)
.map(|(column, colname)| (colname, Arc::new(StringArray::from(column)) as ArrayRef));
let batch = RecordBatch::try_from_iter(columns).map_err(D::Error::custom)?;
Ok(Self { batch })
}
}
impl Serialize for TableData {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let columns = self
.batch
.schema_ref()
.fields()
.iter()
.map(|field| field.name().clone())
.collect();
let data = self
.batch
.columns()
.iter()
.filter_map(|col| {
let Ok(formatter) = ArrayFormatter::try_new(col, &Default::default()) else {
return None;
};
Some(
(0..col.len())
.map(|i| formatter.value(i).to_string())
.collect::<Vec<_>>(),
)
})
.collect();
let value = JsonTableData { columns, data };
value.serialize(serializer)
}
}
pub fn deserialize_string_array<'de, D>(deserializer: D) -> Result<Vec<Vec<String>>, D::Error>
where
D: Deserializer<'de>,
{
let raw_data: Vec<Vec<Option<String>>> = Vec::deserialize(deserializer)?;
Ok(raw_data
.into_iter()
.map(|row| {
row.into_iter()
.map(|cell| cell.unwrap_or_default())
.collect()
})
.collect())
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct PlotRegion {
pub start_timestamp: f64,
pub end_timestamp: f64,
pub color: Color,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Color(ecolor::Color32);
impl Color {
pub const fn as_egui_color(self) -> ecolor::Color32 {
self.0
}
}
impl From<Color> for ecolor::Color32 {
fn from(value: Color) -> Self {
value.as_egui_color()
}
}
impl<'de> Deserialize<'de> for Color {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let input = String::deserialize(deserializer)?;
let hex = input.trim().to_lowercase();
if !hex.starts_with('#') {
return Err(D::Error::custom(
"hex colors must start with the `#` character",
));
}
let color = match ecolor::Color32::from_hex(&hex) {
Ok(color) => color,
Err(ecolor::ParseHexColorError::MissingHash) => {
return Err(D::Error::custom("hex color is missing `#` prefix"));
}
Err(ecolor::ParseHexColorError::InvalidLength) => {
return Err(D::Error::custom(
"hex color has invalid length — must be 7 or 9 characters (e.g., `#RRGGBB` or `#RRGGBBAA`)",
));
}
Err(ecolor::ParseHexColorError::InvalidInt(e)) => {
return Err(D::Error::custom(format!("failed to parse hex digits: {e}")));
}
};
Ok(Color(color))
}
}
impl Serialize for Color {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let hex_string = format!(
"#{:02x}{:02x}{:02x}{:02x}",
self.0.r(),
self.0.g(),
self.0.b(),
self.0.a()
);
serializer.serialize_str(&hex_string)
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Default)]
pub struct GeoPos {
pub latitude: f64,
pub longitude: f64,
}
impl From<DVec2> for GeoPos {
fn from(value: DVec2) -> Self {
Self {
latitude: value.y,
longitude: value.x,
}
}
}
impl From<GeoPos> for DVec2 {
fn from(value: GeoPos) -> Self {
Self {
x: value.longitude,
y: value.latitude,
}
}
}
impl std::fmt::Display for GeoPos {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{},{}", self.latitude, self.longitude)
}
}
const DATE_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
const TIME_FORMAT: &str = "%H:%M:%S";
impl UserValue {
pub fn string_mut(&mut self) -> &mut String {
match *self {
Self::None => self.insert_string(String::new()),
Self::DateTime(ref value) => self.insert_string(format!("{value:?}")),
Self::Time(ref value) => self.insert_string(format!("{value:?}")),
Self::Duration(ref value) => self.insert_string(format!("{value:?}")),
Self::Text(ref mut value) => value,
Self::Number(value) => self.insert_string(value.to_string()),
Self::Bool(value) => self.insert_string(value.to_string()),
Self::Files(ref files) => {
self.insert_string(fold_str(files.iter().map(|p| p.to_string_lossy())))
}
Self::Notes(ref notes) => self.insert_string(fold_str(notes.iter().map(|n| &n.text))),
Self::Table(ref table) => {
self.insert_string(serde_json::to_string(table).unwrap_or_else(|e| e.to_string()))
}
Self::GeoPos(ref geo_pos) => self.insert_string(geo_pos.to_string()),
Self::PlotRegions(ref regions) => {
self.insert_string(serde_json::to_string(regions).unwrap_or_else(|e| e.to_string()))
}
}
}
pub fn number_mut(&mut self, fallback: f64) -> &mut f64 {
match *self {
Self::Number(ref mut value) => value,
Self::Text(ref value) => {
let value = value.parse::<f64>().unwrap_or(fallback);
self.insert_number(value)
}
Self::Bool(value) => self.insert_number(if value { 1.0 } else { 0.0 }),
Self::None
| Self::Files(_)
| Self::Notes(_)
| Self::Table(_)
| Self::PlotRegions(_)
| Self::DateTime(_)
| Self::Time(_)
| Self::Duration(_)
| Self::GeoPos(_) => self.insert_number(fallback),
}
}
pub fn bool_mut(&mut self, fallback: bool) -> &mut bool {
match *self {
Self::Bool(ref mut value) => value,
Self::Text(ref value) => {
let value = value.to_lowercase().parse::<bool>().unwrap_or(fallback);
self.insert_bool(value)
}
Self::Number(value) => self.insert_bool(value != 0.0),
Self::None
| Self::Files(_)
| Self::Notes(_)
| Self::Table(_)
| Self::PlotRegions(_)
| Self::DateTime(_)
| Self::Time(_)
| Self::Duration(_)
| Self::GeoPos(_) => self.insert_bool(fallback),
}
}
pub fn files_mut(&mut self, fallback: impl FnOnce() -> Vec<PathBuf>) -> &mut Vec<PathBuf> {
match *self {
Self::Files(ref mut files) => files,
_ => self.insert_files(fallback()),
}
}
pub fn notes_mut(&mut self, fallback: impl FnOnce() -> Vec<Note>) -> &mut Vec<Note> {
match *self {
Self::Notes(ref mut notes) => notes,
_ => self.insert_notes(fallback()),
}
}
pub fn table_mut(&mut self, fallback: impl FnOnce() -> TableData) -> &mut TableData {
match *self {
Self::Table(ref mut table) => table,
_ => self.insert_table(fallback()),
}
}
pub fn geo_pos_mut(&mut self, fallback: impl FnOnce() -> GeoPos) -> &mut GeoPos {
match *self {
Self::GeoPos(ref mut geo_pos) => geo_pos,
_ => self.insert_geo_pos(fallback()),
}
}
pub fn as_bool(&self) -> Option<bool> {
match *self {
Self::Bool(value) => Some(value),
Self::Text(ref value) => value.to_lowercase().parse::<bool>().ok(),
Self::Number(value) => Some(value != 0.0),
Self::None
| Self::Files(_)
| Self::Notes(_)
| Self::Table(_)
| Self::PlotRegions(_)
| Self::DateTime(_)
| Self::Time(_)
| Self::Duration(_)
| Self::GeoPos(_) => None,
}
}
pub fn as_table(&self) -> Option<&TableData> {
match *self {
Self::Table(ref value) => Some(value),
_ => None,
}
}
pub fn as_str(&self) -> Option<Cow<'_, str>> {
match *self {
Self::Text(ref text) => Some(Cow::Borrowed(text)),
Self::None => Some(Cow::Borrowed("")),
Self::Bool(value) => Some(Cow::Owned(value.to_string())),
Self::Number(value) => Some(Cow::Owned(value.to_string())),
Self::DateTime(value) => Some(Cow::Owned(value.to_string())),
Self::Time(value) => Some(Cow::Owned(value.to_string())),
Self::Duration(value) => Some(Cow::Owned(format!("{value:?}"))),
Self::GeoPos(value) => Some(Cow::Owned(value.to_string())),
Self::Files(_) | Self::Notes(_) | Self::Table(_) | Self::PlotRegions(_) => None,
}
}
pub fn as_date_time(&self) -> Option<NaiveDateTime> {
match *self {
Self::DateTime(value) => Some(value),
Self::Text(ref text) => NaiveDateTime::parse_from_str(text, DATE_TIME_FORMAT).ok(),
Self::Number(timestamp) => chrono::DateTime::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&chrono::Local).naive_local()),
_ => None,
}
}
pub fn as_time(&self) -> Option<NaiveTime> {
match *self {
Self::Time(value) => Some(value),
Self::Text(ref text) => NaiveTime::parse_from_str(text, TIME_FORMAT).ok(),
Self::Number(timestamp) => chrono::DateTime::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&chrono::Local).naive_local().time()),
_ => None,
}
}
pub fn as_geo_pos(&self) -> Option<GeoPos> {
match *self {
Self::GeoPos(value) => Some(value),
_ => None,
}
}
pub fn as_plot_regions(&self) -> Option<&[PlotRegion]> {
match *self {
Self::PlotRegions(ref regions) => Some(regions),
_ => None,
}
}
pub fn insert_string(&mut self, value: String) -> &mut String {
*self = Self::Text(value);
match self {
Self::Text(value) => value,
_ => unreachable!(),
}
}
pub fn insert_number(&mut self, value: f64) -> &mut f64 {
*self = Self::Number(value);
match self {
Self::Number(value) => value,
_ => unreachable!(),
}
}
pub fn insert_bool(&mut self, value: bool) -> &mut bool {
*self = Self::Bool(value);
match self {
Self::Bool(value) => value,
_ => unreachable!(),
}
}
pub fn insert_files(&mut self, value: Vec<PathBuf>) -> &mut Vec<PathBuf> {
*self = Self::Files(value);
match self {
Self::Files(value) => value,
_ => unreachable!(),
}
}
pub fn insert_notes(&mut self, value: Vec<Note>) -> &mut Vec<Note> {
*self = Self::Notes(value);
match self {
Self::Notes(value) => value,
_ => unreachable!(),
}
}
pub fn insert_table(&mut self, value: TableData) -> &mut TableData {
*self = Self::Table(value);
match self {
Self::Table(value) => value,
_ => unreachable!(),
}
}
pub fn insert_datetime(&mut self, value: NaiveDateTime) -> &mut NaiveDateTime {
*self = Self::DateTime(value);
match self {
Self::DateTime(value) => value,
_ => unreachable!(),
}
}
pub fn insert_time(&mut self, value: NaiveTime) -> &mut NaiveTime {
*self = Self::Time(value);
match self {
Self::Time(value) => value,
_ => unreachable!(),
}
}
pub fn insert_geo_pos(&mut self, value: GeoPos) -> &mut GeoPos {
*self = Self::GeoPos(value);
match self {
Self::GeoPos(value) => value,
_ => unreachable!(),
}
}
}
impl From<String> for UserValue {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<f32> for UserValue {
fn from(value: f32) -> Self {
Self::Number(value.into())
}
}
impl From<f64> for UserValue {
fn from(value: f64) -> Self {
Self::Number(value)
}
}
impl From<bool> for UserValue {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
impl From<NaiveDateTime> for UserValue {
fn from(value: NaiveDateTime) -> Self {
Self::DateTime(value)
}
}
impl From<NaiveTime> for UserValue {
fn from(value: NaiveTime) -> Self {
Self::Time(value)
}
}
impl From<Duration> for UserValue {
fn from(value: Duration) -> Self {
Self::Duration(value)
}
}
impl From<GeoPos> for UserValue {
fn from(value: GeoPos) -> Self {
Self::GeoPos(value)
}
}
fn fold_str<S: Into<String> + AsRef<str>>(iter: impl IntoIterator<Item = S>) -> String {
iter.into_iter().fold(String::new(), |mut value, str| {
if value.is_empty() {
str.into()
} else {
value.push('\n');
value.push_str(str.as_ref());
value
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serialize_none() {
assert_eq!(
serde_yaml::to_value(UserValue::None).unwrap(),
serde_yaml::Value::Null
);
assert_eq!(
UserValue::None,
serde_yaml::from_value(serde_yaml::Value::Null).unwrap(),
);
}
#[test]
fn serialize_date_time() {
let date_time =
NaiveDateTime::parse_from_str("2025-5-15T4:46:12", DATE_TIME_FORMAT).unwrap();
assert_eq!(
serde_yaml::to_value(UserValue::DateTime(date_time)).unwrap(),
serde_yaml::Value::String("2025-05-15T04:46:12".to_owned())
);
assert_eq!(
UserValue::DateTime(date_time),
serde_yaml::from_str("2025-05-15T04:46:12").unwrap()
);
}
#[test]
fn serialize_time() {
let date_time = NaiveTime::parse_from_str("04:46:12", TIME_FORMAT).unwrap();
assert_eq!(
serde_yaml::to_value(UserValue::Time(date_time)).unwrap(),
serde_yaml::Value::String("04:46:12".to_owned())
);
assert_eq!(
UserValue::Time(date_time),
serde_yaml::from_str("04:46:12").unwrap()
);
}
#[test]
#[should_panic = "data did not match any variant"]
fn deserialize_error() {
let _: UserValue = serde_yaml::from_value(serde_yaml::Value::Mapping(
serde_yaml::Mapping::from_iter([
(
serde_yaml::Value::String("foo".into()),
serde_yaml::Value::String("bar".into()),
),
]),
))
.unwrap();
}
}