├── .gitignore ├── Cargo.toml ├── README.md ├── examples └── builder.rs └── src ├── event.rs ├── lib.rs └── tuiapp.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .vscode 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tui-template" 3 | version = "0.1.0" 4 | authors = ["Jonathan Kelley "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | tui = { version = "0.15.0", features = ["crossterm"] } 11 | crossterm = "0.19.0" 12 | anyhow = "1.0.40" 13 | thiserror = "1.0.24" 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tui-builder 2 | 3 | An opinionated model on quickly getting up and running with a Tui App - similar to redux toolkit. 4 | 5 | Uses these defaults: 6 | - Model, View, Controller paradigm 7 | - Crossterm with multithreaded event system 8 | - ctrl-c to exit 9 | 10 | Quickly build a tui app 11 | 12 | ```rust 13 | fn main() -> Result<()> { 14 | let mut state = AppState {}; 15 | 16 | let handler = move |state: &AppState, event: InputEvent| -> Result<()> { 17 | // Ingest input events and modify the state 18 | } 19 | 20 | let renderer = move |state: AppState| { 21 | // Render the state with tui 22 | let chunks = tui::layout::Layout::default(); 23 | let block = tui::widgets::Block::default() 24 | .title("Block 3") 25 | .borders(tui::widgets::Borders::ALL); 26 | }; 27 | 28 | // Override the state implementation (normally defaults to state default) 29 | TuiBuilder::::with_state(state) 30 | // Set the default refresh rate on the terminal 31 | .tick_rate(250) 32 | // Provide a way of handling raw input events 33 | .event_handler() 34 | // Provide a key code used to kill the app 35 | .kill_signal(KeyCodes::(CtrlC)) 36 | // Provide a way of rendering the state 37 | .renderer(renderer) 38 | // Launch the app 39 | .launch() 40 | } 41 | ``` 42 | -------------------------------------------------------------------------------- /examples/builder.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | backend::Backend, 3 | layout::{Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style}, 5 | symbols, 6 | text::{Span, Spans}, 7 | widgets::canvas::{Canvas, Line, Map, MapResolution, Rectangle}, 8 | widgets::{ 9 | Axis, BarChart, Block, BorderType, Borders, Cell, Chart, Dataset, Gauge, LineGauge, List, 10 | ListItem, Paragraph, Row, Sparkline, Table, Tabs, Wrap, 11 | }, 12 | Frame, 13 | }; 14 | use tui_template::tuiapp::TuiApp; 15 | 16 | #[derive(Default)] 17 | struct AppState {} 18 | 19 | impl TuiApp for AppState { 20 | fn event_handler(&self, action: crossterm::event::Event) -> anyhow::Result<()> { 21 | Ok(()) 22 | } 23 | 24 | fn handle_key(&mut self, key: crossterm::event::KeyEvent) {} 25 | 26 | fn tick(&mut self) {} 27 | 28 | fn should_quit(&self) -> bool { 29 | false 30 | } 31 | 32 | fn render(&mut self, f: &mut tui::Frame) { 33 | // Wrapping block for a group 34 | // Just draw the block and the group on the same area and build the group 35 | // with at least a margin of 1 36 | let size = f.size(); 37 | let block = Block::default() 38 | .borders(Borders::ALL) 39 | .title("Main block with round corners") 40 | .border_type(BorderType::Rounded); 41 | f.render_widget(block, size); 42 | let chunks = Layout::default() 43 | .direction(Direction::Vertical) 44 | .margin(4) 45 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) 46 | .split(f.size()); 47 | 48 | let top_chunks = Layout::default() 49 | .direction(Direction::Horizontal) 50 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) 51 | .split(chunks[0]); 52 | let block = Block::default() 53 | .title(vec![ 54 | Span::styled("With", Style::default().fg(Color::Yellow)), 55 | Span::from(" background"), 56 | ]) 57 | .style(Style::default().bg(Color::Green)); 58 | f.render_widget(block, top_chunks[0]); 59 | 60 | let block = Block::default().title(Span::styled( 61 | "Styled title", 62 | Style::default() 63 | .fg(Color::White) 64 | .bg(Color::Red) 65 | .add_modifier(Modifier::BOLD), 66 | )); 67 | f.render_widget(block, top_chunks[1]); 68 | 69 | let bottom_chunks = Layout::default() 70 | .direction(Direction::Horizontal) 71 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) 72 | .split(chunks[1]); 73 | let block = Block::default().title("With borders").borders(Borders::ALL); 74 | f.render_widget(block, bottom_chunks[0]); 75 | let block = Block::default() 76 | .title("With styled borders and doubled borders") 77 | .border_style(Style::default().fg(Color::Cyan)) 78 | .borders(Borders::LEFT | Borders::RIGHT) 79 | .border_type(BorderType::Double); 80 | f.render_widget(block, bottom_chunks[1]); 81 | } 82 | } 83 | 84 | fn main() { 85 | let mut state = AppState {}; 86 | 87 | state.launch(250).unwrap(); 88 | // // Override the state implementation (normally defaults to state default) 89 | // TuiBuilder::::with_state(state) 90 | // // Set some options on the TuiApp 91 | // .tick_rate(250) 92 | // // Launch the app 93 | // .launch(); 94 | } 95 | -------------------------------------------------------------------------------- /src/event.rs: -------------------------------------------------------------------------------- 1 | use std::sync::mpsc; 2 | use std::sync::{ 3 | atomic::{AtomicBool, Ordering}, 4 | Arc, 5 | }; 6 | use std::thread; 7 | use std::time::Duration; 8 | 9 | use crossterm::event::{Event as TermEvent, KeyCode}; 10 | 11 | pub enum InputEvent { 12 | UserInput(TermEvent), 13 | Close, 14 | Tick, 15 | } 16 | 17 | /// A small event handler that wrap termion input and tick events. Each event 18 | /// type is handled in its own thread and returned to a common `Receiver` 19 | pub struct TermEvents { 20 | rx: mpsc::Receiver, 21 | ignore_exit_key: Arc, 22 | 23 | #[allow(unused)] 24 | input_handle: thread::JoinHandle<()>, 25 | 26 | #[allow(unused)] 27 | tick_handle: thread::JoinHandle<()>, 28 | } 29 | 30 | #[derive(Debug, Clone, Copy)] 31 | pub struct AppTermConfig { 32 | pub tick_rate: Duration, 33 | } 34 | 35 | impl Default for AppTermConfig { 36 | fn default() -> AppTermConfig { 37 | AppTermConfig { 38 | tick_rate: Duration::from_millis(250), 39 | } 40 | } 41 | } 42 | 43 | impl TermEvents { 44 | pub fn new() -> TermEvents { 45 | TermEvents::with_config(AppTermConfig::default()) 46 | } 47 | 48 | pub fn with_config(config: AppTermConfig) -> TermEvents { 49 | let (tx, rx) = mpsc::channel(); 50 | let ignore_exit_key = Arc::new(AtomicBool::new(false)); 51 | let input_handle = { 52 | let tx = tx.clone(); 53 | thread::spawn(move || loop { 54 | if let Ok(event) = crossterm::event::read() { 55 | // Kill the channel 56 | if event == TermEvent::Key(KeyCode::Esc.into()) { 57 | tx.send(InputEvent::Close).unwrap(); 58 | break; 59 | } 60 | 61 | // Send the message 62 | tx.send(InputEvent::UserInput(event)).unwrap(); 63 | } 64 | }) 65 | }; 66 | let tick_handle = { 67 | thread::spawn(move || loop { 68 | if tx.send(InputEvent::Tick).is_err() { 69 | break; 70 | } 71 | thread::sleep(config.tick_rate); 72 | }) 73 | }; 74 | TermEvents { 75 | rx, 76 | ignore_exit_key, 77 | input_handle, 78 | tick_handle, 79 | } 80 | } 81 | 82 | pub fn next(&self) -> Result { 83 | self.rx.recv() 84 | } 85 | 86 | pub fn disable_exit_key(&mut self) { 87 | self.ignore_exit_key.store(true, Ordering::Relaxed); 88 | } 89 | 90 | pub fn enable_exit_key(&mut self) { 91 | self.ignore_exit_key.store(false, Ordering::Relaxed); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use crossterm; 2 | pub use tui; 3 | 4 | pub mod event; 5 | pub mod tuiapp; 6 | -------------------------------------------------------------------------------- /src/tuiapp.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use crossterm::{ 3 | event, 4 | event::{DisableMouseCapture, EnableMouseCapture, Event as TermEvent, KeyCode, KeyEvent}, 5 | execute, 6 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 7 | }; 8 | use std::{ 9 | io, 10 | sync::mpsc, 11 | thread, 12 | time::{Duration, Instant}, 13 | }; 14 | use tui::{ 15 | backend::{Backend, CrosstermBackend}, 16 | Frame, Terminal, 17 | }; 18 | 19 | enum InputEvent { 20 | UserInput(KeyEvent), 21 | Close, 22 | Tick, 23 | } 24 | 25 | pub trait TuiApp { 26 | // Apply an App Action to the app state 27 | fn event_handler(&self, action: TermEvent) -> Result<()>; 28 | 29 | fn render(&mut self, frame: &mut Frame); 30 | 31 | fn handle_key(&mut self, key: KeyEvent); 32 | 33 | fn tick(&mut self); 34 | 35 | fn should_quit(&self) -> bool; 36 | 37 | fn launch(&mut self, tick_rate: u64) -> Result<()> { 38 | enable_raw_mode()?; 39 | 40 | let mut stdout = std::io::stdout(); 41 | execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 42 | 43 | let backend = CrosstermBackend::new(io::stdout()); 44 | let mut terminal = Terminal::new(backend).unwrap(); 45 | 46 | // Setup input handling 47 | let (tx, rx) = mpsc::channel(); 48 | 49 | let tick_rate = Duration::from_millis(tick_rate); 50 | thread::spawn(move || { 51 | let mut last_tick = Instant::now(); 52 | loop { 53 | // poll for tick rate duration, if no events, sent tick event. 54 | let timeout = tick_rate 55 | .checked_sub(last_tick.elapsed()) 56 | .unwrap_or_else(|| Duration::from_secs(0)); 57 | 58 | if event::poll(timeout).unwrap() { 59 | if let TermEvent::Key(key) = event::read().unwrap() { 60 | tx.send(InputEvent::UserInput(key)).unwrap(); 61 | } 62 | } 63 | if last_tick.elapsed() >= tick_rate { 64 | tx.send(InputEvent::Tick).unwrap(); 65 | last_tick = Instant::now(); 66 | } 67 | } 68 | }); 69 | 70 | terminal.clear()?; 71 | 72 | loop { 73 | terminal.draw(|frame| self.render(frame))?; 74 | 75 | // terminal.draw(|f| ui::draw(f, &mut app))?; 76 | match rx.recv()? { 77 | InputEvent::UserInput(event) => match event.code { 78 | KeyCode::Char('q') => { 79 | disable_raw_mode()?; 80 | execute!( 81 | terminal.backend_mut(), 82 | LeaveAlternateScreen, 83 | DisableMouseCapture 84 | )?; 85 | terminal.show_cursor()?; 86 | break; 87 | } 88 | _ => self.handle_key(event), 89 | }, 90 | InputEvent::Tick => { 91 | self.tick(); 92 | } 93 | InputEvent::Close => { 94 | break; 95 | } 96 | } 97 | if self.should_quit() { 98 | break; 99 | } 100 | } 101 | 102 | Ok(()) 103 | } 104 | } 105 | --------------------------------------------------------------------------------