implement dynamic shortcut list sizing (#668)

* implement dynamic shortcut list sizing

* Remove all dynamic allocations from shortcut creation
This commit is contained in:
cartercanedy 2024-09-28 12:05:19 -07:00 committed by GitHub
parent 97b7d2860a
commit a2480bf1bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 175 additions and 157 deletions

View File

@ -4,13 +4,13 @@ use ratatui::{
Frame, Frame,
}; };
use crate::hint::ShortcutList; use crate::hint::Shortcut;
pub trait FloatContent { pub trait FloatContent {
fn draw(&mut self, frame: &mut Frame, area: Rect); fn draw(&mut self, frame: &mut Frame, area: Rect);
fn handle_key_event(&mut self, key: &KeyEvent) -> bool; fn handle_key_event(&mut self, key: &KeyEvent) -> bool;
fn is_finished(&self) -> bool; fn is_finished(&self) -> bool;
fn get_shortcut_list(&self) -> ShortcutList; fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>);
} }
pub struct Float { 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() self.content.get_shortcut_list()
} }
} }

View File

@ -4,10 +4,7 @@ use std::{
io::{Cursor, Read as _, Seek, SeekFrom, Write as _}, io::{Cursor, Read as _, Seek, SeekFrom, Write as _},
}; };
use crate::{ use crate::{float::FloatContent, hint::Shortcut};
float::FloatContent,
hint::{Shortcut, ShortcutList},
};
use linutil_core::Command; use linutil_core::Command;
@ -293,16 +290,16 @@ impl FloatContent for FloatingText {
true true
} }
fn get_shortcut_list(&self) -> ShortcutList { fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) {
ShortcutList { (
scope_name: self.mode_title, self.mode_title,
hints: vec![ Box::new([
Shortcut::new(vec!["j", "Down"], "Scroll down"), Shortcut::new("Scroll down", ["j", "Down"]),
Shortcut::new(vec!["k", "Up"], "Scroll up"), Shortcut::new("Scroll up", ["k", "Up"]),
Shortcut::new(vec!["h", "Left"], "Scroll left"), Shortcut::new("Scroll left", ["h", "Left"]),
Shortcut::new(vec!["l", "Right"], "Scroll right"), Shortcut::new("Scroll right", ["l", "Right"]),
Shortcut::new(vec!["Enter", "p", "d", "g"], "Close window"), Shortcut::new("Close window", ["Enter", "p", "d", "g"]),
], ]),
} )
} }
} }

View File

@ -1,20 +1,10 @@
use std::borrow::Cow;
use ratatui::{ use ratatui::{
layout::{Margin, Rect},
style::{Style, Stylize}, style::{Style, Stylize},
text::{Line, Span}, 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 struct Shortcut {
pub key_sequences: Vec<Span<'static>>, pub key_sequences: Vec<Span<'static>>,
pub desc: &'static str, 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 { pub fn span_vec_len(span_vec: &[Span]) -> usize {
span_vec.iter().rfold(0, |init, s| init + s.width()) 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 mut shortcut_spans: Vec<Vec<Span<'static>>> = hints.iter().map(|h| h.to_spans()).collect();
let split_idx = acc
let mut lines: Vec<Line<'static>> = vec![];
loop {
let split_idx = shortcut_spans
.iter() .iter()
.scan(0_usize, |total_len, s| { .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); *total_len += span_vec_len(s);
if *total_len > inner_area.width as usize { if *total_len > render_width as usize {
None None
} else { } else {
*total_len += 4; *total_len += 4;
Some(1) Some(())
}
} }
}) })
.count(); .count();
let new_shortcut_list = acc.split_off(split_idx); let rest = shortcut_spans.split_off(split_idx);
lines.push(add_spacing(acc)); lines.push(add_spacing(shortcut_spans));
new_shortcut_list if rest.is_empty() {
}); break;
lines.push(add_spacing(shortcut_list)); } else {
shortcut_spans = rest;
let p = Paragraph::new(lines).block(block);
frame.render_widget(p, area);
} }
} }
lines.into_boxed_slice()
}
impl Shortcut { 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 { Self {
key_sequences: key_sequences key_sequences: key_sequences
.iter() .iter()
.map(|s| Span::styled(*s, Style::default().bold())) .map(|s| Span::styled(Cow::<'static, str>::Borrowed(s), Style::default().bold()))
.collect(), .collect(),
desc, desc,
} }
} }
fn to_spans(&self) -> Vec<Span> { fn to_spans(&self) -> Vec<Span<'static>> {
let mut ret: Vec<_> = self let mut ret: Vec<_> = self
.key_sequences .key_sequences
.iter() .iter()
@ -95,77 +94,3 @@ impl Shortcut {
ret 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);
}

View File

@ -1,7 +1,4 @@
use crate::{ use crate::{float::FloatContent, hint::Shortcut};
float::FloatContent,
hint::{Shortcut, ShortcutList},
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use linutil_core::Command; use linutil_core::Command;
use oneshot::{channel, Receiver}; 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() { if self.is_finished() {
ShortcutList { (
scope_name: "Finished command", "Finished command",
hints: vec![Shortcut::new(vec!["Enter", "q"], "Close window")], Box::new([Shortcut::new("Close window", ["Enter", "q"])]),
} )
} else { } else {
ShortcutList { (
scope_name: "Running command", "Running command",
hints: vec![Shortcut::new(vec!["CTRL-c"], "Kill the command")], Box::new([Shortcut::new("Kill the command", ["CTRL-c"])]),
} )
} }
} }
} }

