scrin 0.1.75

A terminal UI toolkit with panes, widgets, overlays, animations, and Aisling-powered effects/loaders.
Documentation
use crate::core::buffer::{Buffer, Cell};
use crate::core::color::Color;
use crate::core::rect::Rect;
use crate::widgets::Widget;

#[derive(Debug, Clone)]
pub struct Toggle {
    pub label: String,
    pub active: bool,
    pub active_color: Color,
    pub inactive_color: Color,
    pub label_color: Color,
    pub style: ToggleStyle,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ToggleStyle {
    /// ━━━━━━━●━━━━━  (slider)
    Slider,
    /// [●] / [○] (checkbox)
    Checkbox,
    /// ● / ○ (radio)
    Radio,
    /// ON / OFF
    Text,
    /// ████ / ░░░░ (block)
    Block,
}

impl Toggle {
    pub fn new(label: &str, active: bool) -> Self {
        Self {
            label: label.to_string(),
            active,
            active_color: Color::rgb(63, 185, 80),
            inactive_color: Color::rgb(110, 118, 129),
            label_color: Color::rgb(201, 209, 217),
            style: ToggleStyle::Slider,
        }
    }

    pub fn with_style(mut self, style: ToggleStyle) -> Self {
        self.style = style;
        self
    }

    pub fn with_active_color(mut self, c: Color) -> Self {
        self.active_color = c;
        self
    }

    pub fn with_inactive_color(mut self, c: Color) -> Self {
        self.inactive_color = c;
        self
    }

    pub fn with_label_color(mut self, c: Color) -> Self {
        self.label_color = c;
        self
    }

    pub fn toggle(&mut self) {
        self.active = !self.active;
    }

    pub fn set_active(&mut self, v: bool) {
        self.active = v;
    }
}

impl Widget for Toggle {
    fn render(&self, buffer: &mut Buffer, area: Rect) {
        if area.width == 0 || area.height == 0 {
            return;
        }
        clear_row_preserving_bg(buffer, area, self.label_color);

        let indicator_color = if self.active {
            self.active_color
        } else {
            self.inactive_color
        };
        let indicator = self.indicator(area.width as usize);
        let indicator_len = indicator.chars().count();
        write_preserving_bg(
            buffer,
            area.x as usize,
            area.y as usize,
            &indicator,
            indicator_color,
            area.width as usize,
        );

        if self.label.is_empty() || indicator_len >= area.width as usize {
            return;
        }

        let label_x = area.x as usize + indicator_len + 1;
        if label_x >= area.right() as usize {
            return;
        }
        let label_width = area.right() as usize - label_x;
        let label: String = self.label.chars().take(label_width).collect();
        write_preserving_bg(
            buffer,
            label_x,
            area.y as usize,
            &label,
            self.label_color,
            label_width,
        );
    }
}

impl Toggle {
    fn indicator(&self, available_width: usize) -> String {
        match self.style {
            ToggleStyle::Slider => self.slider_indicator(available_width),
            ToggleStyle::Checkbox => {
                let ch = if self.active { '' } else { '' };
                format!("[{}]", ch)
            }
            ToggleStyle::Radio => {
                if self.active {
                    "".to_string()
                } else {
                    "".to_string()
                }
            }
            ToggleStyle::Text => {
                if self.active {
                    "[ON]".to_string()
                } else {
                    "[OFF]".to_string()
                }
            }
            ToggleStyle::Block => {
                let reserved_for_label = if self.label.is_empty() {
                    0
                } else {
                    self.label.chars().count().saturating_add(1)
                };
                let block_width = available_width
                    .saturating_sub(reserved_for_label)
                    .clamp(1, 10);
                let ch = if self.active { '' } else { '' };
                std::iter::repeat(ch).take(block_width).collect()
            }
        }
    }

