custom_widget/
custom_widget.rs

1//! # [Ratatui] Custom Widget example
2//!
3//! The latest version of this example is available in the [examples] folder in the repository.
4//!
5//! Please note that the examples are designed to be run against the `main` branch of the Github
6//! repository. This means that you may not be able to compile with the latest release version on
7//! crates.io, or the one that you have installed locally.
8//!
9//! See the [examples readme] for more information on finding examples that match the version of the
10//! library you are using.
11//!
12//! [Ratatui]: https://github.com/ratatui/ratatui
13//! [examples]: https://github.com/ratatui/ratatui/blob/main/examples
14//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md
15
16use std::{io::stdout, ops::ControlFlow, time::Duration};
17
18use color_eyre::Result;
19use ratatui::{
20    buffer::Buffer,
21    crossterm::{
22        event::{
23            self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, MouseButton, MouseEvent,
24            MouseEventKind,
25        },
26        execute,
27    },
28    layout::{Constraint, Layout, Rect},
29    style::{Color, Style},
30    text::Line,
31    widgets::{Paragraph, Widget},
32    DefaultTerminal, Frame,
33};
34
35fn main() -> Result<()> {
36    color_eyre::install()?;
37    let terminal = ratatui::init();
38    execute!(stdout(), EnableMouseCapture)?;
39    let app_result = run(terminal);
40    ratatui::restore();
41    if let Err(err) = execute!(stdout(), DisableMouseCapture) {
42        eprintln!("Error disabling mouse capture: {err}");
43    }
44    app_result
45}
46
47/// A custom widget that renders a button with a label, theme and state.
48#[derive(Debug, Clone)]
49struct Button<'a> {
50    label: Line<'a>,
51    theme: Theme,
52    state: State,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56enum State {
57    Normal,
58    Selected,
59    Active,
60}
61
62#[derive(Debug, Clone, Copy)]
63struct Theme {
64    text: Color,
65    background: Color,
66    highlight: Color,
67    shadow: Color,
68}
69
70const BLUE: Theme = Theme {
71    text: Color::Rgb(16, 24, 48),
72    background: Color::Rgb(48, 72, 144),
73    highlight: Color::Rgb(64, 96, 192),
74    shadow: Color::Rgb(32, 48, 96),
75};
76
77const RED: Theme = Theme {
78    text: Color::Rgb(48, 16, 16),
79    background: Color::Rgb(144, 48, 48),
80    highlight: Color::Rgb(192, 64, 64),
81    shadow: Color::Rgb(96, 32, 32),
82};
83
84const GREEN: Theme = Theme {
85    text: Color::Rgb(16, 48, 16),
86    background: Color::Rgb(48, 144, 48),
87    highlight: Color::Rgb(64, 192, 64),
88    shadow: Color::Rgb(32, 96, 32),
89};
90
91/// A button with a label that can be themed.
92impl<'a> Button<'a> {
93    pub fn new<T: Into<Line<'a>>>(label: T) -> Self {
94        Button {
95            label: label.into(),
96            theme: BLUE,
97            state: State::Normal,
98        }
99    }
100
101    pub const fn theme(mut self, theme: Theme) -> Self {
102        self.theme = theme;
103        self
104    }
105
106    pub const fn state(mut self, state: State) -> Self {
107        self.state = state;
108        self
109    }
110}
111
112impl<'a> Widget for Button<'a> {
113    #[allow(clippy::cast_possible_truncation)]
114    fn render(self, area: Rect, buf: &mut Buffer) {
115        let (background, text, shadow, highlight) = self.colors();
116        buf.set_style(area, Style::new().bg(background).fg(text));
117
118        // render top line if there's enough space
119        if area.height > 2 {
120            buf.set_string(
121                area.x,
122                area.y,
123                "▔".repeat(area.width as usize),
124                Style::new().fg(highlight).bg(background),
125            );
126        }
127        // render bottom line if there's enough space
128        if area.height > 1 {
129            buf.set_string(
130                area.x,
131                area.y + area.height - 1,
132                "▁".repeat(area.width as usize),
133                Style::new().fg(shadow).bg(background),
134            );
135        }
136        // render label centered
137        buf.set_line(
138            area.x + (area.width.saturating_sub(self.label.width() as u16)) / 2,
139            area.y + (area.height.saturating_sub(1)) / 2,
140            &self.label,
141            area.width,
142        );
143    }
144}
145
146impl Button<'_> {
147    const fn colors(&self) -> (Color, Color, Color, Color) {
148        let theme = self.theme;
149        match self.state {
150            State::Normal => (theme.background, theme.text, theme.shadow, theme.highlight),
151            State::Selected => (theme.highlight, theme.text, theme.shadow, theme.highlight),
152            State::Active => (theme.background, theme.text, theme.highlight, theme.shadow),
153        }
154    }
155}
156
157fn run(mut terminal: DefaultTerminal) -> Result<()> {
158    let mut selected_button: usize = 0;
159    let mut button_states = [State::Selected, State::Normal, State::Normal];
160    loop {
161        terminal.draw(|frame| draw(frame, button_states))?;
162        if !event::poll(Duration::from_millis(100))? {
163            continue;
164        }
165        match event::read()? {
166            Event::Key(key) => {
167                if key.kind != event::KeyEventKind::Press {
168                    continue;
169                }
170                if handle_key_event(key, &mut button_states, &mut selected_button).is_break() {
171                    break;
172                }
173            }
174            Event::Mouse(mouse) => {
175                handle_mouse_event(mouse, &mut button_states, &mut selected_button);
176            }
177            _ => (),
178        }
179    }
180    Ok(())
181}
182
183fn draw(frame: &mut Frame, states: [State; 3]) {
184    let vertical = Layout::vertical([
185        Constraint::Length(1),
186        Constraint::Max(3),
187        Constraint::Length(1),
188        Constraint::Min(0), // ignore remaining space
189    ]);
190    let [title, buttons, help, _] = vertical.areas(frame.area());
191
192    frame.render_widget(
193        Paragraph::new("Custom Widget Example (mouse enabled)"),
194        title,
195    );
196    render_buttons(frame, buttons, states);
197    frame.render_widget(Paragraph::new("←/→: select, Space: toggle, q: quit"), help);
198}
199
200fn render_buttons(frame: &mut Frame<'_>, area: Rect, states: [State; 3]) {
201    let horizontal = Layout::horizontal([
202        Constraint::Length(15),
203        Constraint::Length(15),
204        Constraint::Length(15),
205        Constraint::Min(0), // ignore remaining space
206    ]);
207    let [red, green, blue, _] = horizontal.areas(area);
208
209    frame.render_widget(Button::new("Red").theme(RED).state(states[0]), red);
210    frame.render_widget(Button::new("Green").theme(GREEN).state(states[1]), green);
211    frame.render_widget(Button::new("Blue").theme(BLUE).state(states[2]), blue);
212}
213
214fn handle_key_event(
215    key: event::KeyEvent,
216    button_states: &mut [State; 3],
217    selected_button: &mut usize,
218) -> ControlFlow<()> {
219    match key.code {
220        KeyCode::Char('q') => return ControlFlow::Break(()),
221        KeyCode::Left | KeyCode::Char('h') => {
222            button_states[*selected_button] = State::Normal;
223            *selected_button = selected_button.saturating_sub(1);
224            button_states[*selected_button] = State::Selected;
225        }
226        KeyCode::Right | KeyCode::Char('l') => {
227            button_states[*selected_button] = State::Normal;
228            *selected_button = selected_button.saturating_add(1).min(2);
229            button_states[*selected_button] = State::Selected;
230        }
231        KeyCode::Char(' ') => {
232            if button_states[*selected_button] == State::Active {
233                button_states[*selected_button] = State::Normal;
234            } else {
235                button_states[*selected_button] = State::Active;
236            }
237        }
238        _ => (),
239    }
240    ControlFlow::Continue(())
241}
242
243fn handle_mouse_event(
244    mouse: MouseEvent,
245    button_states: &mut [State; 3],
246    selected_button: &mut usize,
247) {
248    match mouse.kind {
249        MouseEventKind::Moved => {
250            let old_selected_button = *selected_button;
251            *selected_button = match mouse.column {
252                x if x < 15 => 0,
253                x if x < 30 => 1,
254                _ => 2,
255            };
256            if old_selected_button != *selected_button {
257                if button_states[old_selected_button] != State::Active {
258                    button_states[old_selected_button] = State::Normal;
259                }
260                if button_states[*selected_button] != State::Active {
261                    button_states[*selected_button] = State::Selected;
262                }
263            }
264        }
265        MouseEventKind::Down(MouseButton::Left) => {
266            if button_states[*selected_button] == State::Active {
267                button_states[*selected_button] = State::Normal;
268            } else {
269                button_states[*selected_button] = State::Active;
270            }
271        }
272        _ => (),
273    }
274}