envision 0.15.1

A ratatui framework for collaborative TUI development with headless testing support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
//! Histogram component for frequency distribution visualization.
//!
//! Takes raw continuous data, automatically bins it, and displays the
//! frequency distribution as vertical bars using ratatui's `BarChart`
//! widget.
//!
//! # Adaptive Binning
//!
//! By default, the histogram uses a fixed bin count (10). You can choose
//! an adaptive binning method that computes the optimal number of bins
//! based on the data:
//!
//! - [`BinMethod::Fixed`] — a user-specified number of bins (default: 10).
//! - [`BinMethod::Sturges`] — `ceil(log2(n) + 1)`, good for roughly normal data.
//! - [`BinMethod::SquareRoot`] — `ceil(sqrt(n))`, a simple rule of thumb.
//! - [`BinMethod::Scott`] — `ceil(range / (3.49 * std * n^(-1/3)))`, optimal for normal data.
//! - [`BinMethod::FreedmanDiaconis`] — `ceil(range / (2 * IQR * n^(-1/3)))`, robust to outliers.
//!
//! # Example
//!
//! ```rust
//! use envision::component::{Component, Histogram, HistogramState};
//!
//! let state = HistogramState::with_data(vec![1.0, 2.0, 2.5, 3.0, 3.5, 4.0]);
//! assert_eq!(state.data().len(), 6);
//! assert_eq!(state.bin_count(), 10);
//! ```

mod state;

use std::marker::PhantomData;

use ratatui::prelude::*;
use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders};

use super::{Component, EventContext, RenderContext};
use crate::input::Event;

/// Strategy for computing the number of histogram bins.
///
/// The default is `Fixed(10)`, which uses a static bin count. Adaptive
/// methods compute the bin count from the data at render time so the
/// histogram automatically adjusts as data changes.
///
/// All adaptive methods clamp the result to the range `[1, 200]`.
///
/// # Example
///
/// ```rust
/// use envision::component::{BinMethod, HistogramState};
///
/// let state = HistogramState::with_data(vec![1.0, 2.0, 3.0, 4.0])
///     .with_bin_method(BinMethod::Sturges);
/// assert_eq!(state.bin_method(), &BinMethod::Sturges);
/// ```
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
    feature = "serialization",
    derive(serde::Serialize, serde::Deserialize)
)]
pub enum BinMethod {
    /// A fixed, user-specified number of bins.
    Fixed(usize),
    /// Freedman-Diaconis rule: `width = 2 * IQR * n^(-1/3)`, `bins = ceil(range / width)`.
    ///
    /// Robust to outliers because it uses the interquartile range.
    FreedmanDiaconis,
    /// Sturges' formula: `ceil(log2(n) + 1)`.
    ///
    /// Works well for roughly normal data but can undercount bins for
    /// large datasets.
    Sturges,
    /// Scott's normal reference rule: `width = 3.49 * std * n^(-1/3)`,
    /// `bins = ceil(range / width)`.
    ///
    /// Optimal for data drawn from a normal distribution.
    Scott,
    /// Square-root rule: `ceil(sqrt(n))`.
    ///
    /// A simple rule of thumb used in many applications.
    SquareRoot,
}

impl Default for BinMethod {
    fn default() -> Self {
        BinMethod::Fixed(10)
    }
}

/// The minimum number of bins any adaptive method can produce.
const MIN_BINS: usize = 1;

/// The maximum number of bins any adaptive method can produce.
const MAX_BINS: usize = 200;

impl BinMethod {
    /// Computes the effective bin count for the given data.
    ///
    /// For `Fixed(n)`, the value is returned directly (clamped to at least 1).
    /// For adaptive methods, the algorithm inspects the data and clamps the
    /// result to `[1, 200]`.
    ///
    /// # Example
    ///
    /// ```rust
    /// use envision::component::BinMethod;
    ///
    /// let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
    /// assert_eq!(BinMethod::SquareRoot.compute_bin_count(&data), 10);
    /// assert_eq!(BinMethod::Sturges.compute_bin_count(&data), 8);
    /// ```
    pub fn compute_bin_count(&self, data: &[f64]) -> usize {
        match self {
            BinMethod::Fixed(n) => (*n).max(1),
            BinMethod::Sturges => Self::sturges(data),
            BinMethod::SquareRoot => Self::square_root(data),
            BinMethod::Scott => Self::scott(data),
            BinMethod::FreedmanDiaconis => Self::freedman_diaconis(data),
        }
    }

    fn sturges(data: &[f64]) -> usize {
        if data.is_empty() {
            return MIN_BINS;
        }
        let n = data.len() as f64;
        let bins = (n.log2() + 1.0).ceil() as usize;
        bins.clamp(MIN_BINS, MAX_BINS)
    }

