Skip to main content

r/interpreter/builtins/
graphics.rs

1//! Graphics builtins — high-level R plotting functions that accumulate
2//! `PlotItem`s in the interpreter's `PlotState`, then display via
3//! egui_plot (when the `plot` feature is enabled) or print a helpful
4//! message (when it is not).
5
6pub mod color;
7
8use super::CallArgs;
9use crate::interpreter::graphics::plot_data::{BoxSpread, PlotItem, PlotState};
10use crate::interpreter::value::*;
11use crate::interpreter::BuiltinContext;
12use minir_macros::{builtin, interpreter_builtin};
13
14// region: Color helpers
15
16/// Parse an R color specification to RGBA.
17///
18/// Supports: color names ("red", "blue"), hex strings ("#RRGGBB", "#RRGGBBAA"),
19/// and integer indices into the default palette.
20fn parse_color(value: &RValue) -> [u8; 4] {
21    match value {
22        RValue::Vector(rv) => {
23            if let Some(s) = rv.inner.as_character_scalar() {
24                parse_color_string(&s)
25            } else if let Some(i) = rv.inner.as_integer_scalar() {
26                default_palette_color(i)
27            } else {
28                [0, 0, 0, 255] // black fallback
29            }
30        }
31        _ => [0, 0, 0, 255],
32    }
33}
34
35/// Parse a color string: named color or hex.
36fn parse_color_string(s: &str) -> [u8; 4] {
37    match s.to_lowercase().as_str() {
38        "black" => [0, 0, 0, 255],
39        "white" => [255, 255, 255, 255],
40        "red" => [255, 0, 0, 255],
41        "green" | "green3" => [0, 205, 0, 255],
42        "blue" => [0, 0, 255, 255],
43        "cyan" => [0, 255, 255, 255],
44        "magenta" => [255, 0, 255, 255],
45        "yellow" => [255, 255, 0, 255],
46        "gray" | "grey" => [190, 190, 190, 255],
47        "orange" => [255, 165, 0, 255],
48        "purple" => [160, 32, 240, 255],
49        "brown" => [165, 42, 42, 255],
50        "pink" => [255, 192, 203, 255],
51        "darkred" => [139, 0, 0, 255],
52        "darkgreen" => [0, 100, 0, 255],
53        "darkblue" | "navyblue" | "navy" => [0, 0, 128, 255],
54        "lightblue" => [173, 216, 230, 255],
55        "lightgreen" => [144, 238, 144, 255],
56        "lightgray" | "lightgrey" => [211, 211, 211, 255],
57        "darkgray" | "darkgrey" => [169, 169, 169, 255],
58        "transparent" => [0, 0, 0, 0],
59        _ if s.starts_with('#') => parse_hex_color(s),
60        _ => [0, 0, 0, 255], // unknown → black
61    }
62}
63
64/// Parse a hex color string like "#RRGGBB" or "#RRGGBBAA".
65fn parse_hex_color(s: &str) -> [u8; 4] {
66    let hex = s.trim_start_matches('#');
67    let parse_byte =
68        |offset: usize| -> u8 { u8::from_str_radix(&hex[offset..offset + 2], 16).unwrap_or(0) };
69    match hex.len() {
70        6 => [parse_byte(0), parse_byte(2), parse_byte(4), 255],
71        8 => [parse_byte(0), parse_byte(2), parse_byte(4), parse_byte(6)],
72        _ => [0, 0, 0, 255],
73    }
74}
75
76/// Default palette: R's default color cycle.
77fn default_palette_color(index: i64) -> [u8; 4] {
78    const PALETTE: [[u8; 4]; 8] = [
79        [0, 0, 0, 255],       // 1: black
80        [255, 0, 0, 255],     // 2: red
81        [0, 205, 0, 255],     // 3: green3
82        [0, 0, 255, 255],     // 4: blue
83        [0, 255, 255, 255],   // 5: cyan
84        [255, 0, 255, 255],   // 6: magenta
85        [255, 255, 0, 255],   // 7: yellow
86        [190, 190, 190, 255], // 8: gray
87    ];
88    if index < 1 {
89        return [0, 0, 0, 255];
90    }
91    let idx = usize::try_from(index - 1).unwrap_or(0) % PALETTE.len();
92    PALETTE[idx]
93}
94
95// endregion
96
97// region: Vector extraction helpers
98
99/// Extract a numeric vector from an RValue, filtering out NAs.
100fn extract_doubles(value: &RValue) -> Result<Vec<f64>, RError> {
101    match value.as_vector() {
102        Some(v) => Ok(v.to_doubles().into_iter().flatten().collect()),
103        None => Err(RError::new(
104            RErrorKind::Argument,
105            "expected a numeric vector".to_string(),
106        )),
107    }
108}
109
110/// Try to extract a double vector from an optional RValue.
111fn try_extract_doubles(value: Option<&RValue>) -> Result<Option<Vec<f64>>, RError> {
112    match value {
113        Some(RValue::Null) | None => Ok(None),
114        Some(v) => Ok(Some(extract_doubles(v)?)),
115    }
116}
117
118/// Extract a (lo, hi) range from a two-element numeric vector.
119fn extract_limits(value: Option<&RValue>) -> Option<(f64, f64)> {
120    let v = value?;
121    if matches!(v, RValue::Null) {
122        return None;
123    }
124    let vec = v.as_vector()?;
125    let doubles = vec.to_doubles();
126    if doubles.len() >= 2 {
127        match (doubles[0], doubles[1]) {
128            (Some(lo), Some(hi)) => Some((lo, hi)),
129            _ => None,
130        }
131    } else {
132        None
133    }
134}
135
136// endregion
137
138// region: show_or_accumulate
139
140/// Send the current plot to the GUI thread (if any is accumulated).
141///
142/// Called by plot()/hist()/barplot()/boxplot() to flush the PREVIOUS plot
143/// before starting a new one, and by the REPL loop after each expression
144/// to auto-display the plot.
145#[cfg(feature = "plot")]
146fn send_current_plot(ctx: &BuiltinContext) -> Result<(), RError> {
147    // When a file device is active, don't send to GUI — accumulate for dev.off()
148    if ctx.interpreter().file_device.borrow().is_some() {
149        return Ok(());
150    }
151    let state = ctx.interpreter().current_plot.borrow().clone();
152    if let Some(plot_state) = state {
153        let tx = ctx.interpreter().plot_tx.borrow();
154        if let Some(tx) = tx.as_ref() {
155            tx.send(crate::interpreter::graphics::egui_device::PlotMessage::Show(plot_state))
156                .map_err(|e| {
157                    RError::new(
158                        RErrorKind::Other,
159                        format!("failed to send plot to GUI thread: {e}"),
160                    )
161                })?;
162        }
163        *ctx.interpreter().current_plot.borrow_mut() = None;
164    }
165    Ok(())
166}
167
168#[cfg(not(feature = "plot"))]
169fn send_current_plot(ctx: &BuiltinContext) -> Result<(), RError> {
170    if ctx.interpreter().file_device.borrow().is_some() {
171        return Ok(());
172    }
173    if ctx.interpreter().current_plot.borrow().is_some() {
174        ctx.write_err(
175            "plot() requires the 'plot' feature. Build with: cargo build --features plot\n",
176        );
177        *ctx.interpreter().current_plot.borrow_mut() = None;
178    }
179    Ok(())
180}
181
182/// Public API: flush any accumulated plot to the GUI thread.
183/// Called by the REPL loop after each eval to auto-display plots.
184pub fn flush_plot(interp: &crate::interpreter::Interpreter) {
185    let state = interp.current_plot.borrow().clone();
186    if let Some(plot_state) = state {
187        #[cfg(feature = "plot")]
188        {
189            let tx = interp.plot_tx.borrow();
190            if let Some(tx) = tx.as_ref() {
191                let _ = tx
192                    .send(crate::interpreter::graphics::egui_device::PlotMessage::Show(plot_state));
193            }
194        }
195        #[cfg(not(feature = "plot"))]
196        drop(plot_state);
197        *interp.current_plot.borrow_mut() = None;
198    }
199}
200
201/// Ensure a current plot exists, creating a new one if needed.
202fn ensure_plot<'a>(ctx: &'a BuiltinContext<'_>) -> std::cell::RefMut<'a, Option<PlotState>> {
203    let mut plot = ctx.interpreter().current_plot.borrow_mut();
204    if plot.is_none() {
205        *plot = Some(PlotState::new());
206    }
207    plot
208}
209
210// endregion
211
212// region: Device management
213
214/// Open a PDF graphics device.
215///
216/// File-based graphics devices are not yet implemented — this stub prints
217/// a message and returns NULL so that scripts can continue.
218///
219/// @param file output file path (ignored)
220/// @return NULL
221#[interpreter_builtin(namespace = "grDevices")]
222fn interp_pdf(
223    args: &[RValue],
224    named: &[(String, RValue)],
225    context: &BuiltinContext,
226) -> Result<RValue, RError> {
227    let filename = args
228        .first()
229        .and_then(|v| v.as_vector())
230        .and_then(|v| v.as_character_scalar())
231        .unwrap_or_else(|| "Rplots.pdf".to_string());
232    let ca = CallArgs::new(args, named);
233    let width = ca
234        .named("width")
235        .and_then(|v| v.as_vector()?.as_double_scalar())
236        .unwrap_or(7.0);
237    let height = ca
238        .named("height")
239        .and_then(|v| v.as_vector()?.as_double_scalar())
240        .unwrap_or(7.0);
241
242    *context.interpreter().file_device.borrow_mut() =
243        Some(crate::interpreter::graphics::FileDevice {
244            filename,
245            format: crate::interpreter::graphics::FileFormat::Pdf,
246            width,
247            height,
248            jpeg_quality: 75,
249        });
250    *context.interpreter().current_plot.borrow_mut() = Some(PlotState::new());
251    context.interpreter().set_invisible();
252    Ok(RValue::Null)
253}
254
255/// Open a PNG graphics device.
256///
257/// @param filename output file path
258/// @param width width in pixels (default 480)
259/// @param height height in pixels (default 480)
260/// @return NULL (invisibly)
261#[interpreter_builtin(namespace = "grDevices")]
262fn interp_png(
263    args: &[RValue],
264    named: &[(String, RValue)],
265    context: &BuiltinContext,
266) -> Result<RValue, RError> {
267    let filename = args
268        .first()
269        .and_then(|v| v.as_vector())
270        .and_then(|v| v.as_character_scalar())
271        .unwrap_or_else(|| "Rplot.png".to_string());
272    let ca = CallArgs::new(args, named);
273    // PNG uses pixels; convert to inches at 96 DPI for the renderer
274    let width_px = ca
275        .named("width")
276        .and_then(|v| v.as_vector()?.as_double_scalar())
277        .unwrap_or(480.0);
278    let height_px = ca
279        .named("height")
280        .and_then(|v| v.as_vector()?.as_double_scalar())
281        .unwrap_or(480.0);
282
283    *context.interpreter().file_device.borrow_mut() =
284        Some(crate::interpreter::graphics::FileDevice {
285            filename,
286            format: crate::interpreter::graphics::FileFormat::Png,
287            width: width_px / 96.0,
288            height: height_px / 96.0,
289            jpeg_quality: 75,
290        });
291    *context.interpreter().current_plot.borrow_mut() = Some(PlotState::new());
292    context.interpreter().set_invisible();
293    Ok(RValue::Null)
294}
295
296/// Open an SVG graphics device.
297///
298/// Subsequent plot commands will accumulate data. When dev.off() is called,
299/// the plot is rendered to an SVG file.
300///
301/// @param filename output file path
302/// @param width width in inches (default 7)
303/// @param height height in inches (default 7)
304/// @return NULL (invisibly)
305#[interpreter_builtin(namespace = "grDevices")]
306fn interp_svg(
307    args: &[RValue],
308    named: &[(String, RValue)],
309    context: &BuiltinContext,
310) -> Result<RValue, RError> {
311    let filename = args
312        .first()
313        .and_then(|v| v.as_vector())
314        .and_then(|v| v.as_character_scalar())
315        .ok_or_else(|| {
316            RError::new(
317                RErrorKind::Argument,
318                "svg() requires a filename argument".to_string(),
319            )
320        })?;
321    let ca = CallArgs::new(args, named);
322    let width = ca
323        .named("width")
324        .and_then(|v| v.as_vector()?.as_double_scalar())
325        .unwrap_or(7.0);
326    let height = ca
327        .named("height")
328        .and_then(|v| v.as_vector()?.as_double_scalar())
329        .unwrap_or(7.0);
330
331    *context.interpreter().file_device.borrow_mut() =
332        Some(crate::interpreter::graphics::FileDevice {
333            filename,
334            format: crate::interpreter::graphics::FileFormat::Svg,
335            width,
336            height,
337            jpeg_quality: 75,
338        });
339    // Start with a fresh plot
340    *context.interpreter().current_plot.borrow_mut() =
341        Some(crate::interpreter::graphics::plot_data::PlotState::new());
342    context.interpreter().set_invisible();
343    Ok(RValue::Null)
344}
345
346/// Open a JPEG graphics device.
347///
348/// @param filename output file path
349/// @param width width in pixels (default 480)
350/// @param height height in pixels (default 480)
351/// @param quality JPEG quality 0-100 (default 75)
352/// @return NULL (invisibly)
353#[interpreter_builtin(namespace = "grDevices")]
354fn interp_jpeg(
355    args: &[RValue],
356    named: &[(String, RValue)],
357    context: &BuiltinContext,
358) -> Result<RValue, RError> {
359    let filename = args
360        .first()
361        .and_then(|v| v.as_vector())
362        .and_then(|v| v.as_character_scalar())
363        .unwrap_or_else(|| "Rplot.jpeg".to_string());
364    let ca = CallArgs::new(args, named);
365    let width_px = ca
366        .named("width")
367        .and_then(|v| v.as_vector()?.as_double_scalar())
368        .unwrap_or(480.0);
369    let height_px = ca
370        .named("height")
371        .and_then(|v| v.as_vector()?.as_double_scalar())
372        .unwrap_or(480.0);
373    let quality = ca
374        .named("quality")
375        .and_then(|v| v.as_vector()?.as_double_scalar())
376        .unwrap_or(75.0) as u8;
377
378    *context.interpreter().file_device.borrow_mut() =
379        Some(crate::interpreter::graphics::FileDevice {
380            filename,
381            format: crate::interpreter::graphics::FileFormat::Jpeg,
382            width: width_px / 96.0,
383            height: height_px / 96.0,
384            jpeg_quality: quality,
385        });
386    *context.interpreter().current_plot.borrow_mut() = Some(PlotState::new());
387    context.interpreter().set_invisible();
388    Ok(RValue::Null)
389}
390
391/// Open a BMP graphics device.
392///
393/// @param filename output file path
394/// @param width width in pixels (default 480)
395/// @param height height in pixels (default 480)
396/// @return NULL (invisibly)
397#[interpreter_builtin(namespace = "grDevices")]
398fn interp_bmp(
399    args: &[RValue],
400    named: &[(String, RValue)],
401    context: &BuiltinContext,
402) -> Result<RValue, RError> {
403    let filename = args
404        .first()
405        .and_then(|v| v.as_vector())
406        .and_then(|v| v.as_character_scalar())
407        .unwrap_or_else(|| "Rplot.bmp".to_string());
408    let ca = CallArgs::new(args, named);
409    let width_px = ca
410        .named("width")
411        .and_then(|v| v.as_vector()?.as_double_scalar())
412        .unwrap_or(480.0);
413    let height_px = ca
414        .named("height")
415        .and_then(|v| v.as_vector()?.as_double_scalar())
416        .unwrap_or(480.0);
417
418    *context.interpreter().file_device.borrow_mut() =
419        Some(crate::interpreter::graphics::FileDevice {
420            filename,
421            format: crate::interpreter::graphics::FileFormat::Bmp,
422            width: width_px / 96.0,
423            height: height_px / 96.0,
424            jpeg_quality: 75,
425        });
426    *context.interpreter().current_plot.borrow_mut() = Some(PlotState::new());
427    context.interpreter().set_invisible();
428    Ok(RValue::Null)
429}
430
431/// Close the current graphics device.
432///
433/// Closes any open plot window and clears the accumulated plot state.
434///
435/// @return integer 1 (invisibly)
436#[interpreter_builtin(name = "dev.off", namespace = "grDevices")]
437fn interp_dev_off(
438    _args: &[RValue],
439    _named: &[(String, RValue)],
440    context: &BuiltinContext,
441) -> Result<RValue, RError> {
442    // If a file device is active, render to file
443    let file_dev = context.interpreter().file_device.borrow().clone();
444    if let Some(dev) = file_dev {
445        let plot_state = context.interpreter().current_plot.borrow().clone();
446        if let Some(state) = plot_state {
447            #[cfg(feature = "svg-device")]
448            {
449                let svg_str = crate::interpreter::graphics::svg_device::render_svg(
450                    &state, dev.width, dev.height,
451                );
452                match dev.format {
453                    crate::interpreter::graphics::FileFormat::Svg => {
454                        std::fs::write(&dev.filename, &svg_str).map_err(|e| {
455                            RError::new(
456                                RErrorKind::Other,
457                                format!("failed to write SVG file '{}': {e}", dev.filename),
458                            )
459                        })?;
460                    }
461                    crate::interpreter::graphics::FileFormat::Png => {
462                        #[cfg(feature = "raster-device")]
463                        {
464                            let width_px = (dev.width * 96.0) as u32;
465                            let height_px = (dev.height * 96.0) as u32;
466                            let pixmap = crate::interpreter::graphics::raster::svg_to_raster(
467                                &svg_str, width_px, height_px,
468                            )?;
469                            pixmap.save_png(&dev.filename).map_err(|e| {
470                                RError::new(
471                                    RErrorKind::Other,
472                                    format!("failed to write PNG '{}': {e}", dev.filename),
473                                )
474                            })?;
475                        }
476                        #[cfg(not(feature = "raster-device"))]
477                        {
478                            let svg_filename =
479                                dev.filename.strip_suffix(".png").unwrap_or(&dev.filename);
480                            let svg_path = format!("{svg_filename}.svg");
481                            std::fs::write(&svg_path, &svg_str).map_err(|e| {
482                                RError::new(
483                                    RErrorKind::Other,
484                                    format!("failed to write '{}': {e}", svg_path),
485                                )
486                            })?;
487                            context.write_err(&format!(
488                                "Note: PNG rasterization requires 'raster-device' feature. \
489                                 SVG written to '{}' instead.\n",
490                                svg_path
491                            ));
492                        }
493                    }
494                    crate::interpreter::graphics::FileFormat::Jpeg => {
495                        #[cfg(feature = "raster-device")]
496                        {
497                            let width_px = (dev.width * 96.0) as u32;
498                            let height_px = (dev.height * 96.0) as u32;
499                            let pixmap = crate::interpreter::graphics::raster::svg_to_raster(
500                                &svg_str, width_px, height_px,
501                            )?;
502                            let jpeg_bytes = crate::interpreter::graphics::raster::pixmap_to_jpeg(
503                                &pixmap,
504                                dev.jpeg_quality,
505                            )?;
506                            std::fs::write(&dev.filename, &jpeg_bytes).map_err(|e| {
507                                RError::new(
508                                    RErrorKind::Other,
509                                    format!("failed to write JPEG '{}': {e}", dev.filename),
510                                )
511                            })?;
512                        }
513                        #[cfg(not(feature = "raster-device"))]
514                        {
515                            context.write_err("JPEG output requires the 'raster-device' feature\n");
516                        }
517                    }
518                    crate::interpreter::graphics::FileFormat::Bmp => {
519                        #[cfg(feature = "raster-device")]
520                        {
521                            let width_px = (dev.width * 96.0) as u32;
522                            let height_px = (dev.height * 96.0) as u32;
523                            let pixmap = crate::interpreter::graphics::raster::svg_to_raster(
524                                &svg_str, width_px, height_px,
525                            )?;
526                            let bmp_bytes =
527                                crate::interpreter::graphics::raster::pixmap_to_bmp(&pixmap)?;
528                            std::fs::write(&dev.filename, &bmp_bytes).map_err(|e| {
529                                RError::new(
530                                    RErrorKind::Other,
531                                    format!("failed to write BMP '{}': {e}", dev.filename),
532                                )
533                            })?;
534                        }
535                        #[cfg(not(feature = "raster-device"))]
536                        {
537                            context.write_err("BMP output requires the 'raster-device' feature\n");
538                        }
539                    }
540                    crate::interpreter::graphics::FileFormat::Pdf => {
541                        #[cfg(feature = "pdf-device")]
542                        {
543                            let width_pt = (dev.width * 96.0) as f32;
544                            let height_pt = (dev.height * 96.0) as f32;
545                            let pdf_bytes = crate::interpreter::graphics::pdf::svg_to_pdf(
546                                &svg_str, width_pt, height_pt,
547                            )?;
548                            std::fs::write(&dev.filename, &pdf_bytes).map_err(|e| {
549                                RError::new(
550                                    RErrorKind::Other,
551                                    format!("failed to write PDF '{}': {e}", dev.filename),
552                                )
553                            })?;
554                        }
555                        #[cfg(not(feature = "pdf-device"))]
556                        {
557                            let svg_name =
558                                dev.filename.strip_suffix(".pdf").unwrap_or(&dev.filename);
559                            let svg_path = format!("{svg_name}.svg");
560                            std::fs::write(&svg_path, &svg_str).map_err(|e| {
561                                RError::new(
562                                    RErrorKind::Other,
563                                    format!("failed to write '{}': {e}", svg_path),
564                                )
565                            })?;
566                            context.write_err(&format!(
567                                "Note: PDF requires 'pdf-device' feature. SVG written to '{svg_path}'\n"
568                            ));
569                        }
570                    }
571                }
572            }
573            #[cfg(not(feature = "svg-device"))]
574            {
575                context.write_err("File device output requires the 'svg-device' feature\n");
576            }
577        }
578        *context.interpreter().file_device.borrow_mut() = None;
579    }
580
581    // Send close signal to the GUI window
582    #[cfg(feature = "plot")]
583    {
584        let tx = context.interpreter().plot_tx.borrow();
585        if let Some(tx) = tx.as_ref() {
586            drop(tx.send(crate::interpreter::graphics::egui_device::PlotMessage::Close));
587        }
588    }
589    // Clear any accumulated plot data
590    *context.interpreter().current_plot.borrow_mut() = None;
591    context.interpreter().set_invisible();
592    Ok(RValue::vec(Vector::Integer(vec![Some(1i64)].into())))
593}
594
595/// Return the current graphics device number.
596///
597/// Returns 2 if a plot is being accumulated (an "active" device),
598/// or 1 (the null device) otherwise.
599///
600/// @return integer device number
601#[interpreter_builtin(name = "dev.cur", namespace = "grDevices")]
602fn interp_dev_cur(
603    _args: &[RValue],
604    _named: &[(String, RValue)],
605    context: &BuiltinContext,
606) -> Result<RValue, RError> {
607    let num = if context.interpreter().current_plot.borrow().is_some() {
608        2i64
609    } else {
610        1i64
611    };
612    Ok(RValue::vec(Vector::Integer(vec![Some(num)].into())))
613}
614
615/// Open a new graphics device.
616///
617/// Creates a fresh PlotState for accumulating plot items.
618///
619/// @return integer device number (invisibly)
620#[interpreter_builtin(name = "dev.new", namespace = "grDevices")]
621fn interp_dev_new(
622    _args: &[RValue],
623    _named: &[(String, RValue)],
624    context: &BuiltinContext,
625) -> Result<RValue, RError> {
626    *context.interpreter().current_plot.borrow_mut() = Some(PlotState::new());
627    Ok(RValue::vec(Vector::Integer(vec![Some(2i64)].into())))
628}
629
630/// List all open graphics devices.
631///
632/// Returns a named integer vector of device numbers. Device 1 is always
633/// the null device and is not listed.
634///
635/// @return named integer vector of open device numbers
636#[interpreter_builtin(name = "dev.list", namespace = "grDevices")]
637fn interp_dev_list(
638    _args: &[RValue],
639    _named: &[(String, RValue)],
640    context: &BuiltinContext,
641) -> Result<RValue, RError> {
642    let has_device = context.interpreter().current_plot.borrow().is_some()
643        || context.interpreter().file_device.borrow().is_some();
644    if has_device {
645        let dev_name = {
646            let fd = context.interpreter().file_device.borrow();
647            match fd.as_ref().map(|d| d.format) {
648                Some(crate::interpreter::graphics::FileFormat::Png) => "png",
649                Some(crate::interpreter::graphics::FileFormat::Jpeg) => "jpeg",
650                Some(crate::interpreter::graphics::FileFormat::Bmp) => "bmp",
651                Some(crate::interpreter::graphics::FileFormat::Svg) => "svg",
652                Some(crate::interpreter::graphics::FileFormat::Pdf) => "pdf",
653                None => {
654                    if cfg!(target_os = "macos") {
655                        "quartz"
656                    } else {
657                        "X11"
658                    }
659                }
660            }
661        };
662        let mut rv = RValue::vec(Vector::Integer(vec![Some(2i64)].into()));
663        if let RValue::Vector(ref mut v) = rv {
664            v.set_attr(
665                "names".to_string(),
666                RValue::vec(Vector::Character(vec![Some(dev_name.to_string())].into())),
667            );
668        }
669        Ok(rv)
670    } else {
671        // No devices open — return empty named integer vector
672        Ok(RValue::vec(Vector::Integer(vec![].into())))
673    }
674}
675
676// endregion
677
678// region: High-level plotting
679
680/// Create a scatter plot, line plot, or combined plot.
681///
682/// This is R's main `plot()` function. It creates a new PlotState,
683/// adds Points/Lines/Both based on the `type` parameter, then shows
684/// the plot window immediately.
685///
686/// @param x numeric vector of x-coordinates (or sole y if y is missing)
687/// @param y numeric vector of y-coordinates (optional)
688/// @param type character: "p" (points), "l" (lines), "b" (both), "n" (none)
689/// @param main plot title
690/// @param xlab x-axis label
691/// @param ylab y-axis label
692/// @param col color specification
693/// @param pch point character (0-25)
694/// @param cex character expansion factor
695/// @param lwd line width
696/// @param xlim numeric vector c(lo, hi) for x-axis limits
697/// @param ylim numeric vector c(lo, hi) for y-axis limits
698/// @return NULL (invisibly)
699#[interpreter_builtin(namespace = "graphics", min_args = 1)]
700fn interp_plot(
701    args: &[RValue],
702    named: &[(String, RValue)],
703    context: &BuiltinContext,
704) -> Result<RValue, RError> {
705    let ca = CallArgs::new(args, named);
706
707    // Parse log parameter
708    let log_spec = ca
709        .named("log")
710        .and_then(|v| v.as_vector()?.as_character_scalar())
711        .unwrap_or_default();
712    for ch in log_spec.chars() {
713        if ch != 'x' && ch != 'y' {
714            return Err(RError::new(
715                RErrorKind::Argument,
716                format!(
717                    "invalid 'log' specification '{}': must contain only 'x' and/or 'y'",
718                    log_spec
719                ),
720            ));
721        }
722    }
723    let log_x = log_spec.contains('x');
724    let log_y = log_spec.contains('y');
725
726    // Extract x and y — either from formula or direct vectors
727    let first = &args[0];
728    let (x_data, y_data) = if let RValue::Language(lang) = first {
729        if let crate::parser::ast::Expr::Formula { lhs, rhs } = &*lang.inner {
730            // plot(y ~ x, data=df)
731            let y_name = rhs_symbol_name(lhs.as_deref()).ok_or_else(|| {
732                RError::new(
733                    RErrorKind::Argument,
734                    "formula lhs must be a simple variable name".to_string(),
735                )
736            })?;
737            let x_name = rhs_symbol_name(rhs.as_deref()).ok_or_else(|| {
738                RError::new(
739                    RErrorKind::Argument,
740                    "formula rhs must be a simple variable name".to_string(),
741                )
742            })?;
743            let data_arg = ca.named("data").or_else(|| args.get(1));
744            if let Some(data_val) = data_arg {
745                let df = match data_val {
746                    RValue::List(list) => list,
747                    _ => {
748                        return Err(RError::new(
749                            RErrorKind::Type,
750                            "'data' argument must be a data frame (list)".to_string(),
751                        ))
752                    }
753                };
754                let x_col = df_get_column(df, &x_name).ok_or_else(|| {
755                    RError::new(
756                        RErrorKind::Name,
757                        format!("variable '{}' not found in data", x_name),
758                    )
759                })?;
760                let y_col = df_get_column(df, &y_name).ok_or_else(|| {
761                    RError::new(
762                        RErrorKind::Name,
763                        format!("variable '{}' not found in data", y_name),
764                    )
765                })?;
766                (extract_doubles(x_col)?, extract_doubles(y_col)?)
767            } else {
768                // No data arg — look up in calling environment
769                let env = context.env();
770                let x_val = env.get(&x_name).ok_or_else(|| {
771                    RError::new(RErrorKind::Name, format!("object '{}' not found", x_name))
772                })?;
773                let y_val = env.get(&y_name).ok_or_else(|| {
774                    RError::new(RErrorKind::Name, format!("object '{}' not found", y_name))
775                })?;
776                (extract_doubles(&x_val)?, extract_doubles(&y_val)?)
777            }
778        } else {
779            // Language but not formula — try as numeric
780            let second = ca.value("y", 1);
781            if let Some(y_val) = second {
782                (extract_doubles(first)?, extract_doubles(y_val)?)
783            } else {
784                let y = extract_doubles(first)?;
785                let x: Vec<f64> = (1..=y.len()).map(|i| i as f64).collect();
786                (x, y)
787            }
788        }
789    } else {
790        let second = ca.value("y", 1);
791        if let Some(y_val) = second {
792            (extract_doubles(first)?, extract_doubles(y_val)?)
793        } else {
794            let y = extract_doubles(first)?;
795            let x: Vec<f64> = (1..=y.len()).map(|i| i as f64).collect();
796            (x, y)
797        }
798    };
799
800    // Apply log-scale transformations
801    let x_data: Vec<f64> = if log_x {
802        x_data
803            .into_iter()
804            .filter(|v| *v > 0.0)
805            .map(|v| v.ln())
806            .collect()
807    } else {
808        x_data
809    };
810    let y_data: Vec<f64> = if log_y {
811        y_data
812            .into_iter()
813            .filter(|v| *v > 0.0)
814            .map(|v| v.ln())
815            .collect()
816    } else {
817        y_data
818    };
819
820    // Truncate to the shorter length
821    let len = x_data.len().min(y_data.len());
822    let x_data: Vec<f64> = x_data.into_iter().take(len).collect();
823    let y_data: Vec<f64> = y_data.into_iter().take(len).collect();
824
825    // Parse plot parameters
826    let plot_type = ca
827        .optional_string("type", 2)
828        .unwrap_or_else(|| "p".to_string());
829    let title = ca.optional_string("main", 3);
830    let xlab = ca.optional_string("xlab", 4);
831    let ylab = ca.optional_string("ylab", 5);
832    let color = ca
833        .value("col", 6)
834        .map(parse_color)
835        .unwrap_or([0, 0, 0, 255]);
836    let pch = u8::try_from(ca.integer_or("pch", 7, 1)).unwrap_or(1);
837    let cex = ca
838        .value("cex", 8)
839        .and_then(|v| v.as_vector()?.as_double_scalar())
840        .unwrap_or(1.0);
841    let lwd = ca
842        .value("lwd", 9)
843        .and_then(|v| v.as_vector()?.as_double_scalar())
844        .unwrap_or(1.0);
845    let xlim = extract_limits(ca.value("xlim", 10));
846    let ylim = extract_limits(ca.value("ylim", 11));
847
848    // Create a new plot
849    let mut state = PlotState::new();
850    state.title = title;
851    state.x_label = xlab;
852    state.y_label = ylab;
853    state.x_lim = xlim;
854    state.y_lim = ylim;
855
856    // Add items based on type
857    let point_size = (3.0 * cex) as f32;
858    let line_width = lwd as f32;
859
860    match plot_type.as_str() {
861        "p" => {
862            state.items.push(PlotItem::Points {
863                x: x_data,
864                y: y_data,
865                color,
866                size: point_size,
867                shape: pch,
868                label: None,
869            });
870        }
871        "l" => {
872            state.items.push(PlotItem::Line {
873                x: x_data,
874                y: y_data,
875                color,
876                width: line_width,
877                label: None,
878            });
879        }
880        "b" | "o" => {
881            state.items.push(PlotItem::Line {
882                x: x_data.clone(),
883                y: y_data.clone(),
884                color,
885                width: line_width,
886                label: None,
887            });
888            state.items.push(PlotItem::Points {
889                x: x_data,
890                y: y_data,
891                color,
892                size: point_size,
893                shape: pch,
894                label: None,
895            });
896        }
897        "h" => {
898            // Histogram-like vertical lines from x-axis
899            for (&xi, &yi) in x_data.iter().zip(y_data.iter()) {
900                state.items.push(PlotItem::Line {
901                    x: vec![xi, xi],
902                    y: vec![0.0, yi],
903                    color,
904                    width: line_width,
905                    label: None,
906                });
907            }
908        }
909        "n" => {
910            // Plot nothing — just set up axes
911        }
912        _ => {
913            return Err(RError::new(
914                RErrorKind::Argument,
915                format!(
916                    "invalid plot type '{plot_type}': expected 'p', 'l', 'b', 'o', 'h', or 'n'"
917                ),
918            ));
919        }
920    }
921
922    // Store and show
923    *context.interpreter().current_plot.borrow_mut() = Some(state);
924    send_current_plot(context)?;
925
926    Ok(RValue::Null)
927}
928
929/// Compute a histogram and display it as a bar chart.
930///
931/// @param x numeric vector of data values
932/// @param breaks number of bins (default 10) or a vector of break points
933/// @param col bar color
934/// @param main plot title
935/// @param xlab x-axis label
936/// @return a list with breaks, counts, and mids (invisibly)
937#[interpreter_builtin(namespace = "graphics", min_args = 1)]
938fn interp_hist(
939    args: &[RValue],
940    named: &[(String, RValue)],
941    context: &BuiltinContext,
942) -> Result<RValue, RError> {
943    let ca = CallArgs::new(args, named);
944    let data = extract_doubles(&args[0])?;
945
946    if data.is_empty() {
947        return Err(RError::new(
948            RErrorKind::Argument,
949            "'x' must have at least one non-NA value".to_string(),
950        ));
951    }
952
953    // Determine breaks
954    let breaks_val = ca.value("breaks", 1);
955    let n_bins = match breaks_val {
956        Some(v) => {
957            // Could be a single integer or a vector of breakpoints
958            if let Some(n) = v.as_vector().and_then(|vec| vec.as_integer_scalar()) {
959                usize::try_from(n.max(1)).unwrap_or(10)
960            } else {
961                10
962            }
963        }
964        None => 10,
965    };
966
967    // Compute bin edges using Sturges-like approach
968    let min_val = data.iter().copied().fold(f64::INFINITY, f64::min);
969    let max_val = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
970    let range = max_val - min_val;
971    let bin_width = if range == 0.0 {
972        1.0
973    } else {
974        range / n_bins as f64
975    };
976
977    let mut break_points: Vec<f64> = (0..=n_bins)
978        .map(|i| min_val + i as f64 * bin_width)
979        .collect();
980    // Adjust last break to include max_val
981    if let Some(last) = break_points.last_mut() {
982        *last = max_val + bin_width * 0.001;
983    }
984
985    // Count values in each bin
986    let mut counts = vec![0usize; n_bins];
987    for &val in &data {
988        for (i, window) in break_points.windows(2).enumerate() {
989            if val >= window[0] && val < window[1] {
990                counts[i] += 1;
991                break;
992            }
993        }
994    }
995
996    // Compute bar positions (midpoints)
997    let mids: Vec<f64> = break_points
998        .windows(2)
999        .map(|w| (w[0] + w[1]) / 2.0)
1000        .collect();
1001    let heights: Vec<f64> = counts.iter().map(|&c| c as f64).collect();
1002
1003    let color = ca
1004        .value("col", 2)
1005        .map(parse_color)
1006        .unwrap_or([173, 216, 230, 255]); // lightblue default
1007    let title = ca.optional_string("main", 3);
1008    let xlab = ca.optional_string("xlab", 4);
1009
1010    let mut state = PlotState::new();
1011    state.title = title.or_else(|| Some("Histogram of x".to_string()));
1012    state.x_label = xlab;
1013    state.y_label = Some("Frequency".to_string());
1014
1015    state.items.push(PlotItem::Bars {
1016        x: mids.clone(),
1017        heights: heights.clone(),
1018        color,
1019        width: bin_width * 0.9,
1020        label: None,
1021    });
1022
1023    *context.interpreter().current_plot.borrow_mut() = Some(state);
1024    send_current_plot(context)?;
1025
1026    // Return a list with breaks, counts, mids (like R's hist())
1027    let breaks_rv = RValue::vec(Vector::Double(
1028        break_points
1029            .iter()
1030            .map(|&v| Some(v))
1031            .collect::<Vec<_>>()
1032            .into(),
1033    ));
1034    let counts_rv = RValue::vec(Vector::Integer(
1035        counts
1036            .iter()
1037            .map(|&c| Some(i64::try_from(c).unwrap_or(0)))
1038            .collect::<Vec<_>>()
1039            .into(),
1040    ));
1041    let mids_rv = RValue::vec(Vector::Double(
1042        mids.iter().map(|&v| Some(v)).collect::<Vec<_>>().into(),
1043    ));
1044
1045    let result = RList::new(vec![
1046        (Some("breaks".to_string()), breaks_rv),
1047        (Some("counts".to_string()), counts_rv),
1048        (Some("mids".to_string()), mids_rv),
1049    ]);
1050    Ok(RValue::List(result))
1051}
1052
1053/// Create a bar plot from a numeric vector.
1054///
1055/// @param height numeric vector of bar heights
1056/// @param names.arg character vector of bar names
1057/// @param col bar color
1058/// @param main plot title
1059/// @return numeric vector of bar midpoints (invisibly)
1060#[interpreter_builtin(namespace = "graphics", min_args = 1)]
1061fn interp_barplot(
1062    args: &[RValue],
1063    named: &[(String, RValue)],
1064    context: &BuiltinContext,
1065) -> Result<RValue, RError> {
1066    let ca = CallArgs::new(args, named);
1067    let heights = extract_doubles(&args[0])?;
1068
1069    if heights.is_empty() {
1070        return Err(RError::new(
1071            RErrorKind::Argument,
1072            "'height' must have at least one value".to_string(),
1073        ));
1074    }
1075
1076    let color = ca
1077        .value("col", 1)
1078        .map(parse_color)
1079        .unwrap_or([173, 216, 230, 255]); // lightblue
1080    let title = ca.optional_string("main", 2);
1081
1082    // Bar positions: 1, 2, 3, ...
1083    let x_positions: Vec<f64> = (1..=heights.len()).map(|i| i as f64).collect();
1084
1085    let mut state = PlotState::new();
1086    state.title = title;
1087
1088    state.items.push(PlotItem::Bars {
1089        x: x_positions.clone(),
1090        heights,
1091        color,
1092        width: 0.8,
1093        label: None,
1094    });
1095
1096    *context.interpreter().current_plot.borrow_mut() = Some(state);
1097    send_current_plot(context)?;
1098
1099    // Return bar midpoints (like R)
1100    Ok(RValue::vec(Vector::Double(
1101        x_positions
1102            .iter()
1103            .map(|&v| Some(v))
1104            .collect::<Vec<_>>()
1105            .into(),
1106    )))
1107}
1108
1109/// Create box-and-whisker plots.
1110///
1111/// Accepts one or more numeric vectors (as positional args) and computes
1112/// five-number summaries for each.
1113///
1114/// @param ... numeric vectors
1115/// @param col box color
1116/// @param main plot title
1117/// @return NULL (invisibly)
1118#[interpreter_builtin(namespace = "graphics", min_args = 1)]
1119fn interp_boxplot(
1120    args: &[RValue],
1121    named: &[(String, RValue)],
1122    context: &BuiltinContext,
1123) -> Result<RValue, RError> {
1124    let ca = CallArgs::new(args, named);
1125
1126    let color = ca
1127        .named("col")
1128        .map(parse_color)
1129        .unwrap_or([173, 216, 230, 255]);
1130    let title = ca.named_string("main");
1131
1132    let mut positions = Vec::new();
1133    let mut spreads = Vec::new();
1134
1135    for (i, arg) in args.iter().enumerate() {
1136        let mut data = extract_doubles(arg)?;
1137        if data.is_empty() {
1138            continue;
1139        }
1140        data.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1141
1142        let n = data.len();
1143        let median = percentile(&data, 50.0);
1144        let q1 = percentile(&data, 25.0);
1145        let q3 = percentile(&data, 75.0);
1146        let iqr = q3 - q1;
1147        let lower_fence = q1 - 1.5 * iqr;
1148        let upper_fence = q3 + 1.5 * iqr;
1149        let lower_whisker = data
1150            .iter()
1151            .copied()
1152            .find(|&v| v >= lower_fence)
1153            .unwrap_or(data[0]);
1154        let upper_whisker = data
1155            .iter()
1156            .rev()
1157            .copied()
1158            .find(|&v| v <= upper_fence)
1159            .unwrap_or(data[n - 1]);
1160
1161        positions.push((i + 1) as f64);
1162        spreads.push(BoxSpread {
1163            lower_whisker,
1164            q1,
1165            median,
1166            q3,
1167            upper_whisker,
1168        });
1169    }
1170
1171    let mut state = PlotState::new();
1172    state.title = title;
1173
1174    state.items.push(PlotItem::BoxPlot {
1175        positions,
1176        spreads,
1177        color,
1178    });
1179
1180    *context.interpreter().current_plot.borrow_mut() = Some(state);
1181    send_current_plot(context)?;
1182
1183    Ok(RValue::Null)
1184}
1185
1186/// Compute a percentile from sorted data using linear interpolation.
1187fn percentile(sorted: &[f64], p: f64) -> f64 {
1188    if sorted.is_empty() {
1189        return f64::NAN;
1190    }
1191    if sorted.len() == 1 {
1192        return sorted[0];
1193    }
1194    let rank = p / 100.0 * (sorted.len() - 1) as f64;
1195    let lo = rank.floor() as usize;
1196    let hi = rank.ceil() as usize;
1197    let frac = rank - lo as f64;
1198    if lo == hi {
1199        sorted[lo]
1200    } else {
1201        sorted[lo] * (1.0 - frac) + sorted[hi] * frac
1202    }
1203}
1204
1205// endregion
1206
1207// region: Low-level plot additions
1208
1209/// Add points to the current plot.
1210///
1211/// @param x numeric vector of x-coordinates
1212/// @param y numeric vector of y-coordinates
1213/// @param col color
1214/// @param pch point character
1215/// @param cex character expansion factor
1216/// @return NULL (invisibly)
1217#[interpreter_builtin(namespace = "graphics", min_args = 1)]
1218fn interp_points(
1219    args: &[RValue],
1220    named: &[(String, RValue)],
1221    context: &BuiltinContext,
1222) -> Result<RValue, RError> {
1223    let ca = CallArgs::new(args, named);
1224
1225    let first = &args[0];
1226    let second = ca.value("y", 1);
1227
1228    let (x_data, y_data) = if let Some(y_val) = second {
1229        (extract_doubles(first)?, extract_doubles(y_val)?)
1230    } else {
1231        let y = extract_doubles(first)?;
1232        let x: Vec<f64> = (1..=y.len()).map(|i| i as f64).collect();
1233        (x, y)
1234    };
1235
1236    let color = ca
1237        .value("col", 2)
1238        .map(parse_color)
1239        .unwrap_or([0, 0, 0, 255]);
1240    let pch = u8::try_from(ca.integer_or("pch", 3, 1)).unwrap_or(1);
1241    let cex = ca
1242        .value("cex", 4)
1243        .and_then(|v| v.as_vector()?.as_double_scalar())
1244        .unwrap_or(1.0);
1245
1246    let mut plot = ensure_plot(context);
1247    if let Some(ref mut state) = *plot {
1248        state.items.push(PlotItem::Points {
1249            x: x_data,
1250            y: y_data,
1251            color,
1252            size: (3.0 * cex) as f32,
1253            shape: pch,
1254            label: None,
1255        });
1256    }
1257
1258    Ok(RValue::Null)
1259}
1260
1261/// Add connected line segments to the current plot.
1262///
1263/// @param x numeric vector of x-coordinates
1264/// @param y numeric vector of y-coordinates
1265/// @param col color
1266/// @param lwd line width
1267/// @return NULL (invisibly)
1268#[interpreter_builtin(namespace = "graphics", min_args = 1)]
1269fn interp_lines(
1270    args: &[RValue],
1271    named: &[(String, RValue)],
1272    context: &BuiltinContext,
1273) -> Result<RValue, RError> {
1274    let ca = CallArgs::new(args, named);
1275
1276    let first = &args[0];
1277    let second = ca.value("y", 1);
1278
1279    let (x_data, y_data) = if let Some(y_val) = second {
1280        (extract_doubles(first)?, extract_doubles(y_val)?)
1281    } else {
1282        let y = extract_doubles(first)?;
1283        let x: Vec<f64> = (1..=y.len()).map(|i| i as f64).collect();
1284        (x, y)
1285    };
1286
1287    let color = ca
1288        .value("col", 2)
1289        .map(parse_color)
1290        .unwrap_or([0, 0, 0, 255]);
1291    let lwd = ca
1292        .value("lwd", 3)
1293        .and_then(|v| v.as_vector()?.as_double_scalar())
1294        .unwrap_or(1.0);
1295
1296    let mut plot = ensure_plot(context);
1297    if let Some(ref mut state) = *plot {
1298        state.items.push(PlotItem::Line {
1299            x: x_data,
1300            y: y_data,
1301            color,
1302            width: lwd as f32,
1303            label: None,
1304        });
1305    }
1306
1307    Ok(RValue::Null)
1308}
1309
1310/// Add horizontal, vertical, or slope-intercept lines to the current plot.
1311///
1312/// @param a intercept (for slope-intercept form)
1313/// @param b slope (for slope-intercept form)
1314/// @param h y-value for horizontal line
1315/// @param v x-value for vertical line
1316/// @param col color
1317/// @param lwd line width
1318/// @return NULL (invisibly)
1319#[interpreter_builtin(namespace = "graphics")]
1320fn interp_abline(
1321    _args: &[RValue],
1322    named: &[(String, RValue)],
1323    context: &BuiltinContext,
1324) -> Result<RValue, RError> {
1325    let ca = CallArgs::new(&[], named);
1326
1327    let color = ca.named("col").map(parse_color).unwrap_or([0, 0, 0, 255]);
1328    let lwd = ca
1329        .named("lwd")
1330        .and_then(|v| v.as_vector()?.as_double_scalar())
1331        .unwrap_or(1.0);
1332
1333    let mut plot = ensure_plot(context);
1334    if let Some(ref mut state) = *plot {
1335        // Horizontal line(s)
1336        if let Some(h_vals) = try_extract_doubles(ca.named("h"))? {
1337            for h in h_vals {
1338                state.items.push(PlotItem::HLine {
1339                    y: h,
1340                    color,
1341                    width: lwd as f32,
1342                });
1343            }
1344        }
1345
1346        // Vertical line(s)
1347        if let Some(v_vals) = try_extract_doubles(ca.named("v"))? {
1348            for v in v_vals {
1349                state.items.push(PlotItem::VLine {
1350                    x: v,
1351                    color,
1352                    width: lwd as f32,
1353                });
1354            }
1355        }
1356
1357        // Slope-intercept: a + b*x — represented as a line through
1358        // the visible range, which we approximate with a wide range
1359        let a_val = ca
1360            .named("a")
1361            .and_then(|v| v.as_vector()?.as_double_scalar());
1362        let b_val = ca
1363            .named("b")
1364            .and_then(|v| v.as_vector()?.as_double_scalar());
1365        if let (Some(a), Some(b)) = (a_val, b_val) {
1366            let x_lo = -1e6_f64;
1367            let x_hi = 1e6_f64;
1368            state.items.push(PlotItem::Line {
1369                x: vec![x_lo, x_hi],
1370                y: vec![a + b * x_lo, a + b * x_hi],
1371                color,
1372                width: lwd as f32,
1373                label: None,
1374            });
1375        }
1376    }
1377
1378    Ok(RValue::Null)
1379}
1380
1381/// Add a legend to the current plot.
1382///
1383/// Enables the egui_plot legend display. In the MVP the legend labels
1384/// come from the PlotItem label fields.
1385///
1386/// @return NULL (invisibly)
1387#[interpreter_builtin(namespace = "graphics")]
1388fn interp_legend(
1389    _args: &[RValue],
1390    _named: &[(String, RValue)],
1391    context: &BuiltinContext,
1392) -> Result<RValue, RError> {
1393    let mut plot = ensure_plot(context);
1394    if let Some(ref mut state) = *plot {
1395        state.show_legend = true;
1396    }
1397    Ok(RValue::Null)
1398}
1399
1400/// Set or update plot titles.
1401///
1402/// @param main main title
1403/// @param sub subtitle (ignored for now)
1404/// @param xlab x-axis label
1405/// @param ylab y-axis label
1406/// @return NULL (invisibly)
1407#[interpreter_builtin(namespace = "graphics")]
1408fn interp_title(
1409    _args: &[RValue],
1410    named: &[(String, RValue)],
1411    context: &BuiltinContext,
1412) -> Result<RValue, RError> {
1413    let ca = CallArgs::new(&[], named);
1414
1415    let mut plot = ensure_plot(context);
1416    if let Some(ref mut state) = *plot {
1417        if let Some(main) = ca.named_string("main") {
1418            state.title = Some(main);
1419        }
1420        if let Some(xlab) = ca.named_string("xlab") {
1421            state.x_label = Some(xlab);
1422        }
1423        if let Some(ylab) = ca.named_string("ylab") {
1424            state.y_label = Some(ylab);
1425        }
1426    }
1427
1428    Ok(RValue::Null)
1429}
1430
1431/// Add an axis to the current plot.
1432///
1433/// Axes are handled automatically by egui_plot, so this is a no-op
1434/// that exists for compatibility with R code that calls `axis()`.
1435///
1436/// @param side which side (1=bottom, 2=left, 3=top, 4=right)
1437/// @return NULL (invisibly)
1438#[builtin(namespace = "graphics")]
1439fn builtin_axis(_args: &[RValue], _named: &[(String, RValue)]) -> Result<RValue, RError> {
1440    // egui_plot handles axes automatically
1441    Ok(RValue::Null)
1442}
1443
1444// endregion
1445
1446// region: Graphics parameters
1447
1448// par() is implemented in graphics/par.rs
1449
1450// endregion
1451
1452// View() is in tables_display.rs (tabled terminal rendering).
1453
1454// endregion
1455
1456// region: Formula/log helpers
1457
1458/// Extract a simple symbol name from a formula side (lhs or rhs).
1459fn rhs_symbol_name(expr: Option<&crate::parser::ast::Expr>) -> Option<String> {
1460    match expr {
1461        Some(crate::parser::ast::Expr::Symbol(name)) => Some(name.clone()),
1462        _ => None,
1463    }
1464}
1465
1466/// Look up a column in a data frame (RList) by name.
1467fn df_get_column<'a>(df: &'a RList, name: &str) -> Option<&'a RValue> {
1468    for (col_name, val) in &df.values {
1469        if col_name.as_deref() == Some(name) {
1470            return Some(val);
1471        }
1472    }
1473    None
1474}
1475
1476// endregion
1477
1478// region: pairs()
1479
1480/// Scatterplot matrix — plots all pairwise combinations of numeric columns.
1481///
1482/// @param x data frame or numeric matrix
1483/// @return NULL (invisibly)
1484#[interpreter_builtin(namespace = "graphics")]
1485fn interp_pairs(
1486    args: &[RValue],
1487    _named: &[(String, RValue)],
1488    ctx: &BuiltinContext,
1489) -> Result<RValue, RError> {
1490    let x = args.first().ok_or_else(|| {
1491        RError::new(
1492            RErrorKind::Argument,
1493            "pairs() requires at least one argument".to_string(),
1494        )
1495    })?;
1496
1497    // Validate input: must be a data frame or numeric matrix
1498    match x {
1499        RValue::List(list) if super::has_class(x, "data.frame") => {
1500            // Count numeric columns
1501            let numeric_count = list
1502                .values
1503                .iter()
1504                .filter(|(_, v)| {
1505                    matches!(
1506                        v,
1507                        RValue::Vector(rv)
1508                            if matches!(
1509                                rv.inner,
1510                                Vector::Double(_) | Vector::Integer(_) | Vector::Logical(_)
1511                            )
1512                    )
1513                })
1514                .count();
1515            if numeric_count < 2 {
1516                return Err(RError::new(
1517                    RErrorKind::Argument,
1518                    format!(
1519                        "pairs() needs at least 2 numeric columns, got {numeric_count}. \
1520                         Non-numeric columns are skipped."
1521                    ),
1522                ));
1523            }
1524        }
1525        RValue::Vector(rv) => {
1526            let dims = super::get_dim_ints(rv.get_attr("dim"));
1527            if dims.is_none() {
1528                return Err(RError::new(
1529                    RErrorKind::Type,
1530                    "pairs() requires a data frame or matrix, not a plain vector. \
1531                     Use matrix() or data.frame() first."
1532                        .to_string(),
1533                ));
1534            }
1535            if !matches!(
1536                rv.inner,
1537                Vector::Double(_) | Vector::Integer(_) | Vector::Logical(_)
1538            ) {
1539                return Err(RError::new(
1540                    RErrorKind::Type,
1541                    "pairs() requires a numeric matrix".to_string(),
1542                ));
1543            }
1544        }
1545        RValue::List(_) => {
1546            return Err(RError::new(
1547                RErrorKind::Type,
1548                "pairs() requires a data frame (not a plain list). Use as.data.frame() first."
1549                    .to_string(),
1550            ));
1551        }
1552        _ => {
1553            return Err(RError::new(
1554                RErrorKind::Type,
1555                "pairs() requires a data frame or numeric matrix".to_string(),
1556            ));
1557        }
1558    }
1559
1560    // Extract numeric columns as (name, Vec<f64>) pairs
1561    let columns: Vec<(String, Vec<f64>)> = match x {
1562        RValue::List(list) => list
1563            .values
1564            .iter()
1565            .filter_map(|(name, val)| {
1566                if let RValue::Vector(rv) = val {
1567                    if matches!(
1568                        rv.inner,
1569                        Vector::Double(_) | Vector::Integer(_) | Vector::Logical(_)
1570                    ) {
1571                        let doubles: Vec<f64> = rv
1572                            .to_doubles()
1573                            .into_iter()
1574                            .map(|d| d.unwrap_or(f64::NAN))
1575                            .collect();
1576                        return Some((name.clone().unwrap_or_else(|| "?".to_string()), doubles));
1577                    }
1578                }
1579                None
1580            })
1581            .collect(),
1582        RValue::Vector(rv) => {
1583            let dims = super::get_dim_ints(rv.get_attr("dim")).unwrap_or_default();
1584            let nrow = dims.first().and_then(|d| *d).unwrap_or(0) as usize;
1585            let ncol = dims.get(1).and_then(|d| *d).unwrap_or(0) as usize;
1586            let all_doubles = rv.to_doubles();
1587            (0..ncol)
1588                .map(|c| {
1589                    let col: Vec<f64> = (0..nrow)
1590                        .map(|r| all_doubles[r + c * nrow].unwrap_or(f64::NAN))
1591                        .collect();
1592                    (format!("V{}", c + 1), col)
1593                })
1594                .collect()
1595        }
1596        _ => unreachable!(), // validated above
1597    };
1598
1599    // Build PlotState with one Points item per pair
1600    send_current_plot(ctx)?; // flush any previous plot
1601    let mut state = PlotState::new();
1602    state.title = Some("Scatterplot Matrix".to_string());
1603    state.show_legend = true;
1604
1605    let colors: &[[u8; 4]] = &[
1606        [0, 0, 0, 255],
1607        [255, 0, 0, 255],
1608        [0, 0, 255, 255],
1609        [0, 128, 0, 255],
1610        [255, 165, 0, 255],
1611        [128, 0, 128, 255],
1612    ];
1613    let mut color_idx = 0;
1614    for i in 0..columns.len() {
1615        for j in (i + 1)..columns.len() {
1616            let (ref xname, ref xdata) = columns[i];
1617            let (ref yname, ref ydata) = columns[j];
1618            let len = xdata.len().min(ydata.len());
1619            state.items.push(PlotItem::Points {
1620                x: xdata[..len].to_vec(),
1621                y: ydata[..len].to_vec(),
1622                color: colors[color_idx % colors.len()],
1623                size: 3.0,
1624                shape: 1,
1625                label: Some(format!("{xname} vs {yname}")),
1626            });
1627            color_idx += 1;
1628        }
1629    }
1630
1631    *ctx.interpreter().current_plot.borrow_mut() = Some(state);
1632    send_current_plot(ctx)?;
1633    ctx.interpreter().set_invisible();
1634    Ok(RValue::Null)
1635}
1636
1637// endregion