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/// Active Y axis for new series in a dual-axis (`yyaxis`) figure.
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
115pub enum YAxis {
116    /// Primary (left) Y axis — the default.
117    #[default]
118    Left,
119    /// Secondary (right) Y axis.
120    Right,
121}
122
123/// Combined plot style parsed from a MATLAB-style format string.
124#[derive(Clone, Debug, PartialEq)]
125pub struct StyleSpec {
126    /// Optional override color; `None` uses the series default.
127    pub color: Option<StyleColor>,
128    /// Optional marker symbol; `None` draws no marker.
129    pub marker: Option<MarkerKind>,
130    /// Line style; defaults to [`LinestyleKind::Solid`].
131    pub linestyle: LinestyleKind,
132    /// Stroke width in pixels; `None` falls back to the session or hardcoded default (1).
133    pub line_width: Option<f32>,
134    /// Marker radius in pixels; `None` falls back to the session or hardcoded default (3).
135    pub marker_size: Option<u32>,
136}
137
138impl Default for StyleSpec {
139    fn default() -> Self {
140        StyleSpec {
141            color: None,
142            marker: None,
143            linestyle: LinestyleKind::Solid,
144            line_width: None,
145            marker_size: None,
146        }
147    }
148}
149
150/// Tries to parse `token` as a color: single letter, full name, or `#RRGGBB` hex.
151///
152/// Returns `None` for unrecognised tokens.
153pub fn parse_color_token(token: &str) -> Option<StyleColor> {
154    match token.to_ascii_lowercase().as_str() {
155        "r" | "red" => Some(StyleColor(255, 0, 0)),
156        "g" | "green" => Some(StyleColor(0, 128, 0)),
157        "b" | "blue" => Some(StyleColor(0, 0, 255)),
158        "c" | "cyan" => Some(StyleColor(0, 255, 255)),
159        "m" | "magenta" => Some(StyleColor(255, 0, 255)),
160        "y" | "yellow" => Some(StyleColor(255, 255, 0)),
161        "k" | "black" => Some(StyleColor(0, 0, 0)),
162        "w" | "white" => Some(StyleColor(255, 255, 255)),
163        "orange" => Some(StyleColor(255, 165, 0)),
164        "purple" => Some(StyleColor(128, 0, 128)),
165        "gray" | "grey" => Some(StyleColor(128, 128, 128)),
166        s if s.starts_with('#') && s.len() == 7 => {
167            let r = u8::from_str_radix(&s[1..3], 16).ok()?;
168            let g = u8::from_str_radix(&s[3..5], 16).ok()?;
169            let b = u8::from_str_radix(&s[5..7], 16).ok()?;
170            Some(StyleColor(r, g, b))
171        }
172        _ => None,
173    }
174}
175
176/// Returns `true` when `s` looks like a MATLAB-style format string.
177///
178/// Accepts the classic single-char set (`r`, `g`, `--`, `.`, …), full color
179/// names (`red`, `orange`, …), and hex codes (`#RRGGBB`).
180pub fn looks_like_style_str(s: &str) -> bool {
181    if s.is_empty() {
182        return false;
183    }
184    if s.starts_with('#') {
185        return s.len() == 7;
186    }
187    if parse_color_token(s).is_some() {
188        return true;
189    }
190    s.chars().all(|c| "rgbcmykw.-:osx+*d^".contains(c))
191}
192
193/// Parses a MATLAB-style format string into a [`StyleSpec`].
194///
195/// Returns an error for unrecognised characters. An empty string returns the
196/// default spec (solid line, no color override, no marker).
197///
198/// # Examples
199///
200/// ```
201/// use ccalc_plot::style::{parse_style_str, LinestyleKind, MarkerKind, StyleColor};
202///
203/// let spec = parse_style_str("r--").unwrap();
204/// assert_eq!(spec.color, Some(StyleColor(255, 0, 0)));
205/// assert_eq!(spec.linestyle, LinestyleKind::Dashed);
206/// assert_eq!(spec.marker, None);
207///
208/// let spec2 = parse_style_str("b.").unwrap();
209/// assert_eq!(spec2.color, Some(StyleColor(0, 0, 255)));
210/// assert_eq!(spec2.marker, Some(MarkerKind::Dot));
211/// assert_eq!(spec2.linestyle, LinestyleKind::Solid);
212/// ```
213pub fn parse_style_str(s: &str) -> Result<StyleSpec, String> {
214    if s.is_empty() {
215        return Ok(StyleSpec::default());
216    }
217
218    // Full color name or '#RRGGBB' hex — whole string is just a color.
219    if let Some(sc) = parse_color_token(s) {
220        return Ok(StyleSpec {
221            color: Some(sc),
222            ..StyleSpec::default()
223        });
224    }
225
226    let mut spec = StyleSpec::default();
227    let bytes = s.as_bytes();
228    let mut i = 0;
229
230    while i < bytes.len() {
231        // Try 2-char linestyle patterns first (greedy).
232        if i + 1 < bytes.len() && bytes[i] == b'-' && bytes[i + 1] == b'-' {
233            spec.linestyle = LinestyleKind::Dashed;
234            i += 2;
235            continue;
236        }
237        if i + 1 < bytes.len() && bytes[i] == b'-' && bytes[i + 1] == b'.' {
238            spec.linestyle = LinestyleKind::DashDot;
239            i += 2;
240            continue;
241        }
242
243        match bytes[i] {
244            b'-' => spec.linestyle = LinestyleKind::Solid,
245            b':' => spec.linestyle = LinestyleKind::Dotted,
246            b'.' => spec.marker = Some(MarkerKind::Dot),
247            b'o' => spec.marker = Some(MarkerKind::Circle),
248            b'x' => spec.marker = Some(MarkerKind::Cross),
249            b'+' => spec.marker = Some(MarkerKind::Plus),
250            b'*' => spec.marker = Some(MarkerKind::Star),
251            b's' => spec.marker = Some(MarkerKind::Square),
252            b'd' => spec.marker = Some(MarkerKind::Diamond),
253            b'^' => spec.marker = Some(MarkerKind::Triangle),
254            b'r' => spec.color = Some(StyleColor(255, 0, 0)),
255            b'g' => spec.color = Some(StyleColor(0, 128, 0)),
256            b'b' => spec.color = Some(StyleColor(0, 0, 255)),
257            b'c' => spec.color = Some(StyleColor(0, 255, 255)),
258            b'm' => spec.color = Some(StyleColor(255, 0, 255)),
259            b'y' => spec.color = Some(StyleColor(255, 255, 0)),
260            b'k' => spec.color = Some(StyleColor(0, 0, 0)),
261            b'w' => spec.color = Some(StyleColor(255, 255, 255)),
262            other => {
263                return Err(format!(
264                    "plot: unknown style character '{}' in style string '{s}'",
265                    other as char
266                ));
267            }
268        }
269        i += 1;
270    }
271
272    Ok(spec)
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_parse_red_dashed() {
281        let spec = parse_style_str("r--").unwrap();
282        assert_eq!(spec.color, Some(StyleColor(255, 0, 0)));
283        assert_eq!(spec.linestyle, LinestyleKind::Dashed);
284        assert_eq!(spec.marker, None);
285    }
286
287    #[test]
288    fn test_parse_blue_dot() {
289        let spec = parse_style_str("b.").unwrap();
290        assert_eq!(spec.color, Some(StyleColor(0, 0, 255)));
291        assert_eq!(spec.marker, Some(MarkerKind::Dot));
292        assert_eq!(spec.linestyle, LinestyleKind::Solid);
293    }
294
295    #[test]
296    fn test_parse_green_solid() {
297        let spec = parse_style_str("g-").unwrap();
298        assert_eq!(spec.color, Some(StyleColor(0, 128, 0)));
299        assert_eq!(spec.linestyle, LinestyleKind::Solid);
300        assert_eq!(spec.marker, None);
301    }
302
303    #[test]
304    fn test_parse_dashdot() {
305        let spec = parse_style_str("-.").unwrap();
306        assert_eq!(spec.linestyle, LinestyleKind::DashDot);
307        assert_eq!(spec.marker, None);
308    }
309
310    #[test]
311    fn test_parse_dot_then_solid() {
312        // '.' is a marker; '-' that follows is a solid linestyle (not dashdot)
313        let spec = parse_style_str(".-").unwrap();
314        assert_eq!(spec.marker, Some(MarkerKind::Dot));
315        assert_eq!(spec.linestyle, LinestyleKind::Solid);
316    }
317
318    #[test]
319    fn test_parse_dotted_line() {
320        let spec = parse_style_str(":").unwrap();
321        assert_eq!(spec.linestyle, LinestyleKind::Dotted);
322    }
323
324    #[test]
325    fn test_parse_empty_returns_default() {
326        let spec = parse_style_str("").unwrap();
327        assert_eq!(spec, StyleSpec::default());
328    }
329
330    #[test]
331    fn test_parse_unknown_char_errors() {
332        let result = parse_style_str("xyz");
333        assert!(result.is_err());
334        let msg = result.unwrap_err();
335        assert!(msg.contains("unknown style character"));
336    }
337
338    #[test]
339    fn test_looks_like_style_str_valid() {
340        assert!(looks_like_style_str("r--"));
341        assert!(looks_like_style_str("b."));
342        assert!(looks_like_style_str("g-"));
343        assert!(looks_like_style_str("ko"));
344    }
345
346    #[test]
347    fn test_looks_like_style_str_invalid() {
348        assert!(!looks_like_style_str(""));
349        assert!(!looks_like_style_str("time"));
350        assert!(!looks_like_style_str("file.svg"));
351    }
352
353    #[test]
354    fn test_style_full_name_red() {
355        let spec = parse_style_str("red").unwrap();
356        assert_eq!(spec.color, Some(StyleColor(255, 0, 0)));
357        assert_eq!(spec.marker, None);
358        assert_eq!(spec.linestyle, LinestyleKind::Solid);
359    }
360
361    #[test]
362    fn test_style_full_name_orange() {
363        let spec = parse_style_str("orange").unwrap();
364        assert_eq!(spec.color, Some(StyleColor(255, 165, 0)));
365    }
366
367    #[test]
368    fn test_style_gray_grey_alias() {
369        let spec_gray = parse_style_str("gray").unwrap();
370        let spec_grey = parse_style_str("grey").unwrap();
371        assert_eq!(spec_gray.color, spec_grey.color);
372        assert_eq!(spec_gray.color, Some(StyleColor(128, 128, 128)));
373    }
374
375    #[test]
376    fn test_style_hex_color() {
377        let spec = parse_style_str("#1A2B3C").unwrap();
378        assert_eq!(spec.color, Some(StyleColor(0x1A, 0x2B, 0x3C)));
379    }
380
381    #[test]
382    fn test_style_hex_bad_format() {
383        // Too short (6 chars instead of 7) — not a valid hex, falls through to
384        // char-by-char where '#' is an unrecognised character.
385        let result = parse_style_str("#1A2B3");
386        assert!(result.is_err(), "short hex should error");
387    }
388}