diff --git a/src/ui/layout/components/logbox.rs b/src/ui/layout/components/logbox.rs new file mode 100644 index 0000000..571a14d --- /dev/null +++ b/src/ui/layout/components/logbox.rs @@ -0,0 +1,394 @@ +//! ## LogBox +//! +//! `LogBox` component renders a log box view + +/* +* +* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com +* +* This file is part of "TermSCP" +* +* TermSCP is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* TermSCP is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with TermSCP. If not, see . +* +*/ + +// locals +use super::{Component, InputEvent, Msg, Payload, Props, PropsBuilder, Render}; +// ext +use crossterm::event::KeyCode; +use tui::{ + layout::Corner, + style::Style, + text::{Span, Spans}, + widgets::{Block, Borders, List, ListItem}, +}; + +// -- states + +/// ## OwnStates +/// +/// OwnStates contains states for this component +#[derive(Clone)] +struct OwnStates { + list_index: usize, // Index of selected element in list + list_len: usize, // Length of file list + focus: bool, // Has focus? +} + +impl Default for OwnStates { + fn default() -> Self { + OwnStates { + list_index: 0, + list_len: 0, + focus: false, + } + } +} + +impl OwnStates { + /// ### set_list_len + /// + /// Set list length + pub fn set_list_len(&mut self, len: usize) { + self.list_len = len; + } + + /// ### get_list_index + /// + /// Return current value for list index + pub fn get_list_index(&self) -> usize { + self.list_index + } + + /// ### incr_list_index + /// + /// Incremenet list index + pub fn incr_list_index(&mut self) { + // Check if index is at last element + if self.list_index + 1 < self.list_len { + self.list_index += 1; + } + } + + /// ### decr_list_index + /// + /// Decrement list index + pub fn decr_list_index(&mut self) { + // Check if index is bigger than 0 + if self.list_index > 0 { + self.list_index -= 1; + } + } + + /// ### reset_list_index + /// + /// Reset list index to last element + pub fn reset_list_index(&mut self) { + self.list_index = match self.list_len { + 0 => 0, + _ => self.list_len - 1, + }; + } +} + +// -- Component + +/// ## LogBox +/// +/// LogBox list component +pub struct LogBox { + props: Props, + states: OwnStates, +} + +impl LogBox { + /// ### new + /// + /// Instantiates a new FileList starting from Props + /// The method also initializes the component states. + pub fn new(props: Props) -> Self { + // Initialize states + let mut states: OwnStates = OwnStates::default(); + // Set list length + states.set_list_len(match &props.texts.table { + Some(rows) => rows.len(), + None => 0, + }); + // Reset list index + states.reset_list_index(); + LogBox { props, states } + } +} + +impl Component for LogBox { + /// ### render + /// + /// Based on the current properties and states, return a Widget instance for the Component + /// Returns None if the component is hidden + fn render(&self) -> Option { + match self.props.visible { + false => None, + true => { + // Make list + let list_items: Vec = match self.props.texts.table.as_ref() { + None => Vec::new(), + Some(table) => table + .iter() + .map(|row| { + let columns: Vec = row + .iter() + .map(|col| { + Span::styled( + col.content.clone(), + Style::default() + .add_modifier(col.get_modifiers()) + .fg(col.fg) + .bg(col.bg), + ) + }) + .collect(); + ListItem::new(Spans::from(columns)) + }) + .collect(), // Make List item from TextSpan + }; + let title: String = match self.props.texts.title.as_ref() { + Some(t) => t.clone(), + None => String::new(), + }; + // Render + Some(Render { + widget: Box::new( + List::new(list_items) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(match self.states.focus { + true => Style::default().fg(self.props.foreground), + false => Style::default(), + }) + .title(title), + ) + .start_corner(Corner::BottomLeft) + .highlight_style( + Style::default().add_modifier(self.props.get_modifiers()), + ), + ), + cursor: self.states.list_index, + }) + } + } + } + + /// ### update + /// + /// Update component properties + /// Properties should first be retrieved through `get_props` which creates a builder from + /// existing properties and then edited before calling update + fn update(&mut self, props: Props) -> Msg { + self.props = props; + // re-Set list length + self.states.set_list_len(match &self.props.texts.table { + Some(rows) => rows.len(), + None => 0, + }); + // Reset list index + self.states.reset_list_index(); + Msg::None + } + + /// ### get_props + /// + /// Returns a props builder starting from component properties. + /// This returns a prop builder in order to make easier to create + /// new properties for the element. + fn get_props(&self) -> PropsBuilder { + PropsBuilder::from(self.props.clone()) + } + + /// ### on + /// + /// Handle input event and update internal states + fn on(&mut self, ev: InputEvent) -> Msg { + // Match event + if let InputEvent::Key(key) = ev { + match key.code { + KeyCode::Down => { + // Update states + self.states.incr_list_index(); + Msg::None + } + KeyCode::Up => { + // Update states + self.states.decr_list_index(); + Msg::None + } + KeyCode::PageDown => { + // Update states + for _ in 0..8 { + self.states.incr_list_index(); + } + Msg::None + } + KeyCode::PageUp => { + // Update states + for _ in 0..8 { + self.states.decr_list_index(); + } + Msg::None + } + _ => { + // Return key event to activity + Msg::OnKey(key) + } + } + } else { + // Unhandled event + Msg::None + } + } + + /// ### get_value + /// + /// Return component value. File list return index + fn get_value(&self) -> Payload { + Payload::Unsigned(self.states.get_list_index()) + } + + // -- events + + /// ### should_umount + /// + /// The component must provide to the supervisor whether it should be umounted (destroyed) + /// This makes sense to be called after an `on` or after an `update`, where the states changes. + fn should_umount(&self) -> bool { + // Never true + false + } + + /// ### blur + /// + /// Blur component; basically remove focus + fn blur(&mut self) { + self.states.focus = false; + } + + /// ### active + /// + /// Active component; basically give focus + fn active(&mut self) { + self.states.focus = true; + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::ui::layout::props::{TableBuilder, TextParts, TextSpan}; + + use crossterm::event::{KeyCode, KeyEvent}; + + #[test] + fn test_ui_layout_components_logbox() { + let mut component: LogBox = LogBox::new( + PropsBuilder::default() + .with_texts(TextParts::table( + Some(String::from("Log")), + TableBuilder::default() + .add_col(TextSpan::from("12:29")) + .add_col(TextSpan::from("system crashed")) + .add_row() + .add_col(TextSpan::from("12:38")) + .add_col(TextSpan::from("system alive")) + .build(), + )) + .build(), + ); + // Verify states + assert_eq!(component.states.list_index, 1); + assert_eq!(component.states.list_len, 2); + assert_eq!(component.states.focus, false); + // Focus + component.active(); + assert_eq!(component.states.focus, true); + component.blur(); + assert_eq!(component.states.focus, false); + // Increment list index + component.states.list_index -= 1; + assert_eq!(component.render().unwrap().cursor, 0); + // Should umount + assert_eq!(component.should_umount(), false); + // Update + component.update( + component + .get_props() + .with_texts(TextParts::table( + Some(String::from("Log")), + TableBuilder::default() + .add_col(TextSpan::from("12:29")) + .add_col(TextSpan::from("system crashed")) + .add_row() + .add_col(TextSpan::from("12:38")) + .add_col(TextSpan::from("system alive")) + .add_row() + .add_col(TextSpan::from("12:41")) + .add_col(TextSpan::from("system is going down for REBOOT")) + .build(), + )) + .build(), + ); + // Verify states + assert_eq!(component.states.list_index, 2); // Last item + assert_eq!(component.states.list_len, 3); + // get value + assert_eq!(component.get_value(), Payload::Unsigned(2)); + // Render + assert_eq!(component.render().unwrap().cursor, 2); + // Set cursor to 0 + component.states.list_index = 0; + // Handle inputs + assert_eq!( + component.on(InputEvent::Key(KeyEvent::from(KeyCode::Down))), + Msg::None + ); + // Index should be incremented + assert_eq!(component.states.list_index, 1); + // Index should be decremented + assert_eq!( + component.on(InputEvent::Key(KeyEvent::from(KeyCode::Up))), + Msg::None + ); + // Index should be incremented + assert_eq!(component.states.list_index, 0); + // Index should be 2 + assert_eq!( + component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageDown))), + Msg::None + ); + // Index should be incremented + assert_eq!(component.states.list_index, 2); + // Index should be 0 + assert_eq!( + component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageUp))), + Msg::None + ); + // Index should be incremented + assert_eq!(component.states.list_index, 0); + // On key + assert_eq!( + component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))), + Msg::OnKey(KeyEvent::from(KeyCode::Backspace)) + ); + } +} diff --git a/src/ui/layout/components/mod.rs b/src/ui/layout/components/mod.rs index cd0985a..cd9e1d5 100644 --- a/src/ui/layout/components/mod.rs +++ b/src/ui/layout/components/mod.rs @@ -29,6 +29,7 @@ use super::{Component, InputEvent, Msg, Payload, PropValue, Props, PropsBuilder, // exports pub mod file_list; pub mod input; +pub mod logbox; pub mod progress_bar; pub mod radio_group; pub mod table;