feat(tui): add help/key bindings screen

This commit is contained in:
Orhun Parmaksız 2022-02-08 17:08:49 +03:00
parent 77d59e82ae
commit 2084326481
No known key found for this signature in database
GPG key ID: F83424824B3E4B90
4 changed files with 225 additions and 9 deletions

View file

@ -11,6 +11,80 @@ use systeroid_core::sysctl::parameter::Parameter;
use systeroid_core::sysctl::section::Section;
use unicode_width::UnicodeWidthStr;
/// Representation of a key binding.
pub struct KeyBinding<'a> {
/// Pressed key.
pub key: &'a str,
/// Action to perform.
pub action: &'a str,
}
/// Help text to show.
pub const HELP_TEXT: &str = concat!(
"\u{2800} _ __/_ _ '_/\n",
"_) (/_) /(-/ ()/(/\n",
"/ \u{2800}\n",
env!("CARGO_PKG_NAME"),
" v",
env!("CARGO_PKG_VERSION"),
"\n",
env!("CARGO_PKG_REPOSITORY"),
"\nwritten by ",
env!("CARGO_PKG_AUTHORS"),
);
/// Key bindings of the application.
pub const KEY_BINDINGS: &[&KeyBinding] = &[
&KeyBinding {
key: "[?], f1",
action: "show help",
},
&KeyBinding {
key: "up/down, k/j, pgup/pgdown",
action: "scroll list",
},
&KeyBinding {
key: "t/b",
action: "scroll to top/bottom",
},
&KeyBinding {
key: "left/right, h/l",
action: "scroll documentation",
},
&KeyBinding {
key: "tab, [`]",
action: "next/previous section",
},
&KeyBinding {
key: "[:]",
action: "command",
},
&KeyBinding {
key: "[/]",
action: "search",
},
&KeyBinding {
key: "enter",
action: "select / set value",
},
&KeyBinding {
key: "c",
action: "copy to clipboard",
},
&KeyBinding {
key: "r, f5",
action: "refresh",
},
&KeyBinding {
key: "esc",
action: "cancel / exit",
},
&KeyBinding {
key: "ctrl-c/ctrl-d",
action: "exit",
},
];
/// Duration of prompt messages.
const MESSAGE_DURATION: u128 = 1750;
@ -18,6 +92,8 @@ const MESSAGE_DURATION: u128 = 1750;
pub struct App<'a> {
/// Whether if the application is running.
pub running: bool,
/// Whether if the help message is shown.
pub show_help: bool,
/// Input buffer.
pub input: Option<String>,
/// Time tracker for measuring the time for clearing the input.
@ -34,6 +110,8 @@ pub struct App<'a> {
pub parameter_list: SelectableList<Parameter>,
/// List of sysctl sections.
pub section_list: SelectableList<String>,
/// List of key bindings.
pub key_bindings: SelectableList<&'a KeyBinding<'a>>,
#[cfg(feature = "clipboard")]
/// Clipboard context.
clipboard: Option<Box<dyn ClipboardProvider>>,
@ -46,6 +124,7 @@ impl<'a> App<'a> {
pub fn new(sysctl: &'a mut Sysctl) -> Self {
let mut app = Self {
running: true,
show_help: false,
input: None,
input_time: None,
input_cursor: 0,
@ -61,6 +140,7 @@ impl<'a> App<'a> {
sections.insert(0, String::from("all"));
sections
}),
key_bindings: SelectableList::default(),
#[cfg(feature = "clipboard")]
clipboard: None,
sysctl,
@ -160,8 +240,15 @@ impl<'a> App<'a> {
/// Runs the given command and updates the application.
pub fn run_command(&mut self, command: Command) -> Result<()> {
let mut hide_options = true;
let mut hide_popup = true;
match command {
Command::Help => {
self.options = None;
self.key_bindings = SelectableList::with_items(KEY_BINDINGS.to_vec());
self.key_bindings.state.select(None);
self.show_help = true;
hide_popup = false;
}
Command::Select => {
if let Some(copy_option) = self
.options
@ -171,6 +258,8 @@ impl<'a> App<'a> {
{
self.copy_to_clipboard(copy_option)?;
self.options = None;
} else if self.show_help {
self.key_bindings.state.select(None);
} else if let Some(parameter) = self.parameter_list.selected() {
self.search_mode = false;
self.input_time = None;
@ -199,9 +288,12 @@ impl<'a> App<'a> {
}
}
Command::Scroll(ScrollArea::List, Direction::Up, amount) => {
if let Some(options) = self.options.as_mut() {
if self.show_help {
self.key_bindings.previous();
hide_popup = false;
} else if let Some(options) = self.options.as_mut() {
options.previous();
hide_options = false;
hide_popup = false;
} else if !self.parameter_list.items.is_empty() {
self.docs_scroll_amount = 0;
if amount == 1 {
@ -218,9 +310,12 @@ impl<'a> App<'a> {
}
}
Command::Scroll(ScrollArea::List, Direction::Down, amount) => {
if let Some(options) = self.options.as_mut() {
if self.show_help {
self.key_bindings.next();
hide_popup = false;
} else if let Some(options) = self.options.as_mut() {
options.next();
hide_options = false;
hide_popup = false;
} else if !self.parameter_list.items.is_empty() {
self.docs_scroll_amount = 0;
if amount == 1 {
@ -368,7 +463,8 @@ impl<'a> App<'a> {
self.options = Some(SelectableList::with_items(
copy_options.iter().map(|v| v.as_str()).collect(),
));
hide_options = false;
hide_popup = false;
self.show_help = false;
} else {
self.input = Some(String::from("No parameter is selected"));
self.input_time = Some(Instant::now());
@ -393,7 +489,7 @@ impl<'a> App<'a> {
if self.input.is_some() {
self.input = None;
self.input_time = None;
} else if self.options.is_none() {
} else if self.options.is_none() && !self.show_help {
self.running = false;
}
}
@ -402,8 +498,9 @@ impl<'a> App<'a> {
}
Command::Nothing => {}
}
if hide_options {
if hide_popup {
self.options = None;
self.show_help = false;
}
Ok(())
}

View file

@ -5,6 +5,8 @@ use termion::event::Key;
/// Possible application commands.
#[derive(Debug, PartialEq)]
pub enum Command {
/// Show help.
Help,
/// Perform an action based on the selected entry.
Select,
/// Set the value of a parameter.
@ -37,6 +39,7 @@ impl FromStr for Command {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"help" => Ok(Command::Help),
"search" => Ok(Command::Search),
"select" => Ok(Command::Select),
"copy" => Ok(Command::Copy),
@ -124,6 +127,7 @@ mod tests {
#[test]
fn test_command() {
for (command, value) in vec![
(Command::Help, "help"),
(Command::Search, "search"),
(Command::Select, "select"),
(Command::Copy, "copy"),
@ -166,6 +170,7 @@ mod tests {
}
assert_command_parser! {
input_mode: false,
Key::Char('?') => Command::Help,
Key::Up => Command::Scroll(ScrollArea::List, Direction::Up, 1),
Key::Down => Command::Scroll(ScrollArea::List, Direction::Down, 1),
Key::PageUp => Command::Scroll(ScrollArea::List, Direction::Up, 4),

View file

@ -1,4 +1,4 @@
use crate::app::App;
use crate::app::{App, KeyBinding, HELP_TEXT};
use crate::widgets::SelectableList;
use tui::backend::Backend;
use tui::layout::{Alignment, Constraint, Direction, Layout, Rect};
@ -45,6 +45,9 @@ pub fn render<B: Backend>(frame: &mut Frame<'_, B>, app: &mut App) {
&mut app.docs_scroll_amount,
);
}
if app.show_help {
render_help_text(frame, rect, &mut app.key_bindings);
}
}
/// Renders the list that contains the sysctl parameters.
@ -202,6 +205,96 @@ fn render_section_text<B: Backend>(frame: &mut Frame<'_, B>, rect: Rect, section
);
}
/// Renders the text for displaying help.
fn render_help_text<B: Backend>(
frame: &mut Frame<'_, B>,
rect: Rect,
key_bindings: &mut SelectableList<&KeyBinding>,
) {
let (percent_x, percent_y) = (50, 50);
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
]
.as_ref(),
)
.split(rect);
let rect = Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
]
.as_ref(),
)
.split(popup_layout[1])[1];
let area = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Min(
(HELP_TEXT.lines().count() + 2)
.try_into()
.unwrap_or_default(),
),
Constraint::Min(
(key_bindings.items.len() + 2)
.try_into()
.unwrap_or_default(),
),
Constraint::Percentage(100),
]
.as_ref(),
)
.split(rect);
frame.render_widget(Clear, area[0]);
frame.render_widget(
Paragraph::new(HELP_TEXT)
.block(
Block::default()
.title(Span::styled("About", Style::default().fg(Color::White)))
.title_alignment(Alignment::Center)
.borders(Borders::all())
.border_style(Style::default().fg(Color::White))
.border_type(BorderType::Rounded)
.style(Style::default().bg(Color::Black)),
)
.alignment(Alignment::Center)
.wrap(Wrap { trim: false }),
area[0],
);
frame.render_widget(Clear, area[1]);
frame.render_stateful_widget(
Table::new(key_bindings.items.iter().map(|item| {
Row::new(vec![Cell::from(item.key), Cell::from(item.action)])
.height(1)
.bottom_margin(0)
}))
.block(
Block::default()
.title(Span::styled(
"Key Bindings",
Style::default().fg(Color::White),
))
.title_alignment(Alignment::Center)
.borders(Borders::all())
.border_style(Style::default().fg(Color::White))
.border_type(BorderType::Rounded)
.style(Style::default().bg(Color::Black)),
)
.highlight_style(Style::default().bg(Color::White).fg(Color::Black))
.widths(&[Constraint::Percentage(50), Constraint::Percentage(50)]),
area[1],
&mut key_bindings.state,
);
}
/// Renders a list as a popup for showing options.
fn render_options_menu<B: Backend>(
frame: &mut Frame<'_, B>,

View file

@ -80,6 +80,27 @@ fn test_render_tui() -> Result<()> {
terminal.backend(),
)?;
app.run_command(Command::Help)?;
app.run_command(Command::Scroll(ScrollArea::List, Direction::Down, 1))?;
app.run_command(Command::Scroll(ScrollArea::List, Direction::Up, 1))?;
terminal.draw(|frame| render(frame, &mut app))?;
assert_buffer(
Buffer::with_lines(vec![
"╭Parameters──────────────────────|all|─╮",
"│user.name system │",
"│kernel.fi╭──────About───────╮ │",
"│vm.stat_i│ \u{2800} _ __/_ _ │ │",
"│ │ '_/ │ │",
"│ │_) (/_) /(-/ ()/(/│ │",
"│ ╰──────────────────╯ │",
"│ │",
"│ 1/3 │",
"╰──────────────────────────────────────╯",
]),
terminal.backend(),
)?;
app.run_command(Command::Select)?;
app.run_command(Command::Select)?;
terminal.draw(|frame| render(frame, &mut app))?;
assert_buffer(