    fn square_root(data: &[f64]) -> usize {
        if data.is_empty() {
            return MIN_BINS;
        }
        let n = data.len() as f64;
        let bins = n.sqrt().ceil() as usize;
        bins.clamp(MIN_BINS, MAX_BINS)
    }

    fn scott(data: &[f64]) -> usize {
        if data.is_empty() {
            return MIN_BINS;
        }
        let n = data.len() as f64;
        let mean = data.iter().sum::<f64>() / n;
        let variance = data.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
        let std = variance.sqrt();
        if std < f64::EPSILON {
            return MIN_BINS;
        }
        let min = data.iter().copied().reduce(f64::min).unwrap_or(0.0);
        let max = data.iter().copied().reduce(f64::max).unwrap_or(0.0);
        let range = max - min;
        if range < f64::EPSILON {
            return MIN_BINS;
        }
        let width = 3.49 * std * n.powf(-1.0 / 3.0);
        if width < f64::EPSILON {
            return MIN_BINS;
        }
        let bins = (range / width).ceil() as usize;
        bins.clamp(MIN_BINS, MAX_BINS)
    }

    fn freedman_diaconis(data: &[f64]) -> usize {
        if data.is_empty() {
            return MIN_BINS;
        }
        let mut sorted = data.to_vec();
        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
        let n = sorted.len();
        let q1 = sorted[n / 4];
        let q3 = sorted[3 * n / 4];
        let iqr = q3 - q1;
        if iqr < f64::EPSILON {
            return MIN_BINS;
        }
        let min = sorted[0];
        let max = sorted[n - 1];
        let range = max - min;
        if range < f64::EPSILON {
            return MIN_BINS;
        }
        let width = 2.0 * iqr * (n as f64).powf(-1.0 / 3.0);
        if width < f64::EPSILON {
            return MIN_BINS;
        }
        let bins = (range / width).ceil() as usize;
        bins.clamp(MIN_BINS, MAX_BINS)
    }
}

/// State for a Histogram component.
///
/// Contains raw data points and configuration for binning and display.
///
/// # Example
///
/// ```rust
/// use envision::component::HistogramState;
///
/// let state = HistogramState::with_data(vec![10.0, 20.0, 30.0])
///     .with_bin_count(5)
///     .with_title("Latency Distribution");
/// assert_eq!(state.data().len(), 3);
/// assert_eq!(state.bin_count(), 5);
/// assert_eq!(state.title(), Some("Latency Distribution"));
/// ```
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
    feature = "serialization",
    derive(serde::Serialize, serde::Deserialize)
)]
pub struct HistogramState {
    /// Raw data points.
    data: Vec<f64>,
    /// Binning strategy (default: Fixed(10)).
    bin_method: BinMethod,
    /// Manual minimum value (None = auto from data).
    min_value: Option<f64>,
    /// Manual maximum value (None = auto from data).
    max_value: Option<f64>,
    /// Optional title.
    title: Option<String>,
    /// X-axis label.
    x_label: Option<String>,
    /// Y-axis label.
    y_label: Option<String>,
    /// Bar color.
    color: Option<Color>,
    /// Whether to show count labels on bars.
    show_counts: bool,
}

/// Messages that can be sent to a Histogram.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
    feature = "serialization",
    derive(serde::Serialize, serde::Deserialize)
)]
pub enum HistogramMessage {
    /// Replace all data points.
    SetData(Vec<f64>),
    /// Add a single data point.
    PushData(f64),
    /// Add multiple data points.
    PushDataBatch(Vec<f64>),
    /// Clear all data.
    Clear,
    /// Change the number of bins (sets bin method to `Fixed`).
    SetBinCount(usize),
    /// Change the binning strategy.
    SetBinMethod(BinMethod),
    /// Set the manual min/max range.
    SetRange(Option<f64>, Option<f64>),
}

/// A histogram component for frequency distribution visualization.
///
/// Takes raw continuous data, automatically bins it, and renders the
/// frequency distribution as vertical bars.
///
/// This is a display-only component. It does not handle keyboard events.
///
/// # Example
///
/// ```rust
/// use envision::component::{Component, Histogram, HistogramState};
///
/// let state = HistogramState::with_data(vec![1.0, 1.5, 2.0, 2.5, 3.0, 3.5])
///     .with_bin_count(3)
///     .with_title("Value Distribution");
/// let bins = state.compute_bins();
/// assert_eq!(bins.len(), 3);
/// ```
pub struct Histogram(PhantomData<()>);

impl Component for Histogram {
    type State = HistogramState;
    type Message = HistogramMessage;
    type Output = ();

    fn init() -> Self::State {
        HistogramState::default()
    }

