mirror of
https://github.com/orhun/systeroid
synced 2024-10-06 23:59:10 +00:00
feat(tui): add help/key bindings screen
This commit is contained in:
parent
77d59e82ae
commit
2084326481
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue