Skip to main content

ccalc_plot/
dispatch.rs

1//! Argument parsing helpers shared by all plot backends.
2
3use ccalc_engine::env::Value;
4
5use crate::style::{
6    StyleColor, StyleSpec, looks_like_style_str, parse_color_token, parse_style_str,
7};
8
9/// Splits off a trailing file-path argument from `args`.
10///
11/// Returns `(data_args, Some(path))` if the last argument is a string
12/// whose value ends with `.svg`, `.png`, or equals `"ascii"` exactly.
13/// Otherwise returns `(args.to_vec(), None)`.
14pub fn extract_file_arg(args: &[Value]) -> (Vec<Value>, Option<String>) {
15    if let Some(last) = args.last()
16        && let Some(s) = as_str(last)
17        && (s == "ascii" || s.ends_with(".svg") || s.ends_with(".png"))
18    {
19        return (args[..args.len() - 1].to_vec(), Some(s));
20    }
21    (args.to_vec(), None)
22}
23
24/// Extracts a flat `Vec<f64>` from any numeric [`Value`].
25///
26/// Accepts `Scalar` (promoted to a one-element vector) and any `Matrix`
27/// regardless of shape (row-major order).  Unlike [`extract_vector`] this
28/// does **not** require a vector layout, so it is suitable for meshgrid-style
29/// 2-D inputs.
30pub fn extract_flat(v: &Value) -> Result<Vec<f64>, String> {
31    match v {
32        Value::Scalar(f) => Ok(vec![*f]),
33        Value::Matrix(m) => Ok(m.iter().copied().collect()),
34        _ => Err("plot: numeric array argument required".into()),
35    }
36}
37
38/// Extracts a flat `Vec<f64>` from a scalar or vector `Value`.
39///
40/// A `Scalar` is promoted to a one-element vector. A `Matrix` is accepted
41/// only when it is a row or column vector (one dimension equals 1).
42pub fn extract_vector(v: &Value) -> Result<Vec<f64>, String> {
43    match v {
44        Value::Scalar(f) => Ok(vec![*f]),
45        Value::Matrix(m) => {
46            let (r, c) = (m.nrows(), m.ncols());
47            if r == 1 || c == 1 {
48                Ok(m.iter().copied().collect())
49            } else {
50                Err(format!("plot: expected a vector, got {r}×{c} matrix"))
51            }
52        }
53        _ => Err("plot: numeric vector argument required".into()),
54    }
55}
56
57/// Extracts a 2D matrix from a `Value`, returning flat row-major data and
58/// dimensions `(data, nrows, ncols)`.
59///
60/// A `Scalar` is promoted to a 1×1 matrix.  Any other type returns an error.
61pub fn extract_matrix(v: &Value) -> Result<(Vec<f64>, usize, usize), String> {
62    match v {
63        Value::Matrix(m) => {
64            let nrows = m.nrows();
65            let ncols = m.ncols();
66            let mut data = Vec::with_capacity(nrows * ncols);
67            for r in 0..nrows {
68                for c in 0..ncols {
69                    data.push(m[[r, c]]);
70                }
71            }
72            Ok((data, nrows, ncols))
73        }
74        Value::Scalar(f) => Ok((vec![*f], 1, 1)),
75        _ => Err("imagesc: expected a numeric matrix".into()),
76    }
77}
78
79/// Splits off an optional trailing style and/or file-path argument from `args`.
80#[allow(clippy::type_complexity)]
81///
82/// Processing order (first match wins):
83/// 1. Trailing `'color', <value>` named-argument pair.
84/// 2. Trailing 1×3 RGB matrix (values in `[0, 1]`) — only when the number of
85///    remaining data args would exceed `min_data` after stripping.
86/// 3. Trailing MATLAB-style format string (`"r--"`, `"red"`, `"#FF4400"`, …).
87/// 4. Trailing file path (`.svg`, `.png`, or `"ascii"`).
88///
89/// `min_data` sets the minimum number of data arguments that must remain after
90/// removing the style element.  Pass `1` for most callers; pass a higher value
91/// (e.g. `4` for `quiver`) to prevent ambiguous vector data from being consumed
92/// as an RGB colour spec.
93///
94/// Returns `(data_args, style, path)`.
95pub fn extract_style_and_file_arg(
96    args: &[Value],
97) -> Result<(Vec<Value>, Option<StyleSpec>, Option<String>), String> {
98    extract_style_and_file_arg_min(args, 1)
99}
100
101/// Like [`extract_style_and_file_arg`] but with a caller-supplied `min_data` guard.
102#[allow(clippy::type_complexity)]
103pub fn extract_style_and_file_arg_min(
104    args: &[Value],
105    min_data: usize,
106) -> Result<(Vec<Value>, Option<StyleSpec>, Option<String>), String> {
107    let (mut data_args, path) = extract_file_arg(args);
108
109    // ── Additive named-arg pairs: 'linewidth' / 'markersize' ─────────────
110    // Strip any number of these from the trailing end before the
111    // mutually-exclusive color/format-string extraction below.
112    let mut extra_lw: Option<f32> = None;
113    let mut extra_ms: Option<u32> = None;
114    loop {
115        let len = data_args.len();
116        if len < 2 {
117            break;
118        }
119        if let Some(key) = as_str(&data_args[len - 2]) {
120            if key.eq_ignore_ascii_case("linewidth") {
121                extra_lw = Some(match &data_args[len - 1] {
122                    Value::Scalar(f) if *f > 0.0 => *f as f32,
123                    _ => return Err("linewidth: value must be a positive number".into()),
124                });
125                data_args.truncate(len - 2);
126                continue;
127            }
128            if key.eq_ignore_ascii_case("markersize") {
129                extra_ms = Some(match &data_args[len - 1] {
130                    Value::Scalar(f) if *f >= 1.0 => *f as u32,
131                    _ => return Err("markersize: value must be a positive integer".into()),
132                });
133                data_args.truncate(len - 2);
134                continue;
135            }
136        }
137        break;
138    }
139
140    // ── 'color', <value> named-argument pair ─────────────────────────────
141    let len = data_args.len();
142    if len >= 2
143        && let Some(key) = as_str(&data_args[len - 2])
144        && key.eq_ignore_ascii_case("color")
145    {
146        let sc = value_to_style_color(&data_args[len - 1])?;
147        data_args.truncate(len - 2);
148        return Ok((
149            data_args,
150            Some(StyleSpec {
151                color: Some(sc),
152                line_width: extra_lw,
153                marker_size: extra_ms,
154                ..StyleSpec::default()
155            }),
156            path,
157        ));
158    }
159
160    // ── 1×3 RGB matrix (values must all be in [0, 1]) ────────────────────
161    // Require at least `min_data + 1` args so at least `min_data` data args
162    // remain after stripping the colour matrix.
163    let rgb_style = if data_args.len() > min_data {
164        if let Some(Value::Matrix(m)) = data_args.last() {
165            if m.nrows() == 1 && m.ncols() == 3 && m.iter().all(|&v| (0.0..=1.0).contains(&v)) {
166                let clamp = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
167                Some(StyleColor(
168                    clamp(m[[0, 0]]),
169                    clamp(m[[0, 1]]),
170                    clamp(m[[0, 2]]),
171                ))
172            } else {
173                None
174            }
175        } else {
176            None
177        }
178    } else {
179        None
180    };
181    if let Some(sc) = rgb_style {
182        data_args.pop();
183        return Ok((
184            data_args,
185            Some(StyleSpec {
186                color: Some(sc),
187                line_width: extra_lw,
188                marker_size: extra_ms,
189                ..StyleSpec::default()
190            }),
191            path,
192        ));
193    }
194
195    // ── MATLAB-style format string ────────────────────────────────────────
196    let mut style: Option<StyleSpec> = None;
197    if let Some(last) = data_args.last()
198        && let Some(s) = as_str(last)
199        && looks_like_style_str(&s)
200    {
201        let mut sp = parse_style_str(&s)?;
202        sp.line_width = extra_lw;
203        sp.marker_size = extra_ms;
204        style = Some(sp);
205        data_args.pop();
206    } else if extra_lw.is_some() || extra_ms.is_some() {
207        style = Some(StyleSpec {
208            line_width: extra_lw,
209            marker_size: extra_ms,
210            ..StyleSpec::default()
211        });
212    }
213
214    Ok((data_args, style, path))
215}
216
217/// Converts a [`Value`] to a [`StyleColor`] for the `'color'` named argument.
218fn value_to_style_color(v: &Value) -> Result<StyleColor, String> {
219    match v {
220        Value::Str(s) | Value::StringObj(s) => parse_color_token(s)
221            .ok_or_else(|| format!("plot: '{s}' is not a recognised color name or hex code")),
222        Value::Matrix(m) if m.nrows() == 1 && m.ncols() == 3 => {
223            let clamp = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
224            Ok(StyleColor(
225                clamp(m[[0, 0]]),
226                clamp(m[[0, 1]]),
227                clamp(m[[0, 2]]),
228            ))
229        }
230        _ => Err("plot: 'color' value must be a color name string or 1×3 matrix".into()),
231    }
232}
233
234fn as_str(v: &Value) -> Option<String> {
235    match v {
236        Value::Str(s) | Value::StringObj(s) => Some(s.clone()),
237        _ => None,
238    }
239}