    fn slider_indicator(&self, available_width: usize) -> String {
        let label_reserve = if self.label.is_empty() {
            0
        } else {
            self.label.chars().count().saturating_add(1)
        };
        let reserved_space = available_width.saturating_sub(label_reserve);
        let indicator_space = if reserved_space >= 3 {
            reserved_space
        } else {
            available_width
        };
        let track_width = indicator_space.saturating_sub(2).clamp(1, 12);
        let knob = if self.active {
            track_width.saturating_sub(1)
        } else {
            0
        };
        let mut slider = String::with_capacity(track_width + 2);
        slider.push('[');
        for i in 0..track_width {
            if i == knob {
                slider.push('');
            } else if self.active && i < knob {
                slider.push('');
            } else {
                slider.push('');
            }
        }
        slider.push(']');
        slider
    }
}

fn clear_row_preserving_bg(buffer: &mut Buffer, area: Rect, fg: Color) {
    let y = area.y as usize;
    for x in area.x as usize..area.right() as usize {
        let bg = buffer.get(x, y).and_then(|cell| cell.bg);
        buffer.set(x, y, Cell::new(' ', fg, bg));
    }
}

fn write_preserving_bg(
    buffer: &mut Buffer,
    x: usize,
    y: usize,
    text: &str,
    fg: Color,
    max_width: usize,
) {
    for (offset, ch) in text.chars().enumerate() {
        if offset >= max_width {
            break;
        }
        let cell_x = x + offset;
        if cell_x >= buffer.width || y >= buffer.height {
            break;
        }
        let bg = buffer.get(cell_x, y).and_then(|cell| cell.bg);
        buffer.set(cell_x, y, Cell::new(ch, fg, bg));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_toggle_creation() {
        let t = Toggle::new("Light", false);
        assert!(!t.active);
        assert_eq!(t.label, "Light");
    }

    #[test]
    fn test_toggle_switch() {
        let mut t = Toggle::new("Dark", false);
        t.toggle();
        assert!(t.active);
        t.toggle();
        assert!(!t.active);
    }

    #[test]
    fn test_toggle_render_no_panic() {
        let mut buf = Buffer::new(40, 5);
        let toggle = Toggle::new("Test", true).with_style(ToggleStyle::Checkbox);
        toggle.render(&mut buf, Rect::new(0, 0, 40, 1));
        assert_eq!(buf.get(0, 0).unwrap().ch, '[');
    }

    #[test]
    fn test_toggle_all_styles_render() {
        let mut buf = Buffer::new(40, 5);
        let styles = [
            ToggleStyle::Slider,
            ToggleStyle::Checkbox,
            ToggleStyle::Radio,
            ToggleStyle::Text,
            ToggleStyle::Block,
        ];
        for (i, style) in styles.iter().enumerate() {
            let toggle = Toggle::new("Test", true).with_style(*style);
            toggle.render(&mut buf, Rect::new(0, i as u16, 40, 1));
        }
    }

    #[test]
    fn test_toggle_render_too_small() {
        let mut buf = Buffer::new(2, 1);
        let toggle = Toggle::new("Test", true);
        toggle.render(&mut buf, Rect::new(0, 0, 2, 1));
    }

    #[test]
    fn test_toggle_uses_label_color() {
        let mut buf = Buffer::new(20, 1);
        let label_color = Color::rgb(255, 0, 128);
        let toggle = Toggle::new("Label", true)
            .with_style(ToggleStyle::Checkbox)
            .with_label_color(label_color);
        toggle.render(&mut buf, Rect::new(0, 0, 20, 1));
        assert_eq!(buf.get(4, 0).unwrap().fg, label_color);
    }

    #[test]
    fn test_slider_renders_knob_in_both_states() {
        let mut buf = Buffer::new(20, 2);
        Toggle::new("", false)
            .with_style(ToggleStyle::Slider)
            .render(&mut buf, Rect::new(0, 0, 20, 1));
        Toggle::new("", true)
            .with_style(ToggleStyle::Slider)
            .render(&mut buf, Rect::new(0, 1, 20, 1));
        assert_eq!(buf.get(1, 0).unwrap().ch, '');
        assert_eq!(buf.get(12, 1).unwrap().ch, '');
    }

    #[test]
    fn test_toggle_clears_stale_text() {
        let mut buf = Buffer::new(20, 1);
        let mut toggle = Toggle::new("X", false).with_style(ToggleStyle::Text);
        toggle.render(&mut buf, Rect::new(0, 0, 20, 1));
        toggle.set_active(true);
        toggle.render(&mut buf, Rect::new(0, 0, 20, 1));
        assert_eq!(buf.get(6, 0).unwrap().ch, ' ');
    }

    #[test]
    fn test_toggle_preserves_existing_background() {
        let bg = Color::rgb(1, 2, 3);
        let mut buf = Buffer::with_background(20, 1, Some(bg));
        Toggle::new("Label", true)
            .with_style(ToggleStyle::Checkbox)
            .render(&mut buf, Rect::new(0, 0, 20, 1));
        assert_eq!(buf.get(0, 0).unwrap().bg, Some(bg));
        assert_eq!(buf.get(4, 0).unwrap().bg, Some(bg));
    }
}