diff --git a/Cargo.lock b/Cargo.lock index 79e188e..248201a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,52 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "crossterm" version = "0.27.0" @@ -39,6 +73,65 @@ dependencies = [ "winapi", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + [[package]] name = "libc" version = "0.2.177" @@ -60,6 +153,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "mio" version = "0.8.11" @@ -95,6 +197,50 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -104,6 +250,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -151,8 +309,87 @@ name = "ssh-tui" version = "0.1.0" dependencies = [ "crossterm", + "ratatui", ] +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 1c959f7..120af90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,8 @@ edition = "2021" [dependencies] crossterm = "0.27" +ratatui = "0.26" [[bin]] name = "ssh-tui" -path = "main.rs" \ No newline at end of file +path = "src/main.rs" \ No newline at end of file diff --git a/main.rs b/main.rs deleted file mode 100644 index ad43d3b..0000000 --- a/main.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use std::io::{self}; - -fn main() -> Result<(), Box> { - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - - execute!( - stdout, - crossterm::terminal::Clear(crossterm::terminal::ClearType::All) - )?; - execute!(stdout, crossterm::cursor::MoveTo(0, 0))?; - println!("Wellcome to dcorral.com!"); - execute!(stdout, crossterm::cursor::MoveTo(0, 1))?; - println!("Press any key to exit."); - - loop { - if let Event::Key(_) = event::read()? { - break; - } - } - - disable_raw_mode()?; - execute!(stdout, LeaveAlternateScreen, DisableMouseCapture)?; - - Ok(()) -} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..ce87e3d --- /dev/null +++ b/src/app.rs @@ -0,0 +1,41 @@ +use crate::theme::Theme; + +pub const NUM_BOXES: usize = 4; + +pub struct App { + pub focus: usize, + pub theme: Theme, + pub show_help: bool, + pub show_welcome: bool, +} + +impl App { + pub fn new() -> Self { + Self { + focus: 0, + theme: Theme::gruvbox_dark(), + show_help: false, + show_welcome: true, + } + } + + pub fn move_left(&mut self) { + self.focus = (self.focus + 3) % NUM_BOXES; + } + + pub fn move_right(&mut self) { + self.focus = (self.focus + 1) % NUM_BOXES; + } + + pub fn move_down(&mut self) { + if self.focus < 2 { + self.focus += 2; + } else { + self.focus -= 2; + } + } + + pub fn move_up(&mut self) { + self.move_down(); // symmetric + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..a191a72 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,35 @@ +use crossterm::event::{Event, KeyCode}; + +use crate::app::App; + +pub fn handle_input(app: &mut App, event: Event) -> bool { + if app.show_welcome { + if let Event::Key(_) = event { + app.show_welcome = false; + } + } else if app.show_help { + if let Event::Key(key) = event { + match key.code { + KeyCode::Char('q') + | KeyCode::Esc + | KeyCode::Char('?') + | KeyCode::Enter + | KeyCode::Backspace => app.show_help = false, + _ => {} + } + } + } else { + if let Event::Key(key) = event { + match key.code { + KeyCode::Char('h') | KeyCode::Left => app.move_left(), + KeyCode::Char('l') | KeyCode::Right => app.move_right(), + KeyCode::Char('j') | KeyCode::Down => app.move_down(), + KeyCode::Char('k') | KeyCode::Up => app.move_up(), + KeyCode::Char('?') => app.show_help = true, + KeyCode::Char('q') | KeyCode::Esc => return false, + _ => {} + } + } + } + true +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..76deca2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,49 @@ +mod app; +mod input; +mod theme; +mod ui; + +use crossterm::{ + event, execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io::stdout; + +fn main() -> Result<(), Box> { + enable_raw_mode()?; + let (width, height) = crossterm::terminal::size()?; + if width < 15 || height < 15 { + disable_raw_mode()?; + println!("Your console is too small."); + return Ok(()); + } + let mut stdout = stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = app::App::new(); + let mut blink_counter = 0; + + loop { + let blink = (blink_counter / 3) % 2 == 0; + terminal.draw(|f| ui::draw(f, &app, blink))?; + blink_counter += 1; + let timeout = if app.show_welcome { + std::time::Duration::from_millis(70) + } else { + std::time::Duration::from_millis(60) + }; + if event::poll(timeout)? { + let event = event::read()?; + if !input::handle_input(&mut app, event) { + break; + } + } + } + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + Ok(()) +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..6609b82 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,32 @@ +use ratatui::style::Color; + +#[allow(dead_code)] +pub struct Theme { + pub bg: Color, + pub fg: Color, + pub red: Color, + pub green: Color, + pub yellow: Color, + pub blue: Color, + pub purple: Color, + pub aqua: Color, + pub orange: Color, + pub gray: Color, +} + +impl Theme { + pub fn gruvbox_dark() -> Self { + Self { + bg: Color::Rgb(40, 40, 40), + fg: Color::Rgb(235, 219, 178), + red: Color::Rgb(204, 36, 29), + green: Color::Rgb(152, 151, 26), + yellow: Color::Rgb(215, 153, 33), + blue: Color::Rgb(69, 133, 136), + purple: Color::Rgb(177, 98, 134), + aqua: Color::Rgb(104, 157, 106), + orange: Color::Rgb(214, 93, 14), + gray: Color::Rgb(146, 131, 116), + } + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..0df1c2f --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,108 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::app::App; + +pub fn draw(f: &mut Frame, app: &App, blink: bool) { + let bg_block = Block::default().style(Style::default().bg(app.theme.bg)); + f.render_widget(bg_block, f.size()); + + if app.show_welcome { + let fg = app.theme.fg; + let accent_fg = if blink { app.theme.blue } else { app.theme.bg }; + let size = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage(40), + Constraint::Percentage(20), + Constraint::Percentage(40), + ] + .as_ref(), + ) + .split(size); + let welcome_text = Paragraph::new(vec![ + Line::from(vec![ + Span::styled("Welcome to ", Style::default().fg(fg).bg(app.theme.bg)), + Span::styled( + "dcorral.com", + Style::default().fg(app.theme.orange).bg(app.theme.bg), + ), + Span::styled("!", Style::default().fg(fg).bg(app.theme.bg)), + ]), + Line::from(vec![ + Span::styled("press ", Style::default().fg(fg).bg(app.theme.bg)), + Span::styled("any key", Style::default().fg(accent_fg).bg(app.theme.bg)), + Span::styled(" to continue", Style::default().fg(fg).bg(app.theme.bg)), + ]), + ]) + .alignment(Alignment::Center); + f.render_widget(welcome_text, chunks[1]); + } else if app.show_help { + let help_content = "Navigation:\n\ + h / ←: Move left\n\ + l / →: Move right\n\ + j / ↓: Move down\n\ + k / ↑: Move up\n\ + ? : Show help\n\ + q / Esc: Quit"; + let help_text = Paragraph::new(help_content) + .alignment(ratatui::layout::Alignment::Center) + .style(Style::default().fg(app.theme.fg).bg(app.theme.bg)); + f.render_widget(help_text, f.size()); + } else { + let size = f.size(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)].as_ref()) + .split(size); + + let box_area = chunks[0]; + + let box_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(box_area); + + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(box_chunks[0]); + + let bottom_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(box_chunks[1]); + + let areas = [ + top_chunks[0], + top_chunks[1], + bottom_chunks[0], + bottom_chunks[1], + ]; + + for (i, area) in areas.iter().enumerate() { + let (bg, fg) = if app.focus == i { + (app.theme.blue, app.theme.bg) + } else { + (app.theme.bg, app.theme.fg) + }; + let block = Block::default() + .title(format!("Box {}", i)) + .borders(Borders::ALL) + .style(Style::default().bg(bg).fg(fg)); + f.render_widget(block, *area); + } + + let help_text = Paragraph::new("Use hjkl or arrows to navigate, ? for help, q to quit") + .style(Style::default().fg(app.theme.fg).bg(app.theme.bg)); + f.render_widget(help_text, chunks[1]); + } +}