refactor: Create basic widget system

This commit is contained in:
ClementTsang 2021-08-15 16:26:13 -04:00
parent fceae8d442
commit 4f0eb7b7eb
12 changed files with 643 additions and 11 deletions

2
Cargo.lock generated
View File

@ -231,7 +231,7 @@ dependencies = [
[[package]]
name = "bottom"
version = "0.6.3"
version = "0.6.4"
dependencies = [
"anyhow",
"assert_cmd",

View File

@ -1,6 +1,6 @@
[package]
name = "bottom"
version = "0.6.3"
version = "0.6.4"
authors = ["Clement Tsang <cjhtsang@uwaterloo.ca>"]
edition = "2018"
repository = "https://github.com/ClementTsang/bottom"

View File

@ -1,5 +1,6 @@
pub mod data_farmer;
pub mod data_harvester;
pub mod event;
pub mod filter;
pub mod layout_manager;
mod process_killer;

94
src/app/event.rs Normal file
View File

@ -0,0 +1,94 @@
use std::time::{Duration, Instant};
pub enum EventResult {
Quit,
Redraw,
Continue,
}
enum MultiKeyState {
Idle,
Waiting {
trigger_instant: Instant,
checked_index: usize,
},
}
/// The possible outcomes of calling [`MultiKey::input`] on a [`MultiKey`].
pub enum MultiKeyResult {
/// Returned when a character was *accepted*, but has not completed the sequence required.
Accepted,
/// Returned when a character is accepted and completes the sequence.
Completed,
/// Returned if a character breaks the sequence or if it has already timed out.
Rejected,
}
/// A struct useful for managing multi-key keybinds.
pub struct MultiKey {
state: MultiKeyState,
pattern: Vec<char>,
timeout: Duration,
}
impl MultiKey {
pub fn register(pattern: Vec<char>, timeout: Duration) -> Self {
Self {
state: MultiKeyState::Idle,
pattern,
timeout,
}
}
pub fn reset(&mut self) {
self.state = MultiKeyState::Idle;
}
pub fn input(&mut self, c: char) -> MultiKeyResult {
match &mut self.state {
MultiKeyState::Idle => {
if let Some(first) = self.pattern.first() {
if *first == c {
self.state = MultiKeyState::Waiting {
trigger_instant: Instant::now(),
checked_index: 0,
};
return MultiKeyResult::Accepted;
}
}
MultiKeyResult::Rejected
}
MultiKeyState::Waiting {
trigger_instant,
checked_index,
} => {
if trigger_instant.elapsed() > self.timeout {
// Just reset and recursively call (putting it into Idle).
self.reset();
self.input(c)
} else if let Some(next) = self.pattern.get(*checked_index + 1) {
if *next == c {
*checked_index += 1;
if *checked_index == self.pattern.len() - 1 {
self.reset();
MultiKeyResult::Completed
} else {
MultiKeyResult::Accepted
}
} else {
self.reset();
MultiKeyResult::Rejected
}
} else {
self.reset();
MultiKeyResult::Rejected
}
}
}
}
}

View File

@ -0,0 +1,13 @@
//! A collection of basic widgets.
pub mod text_table;
pub use text_table::TextTable;
pub mod time_graph;
pub use time_graph::TimeGraph;
pub mod scrollable;
pub use scrollable::Scrollable;
pub mod text_input;
pub use text_input::TextInput;

View File

@ -0,0 +1,194 @@
use std::time::Duration;
use crossterm::event::{KeyEvent, KeyModifiers, MouseButton, MouseEvent};
use tui::widgets::TableState;
use crate::app::{
event::{EventResult, MultiKey, MultiKeyResult},
Widget,
};
pub enum ScrollDirection {
Up,
Down,
}
/// A "scrollable" [`Widget`] component. Intended for use as part of another [`Widget]].
pub struct Scrollable {
current_index: usize,
previous_index: usize,
scroll_direction: ScrollDirection,
num_items: usize,
tui_state: TableState,
gg_manager: MultiKey,
}
impl Scrollable {
/// Creates a new [`Scrollable`].
pub fn new(num_items: usize) -> Self {
Self {
current_index: 0,
previous_index: 0,
scroll_direction: ScrollDirection::Down,
num_items,
tui_state: TableState::default(),
gg_manager: MultiKey::register(vec!['g', 'g'], Duration::from_millis(400)),
}
}
/// Creates a new [`Scrollable`]. Note this will set the associated [`TableState`] to select the first entry.
pub fn new_selected(num_items: usize) -> Self {
let mut scrollable = Scrollable::new(num_items);
scrollable.tui_state.select(Some(0));
scrollable
}
pub fn index(&self) -> usize {
self.current_index
}
/// Update the index with this! This will automatically update the previous index and scroll direction!
fn update_index(&mut self, new_index: usize) {
use std::cmp::Ordering;
match new_index.cmp(&self.current_index) {
Ordering::Greater => {
self.previous_index = self.current_index;
self.current_index = new_index;
self.scroll_direction = ScrollDirection::Down;
}
Ordering::Less => {
self.previous_index = self.current_index;
self.current_index = new_index;
self.scroll_direction = ScrollDirection::Up;
}
Ordering::Equal => {}
}
}
fn skip_to_first(&mut self) -> EventResult {
if self.current_index != 0 {
self.update_index(0);
EventResult::Redraw
} else {
EventResult::Continue
}
}
fn skip_to_last(&mut self) -> EventResult {
let last_index = self.num_items - 1;
if self.current_index != last_index {
self.update_index(last_index);
EventResult::Redraw
} else {
EventResult::Continue
}
}
/// Moves *downward* by *incrementing* the current index.
fn move_down(&mut self, change_by: usize) -> EventResult {
let new_index = self.current_index + change_by;
if new_index >= self.num_items {
let last_index = self.num_items - 1;
if self.current_index != last_index {
self.update_index(last_index);
EventResult::Redraw
} else {
EventResult::Continue
}
} else {
self.update_index(new_index);
EventResult::Redraw
}
}
/// Moves *upward* by *decrementing* the current index.
fn move_up(&mut self, change_by: usize) -> EventResult {
let new_index = self.current_index.saturating_sub(change_by);
if new_index == 0 {
if self.current_index != 0 {
self.update_index(0);
EventResult::Redraw
} else {
EventResult::Continue
}
} else {
self.update_index(new_index);
EventResult::Redraw
}
}
}
impl Widget for Scrollable {
type UpdateState = usize;
fn handle_key_event(&mut self, event: KeyEvent) -> EventResult {
use crossterm::event::KeyCode::{Char, Down, Up};
if event.modifiers == KeyModifiers::NONE || event.modifiers == KeyModifiers::SHIFT {
match event.code {
Down if event.modifiers == KeyModifiers::NONE => self.move_down(1),
Up if event.modifiers == KeyModifiers::NONE => self.move_up(1),
Char('j') => self.move_down(1),
Char('k') => self.move_up(1),
Char('g') => match self.gg_manager.input('g') {
MultiKeyResult::Completed => self.skip_to_first(),
MultiKeyResult::Accepted => EventResult::Continue,
MultiKeyResult::Rejected => EventResult::Continue,
},
Char('G') => self.skip_to_last(),
_ => EventResult::Continue,
}
} else {
EventResult::Continue
}
}
fn handle_mouse_event(&mut self, event: MouseEvent, _x: u16, y: u16) -> EventResult {
match event.kind {
crossterm::event::MouseEventKind::Down(MouseButton::Left) => {
// This requires a bit of fancy calculation. The main trick is remembering that
// we are using a *visual* index here - not what is the actual index! Luckily, we keep track of that
// inside our linked copy of TableState!
//
// Note that y is assumed to be *relative*;
// we assume that y starts at where the list starts (and there are no gaps or whatever).
if let Some(selected) = self.tui_state.selected() {
let y = y as usize;
if y > selected {
let offset = y - selected;
return self.move_down(offset);
} else {
let offset = selected - y;
return self.move_up(offset);
}
}
EventResult::Continue
}
crossterm::event::MouseEventKind::ScrollDown => self.move_down(1),
crossterm::event::MouseEventKind::ScrollUp => self.move_up(1),
_ => EventResult::Continue,
}
}
fn update(&mut self, new_num_items: usize) {
self.num_items = new_num_items;
if new_num_items <= self.current_index {
self.current_index = new_num_items - 1;
}
if new_num_items <= self.previous_index {
self.previous_index = new_num_items - 1;
}
}
}

View File

@ -0,0 +1 @@
pub struct TextInput {}

View File

@ -0,0 +1,142 @@
use tui::layout::Rect;
use crate::{
app::{event::EventResult, Scrollable, Widget},
constants::TABLE_GAP_HEIGHT_LIMIT,
};
struct Column {
name: &'static str,
// TODO: I would remove these in the future, storing them here feels weird...
desired_column_width: u16,
calculated_column_width: u16,
x_bounds: (u16, u16),
}
impl Column {}
/// The [`Widget::UpdateState`] of a [`TextTable`].
pub struct TextTableUpdateState {
num_items: Option<usize>,
columns: Option<Vec<Column>>,
}
/// A sortable, scrollable table with columns.
pub struct TextTable {
/// Controls the scrollable state.
scrollable: Scrollable,
/// The columns themselves.
columns: Vec<Column>,
/// Whether to show a gap between the column headers and the columns.
show_gap: bool,
/// The bounding box of the [`TextTable`].
bounds: Rect, // TODO: I kinda want to remove this...
/// Which index we're sorting by.
sort_index: usize,
}
impl TextTable {
pub fn new(num_items: usize, columns: Vec<&'static str>) -> Self {
Self {
scrollable: Scrollable::new(num_items),
columns: columns
.into_iter()
.map(|name| Column {
name,
desired_column_width: 0,
calculated_column_width: 0,
x_bounds: (0, 0),
})
.collect(),
show_gap: true,
bounds: Rect::default(),
sort_index: 0,
}
}
pub fn try_show_gap(mut self, show_gap: bool) -> Self {
self.show_gap = show_gap;
self
}
pub fn sort_index(mut self, sort_index: usize) -> Self {
self.sort_index = sort_index;
self
}
pub fn update_bounds(&mut self, new_bounds: Rect) {
self.bounds = new_bounds;
}
pub fn update_calculated_column_bounds(&mut self, calculated_bounds: &[u16]) {
self.columns
.iter_mut()
.zip(calculated_bounds.iter())
.for_each(|(column, bound)| column.calculated_column_width = *bound);
}
pub fn desired_column_bounds(&self) -> Vec<u16> {
self.columns
.iter()
.map(|column| column.desired_column_width)
.collect()
}
pub fn column_names(&self) -> Vec<&'static str> {
self.columns.iter().map(|column| column.name).collect()
}
fn is_drawing_gap(&self) -> bool {
if !self.show_gap {
false
} else {
self.bounds.height >= TABLE_GAP_HEIGHT_LIMIT
}
}
}
impl Widget for TextTable {
type UpdateState = TextTableUpdateState;
fn handle_key_event(&mut self, event: crossterm::event::KeyEvent) -> EventResult {
self.scrollable.handle_key_event(event)
}
fn handle_mouse_event(
&mut self, event: crossterm::event::MouseEvent, x: u16, y: u16,
) -> EventResult {
if y == 0 {
for (index, column) in self.columns.iter().enumerate() {
let (start, end) = column.x_bounds;
if start >= x && end <= y {
self.sort_index = index;
}
}
EventResult::Continue
} else if self.is_drawing_gap() {
self.scrollable.handle_mouse_event(event, x, y - 1)
} else {
self.scrollable.handle_mouse_event(event, x, y - 2)
}
}
fn update(&mut self, update_state: Self::UpdateState) {
if let Some(num_items) = update_state.num_items {
self.scrollable.update(num_items);
}
if let Some(columns) = update_state.columns {
self.columns = columns;
if self.columns.len() <= self.sort_index {
self.sort_index = self.columns.len() - 1;
}
}
}
}

View File

@ -0,0 +1,157 @@
use std::time::{Duration, Instant};
use crossterm::event::{KeyEvent, KeyModifiers, MouseEvent};
use crate::app::{event::EventResult, Widget};
pub enum AutohideTimerState {
Hidden,
Running(Instant),
}
pub enum AutohideTimer {
Disabled,
Enabled {
state: AutohideTimerState,
show_duration: Duration,
},
}
impl AutohideTimer {
fn trigger_display_timer(&mut self) {
match self {
AutohideTimer::Disabled => todo!(),
AutohideTimer::Enabled {
state,
show_duration: _,
} => {
*state = AutohideTimerState::Running(Instant::now());
}
}
}
pub fn update_display_timer(&mut self) {
match self {
AutohideTimer::Disabled => {}
AutohideTimer::Enabled {
state,
show_duration,
} => match state {
AutohideTimerState::Hidden => {}
AutohideTimerState::Running(trigger_instant) => {
if trigger_instant.elapsed() > *show_duration {
*state = AutohideTimerState::Hidden;
}
}
},
}
}
}
/// A graph widget with controllable time ranges along the x-axis.
pub struct TimeGraph {
current_display_time: u64,
autohide_timer: AutohideTimer,
default_time_value: u64,
min_duration: u64,
max_duration: u64,
time_interval: u64,
}
impl TimeGraph {
pub fn new(
start_value: u64, autohide_timer: AutohideTimer, min_duration: u64, max_duration: u64,
time_interval: u64,
) -> Self {
Self {
current_display_time: start_value,
autohide_timer,
default_time_value: start_value,
min_duration,
max_duration,
time_interval,
}
}
fn handle_char(&mut self, c: char) -> EventResult {
match c {
'-' => self.zoom_out(),
'+' => self.zoom_in(),
'=' => self.reset_zoom(),
_ => EventResult::Continue,
}
}
fn zoom_in(&mut self) -> EventResult {
let new_time = self.current_display_time.saturating_sub(self.time_interval);
if new_time >= self.min_duration {
self.current_display_time = new_time;
self.autohide_timer.trigger_display_timer();
EventResult::Redraw
} else if new_time != self.min_duration {
self.current_display_time = self.min_duration;
self.autohide_timer.trigger_display_timer();
EventResult::Redraw
} else {
EventResult::Continue
}
}
fn zoom_out(&mut self) -> EventResult {
let new_time = self.current_display_time + self.time_interval;
if new_time <= self.max_duration {
self.current_display_time = new_time;
self.autohide_timer.trigger_display_timer();
EventResult::Redraw
} else if new_time != self.max_duration {
self.current_display_time = self.max_duration;
self.autohide_timer.trigger_display_timer();
EventResult::Redraw
} else {
EventResult::Continue
}
}
fn reset_zoom(&mut self) -> EventResult {
if self.current_display_time == self.default_time_value {
EventResult::Continue
} else {
self.current_display_time = self.default_time_value;
self.autohide_timer.trigger_display_timer();
EventResult::Redraw
}
}
}
impl Widget for TimeGraph {
type UpdateState = ();
fn handle_key_event(&mut self, event: KeyEvent) -> EventResult {
use crossterm::event::KeyCode::Char;
if event.modifiers == KeyModifiers::NONE || event.modifiers == KeyModifiers::SHIFT {
match event.code {
Char(c) => self.handle_char(c),
_ => EventResult::Continue,
}
} else {
EventResult::Continue
}
}
fn handle_mouse_event(&mut self, event: MouseEvent, _x: u16, _y: u16) -> EventResult {
match event.kind {
crossterm::event::MouseEventKind::ScrollDown => self.zoom_out(),
crossterm::event::MouseEventKind::ScrollUp => self.zoom_in(),
_ => EventResult::Continue,
}
}
}

View File

@ -1,8 +1,15 @@
use std::time::Instant;
use tui::widgets::TableState;
use crossterm::event::{KeyEvent, MouseEvent};
use tui::{layout::Rect, widgets::TableState};
use crate::{app::layout_manager::BottomWidgetType, constants};
use crate::{
app::{event::EventResult, layout_manager::BottomWidgetType},
constants,
};
pub mod base;
pub use base::*;
pub mod process;
pub use process::*;
@ -25,6 +32,34 @@ pub use self::battery::*;
pub mod temp;
pub use temp::*;
#[allow(unused_variables)]
pub trait Widget {
type UpdateState;
/// Handles a [`KeyEvent`].
///
/// Defaults to returning [`EventResult::Continue`], indicating nothing should be done.
fn handle_key_event(&mut self, event: KeyEvent) -> EventResult {
EventResult::Continue
}
/// Handles a [`MouseEvent`]. `x` and `y` represent *relative* mouse coordinates to the [`Widget`] - those should
/// be used as opposed to the coordinates in the `event` unless you need absolute coordinates for some reason!
///
/// Defaults to returning [`EventResult::Continue`], indicating nothing should be done.
fn handle_mouse_event(&mut self, event: MouseEvent, x: u16, y: u16) -> EventResult {
EventResult::Continue
}
/// Updates a [`Widget`]. Defaults to doing nothing.
fn update(&mut self, update_state: Self::UpdateState) {}
/// Returns a [`Widget`]'s bounding box, if possible. Defaults to returning [`None`].
fn bounding_box(&self) -> Option<Rect> {
None
}
}
#[derive(Debug)]
pub enum ScrollDirection {
// UP means scrolling up --- this usually DECREMENTS

View File

@ -4,7 +4,7 @@
#[macro_use]
extern crate log;
use bottom::{canvas, constants::*, data_conversion::*, options::*, *};
use bottom::{app::event::EventResult, canvas, constants::*, data_conversion::*, options::*, *};
use std::{
boxed::Box,

View File

@ -31,6 +31,7 @@ use crossterm::{
use app::{
data_harvester::{self, processes::ProcessSorting},
event::EventResult,
layout_manager::{UsedWidgets, WidgetDirection},
AppState,
};
@ -74,12 +75,6 @@ pub enum ThreadControlEvent {
UpdateUpdateTime(u64),
}
pub enum EventResult {
Quit,
Redraw,
Continue,
}
pub fn handle_mouse_event(event: MouseEvent, app: &mut AppState) -> EventResult {
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {