runmat-runtime 0.4.8

Core runtime for RunMat with builtins, BLAS/LAPACK integration, and execution APIs
Documentation
use runmat_builtins::{Tensor, Value};
use runmat_macros::runtime_builtin;
use runmat_plot::plots::{ReferenceLine, ReferenceLineOrientation};

use super::plotting_error;
use super::state::{append_active_plot, register_reference_line_handle, PlotRenderOptions};
use super::style::{
    color_from_token, looks_like_option_name, parse_line_style_args, value_as_bool, value_as_f64,
    value_as_string, LineAppearance, LineStyleParseOptions,
};
use crate::builtins::plotting::type_resolvers::handle_scalar_type;
use crate::BuiltinResult;

const BUILTIN_NAME: &str = "xline";

#[runtime_builtin(
    name = "xline",
    category = "plotting",
    summary = "Draw vertical reference lines on the current axes.",
    keywords = "xline,reference,line,plotting",
    sink = true,
    suppress_auto_output = true,
    type_resolver(handle_scalar_type),
    builtin_path = "crate::builtins::plotting::xline"
)]
pub fn xline_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
    reference_line_builtin(BUILTIN_NAME, ReferenceLineOrientation::Vertical, args)
}

pub(crate) fn reference_line_builtin(
    builtin: &'static str,
    orientation: ReferenceLineOrientation,
    args: Vec<Value>,
) -> BuiltinResult<Value> {
    if args.is_empty() {
        return Err(reference_line_error(
            builtin,
            "expected a coordinate argument",
        ));
    }
    let coords = coordinates_from_value(&args[0], builtin)?;
    let options = parse_reference_line_options(builtin, &args[1..])?;
    let figure_handle = crate::builtins::plotting::current_figure_handle();
    let mut lines = coords
        .iter()
        .map(|&value| {
            let mut line = ReferenceLine::new(orientation, value)
                .map_err(|err| reference_line_error(builtin, err))?
                .with_style(
                    options.appearance.color,
                    options.appearance.line_width,
                    options.appearance.line_style,
                );
            line.label = options.label.clone();
            line.display_name = options.display_name.clone();
            line.label_orientation = options.label_orientation.clone();
            line.visible = options.visible;
            Ok(line)
        })
        .collect::<BuiltinResult<Vec<_>>>()?;

    let plot_indices = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
    let plot_indices_slot = std::rc::Rc::clone(&plot_indices);
    let opts = PlotRenderOptions {
        title: "",
        x_label: "",
        y_label: "",
        ..Default::default()
    };
    let axes_index_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
    let axes_index_out = std::rc::Rc::clone(&axes_index_slot);
    let render_result = append_active_plot(builtin, opts, move |figure, axes_index| {
        *axes_index_out.borrow_mut() = Some(axes_index);
        for line in lines.drain(..) {
            let plot_index = figure.add_reference_line_on_axes(line, axes_index);
            plot_indices_slot.borrow_mut().push(plot_index);
        }
        Ok(())
    });

    if let Err(err) = render_result {
        let lower = err.to_string().to_lowercase();
        if !(lower.contains("plotting is unavailable") || lower.contains("non-main thread")) {
            return Err(err);
        }
    }

    let axes_index = axes_index_slot.borrow().unwrap_or(0);
    let handles = plot_indices
        .borrow()
        .iter()
        .map(|plot_index| register_reference_line_handle(figure_handle, axes_index, *plot_index))
        .collect::<Vec<_>>();
    if handles.len() == 1 {
        Ok(Value::Num(handles[0]))
    } else {
        Ok(Value::Tensor(Tensor {
            data: handles.clone(),
            rows: 1,
            cols: handles.len(),
            shape: vec![1, handles.len()],
            dtype: runmat_builtins::NumericDType::F64,
        }))
    }
}

#[derive(Clone)]
struct ReferenceLineOptions {
    appearance: LineAppearance,
    label: Option<String>,
    display_name: Option<String>,
    label_orientation: String,
    visible: bool,
}

fn parse_reference_line_options(
    builtin: &'static str,
    args: &[Value],
) -> BuiltinResult<ReferenceLineOptions> {
    let mut style_args = Vec::new();
    let mut label = None;
    let mut label_orientation = "aligned".to_string();
    let mut visible = true;
    let mut color_explicit = false;
    let mut width_explicit = false;

    let mut idx = 0usize;
    while idx < args.len() {
        let Some(text) = value_as_string(&args[idx]) else {
            return Err(reference_line_error(
                builtin,
                "style and option names must be strings",
            ));
        };
        let lower = text.trim().to_ascii_lowercase();
        if lower == "labelorientation" || lower == "visible" {
            if idx + 1 >= args.len() {
                return Err(reference_line_error(
                    builtin,
                    "name-value arguments must come in pairs",
                ));
            }
            match lower.as_str() {
                "labelorientation" => {
                    let Some(value) = value_as_string(&args[idx + 1]) else {
                        return Err(reference_line_error(
                            builtin,
                            "LabelOrientation must be a string",
                        ));
                    };
                    let normalized = value.trim().to_ascii_lowercase();
                    match normalized.as_str() {
                        "aligned" | "horizontal" => label_orientation = normalized,
                        _ => {
                            return Err(reference_line_error(
                                builtin,
                                "LabelOrientation must be 'aligned' or 'horizontal'",
                            ));
                        }
                    }
                }
                "visible" => {
                    visible = value_as_bool(&args[idx + 1])
                        .or_else(|| {
                            value_as_string(&args[idx + 1]).map(|s| {
                                !matches!(s.trim().to_ascii_lowercase().as_str(), "off" | "false")
                            })
                        })
                        .ok_or_else(|| reference_line_error(builtin, "Visible must be boolean"))?;
                }
                _ => unreachable!(),
            }
            idx += 2;
            continue;
        }

        if looks_like_option_name(&lower) {
            if idx + 1 >= args.len() {
                return Err(reference_line_error(
                    builtin,
                    "name-value arguments must come in pairs",
                ));
            }
            if lower == "color" {
                color_explicit = true;
            }
            if lower == "linewidth" {
                width_explicit = true;
            }
            style_args.push(args[idx].clone());
            style_args.push(args[idx + 1].clone());
            idx += 2;
            continue;
        }

        if parse_line_style_args(
            &[args[idx].clone()],
            &LineStyleParseOptions::generic(builtin),
        )
        .is_ok()
        {
            color_explicit |= text.chars().any(|ch| color_from_token(ch).is_some());
            style_args.push(args[idx].clone());
        } else if label.is_none() {
            label = Some(text);
        } else {
            return Err(reference_line_error(
                builtin,
                "unexpected extra label or style argument",
            ));
        }
        idx += 1;
    }

    let mut parsed = parse_line_style_args(&style_args, &LineStyleParseOptions::generic(builtin))?;
    if !color_explicit {
        parsed.appearance.color = glam::Vec4::new(0.0, 0.0, 0.0, 1.0);
    }
    if !width_explicit {
        parsed.appearance.line_width = 1.0;
    }
    if parsed.label.is_some() {
        label = parsed.label.clone();
    }

    Ok(ReferenceLineOptions {
        appearance: parsed.appearance,
        label,
        display_name: parsed.label,
        label_orientation,
        visible,
    })
}

fn coordinates_from_value(value: &Value, builtin: &'static str) -> BuiltinResult<Vec<f64>> {
    match value {
        Value::Num(_) | Value::Int(_) | Value::Bool(_) => {
            let value = value_as_f64(value).expect("scalar numeric value");
            if value.is_finite() {
                return Ok(vec![value]);
            }
            return Err(reference_line_error(
                builtin,
                "coordinate values must be finite",
            ));
        }
        _ => {}
    }
    let tensor =
        Tensor::try_from(value).map_err(|err| reference_line_error(builtin, err.to_string()))?;
    if tensor.data.is_empty() {
        return Err(reference_line_error(
            builtin,
            "coordinate vector cannot be empty",
        ));
    }
    if tensor.data.iter().any(|value| !value.is_finite()) {
        return Err(reference_line_error(
            builtin,
            "coordinate values must be finite",
        ));
    }
    Ok(tensor.data)
}

fn reference_line_error(builtin: &'static str, msg: impl Into<String>) -> crate::RuntimeError {
    plotting_error(builtin, format!("{builtin}: {}", msg.into()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::builtins::plotting::get::get_builtin;
    use crate::builtins::plotting::plot::plot_builtin;
    use crate::builtins::plotting::state::PlotTestLockGuard;
    use crate::builtins::plotting::tests::{ensure_plot_test_env, lock_plot_registry};
    use crate::builtins::plotting::xlabel::xlabel_builtin;
    use crate::builtins::plotting::{clear_figure, clone_figure, current_figure_handle};

    fn setup() -> PlotTestLockGuard {
        let guard = lock_plot_registry();
        ensure_plot_test_env();
        super::super::state::reset_hold_state_for_run();
        let _ = clear_figure(None);
        guard
    }

    fn tensor(data: &[f64]) -> Tensor {
        Tensor::new_2d(data.to_vec(), 1, data.len()).unwrap()
    }

    #[test]
    fn xline_adds_reference_line_without_clearing_existing_plot() {
        let _guard = setup();
        futures::executor::block_on(plot_builtin(vec![
            Value::Tensor(tensor(&[0.0, 1.0])),
            Value::Tensor(tensor(&[2.0, 3.0])),
        ]))
        .unwrap();
        let handle = xline_builtin(vec![Value::Num(0.5), Value::String("--r".into())]).unwrap();
        assert!(matches!(handle, Value::Num(_)));
        let figure = clone_figure(current_figure_handle()).unwrap();
        assert_eq!(figure.len(), 2);
    }

    #[test]
    fn xline_supports_label_and_name_value_style() {
        let _guard = setup();
        let handle = xline_builtin(vec![
            Value::Num(5.0),
            Value::String("--".into()),
            Value::String("Threshold".into()),
            Value::String("LineWidth".into()),
            Value::Num(3.0),
            Value::String("LabelOrientation".into()),
            Value::String("horizontal".into()),
        ])
        .unwrap();
        let Value::Num(handle) = handle else {
            panic!("expected scalar handle");
        };
        assert_eq!(
            get_builtin(vec![Value::Num(handle), Value::String("Label".into())]).unwrap(),
            Value::String("Threshold".into())
        );
        assert_eq!(
            get_builtin(vec![Value::Num(handle), Value::String("LineWidth".into())]).unwrap(),
            Value::Num(3.0)
        );
    }

    #[test]
    fn xline_vector_returns_handle_vector() {
        let _guard = setup();
        let handles = xline_builtin(vec![Value::Tensor(tensor(&[1.0, 2.0, 3.0]))]).unwrap();
        let tensor = Tensor::try_from(&handles).unwrap();
        assert_eq!(tensor.data.len(), 3);
    }

    #[test]
    fn xline_preserves_existing_axis_labels() {
        let _guard = setup();
        xlabel_builtin(vec![Value::String("Time".into())]).unwrap();
        xline_builtin(vec![Value::Num(5.0)]).unwrap();

        let figure = clone_figure(current_figure_handle()).unwrap();
        let meta = figure.axes_metadata(0).unwrap();
        assert_eq!(meta.x_label.as_deref(), Some("Time"));
    }
}