Skip to main content

r/interpreter/builtins/
progress.rs

1//! Text progress bar builtins backed by the `indicatif` crate.
2//!
3//! R's `utils::txtProgressBar` API lets scripts report progress for
4//! long-running operations. This module stores progress bar state on the
5//! `Interpreter` struct as a `Vec<Option<ProgressBarState>>` — each bar is
6//! addressed by its index in this Vec. The R side sees an integer ID with
7//! class `"txtProgressBar"`.
8//!
9//! Builtins:
10//! - `txtProgressBar(min, max, style)` — create a bar, return integer ID
11//! - `setTxtProgressBar(pb, value)` — update the bar's position
12//! - `getTxtProgressBar(pb)` — return the bar's current value
13//! - `close(pb)` — finish and remove the bar (dispatched from the
14//!   connection-layer `close()` when the argument has class `"txtProgressBar"`)
15
16use indicatif::{ProgressBar, ProgressStyle};
17
18use super::CallArgs;
19use crate::interpreter::value::*;
20use crate::interpreter::BuiltinContext;
21use crate::interpreter::Interpreter;
22use minir_macros::interpreter_builtin;
23
24// region: ProgressBarState
25
26/// Per-bar state stored on the interpreter.
27pub struct ProgressBarState {
28    /// The `indicatif` bar handle.
29    bar: ProgressBar,
30    /// The R-level `min` value (default 0.0).
31    min: f64,
32    /// The R-level `max` value (default 1.0).
33    max: f64,
34    /// Current R-level value (between `min` and `max`).
35    value: f64,
36}
37
38// endregion
39
40// region: Interpreter helpers
41
42impl Interpreter {
43    /// Allocate a new progress bar, returning its integer ID.
44    pub(crate) fn add_progress_bar(&self, state: ProgressBarState) -> usize {
45        let mut bars = self.progress_bars.borrow_mut();
46        let id = bars.len();
47        bars.push(Some(state));
48        id
49    }
50
51    /// Finish and remove a progress bar by ID. Returns `true` if the bar
52    /// existed and was removed.
53    pub(crate) fn close_progress_bar(&self, id: usize) -> bool {
54        let mut bars = self.progress_bars.borrow_mut();
55        if let Some(slot) = bars.get_mut(id) {
56            if let Some(state) = slot.take() {
57                state.bar.finish_and_clear();
58                return true;
59            }
60        }
61        false
62    }
63}
64
65// endregion
66
67// region: Helpers
68
69/// Build an integer scalar with class `"txtProgressBar"` representing bar `id`.
70fn progress_bar_value(id: usize) -> RValue {
71    let mut rv = RVector::from(Vector::Integer(
72        vec![Some(i64::try_from(id).unwrap_or(0))].into(),
73    ));
74    rv.set_attr(
75        "class".to_string(),
76        RValue::vec(Vector::Character(
77            vec![Some("txtProgressBar".to_string())].into(),
78        )),
79    );
80    RValue::Vector(rv)
81}
82
83/// Extract a progress bar ID from an argument that carries class `"txtProgressBar"`.
84fn progress_bar_id(val: &RValue) -> Option<usize> {
85    val.as_vector()
86        .and_then(|v| v.as_integer_scalar())
87        .and_then(|i| usize::try_from(i).ok())
88}
89
90/// Returns `true` if `val` carries the `"txtProgressBar"` class attribute.
91pub fn is_progress_bar(val: &RValue) -> bool {
92    match val {
93        RValue::Vector(rv) => rv
94            .class()
95            .map(|cls| cls.iter().any(|c| c == "txtProgressBar"))
96            .unwrap_or(false),
97        _ => false,
98    }
99}
100
101/// Map an R-level value in `[min, max]` to a `u64` position in `[0, total]`.
102fn value_to_position(value: f64, min: f64, max: f64, total: u64) -> u64 {
103    if max <= min {
104        return 0;
105    }
106    let fraction = ((value - min) / (max - min)).clamp(0.0, 1.0);
107    // Use f64 -> u64 via rounding to avoid lossy `as` cast.
108    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
109    let pos = (fraction * total as f64).round() as u64;
110    pos
111}
112
113/// Build an indicatif `ProgressStyle` for the given R style number.
114fn style_for(style: i64) -> ProgressStyle {
115    match style {
116        1 => ProgressStyle::with_template("  |{bar:50}|")
117            .expect("valid progress template")
118            .progress_chars("= "),
119        // Style 2: no bar, just percentage
120        2 => ProgressStyle::with_template("  {percent}%")
121            .expect("valid progress template")
122            .progress_chars("= "),
123        // Style 3 (default): bar with percentage
124        _ => ProgressStyle::with_template("  |{bar:50}| {percent}%")
125            .expect("valid progress template")
126            .progress_chars("= "),
127    }
128}
129
130// endregion
131
132// region: Builtins
133
134/// Create a text progress bar.
135///
136/// Returns an integer ID with class "txtProgressBar". The bar is immediately
137/// visible in the terminal.
138///
139/// @param min numeric scalar: minimum value (default 0)
140/// @param max numeric scalar: maximum value (default 1)
141/// @param style integer scalar: display style 1, 2, or 3 (default 3)
142/// @return integer scalar with class "txtProgressBar"
143#[interpreter_builtin(name = "txtProgressBar")]
144fn interp_txt_progress_bar(
145    args: &[RValue],
146    named: &[(String, RValue)],
147    context: &BuiltinContext,
148) -> Result<RValue, RError> {
149    let call_args = CallArgs::new(args, named);
150
151    let min = call_args
152        .value("min", 0)
153        .and_then(|v| v.as_vector()?.as_double_scalar())
154        .unwrap_or(0.0);
155    let max = call_args
156        .value("max", 1)
157        .and_then(|v| v.as_vector()?.as_double_scalar())
158        .unwrap_or(1.0);
159    let style = call_args.integer_or("style", 2, 3);
160
161    if max <= min {
162        return Err(RError::new(
163            RErrorKind::Argument,
164            format!("'max' ({max}) must be greater than 'min' ({min}) in txtProgressBar()"),
165        ));
166    }
167
168    let total = 1000u64; // internal resolution
169    let bar = ProgressBar::new(total);
170    bar.set_style(style_for(style));
171    bar.set_position(0);
172
173    let state = ProgressBarState {
174        bar,
175        min,
176        max,
177        value: min,
178    };
179
180    let interp = context.interpreter();
181    let id = interp.add_progress_bar(state);
182    Ok(progress_bar_value(id))
183}
184
185/// Update a text progress bar's position.
186///
187/// @param pb integer scalar with class "txtProgressBar": the bar ID
188/// @param value numeric scalar: the new position (between min and max)
189/// @return NULL (invisibly)
190#[interpreter_builtin(name = "setTxtProgressBar", min_args = 2)]
191fn interp_set_txt_progress_bar(
192    args: &[RValue],
193    named: &[(String, RValue)],
194    context: &BuiltinContext,
195) -> Result<RValue, RError> {
196    let call_args = CallArgs::new(args, named);
197
198    let pb_val = call_args
199        .value("pb", 0)
200        .ok_or_else(|| RError::new(RErrorKind::Argument, "argument 'pb' is missing".to_string()))?;
201    let id = progress_bar_id(pb_val).ok_or_else(|| {
202        RError::new(
203            RErrorKind::Argument,
204            "argument 'pb' is not a valid txtProgressBar".to_string(),
205        )
206    })?;
207
208    let value = call_args
209        .value("value", 1)
210        .and_then(|v| v.as_vector()?.as_double_scalar())
211        .ok_or_else(|| {
212            RError::new(
213                RErrorKind::Argument,
214                "argument 'value' is missing or not numeric".to_string(),
215            )
216        })?;
217
218    let interp = context.interpreter();
219    let mut bars = interp.progress_bars.borrow_mut();
220    if let Some(Some(state)) = bars.get_mut(id) {
221        state.value = value;
222        let total = state.bar.length().unwrap_or(1000);
223        let pos = value_to_position(value, state.min, state.max, total);
224        state.bar.set_position(pos);
225    } else {
226        return Err(RError::new(
227            RErrorKind::Argument,
228            format!("progress bar {id} has been closed or does not exist"),
229        ));
230    }
231
232    context.interpreter().set_invisible();
233    Ok(RValue::Null)
234}
235
236/// Get the current value of a text progress bar.
237///
238/// @param pb integer scalar with class "txtProgressBar": the bar ID
239/// @return numeric scalar: the current value
240#[interpreter_builtin(name = "getTxtProgressBar", min_args = 1)]
241fn interp_get_txt_progress_bar(
242    args: &[RValue],
243    named: &[(String, RValue)],
244    context: &BuiltinContext,
245) -> Result<RValue, RError> {
246    let call_args = CallArgs::new(args, named);
247
248    let pb_val = call_args
249        .value("pb", 0)
250        .ok_or_else(|| RError::new(RErrorKind::Argument, "argument 'pb' is missing".to_string()))?;
251    let id = progress_bar_id(pb_val).ok_or_else(|| {
252        RError::new(
253            RErrorKind::Argument,
254            "argument 'pb' is not a valid txtProgressBar".to_string(),
255        )
256    })?;
257
258    let interp = context.interpreter();
259    let bars = interp.progress_bars.borrow();
260    if let Some(Some(state)) = bars.get(id) {
261        Ok(RValue::vec(Vector::Double(vec![Some(state.value)].into())))
262    } else {
263        Err(RError::new(
264            RErrorKind::Argument,
265            format!("progress bar {id} has been closed or does not exist"),
266        ))
267    }
268}
269
270// endregion