View File

@ -2,7 +2,7 @@ use crate::{
filter::{Filter, SearchAction}, filter::{Filter, SearchAction},
float::{Float, FloatContent}, float::{Float, FloatContent},
floating_text::{FloatingText, FloatingTextMode}, floating_text::{FloatingText, FloatingTextMode},
hint::{draw_shortcuts, SHORTCUT_LINES}, hint::{create_shortcut_list, Shortcut},
running_command::RunningCommand, running_command::RunningCommand,
theme::Theme, theme::Theme,
}; };
@ -12,9 +12,9 @@ use linutil_core::{Command, ListNode, Tab};
#[cfg(feature = "tips")] #[cfg(feature = "tips")]
use rand::Rng; use rand::Rng;
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout}, layout::{Alignment, Constraint, Direction, Flex, Layout},
style::{Style, Stylize}, style::{Style, Stylize},
text::{Line, Span}, text::{Line, Span, Text},
widgets::{Block, Borders, List, ListState, Paragraph}, widgets::{Block, Borders, List, ListState, Paragraph},
Frame, Frame,
}; };
@ -76,6 +76,7 @@ impl AppState {
pub fn new(theme: Theme, override_validation: bool) -> Self { pub fn new(theme: Theme, override_validation: bool) -> Self {
let tabs = linutil_core::get_tabs(!override_validation); let tabs = linutil_core::get_tabs(!override_validation);
let root_id = tabs[0].tree.root().id(); let root_id = tabs[0].tree.root().id();
let mut state = Self { let mut state = Self {
theme, theme,
focus: Focus::List, focus: Focus::List,
@ -90,9 +91,82 @@ impl AppState {
#[cfg(feature = "tips")] #[cfg(feature = "tips")]
tip: get_random_tip(), tip: get_random_tip(),
}; };
state.update_items(); state.update_items();
state 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) { pub fn draw(&mut self, frame: &mut Frame) {
let terminal_size = frame.area(); let terminal_size = frame.area();
@ -153,12 +227,26 @@ impl AppState {
.unwrap_or(0) .unwrap_or(0)
.max(str1.len() + str2.len()); .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() let vertical = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Percentage(100), Constraint::Percentage(0),
Constraint::Min(2 + SHORTCUT_LINES as u16), Constraint::Max(n_lines as u16 + 2),
]) ])
.flex(Flex::Legacy)
.margin(0) .margin(0)
.split(frame.area()); .split(frame.area());
@ -305,7 +393,7 @@ impl AppState {
float.draw(frame, chunks[1]); 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 { pub fn handle_key(&mut self, key: &KeyEvent) -> bool {
@ -355,11 +443,13 @@ impl AppState {
self.focus = Focus::List; self.focus = Focus::List;
} }
} }
Focus::Search => match self.filter.handle_key(key) { Focus::Search => match self.filter.handle_key(key) {
SearchAction::Exit => self.exit_search(), SearchAction::Exit => self.exit_search(),
SearchAction::Update => self.update_items(), SearchAction::Update => self.update_items(),
SearchAction::None => {} SearchAction::None => {}
}, },
Focus::TabList => match key.code { Focus::TabList => match key.code {
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.focus = Focus::List, 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(), KeyCode::Char('g') => self.toggle_task_list_guide(),
_ => {} _ => {}
}, },
Focus::List if key.kind != KeyEventKind::Release => match key.code { Focus::List if key.kind != KeyEventKind::Release => match key.code {
KeyCode::Char('j') | KeyCode::Down => self.selection.select_next(), KeyCode::Char('j') | KeyCode::Down => self.selection.select_next(),
KeyCode::Char('k') | KeyCode::Up => self.selection.select_previous(), KeyCode::Char('k') | KeyCode::Up => self.selection.select_previous(),
KeyCode::Char('p') | KeyCode::Char('P') => self.enable_preview(), KeyCode::Char('p') | KeyCode::Char('P') => self.enable_preview(),
KeyCode::Char('d') | KeyCode::Char('D') => self.enable_description(), KeyCode::Char('d') | KeyCode::Char('D') => self.enable_description(),
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.handle_enter(), KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.handle_enter(),
KeyCode::Char('h') | KeyCode::Left => { KeyCode::Char('h') | KeyCode::Left => self.go_back(),
if self.at_root() {
self.focus = Focus::TabList;
} else {
self.enter_parent_directory();
}
}
KeyCode::Char('/') => self.enter_search(), KeyCode::Char('/') => self.enter_search(),
KeyCode::Char('t') => self.theme.next(), KeyCode::Char('t') => self.theme.next(),
KeyCode::Char('T') => self.theme.prev(), KeyCode::Char('T') => self.theme.prev(),
@ -402,10 +487,12 @@ impl AppState {
KeyCode::Char(' ') if self.multi_select => self.toggle_selection(), KeyCode::Char(' ') if self.multi_select => self.toggle_selection(),
_ => {} _ => {}
}, },
_ => (), _ => (),
}; };
true true
} }
fn toggle_multi_select(&mut self) { fn toggle_multi_select(&mut self) {
if self.is_current_tab_multi_selectable() { if self.is_current_tab_multi_selectable() {
self.multi_select = !self.multi_select; self.multi_select = !self.multi_select;
@ -414,6 +501,7 @@ impl AppState {
} }
} }
} }
fn toggle_selection(&mut self) { fn toggle_selection(&mut self) {
if let Some(command) = self.get_selected_command() { if let Some(command) = self.get_selected_command() {
if self.selected_commands.contains(&command) { if self.selected_commands.contains(&command) {
@ -423,12 +511,14 @@ impl AppState {
} }
} }
} }
pub fn is_current_tab_multi_selectable(&self) -> bool { pub fn is_current_tab_multi_selectable(&self) -> bool {
let index = self.current_tab.selected().unwrap_or(0); let index = self.current_tab.selected().unwrap_or(0);
self.tabs self.tabs
.get(index) .get(index)
.map_or(false, |tab| tab.multi_selectable) .map_or(false, |tab| tab.multi_selectable)
} }
fn update_items(&mut self) { fn update_items(&mut self) {
self.filter.update_items( self.filter.update_items(
&self.tabs, &self.tabs,
@ -448,11 +538,20 @@ impl AppState {
self.visit_stack.len() == 1 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) { fn enter_parent_directory(&mut self) {
self.visit_stack.pop(); self.visit_stack.pop();
self.selection.select(Some(0)); self.selection.select(Some(0));
self.update_items(); self.update_items();
} }
fn get_selected_node(&self) -> Option<&ListNode> { fn get_selected_node(&self) -> Option<&ListNode> {
let mut selected_index = self.selection.selected().unwrap_or(0); let mut selected_index = self.selection.selected().unwrap_or(0);