diff --git a/src/app.rs b/src/app.rs index a7d0da4..24e7a6c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -47,9 +47,6 @@ impl App { } } - /// Handles the tick event of the terminal. - pub fn tick(&self) {} - /// Set running to false to quit the application. pub fn quit(&mut self) { self.running = false; diff --git a/src/lib.rs b/src/lib.rs index 3be8c80..4dc7d92 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,15 +5,15 @@ /// Application. pub mod app; -/// Terminal events handler. +/// Event handler. pub mod event; -/// Widget renderer. -pub mod ui; - -/// Terminal user interface. +/// TUI renderer. pub mod tui; +/// Terminal handler +pub mod terminal; + /// Application theme. pub mod theme; diff --git a/src/main.rs b/src/main.rs index 5e3ff95..0cdf08f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use flawz::cve::Cve; use flawz::error::Error; use flawz::event::{Event, EventHandler}; use flawz::handler::handle_key_events; -use flawz::tui::Tui; +use flawz::terminal::Tui; use flawz::widgets::SelectableList; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; @@ -59,7 +59,7 @@ fn main() -> AppResult<()> { tui.draw(&mut app)?; // Handle events. match tui.events.next()? { - Event::Tick => app.tick(), + Event::Tick => {} Event::Key(key_event) => handle_key_events(key_event, &mut app, &tui.events.sender)?, Event::Mouse(_) => {} Event::Resize(_, _) => {} diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..615699c --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,76 @@ +use crate::app::{App, AppResult}; +use crate::event::EventHandler; +use crate::tui; +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use ratatui::backend::Backend; +use ratatui::Terminal; +use std::io; +use std::panic; + +/// Representation of a terminal user interface. +/// +/// It is responsible for setting up the terminal, +/// initializing the interface and handling the draw events. +#[derive(Debug)] +pub struct Tui { + /// Interface to the Terminal. + terminal: Terminal, + /// Terminal event handler. + pub events: EventHandler, +} + +impl Tui { + /// Constructs a new instance of [`Tui`]. + pub fn new(terminal: Terminal, events: EventHandler) -> Self { + Self { terminal, events } + } + + /// Initializes the terminal interface. + /// + /// It enables the raw mode and sets terminal properties. + pub fn init(&mut self) -> AppResult<()> { + terminal::enable_raw_mode()?; + crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?; + + // Define a custom panic hook to reset the terminal properties. + // This way, you won't have your terminal messed up if an unexpected error happens. + let panic_hook = panic::take_hook(); + panic::set_hook(Box::new(move |panic| { + Self::reset().expect("failed to reset the terminal"); + panic_hook(panic); + })); + + self.terminal.hide_cursor()?; + self.terminal.clear()?; + Ok(()) + } + + /// [`Draw`] the terminal interface by [`rendering`] the widgets. + /// + /// [`Draw`]: ratatui::Terminal::draw + /// [`rendering`]: crate::tui::render + pub fn draw(&mut self, app: &mut App) -> AppResult<()> { + self.terminal.draw(|frame| tui::render(app, frame))?; + Ok(()) + } + + /// Resets the terminal interface. + /// + /// This function is also used for the panic hook to revert + /// the terminal properties if unexpected errors occur. + fn reset() -> AppResult<()> { + terminal::disable_raw_mode()?; + crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?; + Ok(()) + } + + /// Exits the terminal interface. + /// + /// It disables the raw mode and reverts back the terminal properties. + pub fn exit(&mut self) -> AppResult<()> { + Self::reset()?; + self.terminal.show_cursor()?; + Ok(()) + } +} diff --git a/src/tui.rs b/src/tui.rs index d90bbe3..89b8b63 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,76 +1,283 @@ -use crate::app::{App, AppResult}; -use crate::event::EventHandler; -use crate::ui; -use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; -use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; -use ratatui::backend::Backend; -use ratatui::Terminal; -use std::io; -use std::panic; +use crate::app::App; +use ratatui::{ + layout::{Alignment, Constraint, Layout, Margin, Rect}, + style::{Styled, Stylize}, + text::{Line, Span}, + widgets::{ + Block, BorderType, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, + Table, TableState, + }, + Frame, +}; +use tui_input::Input; +use tui_popup::{Popup, SizedWrapper}; -/// Representation of a terminal user interface. -/// -/// It is responsible for setting up the terminal, -/// initializing the interface and handling the draw events. -#[derive(Debug)] -pub struct Tui { - /// Interface to the Terminal. - terminal: Terminal, - /// Terminal event handler. - pub events: EventHandler, -} +/// Maximum number of elements to show in the table. +const TABLE_PAGE_LIMIT: usize = 50; -impl Tui { - /// Constructs a new instance of [`Tui`]. - pub fn new(terminal: Terminal, events: EventHandler) -> Self { - Self { terminal, events } - } +/// Key bindings. +const KEY_BINDINGS: &[(&[&str], &str)] = &[ + (&["Enter"], "Details"), + (&["s", "/"], "Search"), + (&["↕", "j/k"], "Next/Prev"), + (&["q"], "Quit"), +]; - /// Initializes the terminal interface. - /// - /// It enables the raw mode and sets terminal properties. - pub fn init(&mut self) -> AppResult<()> { - terminal::enable_raw_mode()?; - crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?; +/// Renders the user interface widgets. +pub fn render(app: &mut App, frame: &mut Frame) { + let rects = + Layout::vertical([Constraint::Min(1), Constraint::Percentage(100)]).split(frame.size()); + render_header(app, frame, rects[0]); + render_list(app, frame, rects[1]); + render_cursor(app, frame, rects[1]); + render_details(app, frame, rects[1]); +} - // Define a custom panic hook to reset the terminal properties. - // This way, you won't have your terminal messed up if an unexpected error happens. - let panic_hook = panic::take_hook(); - panic::set_hook(Box::new(move |panic| { - Self::reset().expect("failed to reset the terminal"); - panic_hook(panic); - })); +fn render_list(app: &mut App, frame: &mut Frame<'_>, area: Rect) { + let selected_index = app.list.state.selected().unwrap_or_default(); + let items_len = app.list.items.len(); + let page = selected_index / TABLE_PAGE_LIMIT; + let mut table_state = TableState::default(); + table_state.select(Some(selected_index % TABLE_PAGE_LIMIT)); + let items = app + .list + .items + .iter() + .skip(page * TABLE_PAGE_LIMIT) + .take(TABLE_PAGE_LIMIT) + .map(|cve| { + let description = match &cve.description { + Some(v) => textwrap::wrap( + v, + textwrap::Options::new(area.width.saturating_sub(15) as usize), + ) + .join("\n"), + None => "No description available.".into(), + }; + Row::new(vec![cve.id.to_string(), description]) + .height(2) + .top_margin(1) + }) + .collect::>(); + let block = Block::bordered() + .style(if app.show_details { + app.theme.dim + } else { + app.theme.background + }) + .border_style(app.theme.borders) + .border_type(BorderType::Double) + .title_bottom( + if items_len != 0 { + Line::from(vec![ + "|".set_style(app.theme.separator), + format!("{}/{}", selected_index.saturating_add(1), items_len) + .set_style(app.theme.index), + "|".set_style(app.theme.separator), + ]) + } else { + Line::default() + } + .right_aligned(), + ) + .title_bottom( + Line::from( + KEY_BINDINGS + .iter() + .enumerate() + .flat_map(|(i, (keys, desc))| { + vec![ + "<".set_style(app.theme.separator), + keys.join("-").set_style(app.theme.footer), + ": ".set_style(app.theme.separator), + Span::from(*desc).set_style(app.theme.footer), + ">".set_style(app.theme.separator), + if i != KEY_BINDINGS.len() - 1 { " " } else { "" }.into(), + ] + }) + .collect::>(), + ) + .centered(), + ) + .title_bottom(if !app.input.value().is_empty() || app.input_mode { + Line::from(vec![ + "|".set_style(app.theme.separator), + "Search: ".set_style(app.theme.highlight).bold(), + app.input.value().set_style(if items.is_empty() { + app.theme.input_empty + } else { + app.theme.input + }), + if app.input_mode { " " } else { "" }.into(), + "|".set_style(app.theme.separator), + ]) + } else { + Line::default() + }); + frame.render_stateful_widget( + Table::new(items, &[Constraint::Min(13), Constraint::Percentage(100)]) + .header(Row::new(vec![ + "Name".set_style(app.theme.highlight).bold(), + "Description".set_style(app.theme.highlight).bold(), + ])) + .block(block) + .highlight_style(app.theme.selected.bold()), + area, + &mut table_state, + ); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .style(app.theme.scrollbar) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")), + area.inner(&Margin { + vertical: 1, + horizontal: 0, + }), + &mut ScrollbarState::new(items_len).position(selected_index), + ); +} - self.terminal.hide_cursor()?; - self.terminal.clear()?; - Ok(()) - } +fn render_header(app: &mut App, frame: &mut Frame<'_>, area: Rect) { + let title = Paragraph::new( + format!( + " {} - {} ", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_DESCRIPTION") + ) + .bold(), + ) + .block(Block::default().style(app.theme.header)) + .alignment(Alignment::Left); + frame.render_widget(title, area); - /// [`Draw`] the terminal interface by [`rendering`] the widgets. - /// - /// [`Draw`]: ratatui::Terminal::draw - /// [`rendering`]: crate::ui::render - pub fn draw(&mut self, app: &mut App) -> AppResult<()> { - self.terminal.draw(|frame| ui::render(app, frame))?; - Ok(()) - } + let text = format!("v{} with ♥ by @orhun ", env!("CARGO_PKG_VERSION")); + let meta = Paragraph::new(text) + .block(Block::default().style(app.theme.header)) + .alignment(Alignment::Right); + frame.render_widget(meta, area); +} - /// Resets the terminal interface. - /// - /// This function is also used for the panic hook to revert - /// the terminal properties if unexpected errors occur. - fn reset() -> AppResult<()> { - terminal::disable_raw_mode()?; - crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?; - Ok(()) +fn render_cursor(app: &mut App, frame: &mut Frame<'_>, area: Rect) { + if app.input_mode { + let (x, y) = ( + area.x + + Input::default() + .with_value(format!("Search: {}", app.input.value())) + .visual_cursor() as u16 + + 2, + area.bottom().saturating_sub(1), + ); + frame.render_widget( + Clear, + Rect { + x, + y, + width: 1, + height: 1, + }, + ); + frame.set_cursor(x, y); } +} + +fn render_details(app: &mut App, frame: &mut Frame<'_>, area: Rect) { + if let (true, Some(cve)) = (app.show_details, app.list.selected()) { + let mut reference_lines = Vec::new(); + for reference in &cve.references { + let line: Line = vec![ + "Reference".set_style(app.theme.foreground).bold(), + ": ".set_style(app.theme.separator), + reference.to_string().set_style(app.theme.foreground), + ] + .into(); + reference_lines.push(line); + } - /// Exits the terminal interface. - /// - /// It disables the raw mode and reverts back the terminal properties. - pub fn exit(&mut self) -> AppResult<()> { - Self::reset()?; - self.terminal.show_cursor()?; - Ok(()) + let description = cve + .description + .clone() + .unwrap_or_default() + .trim() + .to_string(); + let mut lines = vec![vec![ + "Assigner".set_style(app.theme.foreground).bold(), + ": ".set_style(app.theme.separator), + cve.assigner.to_string().set_style(app.theme.foreground), + ] + .into()]; + let max_row_width = if reference_lines + .iter() + .map(|v| v.width()) + .max() + .unwrap_or_default() as u16 + > area.width - 2 + { + area.width - 4 + } else { + (area.width - 4) / 2 + }; + if (Line::raw(&description).width() as u16) < max_row_width { + lines.push( + vec![ + "Description".set_style(app.theme.foreground).bold(), + ": ".set_style(app.theme.separator), + description.set_style(app.theme.foreground), + ] + .into(), + ); + } else { + lines.push( + vec![ + "Description".set_style(app.theme.foreground).bold(), + ": ".set_style(app.theme.separator), + ] + .into(), + ); + lines.extend( + textwrap::wrap(&description, textwrap::Options::new(max_row_width as usize)) + .into_iter() + .map(|v| Line::from(v.to_string()).style(app.theme.foreground)) + .collect::>(), + ); + } + lines.extend(reference_lines); + if lines.len() > area.height.saturating_sub(2) as usize { + lines = lines.into_iter().skip(app.scroll_index).collect(); + } + let height = lines.len(); + let paragraph = Paragraph::new(lines.clone()); + let sized_paragraph = SizedWrapper { + inner: paragraph, + width: lines.iter().map(|v| v.width()).max().unwrap_or_default(), + height, + }; + let popup = Popup::new( + vec![ + "|".set_style(app.theme.separator), + cve.id.to_string().set_style(app.theme.highlight).bold(), + "|".set_style(app.theme.separator), + ], + sized_paragraph, + ) + .style(app.theme.background); + frame.render_widget(&popup, area); + app.scroll_details = height > area.height.saturating_sub(2) as usize; + if app.scroll_details { + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .style(app.theme.scrollbar) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")), + area.inner(&Margin { + vertical: 1, + horizontal: (area.width.saturating_sub( + lines.iter().map(|v| v.width()).max().unwrap_or_default() as u16, + ) / 2), + }), + &mut ScrollbarState::new(lines.len().saturating_sub(area.height as usize) + 2) + .position(app.scroll_index), + ); + } } } diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index 04dcffd..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,285 +0,0 @@ -use crate::app::App; -use ratatui::{ - layout::{Alignment, Constraint, Layout, Margin, Rect}, - style::{Styled, Stylize}, - text::{Line, Span}, - widgets::{ - Block, BorderType, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, - Table, TableState, - }, - Frame, -}; -use tui_input::Input; -use tui_popup::{Popup, SizedWrapper}; - -/// Maximum number of elements to show in the table. -const TABLE_PAGE_LIMIT: usize = 50; - -/// Key bindings. -const KEY_BINDINGS: &[(&[&str], &str)] = &[ - (&["Enter"], "Details"), - (&["s", "/"], "Search"), - (&["↕", "j/k"], "Next/Prev"), - (&["q"], "Quit"), -]; - -/// Renders the user interface widgets. -pub fn render(app: &mut App, frame: &mut Frame) { - let rects = - Layout::vertical([Constraint::Min(1), Constraint::Percentage(100)]).split(frame.size()); - render_header(app, frame, rects[0]); - render_list(app, frame, rects[1]); - render_cursor(app, frame, rects[1]); - render_details(app, frame, rects[1]); -} - -fn render_list(app: &mut App, frame: &mut Frame<'_>, area: Rect) { - let selected_index = app.list.state.selected().unwrap_or_default(); - let items_len = app.list.items.len(); - let page = selected_index / TABLE_PAGE_LIMIT; - let mut table_state = TableState::default(); - table_state.select(Some(selected_index % TABLE_PAGE_LIMIT)); - let items = app - .list - .items - .iter() - .skip(page * TABLE_PAGE_LIMIT) - .take(TABLE_PAGE_LIMIT) - .map(|cve| { - let description = match &cve.description { - Some(v) => textwrap::wrap( - v, - textwrap::Options::new(area.width.saturating_sub(15) as usize), - ) - .join("\n"), - None => "No description available.".into(), - }; - Row::new(vec![cve.id.to_string(), description]) - .height(2) - .top_margin(1) - }) - .collect::>(); - let block = Block::bordered() - .style(if app.show_details { - app.theme.dim - } else { - app.theme.background - }) - .border_style(app.theme.borders) - .border_type(BorderType::Double) - .title_bottom( - if items_len != 0 { - Line::from(vec![ - "|".set_style(app.theme.separator), - format!("{}/{}", selected_index.saturating_add(1), items_len) - .set_style(app.theme.index), - "|".set_style(app.theme.separator), - ]) - } else { - Line::default() - } - .right_aligned(), - ) - .title_bottom( - Line::from( - KEY_BINDINGS - .iter() - .enumerate() - .flat_map(|(i, (keys, desc))| { - vec![ - "<".set_style(app.theme.separator), - keys.join("-").set_style(app.theme.footer), - ": ".set_style(app.theme.separator), - Span::from(*desc).set_style(app.theme.footer), - ">".set_style(app.theme.separator), - if i != KEY_BINDINGS.len() - 1 { " " } else { "" }.into(), - ] - }) - .collect::>(), - ) - .centered(), - ) - .title_bottom(if !app.input.value().is_empty() || app.input_mode { - Line::from(vec![ - "|".set_style(app.theme.separator), - "Search: ".set_style(app.theme.highlight).bold(), - app.input.value().set_style(if items.is_empty() { - app.theme.input_empty - } else { - app.theme.input - }), - if app.input_mode { " " } else { "" }.into(), - "|".set_style(app.theme.separator), - ]) - } else { - Line::default() - }); - frame.render_stateful_widget( - Table::new(items, &[Constraint::Min(13), Constraint::Percentage(100)]) - .header(Row::new(vec![ - "Name".set_style(app.theme.highlight).bold(), - "Description".set_style(app.theme.highlight).bold(), - ])) - .block(block) - .highlight_style(app.theme.selected.bold()), - area, - &mut table_state, - ); - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .style(app.theme.scrollbar) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")), - area.inner(&Margin { - vertical: 1, - horizontal: 0, - }), - &mut ScrollbarState::new(items_len).position(selected_index), - ); -} - -fn render_header(app: &mut App, frame: &mut Frame<'_>, area: Rect) { - let title = Paragraph::new( - format!( - " {} - {} ", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_DESCRIPTION") - ) - .bold(), - ) - .block(Block::default().style(app.theme.header)) - .alignment(Alignment::Left); - frame.render_widget(title, area); - - let text = format!("v{} with ♥ by @orhun ", env!("CARGO_PKG_VERSION")); - let meta = Paragraph::new(text) - .block(Block::default().style(app.theme.header)) - .alignment(Alignment::Right); - frame.render_widget(meta, area); -} - -/// Renders the cursor. -fn render_cursor(app: &mut App, frame: &mut Frame<'_>, area: Rect) { - if app.input_mode { - let (x, y) = ( - area.x - + Input::default() - .with_value(format!("Search: {}", app.input.value())) - .visual_cursor() as u16 - + 2, - area.bottom().saturating_sub(1), - ); - frame.render_widget( - Clear, - Rect { - x, - y, - width: 1, - height: 1, - }, - ); - frame.set_cursor(x, y); - } -} - -/// Render the details popup. -fn render_details(app: &mut App, frame: &mut Frame<'_>, area: Rect) { - if let (true, Some(cve)) = (app.show_details, app.list.selected()) { - let mut reference_lines = Vec::new(); - for reference in &cve.references { - let line: Line = vec![ - "Reference".set_style(app.theme.foreground).bold(), - ": ".set_style(app.theme.separator), - reference.to_string().set_style(app.theme.foreground), - ] - .into(); - reference_lines.push(line); - } - - let description = cve - .description - .clone() - .unwrap_or_default() - .trim() - .to_string(); - let mut lines = vec![vec![ - "Assigner".set_style(app.theme.foreground).bold(), - ": ".set_style(app.theme.separator), - cve.assigner.to_string().set_style(app.theme.foreground), - ] - .into()]; - let max_row_width = if reference_lines - .iter() - .map(|v| v.width()) - .max() - .unwrap_or_default() as u16 - > area.width - 2 - { - area.width - 4 - } else { - (area.width - 4) / 2 - }; - if (Line::raw(&description).width() as u16) < max_row_width { - lines.push( - vec![ - "Description".set_style(app.theme.foreground).bold(), - ": ".set_style(app.theme.separator), - description.set_style(app.theme.foreground), - ] - .into(), - ); - } else { - lines.push( - vec![ - "Description".set_style(app.theme.foreground).bold(), - ": ".set_style(app.theme.separator), - ] - .into(), - ); - lines.extend( - textwrap::wrap(&description, textwrap::Options::new(max_row_width as usize)) - .into_iter() - .map(|v| Line::from(v.to_string()).style(app.theme.foreground)) - .collect::>(), - ); - } - lines.extend(reference_lines); - if lines.len() > area.height.saturating_sub(2) as usize { - lines = lines.into_iter().skip(app.scroll_index).collect(); - } - let height = lines.len(); - let paragraph = Paragraph::new(lines.clone()); - let sized_paragraph = SizedWrapper { - inner: paragraph, - width: lines.iter().map(|v| v.width()).max().unwrap_or_default(), - height, - }; - let popup = Popup::new( - vec![ - "|".set_style(app.theme.separator), - cve.id.to_string().set_style(app.theme.highlight).bold(), - "|".set_style(app.theme.separator), - ], - sized_paragraph, - ) - .style(app.theme.background); - frame.render_widget(&popup, area); - app.scroll_details = height > area.height.saturating_sub(2) as usize; - if app.scroll_details { - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .style(app.theme.scrollbar) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")), - area.inner(&Margin { - vertical: 1, - horizontal: (area.width.saturating_sub( - lines.iter().map(|v| v.width()).max().unwrap_or_default() as u16, - ) / 2), - }), - &mut ScrollbarState::new(lines.len().saturating_sub(area.height as usize) + 2) - .position(app.scroll_index), - ); - } - } -}