mirror of
https://github.com/ChrisTitusTech/linutil.git
synced 2024-12-23 20:09:44 +00:00
implement dynamic shortcut list sizing (#668)
* implement dynamic shortcut list sizing * Remove all dynamic allocations from shortcut creation
This commit is contained in:
parent
97b7d2860a
commit
a2480bf1bd
|
@ -4,13 +4,13 @@ use ratatui::{
|
|||
Frame,
|
||||
};
|
||||
|
||||
use crate::hint::ShortcutList;
|
||||
use crate::hint::Shortcut;
|
||||
|
||||
pub trait FloatContent {
|
||||
fn draw(&mut self, frame: &mut Frame, area: Rect);
|
||||
fn handle_key_event(&mut self, key: &KeyEvent) -> bool;
|
||||
fn is_finished(&self) -> bool;
|
||||
fn get_shortcut_list(&self) -> ShortcutList;
|
||||
fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>);
|
||||
}
|
||||
|
||||
pub struct Float {
|
||||
|
@ -69,7 +69,7 @@ impl Float {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn get_shortcut_list(&self) -> ShortcutList {
|
||||
pub fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) {
|
||||
self.content.get_shortcut_list()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,10 +4,7 @@ use std::{
|
|||
io::{Cursor, Read as _, Seek, SeekFrom, Write as _},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
float::FloatContent,
|
||||
hint::{Shortcut, ShortcutList},
|
||||
};
|
||||
use crate::{float::FloatContent, hint::Shortcut};
|
||||
|
||||
use linutil_core::Command;
|
||||
|
||||
|
@ -293,16 +290,16 @@ impl FloatContent for FloatingText {
|
|||
true
|
||||
}
|
||||
|
||||
fn get_shortcut_list(&self) -> ShortcutList {
|
||||
ShortcutList {
|
||||
scope_name: self.mode_title,
|
||||
hints: vec![
|
||||
Shortcut::new(vec!["j", "Down"], "Scroll down"),
|
||||
Shortcut::new(vec!["k", "Up"], "Scroll up"),
|
||||
Shortcut::new(vec!["h", "Left"], "Scroll left"),
|
||||
Shortcut::new(vec!["l", "Right"], "Scroll right"),
|
||||
Shortcut::new(vec!["Enter", "p", "d", "g"], "Close window"),
|
||||
],
|
||||
}
|
||||
fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) {
|
||||
(
|
||||
self.mode_title,
|
||||
Box::new([
|
||||
Shortcut::new("Scroll down", ["j", "Down"]),
|
||||
Shortcut::new("Scroll up", ["k", "Up"]),
|
||||
Shortcut::new("Scroll left", ["h", "Left"]),
|
||||
Shortcut::new("Scroll right", ["l", "Right"]),
|
||||
Shortcut::new("Close window", ["Enter", "p", "d", "g"]),
|
||||
]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
151
tui/src/hint.rs
151
tui/src/hint.rs
|
@ -1,20 +1,10 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use ratatui::{
|
||||
layout::{Margin, Rect},
|
||||
style::{Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::state::{AppState, Focus};
|
||||
|
||||
pub const SHORTCUT_LINES: usize = 2;
|
||||
|
||||
pub struct ShortcutList {
|
||||
pub scope_name: &'static str,
|
||||
pub hints: Vec<Shortcut>,
|
||||
}
|
||||
|
||||
pub struct Shortcut {
|
||||
pub key_sequences: Vec<Span<'static>>,
|
||||
pub desc: &'static str,
|
||||
|
@ -32,54 +22,63 @@ fn add_spacing(list: Vec<Vec<Span>>) -> Line {
|
|||
pub fn span_vec_len(span_vec: &[Span]) -> usize {
|
||||
span_vec.iter().rfold(0, |init, s| init + s.width())
|
||||
}
|
||||
impl ShortcutList {
|
||||
pub fn draw(&self, frame: &mut Frame, area: Rect) {
|
||||
let block = Block::default()
|
||||
.title(format!(" {} ", self.scope_name))
|
||||
.borders(Borders::all());
|
||||
let inner_area = area.inner(Margin::new(1, 1));
|
||||
let shortcut_spans: Vec<Vec<Span>> = self.hints.iter().map(|h| h.to_spans()).collect();
|
||||
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(SHORTCUT_LINES);
|
||||
pub fn create_shortcut_list(
|
||||
shortcuts: impl IntoIterator<Item = Shortcut>,
|
||||
render_width: u16,
|
||||
) -> Box<[Line<'static>]> {
|
||||
let hints = shortcuts.into_iter().collect::<Box<[Shortcut]>>();
|
||||
|
||||
let shortcut_list = (0..SHORTCUT_LINES - 1).fold(shortcut_spans, |mut acc, _| {
|
||||
let split_idx = acc
|
||||
.iter()
|
||||
.scan(0_usize, |total_len, s| {
|
||||
let mut shortcut_spans: Vec<Vec<Span<'static>>> = hints.iter().map(|h| h.to_spans()).collect();
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![];
|
||||
|
||||
loop {
|
||||
let split_idx = shortcut_spans
|
||||
.iter()
|
||||
.scan(0usize, |total_len, s| {
|
||||
// take at least one so that we guarantee that we drain the list
|
||||
// otherwise, this might lock up if there's a shortcut that exceeds the window width
|
||||
if *total_len == 0 {
|
||||
*total_len += span_vec_len(s) + 4;
|
||||
Some(())
|
||||
} else {
|
||||
*total_len += span_vec_len(s);
|
||||
if *total_len > inner_area.width as usize {
|
||||
if *total_len > render_width as usize {
|
||||
None
|
||||
} else {
|
||||
*total_len += 4;
|
||||
Some(1)
|
||||
Some(())
|
||||
}
|
||||
})
|
||||
.count();
|
||||
}
|
||||
})
|
||||
.count();
|
||||
|
||||
let new_shortcut_list = acc.split_off(split_idx);
|
||||
lines.push(add_spacing(acc));
|
||||
let rest = shortcut_spans.split_off(split_idx);
|
||||
lines.push(add_spacing(shortcut_spans));
|
||||
|
||||
new_shortcut_list
|
||||
});
|
||||
lines.push(add_spacing(shortcut_list));
|
||||
|
||||
let p = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(p, area);
|
||||
if rest.is_empty() {
|
||||
break;
|
||||
} else {
|
||||
shortcut_spans = rest;
|
||||
}
|
||||
}
|
||||
|
||||
lines.into_boxed_slice()
|
||||
}
|
||||
|
||||
impl Shortcut {
|
||||
pub fn new(key_sequences: Vec<&'static str>, desc: &'static str) -> Self {
|
||||
pub fn new<const N: usize>(desc: &'static str, key_sequences: [&'static str; N]) -> Self {
|
||||
Self {
|
||||
key_sequences: key_sequences
|
||||
.iter()
|
||||
.map(|s| Span::styled(*s, Style::default().bold()))
|
||||
.map(|s| Span::styled(Cow::<'static, str>::Borrowed(s), Style::default().bold()))
|
||||
.collect(),
|
||||
desc,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_spans(&self) -> Vec<Span> {
|
||||
fn to_spans(&self) -> Vec<Span<'static>> {
|
||||
let mut ret: Vec<_> = self
|
||||
.key_sequences
|
||||
.iter()
|
||||
|
@ -95,77 +94,3 @@ impl Shortcut {
|
|||
ret
|
||||
}
|
||||
}
|
||||
|
||||
fn get_list_item_shortcut(state: &AppState) -> Vec<Shortcut> {
|
||||
if state.selected_item_is_dir() {
|
||||
vec![Shortcut::new(
|
||||
vec!["l", "Right", "Enter"],
|
||||
"Go to selected dir",
|
||||
)]
|
||||
} else {
|
||||
vec![
|
||||
Shortcut::new(vec!["l", "Right", "Enter"], "Run selected command"),
|
||||
Shortcut::new(vec!["p"], "Enable preview"),
|
||||
Shortcut::new(vec!["d"], "Command Description"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_shortcuts(state: &AppState, frame: &mut Frame, area: Rect) {
|
||||
match state.focus {
|
||||
Focus::Search => ShortcutList {
|
||||
scope_name: "Search bar",
|
||||
hints: vec![Shortcut::new(vec!["Enter"], "Finish search")],
|
||||
},
|
||||
|
||||
Focus::List => {
|
||||
let mut hints = Vec::new();
|
||||
hints.push(Shortcut::new(vec!["q", "CTRL-c"], "Exit linutil"));
|
||||
|
||||
if state.at_root() {
|
||||
hints.push(Shortcut::new(vec!["h", "Left"], "Focus tab list"));
|
||||
hints.extend(get_list_item_shortcut(state));
|
||||
} else if state.selected_item_is_up_dir() {
|
||||
hints.push(Shortcut::new(
|
||||
vec!["l", "Right", "Enter", "h", "Left"],
|
||||
"Go to parent directory",
|
||||
));
|
||||
} else {
|
||||
hints.push(Shortcut::new(vec!["h", "Left"], "Go to parent directory"));
|
||||
hints.extend(get_list_item_shortcut(state));
|
||||
}
|
||||
hints.push(Shortcut::new(vec!["k", "Up"], "Select item above"));
|
||||
hints.push(Shortcut::new(vec!["j", "Down"], "Select item below"));
|
||||
hints.push(Shortcut::new(vec!["t"], "Next theme"));
|
||||
hints.push(Shortcut::new(vec!["T"], "Previous theme"));
|
||||
if state.is_current_tab_multi_selectable() {
|
||||
hints.push(Shortcut::new(vec!["v"], "Toggle multi-selection mode"));
|
||||
hints.push(Shortcut::new(vec!["Space"], "Select multiple commands"));
|
||||
}
|
||||
hints.push(Shortcut::new(vec!["Tab"], "Next tab"));
|
||||
hints.push(Shortcut::new(vec!["Shift-Tab"], "Previous tab"));
|
||||
hints.push(Shortcut::new(vec!["g"], "Important actions guide"));
|
||||
ShortcutList {
|
||||
scope_name: "Command list",
|
||||
hints,
|
||||
}
|
||||
}
|
||||
|
||||
Focus::TabList => ShortcutList {
|
||||
scope_name: "Tab list",
|
||||
hints: vec![
|
||||
Shortcut::new(vec!["q", "CTRL-c"], "Exit linutil"),
|
||||
Shortcut::new(vec!["l", "Right", "Enter"], "Focus action list"),
|
||||
Shortcut::new(vec!["k", "Up"], "Select item above"),
|
||||
Shortcut::new(vec!["j", "Down"], "Select item below"),
|
||||
Shortcut::new(vec!["t"], "Next theme"),
|
||||
Shortcut::new(vec!["T"], "Previous theme"),
|
||||
Shortcut::new(vec!["Tab"], "Next tab"),
|
||||
Shortcut::new(vec!["Shift-Tab"], "Previous tab"),
|
||||
],
|
||||
},
|
||||
|
||||
Focus::FloatingWindow(ref float) => float.get_shortcut_list(),
|
||||
}
|
||||
.draw(frame, area);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
use crate::{
|
||||
float::FloatContent,
|
||||
hint::{Shortcut, ShortcutList},
|
||||
};
|
||||
use crate::{float::FloatContent, hint::Shortcut};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use linutil_core::Command;
|
||||
use oneshot::{channel, Receiver};
|
||||
|
@ -120,17 +117,17 @@ impl FloatContent for RunningCommand {
|
|||
}
|
||||
}
|
||||
|
||||
fn get_shortcut_list(&self) -> ShortcutList {
|
||||
fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) {
|
||||
if self.is_finished() {
|
||||
ShortcutList {
|
||||
scope_name: "Finished command",
|
||||
hints: vec![Shortcut::new(vec!["Enter", "q"], "Close window")],
|
||||
}
|
||||
(
|
||||
"Finished command",
|
||||
Box::new([Shortcut::new("Close window", ["Enter", "q"])]),
|
||||
)
|
||||
} else {
|
||||
ShortcutList {
|
||||
scope_name: "Running command",
|
||||
hints: vec![Shortcut::new(vec!["CTRL-c"], "Kill the command")],
|
||||
}
|
||||
(
|
||||
"Running command",
|
||||
Box::new([Shortcut::new("Kill the command", ["CTRL-c"])]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
125
tui/src/state.rs
125
tui/src/state.rs
|
@ -2,7 +2,7 @@ use crate::{
|
|||
filter::{Filter, SearchAction},
|
||||
float::{Float, FloatContent},
|
||||
floating_text::{FloatingText, FloatingTextMode},
|
||||
hint::{draw_shortcuts, SHORTCUT_LINES},
|
||||
hint::{create_shortcut_list, Shortcut},
|
||||
running_command::RunningCommand,
|
||||
theme::Theme,
|
||||
};
|
||||
|
@ -12,9 +12,9 @@ use linutil_core::{Command, ListNode, Tab};
|
|||
#[cfg(feature = "tips")]
|
||||
use rand::Rng;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout},
|
||||
layout::{Alignment, Constraint, Direction, Flex, Layout},
|
||||
style::{Style, Stylize},
|
||||
text::{Line, Span},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, List, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
@ -76,6 +76,7 @@ impl AppState {
|
|||
pub fn new(theme: Theme, override_validation: bool) -> Self {
|
||||
let tabs = linutil_core::get_tabs(!override_validation);
|
||||
let root_id = tabs[0].tree.root().id();
|
||||
|
||||
let mut state = Self {
|
||||
theme,
|
||||
focus: Focus::List,
|
||||
|
@ -90,9 +91,82 @@ impl AppState {
|
|||
#[cfg(feature = "tips")]
|
||||
tip: get_random_tip(),
|
||||
};
|
||||
|
||||
state.update_items();
|
||||
state
|
||||
}
|
||||
|
||||
fn get_list_item_shortcut(&self) -> Box<[Shortcut]> {
|
||||
if self.selected_item_is_dir() {
|
||||
Box::new([Shortcut::new("Go to selected dir", ["l", "Right", "Enter"])])
|
||||
} else {
|
||||
Box::new([
|
||||
Shortcut::new("Run selected command", ["l", "Right", "Enter"]),
|
||||
Shortcut::new("Enable preview", ["p"]),
|
||||
Shortcut::new("Command Description", ["d"]),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_keybinds(&self) -> (&str, Box<[Shortcut]>) {
|
||||
match self.focus {
|
||||
Focus::Search => (
|
||||
"Search bar",
|
||||
Box::new([Shortcut::new("Finish search", ["Enter"])]),
|
||||
),
|
||||
|
||||
Focus::List => {
|
||||
let mut hints = Vec::new();
|
||||
hints.push(Shortcut::new("Exit linutil", ["q", "CTRL-c"]));
|
||||
|
||||
if self.at_root() {
|
||||
hints.push(Shortcut::new("Focus tab list", ["h", "Left"]));
|
||||
hints.extend(self.get_list_item_shortcut());
|
||||
} else if self.selected_item_is_up_dir() {
|
||||
hints.push(Shortcut::new(
|
||||
"Go to parent directory",
|
||||
["l", "Right", "Enter", "h", "Left"],
|
||||
));
|
||||
} else {
|
||||
hints.push(Shortcut::new("Go to parent directory", ["h", "Left"]));
|
||||
hints.extend(self.get_list_item_shortcut());
|
||||
}
|
||||
|
||||
hints.push(Shortcut::new("Select item above", ["k", "Up"]));
|
||||
hints.push(Shortcut::new("Select item below", ["j", "Down"]));
|
||||
hints.push(Shortcut::new("Next theme", ["t"]));
|
||||
hints.push(Shortcut::new("Previous theme", ["T"]));
|
||||
|
||||
if self.is_current_tab_multi_selectable() {
|
||||
hints.push(Shortcut::new("Toggle multi-selection mode", ["v"]));
|
||||
hints.push(Shortcut::new("Select multiple commands", ["Space"]));
|
||||
}
|
||||
|
||||
hints.push(Shortcut::new("Next tab", ["Tab"]));
|
||||
hints.push(Shortcut::new("Previous tab", ["Shift-Tab"]));
|
||||
hints.push(Shortcut::new("Important actions guide", ["g"]));
|
||||
|
||||
("Command list", hints.into_boxed_slice())
|
||||
}
|
||||
|
||||
Focus::TabList => (
|
||||
"Tab list",
|
||||
Box::new([
|
||||
Shortcut::new("Exit linutil", ["q", "CTRL-c"]),
|
||||
Shortcut::new("Focus action list", ["l", "Right", "Enter"]),
|
||||
Shortcut::new("Select item above", ["k", "Up"]),
|
||||
Shortcut::new("Select item below", ["j", "Down"]),
|
||||
Shortcut::new("Next theme", ["t"]),
|
||||
Shortcut::new("Previous theme", ["T"]),
|
||||
Shortcut::new("Next tab", ["Tab"]),
|
||||
Shortcut::new("Previous tab", ["Shift-Tab"]),
|
||||
]),
|
||||
),
|
||||
|
||||
Focus::FloatingWindow(ref float) => float.get_shortcut_list(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, frame: &mut Frame) {
|
||||
let terminal_size = frame.area();
|
||||
|
||||
|
@ -153,12 +227,26 @@ impl AppState {
|
|||
.unwrap_or(0)
|
||||
.max(str1.len() + str2.len());
|
||||
|
||||
let (keybind_scope, shortcuts) = self.get_keybinds();
|
||||
|
||||
let keybind_render_width = terminal_size.width - 2;
|
||||
|
||||
let keybinds_block = Block::default()
|
||||
.title(format!(" {} ", keybind_scope))
|
||||
.borders(Borders::all());
|
||||
|
||||
let keybinds = create_shortcut_list(shortcuts, keybind_render_width);
|
||||
let n_lines = keybinds.len() as u16;
|
||||
|
||||
let keybind_para = Paragraph::new(Text::from_iter(keybinds)).block(keybinds_block);
|
||||
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(100),
|
||||
Constraint::Min(2 + SHORTCUT_LINES as u16),
|
||||
Constraint::Percentage(0),
|
||||
Constraint::Max(n_lines as u16 + 2),
|
||||
])
|
||||
.flex(Flex::Legacy)
|
||||
.margin(0)
|
||||
.split(frame.area());
|
||||
|
||||
|
@ -305,7 +393,7 @@ impl AppState {
|
|||
float.draw(frame, chunks[1]);
|
||||
}
|
||||
|
||||
draw_shortcuts(self, frame, vertical[1]);
|
||||
frame.render_widget(keybind_para, vertical[1]);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: &KeyEvent) -> bool {
|
||||
|
@ -355,11 +443,13 @@ impl AppState {
|
|||
self.focus = Focus::List;
|
||||
}
|
||||
}
|
||||
|
||||
Focus::Search => match self.filter.handle_key(key) {
|
||||
SearchAction::Exit => self.exit_search(),
|
||||
SearchAction::Update => self.update_items(),
|
||||
SearchAction::None => {}
|
||||
},
|
||||
|
||||
Focus::TabList => match key.code {
|
||||
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.focus = Focus::List,
|
||||
|
||||
|
@ -381,19 +471,14 @@ impl AppState {
|
|||
KeyCode::Char('g') => self.toggle_task_list_guide(),
|
||||
_ => {}
|
||||
},
|
||||
|
||||
Focus::List if key.kind != KeyEventKind::Release => match key.code {
|
||||
KeyCode::Char('j') | KeyCode::Down => self.selection.select_next(),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.selection.select_previous(),
|
||||
KeyCode::Char('p') | KeyCode::Char('P') => self.enable_preview(),
|
||||
KeyCode::Char('d') | KeyCode::Char('D') => self.enable_description(),
|
||||
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.handle_enter(),
|
||||
KeyCode::Char('h') | KeyCode::Left => {
|
||||
if self.at_root() {
|
||||
self.focus = Focus::TabList;
|
||||
} else {
|
||||
self.enter_parent_directory();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('h') | KeyCode::Left => self.go_back(),
|
||||
KeyCode::Char('/') => self.enter_search(),
|
||||
KeyCode::Char('t') => self.theme.next(),
|
||||
KeyCode::Char('T') => self.theme.prev(),
|
||||
|
@ -402,10 +487,12 @@ impl AppState {
|
|||
KeyCode::Char(' ') if self.multi_select => self.toggle_selection(),
|
||||
_ => {}
|
||||
},
|
||||
|
||||
_ => (),
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
fn toggle_multi_select(&mut self) {
|
||||
if self.is_current_tab_multi_selectable() {
|
||||
self.multi_select = !self.multi_select;
|
||||
|
@ -414,6 +501,7 @@ impl AppState {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self) {
|
||||
if let Some(command) = self.get_selected_command() {
|
||||
if self.selected_commands.contains(&command) {
|
||||
|
@ -423,12 +511,14 @@ impl AppState {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_current_tab_multi_selectable(&self) -> bool {
|
||||
let index = self.current_tab.selected().unwrap_or(0);
|
||||
self.tabs
|
||||
.get(index)
|
||||
.map_or(false, |tab| tab.multi_selectable)
|
||||
}
|
||||
|
||||
fn update_items(&mut self) {
|
||||
self.filter.update_items(
|
||||
&self.tabs,
|
||||
|
@ -448,11 +538,20 @@ impl AppState {
|
|||
self.visit_stack.len() == 1
|
||||
}
|
||||
|
||||
fn go_back(&mut self) {
|
||||
if self.at_root() {
|
||||
self.focus = Focus::TabList;
|
||||
} else {
|
||||
self.enter_parent_directory();
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_parent_directory(&mut self) {
|
||||
self.visit_stack.pop();
|
||||
self.selection.select(Some(0));
|
||||
self.update_items();
|
||||
}
|
||||
|
||||
fn get_selected_node(&self) -> Option<&ListNode> {
|
||||
let mut selected_index = self.selection.selected().unwrap_or(0);
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user