Skip to main content

ccalc_plot/
style.rs

1//! MATLAB-style plot style string parsing.
2//!
3//! A style string combines an optional color code, an optional marker code,
4//! and an optional line-style code in any order:
5//!
6//! | Code | Meaning |
7//! |------|---------|
8//! | `r` `g` `b` `c` `m` `y` `k` `w` | Color |
9//! | `.` `o` `x` `+` `*` `s` `d` `^` | Marker |
10//! | `-` `--` `-.` `:` | Line style |
11
12/// RGB color triple (red, green, blue).
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub struct StyleColor(pub u8, pub u8, pub u8);
15
16/// Marker symbol kind.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum MarkerKind {
19    /// Small dot (`.`).
20    Dot,
21    /// Circle (`o`).
22    Circle,
23    /// Cross / × (`x`).
24    Cross,
25    /// Plus / + (`+`).
26    Plus,
27    /// Asterisk (`*`).
28    Star,
29    /// Square (`s`).
30    Square,
31    /// Diamond (`d`).
32    Diamond,
33    /// Triangle / up-arrow (`^`).
34    Triangle,
35}
36
37/// Line drawing style.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum LinestyleKind {
40    /// Continuous line (`-`).
41    Solid,
42    /// Dashed line (`--`).
43    Dashed,
44    /// Dotted line (`:`).
45    Dotted,
46    /// Dash-dot alternating line (`-.`).
47    DashDot,
48}
49
50/// Coordinated colour preset for a figure.
51#[derive(Clone, Debug, PartialEq)]
52pub struct Theme {
53    /// Background fill colour.
54    pub bg: StyleColor,
55    /// Title and axis-label text colour.
56    pub text: StyleColor,
57    /// Axis line and tick colour.
58    pub axis: StyleColor,
59    /// Bold (major) grid line colour.
60    pub grid_bold: StyleColor,
61    /// Light (minor) grid line colour.
62    pub grid_light: StyleColor,
63}
64
65impl Theme {
66    /// Returns the built-in light theme (white background, black text).
67    pub fn light() -> Self {
68        Theme {
69            bg: StyleColor(255, 255, 255),
70            text: StyleColor(0, 0, 0),
71            axis: StyleColor(0, 0, 0),
72            grid_bold: StyleColor(180, 180, 180),
73            grid_light: StyleColor(220, 220, 220),
74        }
75    }
76
77    /// Returns the built-in dark theme (Catppuccin Mocha palette).
78    pub fn dark() -> Self {
79        Theme {
80            bg: StyleColor(0x1E, 0x1E, 0x2E),
81            text: StyleColor(0xCD, 0xD6, 0xF4),
82            axis: StyleColor(0x6C, 0x70, 0x86),
83            grid_bold: StyleColor(0x45, 0x47, 0x5A),
84            grid_light: StyleColor(0x31, 0x32, 0x44),
85        }
86    }
87
88    /// Looks up a theme by name (`"light"` or `"dark"`), case-insensitive.
89    ///
90    /// Returns `Err` for unrecognised names.
91    pub fn from_name(name: &str) -> Result<Self, String> {
92        match name.to_ascii_lowercase().as_str() {
93            "light" => Ok(Theme::light()),
94            "dark" => Ok(Theme::dark()),
95            other => Err(format!(
96                "theme: unknown theme '{other}' — expected 'light' or 'dark'"
97            )),
98        }
99    }
100}
101
102/// Axis display mode set via `axis(...)`.
103#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104pub enum AxisMode {
105    /// Equal scaling: same data-units per pixel on both axes.
106    Equal,
107    /// Tight: no margin added around the data range.
108    Tight,
109    /// Hidden: axis lines and tick labels are not drawn.
110    Off,
111}
112
113/// Combined plot style parsed from a MATLAB-style format string.
114#[derive(Clone, Debug, PartialEq)]
115pub struct StyleSpec {
116    /// Optional override color; `None` uses the series default.
117    pub color: Option<StyleColor>,
118    /// Optional marker symbol; `None` draws no marker.
119    pub marker: Option<MarkerKind>,
120    /// Line style; defaults to [`LinestyleKind::Solid`].
121    pub linestyle: LinestyleKind,
122    /// Stroke width in pixels; `None` falls back to the session or hardcoded default (1).
123    pub line_width: Option<f32>,
124    /// Marker radius in pixels; `None` falls back to the session or hardcoded default (3).
125    pub marker_size: Option<u32>,
126}
127
128impl Default for StyleSpec {
129    fn default() -> Self {
130        StyleSpec {
131            color: None,
132            marker: None,
133            linestyle: LinestyleKind::Solid,
134            line_width: None,
135            marker_size: None,
136        }
137    }
138}
139
140/// Tries to parse `token` as a color: single letter, full name, or `#RRGGBB` hex.
141///
142/// Returns `None` for unrecognised tokens.
143pub fn parse_color_token(token: &str) -> Option<StyleColor> {
144    match token.to_ascii_lowercase().as_str() {
145        "r" | "red" => Some(StyleColor(255, 0, 0)),
146        "g" | "green" => Some(StyleColor(0, 128, 0)),
147        "b" | "blue" => Some(StyleColor(0, 0, 255)),
148        "c" | "cyan" => Some(StyleColor(0, 255, 255)),
149        "m" | "magenta" => Some(StyleColor(255, 0, 255)),
150        "y" | "yellow" => Some(StyleColor(255, 255, 0)),
151        "k" | "black" => Some(StyleColor(0, 0, 0)),
152        "w" | "white" => Some(StyleColor(255, 255, 255)),
153        "orange" => Some(StyleColor(255, 165, 0)),
154        "purple" => Some(StyleColor(128, 0, 128)),
155        "gray" | "grey" => Some(StyleColor(128, 128, 128)),
156        s if s.starts_with('#') && s.len() == 7 => {
157            let r = u8::from_str_radix(&s[1..3], 16).ok()?;
158            let g = u8::from_str_radix(&s[3..5], 16).ok()?;
159            let b = u8::from_str_radix(&s[5..7], 16).ok()?;
160            Some(StyleColor(r, g, b))
161        }
162        _ => None,
163    }
164}
165
166/// Returns `true` when `s` looks like a MATLAB-style format string.
167///
168/// Accepts the classic single-char set (`r`, `g`, `--`, `.`, …), full color
169/// names (`red`, `orange`, …), and hex codes (`#RRGGBB`).
170pub fn looks_like_style_str(s: &str) -> bool {
171    if s.is_empty() {
172        return false;
173    }
174    if s.starts_with('#') {
175        return s.len() == 7;
176    }
177    if parse_color_token(s).is_some() {
178        return true;
179    }
180    s.chars().all(|c| "rgbcmykw.-:osx+*d^".contains(c))
181}
182
183/// Parses a MATLAB-style format string into a [`StyleSpec`].
184///
185/// Returns an error for unrecognised characters. An empty string returns the
186/// default spec (solid line, no color override, no marker).
187///
188/// # Examples
189///
190/// ```
191/// use ccalc_plot::style::{parse_style_str, LinestyleKind, MarkerKind, StyleColor};
192///
193/// let spec = parse_style_str("r--").unwrap();
194/// assert_eq!(spec.color, Some(StyleColor(255, 0, 0)));
195/// assert_eq!(spec.linestyle, LinestyleKind::Dashed);
196/// assert_eq!(spec.marker, None);
197///
198/// let spec2 = parse_style_str("b.").unwrap();
199/// assert_eq!(spec2.color, Some(StyleColor(0, 0, 255)));
200/// assert_eq!(spec2.marker, Some(MarkerKind::Dot));
201/// assert_eq!(spec2.linestyle, LinestyleKind::Solid);
202/// ```
203pub fn parse_style_str(s: &str) -> Result<StyleSpec, String> {
204    if s.is_empty() {
205        return Ok(StyleSpec::default());
206    }
207
208    // Full color name or '#RRGGBB' hex — whole string is just a color.
209    if let Some(sc) = parse_color_token(s) {
210        return Ok(StyleSpec {
211            color: Some(sc),
212            ..StyleSpec::default()
213        });
214    }
215
216    let mut spec = StyleSpec::default();
217    let bytes = s.as_bytes();
218    let mut i = 0;
219
220    while i < bytes.len() {
221        // Try 2-char linestyle patterns first (greedy).
222        if i + 1 < bytes.len() && bytes[i] == b'-' && bytes[i + 1] == b'-' {
223            spec.linestyle = LinestyleKind::Dashed;
224            i += 2;
225            continue;
226        }
227        if i + 1 < bytes.len() && bytes[i] == b'-' && bytes[i + 1] == b'.' {
228            spec.linestyle = LinestyleKind::DashDot;
229            i += 2;
230            continue;
231        }
232
233        match bytes[i] {
234            b'-' => spec.linestyle = LinestyleKind::Solid,
235            b':' => spec.linestyle = LinestyleKind::Dotted,
236            b'.' => spec.marker = Some(MarkerKind::Dot),
237            b'o' => spec.marker = Some(MarkerKind::Circle),
238            b'x' => spec.marker = Some(MarkerKind::Cross),
239            b'+' => spec.marker = Some(MarkerKind::Plus),
240            b'*' => spec.marker = Some(MarkerKind::Star),
241            b's' => spec.marker = Some(MarkerKind::Square),
242            b'd' => spec.marker = Some(MarkerKind::Diamond),
243            b'^' => spec.marker = Some(MarkerKind::Triangle),
244            b'r' => spec.color = Some(StyleColor(255, 0, 0)),
245            b'g' => spec.color = Some(StyleColor(0, 128, 0)),
246            b'b' => spec.color = Some(StyleColor(0, 0, 255)),
247            b'c' => spec.color = Some(StyleColor(0, 255, 255)),
248            b'm' => spec.color = Some(StyleColor(255, 0, 255)),
249            b'y' => spec.color = Some(StyleColor(255, 255, 0)),
250            b'k' => spec.color = Some(StyleColor(0, 0, 0)),
251            b'w' => spec.color = Some(StyleColor(255, 255, 255)),
252            other => {
253                return Err(format!(
254                    "plot: unknown style character '{}' in style string '{s}'",
255                    other as char
256                ));
257            }
258        }
259        i += 1;
260    }
261
262    Ok(spec)
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_parse_red_dashed() {
271        let spec = parse_style_str("r--").unwrap();
272        assert_eq!(spec.color, Some(StyleColor(255, 0, 0)));
273        assert_eq!(spec.linestyle, LinestyleKind::Dashed);
274        assert_eq!(spec.marker, None);
275    }
276
277    #[test]
278    fn test_parse_blue_dot() {
279        let spec = parse_style_str("b.").unwrap();
280        assert_eq!(spec.color, Some(StyleColor(0, 0, 255)));
281        assert_eq!(spec.marker, Some(MarkerKind::Dot));
282        assert_eq!(spec.linestyle, LinestyleKind::Solid);
283    }
284
285    #[test]
286    fn test_parse_green_solid() {
287        let spec = parse_style_str("g-").unwrap();
288        assert_eq!(spec.color, Some(StyleColor(0, 128, 0)));
289        assert_eq!(spec.linestyle, LinestyleKind::Solid);
290        assert_eq!(spec.marker, None);
291    }
292
293    #[test]
294    fn test_parse_dashdot() {
295        let spec = parse_style_str("-.").unwrap();
296        assert_eq!(spec.linestyle, LinestyleKind::DashDot);
297        assert_eq!(spec.marker, None);
298    }
299
300    #[test]
301    fn test_parse_dot_then_solid() {
302        // '.' is a marker; '-' that follows is a solid linestyle (not dashdot)
303        let spec = parse_style_str(".-").unwrap();
304        assert_eq!(spec.marker, Some(MarkerKind::Dot));
305        assert_eq!(spec.linestyle, LinestyleKind::Solid);
306    }
307
308    #[test]
309    fn test_parse_dotted_line() {
310        let spec = parse_style_str(":").unwrap();
311        assert_eq!(spec.linestyle, LinestyleKind::Dotted);
312    }
313
314    #[test]
315    fn test_parse_empty_returns_default() {
316        let spec = parse_style_str("").unwrap();
317        assert_eq!(spec, StyleSpec::default());
318    }
319
320    #[test]
321    fn test_parse_unknown_char_errors() {
322        let result = parse_style_str("xyz");
323        assert!(result.is_err());
324        let msg = result.unwrap_err();
325        assert!(msg.contains("unknown style character"));
326    }
327
328    #[test]
329    fn test_looks_like_style_str_valid() {
330        assert!(looks_like_style_str("r--"));
331        assert!(looks_like_style_str("b."));
332        assert!(looks_like_style_str("g-"));
333        assert!(looks_like_style_str("ko"));
334    }
335
336    #[test]
337    fn test_looks_like_style_str_invalid() {
338        assert!(!looks_like_style_str(""));
339        assert!(!looks_like_style_str("time"));
340        assert!(!looks_like_style_str("file.svg"));
341    }
342
343    #[test]
344    fn test_style_full_name_red() {
345        let spec = parse_style_str("red").unwrap();
346        assert_eq!(spec.color, Some(StyleColor(255, 0, 0)));
347        assert_eq!(spec.marker, None);
348        assert_eq!(spec.linestyle, LinestyleKind::Solid);
349    }
350
351    #[test]
352    fn test_style_full_name_orange() {
353        let spec = parse_style_str("orange").unwrap();
354        assert_eq!(spec.color, Some(StyleColor(255, 165, 0)));
355    }
356
357    #[test]
358    fn test_style_gray_grey_alias() {
359        let spec_gray = parse_style_str("gray").unwrap();
360        let spec_grey = parse_style_str("grey").unwrap();
361        assert_eq!(spec_gray.color, spec_grey.color);
362        assert_eq!(spec_gray.color, Some(StyleColor(128, 128, 128)));
363    }
364
365    #[test]
366    fn test_style_hex_color() {
367        let spec = parse_style_str("#1A2B3C").unwrap();
368        assert_eq!(spec.color, Some(StyleColor(0x1A, 0x2B, 0x3C)));
369    }
370
371    #[test]
372    fn test_style_hex_bad_format() {
373        // Too short (6 chars instead of 7) — not a valid hex, falls through to
374        // char-by-char where '#' is an unrecognised character.
375        let result = parse_style_str("#1A2B3");
376        assert!(result.is_err(), "short hex should error");
377    }
378}