From a2480bf1bdc86ffc5ff685eb1b92589c6aa99083 Mon Sep 17 00:00:00 2001 From: cartercanedy <99052281+cartercanedy@users.noreply.github.com> Date: Sat, 28 Sep 2024 12:05:19 -0700 Subject: [PATCH] implement dynamic shortcut list sizing (#668) * implement dynamic shortcut list sizing * Remove all dynamic allocations from shortcut creation --- tui/src/float.rs | 6 +- tui/src/floating_text.rs | 27 +++---- tui/src/hint.rs | 151 ++++++++++--------------------------- tui/src/running_command.rs | 23 +++--- tui/src/state.rs | 125 ++++++++++++++++++++++++++---- 5 files changed, 175 insertions(+), 157 deletions(-) diff --git a/tui/src/float.rs b/tui/src/float.rs index 1f5b56c0..1a12d6b1 100644 --- a/tui/src/float.rs +++ b/tui/src/float.rs @@ -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() } } diff --git a/tui/src/floating_text.rs b/tui/src/floating_text.rs index 2cd63824..fefe9b9a 100644 --- a/tui/src/floating_text.rs +++ b/tui/src/floating_text.rs @@ -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"]), + ]), + ) } } diff --git a/tui/src/hint.rs b/tui/src/hint.rs index e59eab5a..8e16e749 100644 --- a/tui/src/hint.rs +++ b/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, -} - pub struct Shortcut { pub key_sequences: Vec>, pub desc: &'static str, @@ -32,54 +22,63 @@ fn add_spacing(list: Vec>) -> 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> = self.hints.iter().map(|h| h.to_spans()).collect(); - let mut lines: Vec = Vec::with_capacity(SHORTCUT_LINES); +pub fn create_shortcut_list( + shortcuts: impl IntoIterator, + render_width: u16, +) -> Box<[Line<'static>]> { + let hints = shortcuts.into_iter().collect::>(); - 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>> = hints.iter().map(|h| h.to_spans()).collect(); + + let mut lines: Vec> = 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(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 { + fn to_spans(&self) -> Vec> { let mut ret: Vec<_> = self .key_sequences .iter() @@ -95,77 +94,3 @@ impl Shortcut { ret } } - -fn get_list_item_shortcut(state: &AppState) -> Vec { - 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); -} diff --git a/tui/src/running_command.rs b/tui/src/running_command.rs index e1d4b0a0..366a3dc3 100644 --- a/tui/src/running_command.rs +++ b/tui/src/running_command.rs @@ -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"])]), + ) } } } diff --git a/tui/src/state.rs b/tui/src/state.rs index 4aee0aaa..3448a3af 100644 --- a/tui/src/state.rs +++ b/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);