Skip to content

Commit

Permalink
docs(examples): make custom widget example into a button (ratatui-org…
Browse files Browse the repository at this point in the history
…#539)

The widget also now supports mouse
  • Loading branch information
joshka authored and test committed Oct 6, 2023
1 parent d241ca6 commit 31d18e0
Show file tree
Hide file tree
Showing 2 changed files with 229 additions and 23 deletions.
235 changes: 216 additions & 19 deletions examples/custom_widget.rs
Original file line number Diff line number Diff line change
@@ -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> {
#[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<T: Into<Line<'a>>>(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> Widget for Button<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
buf.set_string(area.left(), area.top(), self.text, Style::default());
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<'a> Label<'a> {
fn text(mut self, text: &'a str) -> Label<'a> {
self.text = text;
self
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),
}
}
}

Expand Down Expand Up @@ -53,19 +147,122 @@ fn main() -> Result<(), Box<dyn Error>> {
}

fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> io::Result<()> {
let mut selected_button: usize = 0;
let button_states = &mut [State::Selected, State::Normal, State::Normal];
loop {
terminal.draw(ui)?;
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(())
}

if let Event::Key(key) = event::read()? {
if let KeyCode::Char('q') = key.code {
return Ok(());
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 ui(f: &mut Frame) {
let size = f.size();
let label = Label::default().text("Test");
f.render_widget(label, size);
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;
}
}
_ => (),
}
}
17 changes: 13 additions & 4 deletions examples/custom_widget.tape
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 31d18e0

Please sign in to comment.