    fn handle_event(
        _state: &Self::State,
        _event: &Event,
        _ctx: &EventContext,
    ) -> Option<Self::Message> {
        // Display-only component; no event handling.
        None
    }

    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
        match msg {
            HistogramMessage::SetData(data) => {
                state.data = data;
            }
            HistogramMessage::PushData(value) => {
                state.data.push(value);
            }
            HistogramMessage::PushDataBatch(values) => {
                state.data.extend(values);
            }
            HistogramMessage::Clear => {
                state.data.clear();
            }
            HistogramMessage::SetBinCount(count) => {
                state.bin_method = BinMethod::Fixed(count.max(1));
            }
            HistogramMessage::SetBinMethod(method) => {
                state.bin_method = method;
            }
            HistogramMessage::SetRange(min, max) => {
                state.min_value = min;
                state.max_value = max;
            }
        }
        None
    }

    fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
        if ctx.area.height < 3 || ctx.area.width < 3 {
            return;
        }

        crate::annotation::with_registry(|reg| {
            reg.register(
                ctx.area,
                crate::annotation::Annotation::container("histogram")
                    .with_focus(ctx.focused)
                    .with_disabled(ctx.disabled),
            );
        });

        let border_style = if ctx.disabled {
            ctx.theme.disabled_style()
        } else if ctx.focused {
            ctx.theme.focused_border_style()
        } else {
            ctx.theme.border_style()
        };

        let mut block = Block::default()
            .borders(Borders::ALL)
            .border_style(border_style);

        if let Some(ref title) = state.title {
            block = block.title(title.as_str());
        }

        let inner = block.inner(ctx.area);
        ctx.frame.render_widget(block, ctx.area);

        if inner.height == 0 || inner.width == 0 {
            return;
        }

        // Reserve space for axis labels
        let x_label_height = if state.x_label.is_some() { 1u16 } else { 0 };
        let y_label_height = if state.y_label.is_some() { 1u16 } else { 0 };

        let (chart_area, x_label_area, y_label_area) = if x_label_height > 0 || y_label_height > 0 {
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Length(y_label_height),
                    Constraint::Min(1),
                    Constraint::Length(x_label_height),
                ])
                .split(inner);
            (
                chunks[1],
                if x_label_height > 0 {
                    Some(chunks[2])
                } else {
                    None
                },
                if y_label_height > 0 {
                    Some(chunks[0])
                } else {
                    None
                },
            )
        } else {
            (inner, None, None)
        };

        // Render y-axis label above the chart
        if let Some(y_area) = y_label_area {
            if let Some(ref label) = state.y_label {
                let p = ratatui::widgets::Paragraph::new(label.as_str())
                    .alignment(Alignment::Left)
                    .style(Style::default().fg(Color::DarkGray));
                ctx.frame.render_widget(p, y_area);
            }
        }

        // Render x-axis label below the chart
        if let Some(x_area) = x_label_area {
            if let Some(ref label) = state.x_label {
                let p = ratatui::widgets::Paragraph::new(label.as_str())
                    .alignment(Alignment::Center)
                    .style(Style::default().fg(Color::DarkGray));
                ctx.frame.render_widget(p, x_area);
            }
        }

        // Compute bins and render bar chart
        let bins = state.compute_bins();
        let max_count = bins.iter().map(|(_, _, c)| *c).max().unwrap_or(0);

        let bar_color = state.color.unwrap_or(Color::Cyan);
        let bar_style = if ctx.disabled {
            ctx.theme.disabled_style()
        } else {
            Style::default().fg(bar_color)
        };

        let bars: Vec<Bar> = bins
            .iter()
            .map(|(start, end, count)| {
                let label = format!("{:.0}", (start + end) / 2.0);
                let mut bar = Bar::default()
                    .value(*count as u64)
                    .label(Line::from(label))
                    .style(bar_style);
                if state.show_counts {
                    bar = bar.text_value(format!("{}", count));
                }
                bar
            })
            .collect();

        let bar_group = BarGroup::default().bars(&bars);

        // Calculate bar width based on available space
        let bin_count = bins.len() as u16;
        let bar_width = if bin_count > 0 {
            // Each bar needs bar_width + gap (1). Total = bin_count * (bar_width + 1) - 1
            // Solve for bar_width: bar_width = (available + 1) / bin_count - 1
            let available = chart_area.width;
            let width = (available.saturating_add(1)) / bin_count.max(1);
            width.saturating_sub(1).max(1)
        } else {
            1
        };

        let chart = BarChart::default()
            .data(bar_group)
            .bar_width(bar_width)
            .bar_gap(1)
            .bar_style(bar_style)
            .max(max_count as u64);

        ctx.frame.render_widget(chart, chart_area);
    }
}

#[cfg(test)]
mod tests;