diff --git a/examples/custom_widget.rs b/examples/custom_widget.rs index f3e86b9b..89193918 100644 --- a/examples/custom_widget.rs +++ b/examples/custom_widget.rs @@ -1,27 +1,121 @@ -use std::{error::Error, io}; +use std::{error::Error, io, ops::ControlFlow, time::Duration}; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, MouseButton, MouseEvent, + MouseEventKind, + }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{prelude::*, widgets::*}; -#[derive(Default)] -struct Label<'a> { - text: &'a str, +/// A custom widget that renders a button with a label, theme and state. +#[derive(Debug, Clone)] +struct Button<'a> { + label: Line<'a>, + theme: Theme, + state: State, } -impl<'a> Widget for Label<'a> { - fn render(self, area: Rect, buf: &mut Buffer) { - buf.set_string(area.left(), area.top(), self.text, Style::default()); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + Normal, + Selected, + Active, +} + +#[derive(Debug, Clone, Copy)] +struct Theme { + text: Color, + background: Color, + highlight: Color, + shadow: Color, +} + +const BLUE: Theme = Theme { + text: Color::Rgb(16, 24, 48), + background: Color::Rgb(48, 72, 144), + highlight: Color::Rgb(64, 96, 192), + shadow: Color::Rgb(32, 48, 96), +}; + +const RED: Theme = Theme { + text: Color::Rgb(48, 16, 16), + background: Color::Rgb(144, 48, 48), + highlight: Color::Rgb(192, 64, 64), + shadow: Color::Rgb(96, 32, 32), +}; + +const GREEN: Theme = Theme { + text: Color::Rgb(16, 48, 16), + background: Color::Rgb(48, 144, 48), + highlight: Color::Rgb(64, 192, 64), + shadow: Color::Rgb(32, 96, 32), +}; + +/// A button with a label that can be themed. +impl<'a> Button<'a> { + pub fn new>>(label: T) -> Button<'a> { + Button { + label: label.into(), + theme: BLUE, + state: State::Normal, + } + } + + pub fn theme(mut self, theme: Theme) -> Button<'a> { + self.theme = theme; + self + } + + pub fn state(mut self, state: State) -> Button<'a> { + self.state = state; + self } } -impl<'a> Label<'a> { - fn text(mut self, text: &'a str) -> Label<'a> { - self.text = text; - self +impl<'a> Widget for Button<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let (background, text, shadow, highlight) = self.colors(); + buf.set_style(area, Style::new().bg(background).fg(text)); + + // render top line if there's enough space + if area.height > 2 { + buf.set_string( + area.x, + area.y, + "▔".repeat(area.width as usize), + Style::new().fg(highlight).bg(background), + ); + } + // render bottom line if there's enough space + if area.height > 1 { + buf.set_string( + area.x, + area.y + area.height - 1, + "▁".repeat(area.width as usize), + Style::new().fg(shadow).bg(background), + ); + } + // render label centered + buf.set_line( + area.x + (area.width.saturating_sub(self.label.width() as u16)) / 2, + area.y + (area.height.saturating_sub(1)) / 2, + &self.label, + area.width, + ); + } +} + +impl Button<'_> { + fn colors(&self) -> (Color, Color, Color, Color) { + let theme = self.theme; + match self.state { + State::Normal => (theme.background, theme.text, theme.shadow, theme.highlight), + State::Selected => (theme.highlight, theme.text, theme.shadow, theme.highlight), + State::Active => (theme.background, theme.text, theme.highlight, theme.shadow), + } } } @@ -53,19 +147,122 @@ fn main() -> Result<(), Box> { } fn run_app(terminal: &mut Terminal) -> io::Result<()> { + let mut selected_button: usize = 0; + let button_states = &mut [State::Selected, State::Normal, State::Normal]; loop { - terminal.draw(ui)?; - - if let Event::Key(key) = event::read()? { - if let KeyCode::Char('q') = key.code { - return Ok(()); + terminal.draw(|frame| ui(frame, button_states))?; + if !event::poll(Duration::from_millis(100))? { + continue; + } + match event::read()? { + Event::Key(key) => { + if key.kind != event::KeyEventKind::Press { + continue; + } + if handle_key_event(key, button_states, &mut selected_button).is_break() { + break; + } } + Event::Mouse(mouse) => handle_mouse_event(mouse, button_states, &mut selected_button), + _ => (), } } + Ok(()) } -fn ui(f: &mut Frame) { - let size = f.size(); - let label = Label::default().text("Test"); - f.render_widget(label, size); +fn ui(frame: &mut Frame, states: &[State; 3]) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![ + Constraint::Length(1), + Constraint::Max(3), + Constraint::Length(1), + Constraint::Min(0), // ignore remaining space + ]) + .split(frame.size()); + frame.render_widget( + Paragraph::new("Custom Widget Example (mouse enabled)"), + layout[0], + ); + render_buttons(frame, layout[1], states); + frame.render_widget( + Paragraph::new("←/→: select, Space: toggle, q: quit"), + layout[2], + ); +} + +fn render_buttons(frame: &mut Frame<'_>, area: Rect, states: &[State; 3]) { + let layout = Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![ + Constraint::Length(15), + Constraint::Length(15), + Constraint::Length(15), + Constraint::Min(0), // ignore remaining space + ]) + .split(area); + frame.render_widget(Button::new("Red").theme(RED).state(states[0]), layout[0]); + frame.render_widget( + Button::new("Green").theme(GREEN).state(states[1]), + layout[1], + ); + frame.render_widget(Button::new("Blue").theme(BLUE).state(states[2]), layout[2]); +} + +fn handle_key_event( + key: event::KeyEvent, + button_states: &mut [State; 3], + selected_button: &mut usize, +) -> ControlFlow<()> { + match key.code { + KeyCode::Char('q') => return ControlFlow::Break(()), + KeyCode::Left => { + button_states[*selected_button] = State::Normal; + *selected_button = selected_button.saturating_sub(1); + button_states[*selected_button] = State::Selected; + } + KeyCode::Right => { + button_states[*selected_button] = State::Normal; + *selected_button = selected_button.saturating_add(1).min(2); + button_states[*selected_button] = State::Selected; + } + KeyCode::Char(' ') => { + if button_states[*selected_button] == State::Active { + button_states[*selected_button] = State::Normal; + } else { + button_states[*selected_button] = State::Active; + } + } + _ => (), + } + ControlFlow::Continue(()) +} + +fn handle_mouse_event( + mouse: MouseEvent, + button_states: &mut [State; 3], + selected_button: &mut usize, +) { + match mouse.kind { + MouseEventKind::Moved => { + let old_selected_button = *selected_button; + *selected_button = match mouse.column { + x if x < 15 => 0, + x if x < 30 => 1, + _ => 2, + }; + if old_selected_button != *selected_button { + button_states[old_selected_button] = State::Normal; + button_states[*selected_button] = State::Selected; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if button_states[*selected_button] == State::Active { + button_states[*selected_button] = State::Normal; + } else { + button_states[*selected_button] = State::Active; + } + } + _ => (), + } } diff --git a/examples/custom_widget.tape b/examples/custom_widget.tape index f9594a86..533b8a11 100644 --- a/examples/custom_widget.tape +++ b/examples/custom_widget.tape @@ -2,11 +2,20 @@ # To run this script, install vhs and run `vhs ./examples/custom_widget.tape` Output "target/custom_widget.gif" Set Theme "OceanicMaterial" -Set Width 1200 -Set Height 200 +Set Width 760 +Set Height 260 Hide Type "cargo run --example=custom_widget --features=crossterm" Enter -Sleep 1s +Sleep 2s Show -Sleep 5s +Sleep 1s +Set TypingSpeed 0.7s +Right +Right +Space +Space +Left +Space +Left +Space \ No newline at end of file