use retiretui_engine::plan::Plan;
use toml::{Table, Value};
use super::codec::{get_path, parse_text, to_text};
use super::domain::{FieldKind, FieldSpec};
use super::group::{nth, nth_back};
use super::offers::{Offer, display_name, ref_offers};
use crate::commands::tui::present;
#[derive(Clone, Copy)]
pub struct Column {
pub key: &'static str,
header: Option<&'static str>,
pub(super) phrase: Option<fn(&Table, &Plan) -> String>,
}
impl Column {
pub const fn new(key: &'static str) -> Self {
Self {
key,
header: None,
phrase: None,
}
}
pub const fn headed(self, header: &'static str) -> Self {
Self {
header: Some(header),
..self
}
}
pub const fn phrased(self, phrase: fn(&Table, &Plan) -> String) -> Self {
Self {
phrase: Some(phrase),
..self
}
}
pub fn header(&self, fields: &[FieldSpec]) -> &'static str {
let label = || field_of(fields, self.key).map_or(self.key, |spec| spec.label);
self.header.unwrap_or_else(label)
}
pub fn is_numeric(&self, fields: &[FieldSpec]) -> bool {
self.phrase.is_none()
&& field_of(fields, self.key).is_some_and(|spec| {
matches!(
spec.kind,
FieldKind::Money | FieldKind::Whole | FieldKind::Rate | FieldKind::Share
)
})
}
}
pub(super) fn field_of<'a>(fields: &'a [FieldSpec], key: &str) -> Option<&'a FieldSpec> {
fields.iter().find(|spec| spec.key == key)
}
#[derive(Clone, PartialEq, Debug, Default)]
pub struct Cell {
pub text: String,
pub number: Option<f64>,
}
pub struct Shown<'a> {
key: &'static str,
spec: Option<&'a FieldSpec>,
offers: Vec<Offer>,
identity: Option<&'a str>,
phrase: Option<fn(&Table, &Plan) -> String>,
fields: &'a [FieldSpec],
}
impl<'a> Shown<'a> {
pub fn of(column: &Column, fields: &'a [FieldSpec], identity: &'a str, plan: &Plan) -> Self {
Self::at(column, field_of(fields, column.key), fields, identity, plan)
}
pub(super) fn of_field(spec: &'a FieldSpec, fields: &'a [FieldSpec], plan: &Plan) -> Self {
Self::at(&Column::new(spec.key), Some(spec), fields, "", plan)
}
fn at(
column: &Column,
spec: Option<&'a FieldSpec>,
fields: &'a [FieldSpec],
identity: &'a str,
plan: &Plan,
) -> Self {
let offers = match spec.map(|spec| spec.kind) {
Some(FieldKind::Choice(vocabulary) | FieldKind::Order(vocabulary, _)) => {
vocabulary.offers()
}
Some(FieldKind::Ref(source)) => ref_offers(plan, source),
_ => Vec::new(),
};
Self {
key: column.key,
spec,
offers,
identity: (column.key == identity).then_some(identity),
phrase: column.phrase,
fields,
}
}
pub fn cell(&self, item: &Table, plan: &Plan) -> Cell {
if let Some(identity) = self.identity {
let text = display_name(item, identity, self.fields).unwrap_or_default();
return Cell { text, number: None };
}
if let Some(phrase) = self.phrase {
let text = phrase(item, plan);
return Cell { text, number: None };
}
let value = get_path(item, self.key);
let number = value.and_then(|value| match value {
Value::Integer(whole) => Some(*whole as f64),
Value::Float(real) => Some(*real),
_ => None,
});
Cell {
text: self.text(value, plan),
number,
}
}
pub(super) fn text(&self, value: Option<&Value>, plan: &Plan) -> String {
let Some(spec) = self.spec else {
return value.map(to_text).unwrap_or_default();
};
let value = match spec.kind {
FieldKind::Listed(back) => nth_back(value, back),
FieldKind::Order(_, place) => nth(value, place),
_ => value,
};
let Some(value) = value else {
return spec.blank.unwrap_or_default().to_owned();
};
match spec.kind {
FieldKind::Flag => ticked(value.as_bool() == Some(true)),
FieldKind::Presence(_) => ticked(value.is_table()),
FieldKind::Trigger => present::trigger(value, plan),
FieldKind::Choice(_) | FieldKind::Ref(_) | FieldKind::Order(..) => self.offered(value),
kind => field_text(kind, Some(value), false),
}
}
fn offered(&self, value: &Value) -> String {
let held = value.as_str();
let offer = self
.offers
.iter()
.find(|offer| Some(offer.value.as_str()) == held);
offer.map_or_else(|| to_text(value), |offer| offer.label.clone())
}
}
const TICKED: &str = "✓";
fn ticked(is_ticked: bool) -> String {
if is_ticked {
TICKED.to_owned()
} else {
String::new()
}
}
pub fn field_text(kind: FieldKind, value: Option<&Value>, is_focused: bool) -> String {
let Some(value) = value else {
return String::new();
};
match (kind, value) {
(FieldKind::Money | FieldKind::Listed(_), Value::Integer(amount)) if !is_focused => {
present::money(*amount)
}
(FieldKind::Rate | FieldKind::Share, Value::Float(rate)) => present::rate(*rate),
(FieldKind::Rate | FieldKind::Share, Value::Integer(rate)) => present::rate(*rate as f64),
(FieldKind::Growth, value) => present::growth(Some(value)),
_ => to_text(value),
}
}
pub fn parse_field(kind: FieldKind, text: &str) -> Option<Value> {
let read = match kind {
FieldKind::Money | FieldKind::Listed(_) => present::parse_money(text).map(Value::Integer),
FieldKind::Rate | FieldKind::Share => present::parse_rate(text).map(Value::Float),
FieldKind::Growth => present::parse_growth(text),
_ => None,
};
read.or_else(|| parse_text(text))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_field_reads_back_what_it_shows() {
let cases = [
(FieldKind::Money, Value::Integer(450_000)),
(FieldKind::Money, Value::Integer(-5_000)),
(FieldKind::Rate, Value::Float(0.025)),
(FieldKind::Growth, Value::Boolean(false)),
(FieldKind::Growth, Value::Float(0.0125)),
(FieldKind::Whole, Value::Integer(2030)),
(FieldKind::Text, Value::String("two words".to_owned())),
];
for (kind, value) in cases {
for is_focused in [false, true] {
let shown = field_text(kind, Some(&value), is_focused);
assert_eq!(parse_field(kind, &shown), Some(value.clone()), "{shown}");
}
}
}
#[test]
fn money_is_dressed_until_it_is_typed_in() {
let amount = Value::Integer(450_000);
assert_eq!(
field_text(FieldKind::Money, Some(&amount), false),
"$450,000"
);
assert_eq!(field_text(FieldKind::Money, Some(&amount), true), "450000");
assert_eq!(
field_text(FieldKind::Whole, Some(&Value::Integer(2030)), false),
"2030"
);
}
#[test]
fn text_a_kind_cannot_read_is_left_to_the_schema() {
let typed = parse_field(FieldKind::Money, "lots");
assert_eq!(typed, Some(Value::String("lots".to_owned())));
assert_eq!(parse_field(FieldKind::Money, " "), None);
}
}