Skip to main content

gridline_engine/
plot.rs

1//! Plot spec encoding for chart cells.
2//!
3//! The engine returns a tagged string for plot formulas (e.g. `=BARCHART(A1:A10)`).
4//! The TUI detects and renders these in a modal.
5
6pub const PLOT_PREFIX: &str = "@PLOT:";
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum PlotKind {
10    Bar,
11    Line,
12    Scatter,
13}
14
15impl PlotKind {
16    pub fn as_tag(self) -> &'static str {
17        match self {
18            PlotKind::Bar => "BAR",
19            PlotKind::Line => "LINE",
20            PlotKind::Scatter => "SCATTER",
21        }
22    }
23
24    pub fn from_tag(tag: &str) -> Option<Self> {
25        match tag {
26            "BAR" => Some(PlotKind::Bar),
27            "LINE" => Some(PlotKind::Line),
28            "SCATTER" => Some(PlotKind::Scatter),
29            _ => None,
30        }
31    }
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct PlotSpec {
36    pub kind: PlotKind,
37    pub r1: usize,
38    pub c1: usize,
39    pub r2: usize,
40    pub c2: usize,
41
42    pub title: Option<String>,
43    pub x_label: Option<String>,
44    pub y_label: Option<String>,
45}
46
47fn percent_encode(s: &str) -> String {
48    let mut out = String::new();
49    for b in s.as_bytes() {
50        match *b {
51            b'%' | b'|' | b':' | b'\n' | b'\r' => {
52                out.push('%');
53                out.push_str(&format!("{:02X}", b));
54            }
55            _ => out.push(*b as char),
56        }
57    }
58    out
59}
60
61fn from_hex_digit(b: u8) -> Option<u8> {
62    match b {
63        b'0'..=b'9' => Some(b - b'0'),
64        b'a'..=b'f' => Some(b - b'a' + 10),
65        b'A'..=b'F' => Some(b - b'A' + 10),
66        _ => None,
67    }
68}
69
70fn percent_decode(s: &str) -> Option<String> {
71    let bytes = s.as_bytes();
72    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
73    let mut i = 0;
74    while i < bytes.len() {
75        if bytes[i] == b'%' {
76            if i + 2 >= bytes.len() {
77                return None;
78            }
79            let hi = from_hex_digit(bytes[i + 1])?;
80            let lo = from_hex_digit(bytes[i + 2])?;
81            out.push((hi << 4) | lo);
82            i += 3;
83        } else {
84            out.push(bytes[i]);
85            i += 1;
86        }
87    }
88    String::from_utf8(out).ok()
89}
90
91pub fn format_plot_spec(spec: &PlotSpec) -> String {
92    let base = format!(
93        "{}{}:{},{},{},{}",
94        PLOT_PREFIX,
95        spec.kind.as_tag(),
96        spec.r1,
97        spec.c1,
98        spec.r2,
99        spec.c2
100    );
101
102    let title = spec.title.as_deref().unwrap_or("");
103    let x = spec.x_label.as_deref().unwrap_or("");
104    let y = spec.y_label.as_deref().unwrap_or("");
105    if title.is_empty() && x.is_empty() && y.is_empty() {
106        return base;
107    }
108
109    format!(
110        "{}|{}|{}|{}",
111        base,
112        percent_encode(title),
113        percent_encode(x),
114        percent_encode(y)
115    )
116}
117
118pub fn parse_plot_spec(s: &str) -> Option<PlotSpec> {
119    let s = s.trim();
120    let rest = s.strip_prefix(PLOT_PREFIX)?;
121    let (kind_tag, rest) = rest.split_once(':')?;
122    let kind = PlotKind::from_tag(kind_tag)?;
123
124    let (coords, meta) = rest
125        .split_once('|')
126        .map_or((rest, None), |(a, b)| (a, Some(b)));
127
128    let mut it = coords.split(',');
129    let r1 = it.next()?.parse::<usize>().ok()?;
130    let c1 = it.next()?.parse::<usize>().ok()?;
131    let r2 = it.next()?.parse::<usize>().ok()?;
132    let c2 = it.next()?.parse::<usize>().ok()?;
133    if it.next().is_some() {
134        return None;
135    }
136
137    let mut title: Option<String> = None;
138    let mut x_label: Option<String> = None;
139    let mut y_label: Option<String> = None;
140    if let Some(meta) = meta {
141        let parts: Vec<&str> = meta.split('|').collect();
142        if let Some(p) = parts.first()
143            && !p.is_empty()
144        {
145            title = percent_decode(p);
146        }
147        if let Some(p) = parts.get(1)
148            && !p.is_empty()
149        {
150            x_label = percent_decode(p);
151        }
152        if let Some(p) = parts.get(2)
153            && !p.is_empty()
154        {
155            y_label = percent_decode(p);
156        }
157    }
158
159    Some(PlotSpec {
160        kind,
161        r1,
162        c1,
163        r2,
164        c2,
165        title,
166        x_label,
167        y_label,
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_plot_spec_round_trip() {
177        let spec = PlotSpec {
178            kind: PlotKind::Bar,
179            r1: 0,
180            c1: 1,
181            r2: 9,
182            c2: 1,
183            title: Some("My Plot".to_string()),
184            x_label: Some("X".to_string()),
185            y_label: Some("Y".to_string()),
186        };
187        let s = format_plot_spec(&spec);
188        assert_eq!(parse_plot_spec(&s), Some(spec));
189    }
190}