ratatui_widgets/
toggle_switch.rs

1use crate::events::{EventHandler, Key, KeyPressedEvent};
2use itertools::Itertools;
3use ratatui::{
4    buffer::Buffer,
5    layout::{Constraint, Layout, Rect},
6    style::{Color, Stylize},
7    text::Text,
8    widgets::Widget,
9};
10
11/// A toggle switch widget
12///
13/// Displays a switch that can be toggled on or off
14///
15/// # Examples
16///
17/// ```rust
18/// use ratatui::widgets::Widget;
19/// use ratatui_widgets::toggle_switch::{ToggleSwitch, State};
20///
21/// # fn draw(frame: &mut ratatui::Frame) {
22/// let toggle_switch = ToggleSwitch::new("Toggle me", State::Off);
23/// frame.render_widget(toggle_switch, frame.area());
24/// # }
25/// ```
26#[derive(Debug, Clone)]
27pub struct ToggleSwitch<'text> {
28    text: Text<'text>,
29    theme: Theme,
30    state: State,
31    focus: Focus,
32}
33
34#[derive(Default, PartialEq, Eq, Clone, Debug, Copy)]
35pub enum State {
36    On,
37    #[default]
38    Off,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Focus {
43    Focused,
44    Unfocused,
45}
46
47#[derive(Copy, Clone, Debug)]
48pub struct Theme {
49    focused_text: Color,
50
51    focused_on_fg: Color,
52    focused_on_bg_main: Color,
53    focused_on_bg_highlight: Color,
54    focused_on_bg_shadow: Color,
55
56    focused_off_fg: Color,
57    focused_off_bg_main: Color,
58    focused_off_bg_highlight: Color,
59    focused_off_bg_shadow: Color,
60
61    unfocused_text: Color,
62
63    unfocused_on_fg: Color,
64    unfocused_on_bg_main: Color,
65    unfocused_on_bg_highlight: Color,
66    unfocused_on_bg_shadow: Color,
67
68    unfocused_off_fg: Color,
69    unfocused_off_bg_main: Color,
70    unfocused_off_bg_highlight: Color,
71    unfocused_off_bg_shadow: Color,
72}
73
74impl Default for Theme {
75    fn default() -> Self {
76        themes::NORMAL
77    }
78}
79
80impl<'text> ToggleSwitch<'text> {
81    pub fn new<T: Into<Text<'text>>>(text: T, default_state: State) -> Self {
82        Self {
83            text: text.into(),
84            theme: Theme::default(),
85            state: default_state,
86            focus: Focus::Unfocused,
87        }
88    }
89
90    pub fn with_theme(mut self, theme: Theme) -> Self {
91        self.theme = theme;
92        self
93    }
94}
95
96impl EventHandler for ToggleSwitch<'_> {
97    fn handle_key(&mut self, key_event: KeyPressedEvent) {
98        match key_event.key {
99            Key::Char(' ') | Key::Enter => self.toggle_state(),
100            Key::Char('h') | Key::Left => self.toggle_off(),
101            Key::Char('l') | Key::Right => self.toggle_on(),
102            _ => {}
103        }
104    }
105}
106
107impl ToggleSwitch<'_> {
108    pub fn toggle_state(&mut self) {
109        self.focus();
110        match self.state {
111            State::On => self.toggle_off(),
112            State::Off => self.toggle_on(),
113        }
114    }
115
116    pub fn toggle_on(&mut self) {
117        self.state = State::On;
118    }
119
120    pub fn toggle_off(&mut self) {
121        self.state = State::Off;
122    }
123
124    pub fn focus(&mut self) {
125        self.focus = Focus::Focused;
126    }
127
128    pub fn blur(&mut self) {
129        self.focus = Focus::Unfocused;
130    }
131}
132
133impl Widget for &ToggleSwitch<'_> {
134    fn render(self, area: Rect, buf: &mut Buffer) {
135        let theme = self.theme;
136
137        // TODO: refactor this to use a more generic approach
138        let (tick_fg, tick_bg, cross_fg, cross_bg) = match (self.focus, self.state) {
139            (Focus::Focused, State::On) => (
140                theme.focused_on_fg,
141                theme.focused_on_bg_main,
142                theme.focused_off_fg,
143                theme.focused_off_bg_main,
144            ),
145            (Focus::Focused, State::Off) => (
146                theme.focused_off_fg,
147                theme.focused_off_bg_main,
148                theme.focused_on_fg,
149                theme.focused_on_bg_main,
150            ),
151            (Focus::Unfocused, State::On) => (
152                theme.unfocused_on_fg,
153                theme.unfocused_on_bg_main,
154                theme.unfocused_off_fg,
155                theme.unfocused_off_bg_main,
156            ),
157            (Focus::Unfocused, State::Off) => (
158                theme.unfocused_off_fg,
159                theme.unfocused_off_bg_main,
160                theme.unfocused_on_fg,
161                theme.unfocused_on_bg_main,
162            ),
163        };
164
165        let (tick_highlight, tick_shadow, cross_highlight, cross_shadow) =
166            match (self.state, self.focus) {
167                (State::On, Focus::Focused) => (
168                    theme.focused_on_bg_highlight,
169                    theme.focused_on_bg_shadow,
170                    theme.focused_off_bg_highlight,
171                    theme.focused_off_bg_shadow,
172                ),
173                (State::On, Focus::Unfocused) => (
174                    theme.unfocused_on_bg_highlight,
175                    theme.unfocused_on_bg_shadow,
176                    theme.unfocused_off_bg_highlight,
177                    theme.unfocused_off_bg_shadow,
178                ),
179                (State::Off, Focus::Focused) => (
180                    theme.focused_off_bg_highlight,
181                    theme.focused_off_bg_shadow,
182                    theme.focused_on_bg_highlight,
183                    theme.focused_on_bg_shadow,
184                ),
185                (State::Off, Focus::Unfocused) => (
186                    theme.unfocused_off_bg_highlight,
187                    theme.unfocused_off_bg_shadow,
188                    theme.unfocused_on_bg_highlight,
189                    theme.unfocused_on_bg_shadow,
190                ),
191            };
192
193        let [switch, label] = Layout::horizontal([Constraint::Max(10), Constraint::Fill(1)])
194            .spacing(2)
195            .areas(area);
196        let [cross, tick] = Layout::horizontal([Constraint::Fill(1); 2]).areas(switch);
197
198        buf.set_style(cross, (cross_fg, cross_bg));
199        buf.set_style(tick, (tick_fg, tick_bg));
200
201        let rows = switch.rows().collect_vec();
202        let last_index = rows.len().saturating_sub(1);
203        let (first, middle, last) = match rows.len() {
204            0 | 1 => (None, &rows[..], None),
205            2 => (None, &rows[..last_index], Some(rows[last_index])),
206            _ => (Some(rows[0]), &rows[1..last_index], Some(rows[last_index])),
207        };
208
209        // render top line if there's enough space
210        if let Some(first) = first {
211            let [left, right] = Layout::horizontal([Constraint::Fill(1); 2]).areas(first);
212            "▔"
213                .repeat(cross.width as usize)
214                .fg(cross_highlight)
215                .bg(cross_bg)
216                .render(left, buf);
217            "▔"
218                .repeat(tick.width as usize)
219                .fg(tick_highlight)
220                .bg(tick_bg)
221                .render(right, buf);
222        }
223        // render bottom line if there's enough space
224        if let Some(last) = last {
225            let [left, right] = Layout::horizontal([Constraint::Fill(1); 2]).areas(last);
226            "▁"
227                .repeat(cross.width as usize)
228                .fg(cross_shadow)
229                .bg(cross_bg)
230                .render(left, buf);
231            "▁"
232                .repeat(tick.width as usize)
233                .fg(tick_shadow)
234                .bg(tick_bg)
235                .render(right, buf);
236        }
237        let text_style = match self.focus {
238            Focus::Focused => theme.focused_text,
239            Focus::Unfocused => theme.unfocused_text,
240        };
241        buf.set_style(label, text_style);
242        let middle_row_index = label.height as usize / 2;
243        let middle_row = label.rows().collect_vec()[middle_row_index];
244        self.text.clone().left_aligned().render(middle_row, buf);
245
246        let middle_row_index = middle.len() / 2;
247        let middle_row = middle[middle_row_index];
248        let [cross, tick] = Layout::horizontal([Constraint::Fill(1); 2]).areas(middle_row);
249        Text::from("✗").centered().render(cross, buf);
250        Text::from("✓").centered().render(tick, buf);
251    }
252}
253
254pub mod themes {
255    use super::Theme;
256    use ratatui::style::palette::tailwind;
257
258    pub const NORMAL: Theme = Theme {
259        focused_text: tailwind::SLATE.c300,
260
261        focused_on_fg: tailwind::BLUE.c100,
262        focused_on_bg_main: tailwind::BLUE.c500,
263        focused_on_bg_highlight: tailwind::BLUE.c300,
264        focused_on_bg_shadow: tailwind::BLUE.c700,
265
266        focused_off_fg: tailwind::SLATE.c300,
267        focused_off_bg_main: tailwind::SLATE.c700,
268        focused_off_bg_highlight: tailwind::SLATE.c500,
269        focused_off_bg_shadow: tailwind::SLATE.c900,
270
271        unfocused_text: tailwind::SLATE.c400,
272
273        unfocused_on_fg: tailwind::BLUE.c200,
274        unfocused_on_bg_main: tailwind::BLUE.c600,
275        unfocused_on_bg_highlight: tailwind::BLUE.c400,
276        unfocused_on_bg_shadow: tailwind::BLUE.c800,
277
278        unfocused_off_fg: tailwind::SLATE.c400,
279        unfocused_off_bg_main: tailwind::SLATE.c800,
280        unfocused_off_bg_highlight: tailwind::SLATE.c600,
281        unfocused_off_bg_shadow: tailwind::SLATE.c950,
282    };
283}