From ec76dcd99b5d77c4346844a401a3cb08128d1857 Mon Sep 17 00:00:00 2001 From: Andrii Dokhniak Date: Fri, 6 Sep 2024 23:36:12 +0200 Subject: [PATCH 1/3] Add the hint --- src/float.rs | 7 ++ src/floating_text.rs | 17 ++++- src/hint.rs | 161 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 2 +- src/running_command.rs | 19 ++++- src/state.rs | 90 ++++++++++++++++++++--- 6 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 src/hint.rs diff --git a/src/float.rs b/src/float.rs index 3b063cae..e60fe464 100644 --- a/src/float.rs +++ b/src/float.rs @@ -4,10 +4,13 @@ use ratatui::{ Frame, }; +use crate::hint::ShortcutList; + 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; } pub struct Float { @@ -69,4 +72,8 @@ impl Float { _ => self.content.handle_key_event(key), } } + + pub fn get_shortcut_list(&self) -> ShortcutList { + self.content.get_shortcut_list() + } } diff --git a/src/floating_text.rs b/src/floating_text.rs index 482a8822..e1d8deb3 100644 --- a/src/floating_text.rs +++ b/src/floating_text.rs @@ -1,4 +1,8 @@ -use crate::{float::FloatContent, running_command::Command}; +use crate::{ + float::FloatContent, + hint::{Shortcut, ShortcutList}, + running_command::Command, +}; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ layout::Rect, @@ -103,4 +107,15 @@ impl FloatContent for FloatingText { fn is_finished(&self) -> bool { true } + + fn get_shortcut_list(&self) -> ShortcutList { + ShortcutList { + scope_name: "Floating text", + hints: vec![ + Shortcut::new(vec!["j", "Down"], "Scroll down"), + Shortcut::new(vec!["k", "Up"], "Scroll up"), + Shortcut::new(vec!["Enter", "q"], "Close window"), + ], + } + } } diff --git a/src/hint.rs b/src/hint.rs new file mode 100644 index 00000000..27474ea9 --- /dev/null +++ b/src/hint.rs @@ -0,0 +1,161 @@ +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_sequenses: Vec>, + pub desc: &'static str, +} + +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(self.scope_name) + .borders(Borders::all()); + let inner_area = area.inner(Margin::new(1, 1)); + let mut shortcut_list: Vec> = self.hints.iter().map(|h| h.to_spans()).collect(); + + let mut lines = vec![Line::default(); SHORTCUT_LINES]; + let mut idx = 0; + + while idx < SHORTCUT_LINES - 1 { + let split_idx = shortcut_list + .iter() + .scan(0usize, |total_len, s| { + *total_len += span_vec_len(s); + if *total_len > inner_area.width as usize { + None + } else { + *total_len += 4; + Some(1) + } + }) + .count(); + let new_shortcut_list = shortcut_list.split_off(split_idx); + let line: Vec<_> = shortcut_list + .into_iter() + .flat_map(|mut s| { + s.push(Span::default().content(" ")); + s + }) + .collect(); + shortcut_list = new_shortcut_list; + lines[idx] = line.into(); + idx += 1; + } + lines[idx] = shortcut_list + .into_iter() + .flat_map(|mut s| { + s.push(Span::default().content(" ")); + s + }) + .collect(); + + let p = Paragraph::new(lines).block(block); + frame.render_widget(p, area); + } +} + +impl Shortcut { + pub fn new(key_sequences: Vec<&'static str>, desc: &'static str) -> Self { + Self { + key_sequenses: key_sequences + .iter() + .map(|s| Span::styled(*s, Style::default().bold())) + .collect(), + desc, + } + } + + fn to_spans(&self) -> Vec { + let mut ret: Vec<_> = self + .key_sequenses + .iter() + .flat_map(|seq| { + [ + Span::default().content("["), + seq.clone(), + Span::default().content("] "), + ] + }) + .collect(); + ret.push(Span::styled(self.desc, Style::default().italic())); + ret + } +} + +fn get_list_item_shortcut(state: &AppState) -> Shortcut { + if state.selected_item_is_dir() { + Shortcut::new(vec!["l", "Right", "Enter"], "Go to selected dir") + } else { + Shortcut::new(vec!["l", "Right", "Enter"], "Run selected command") + } +} + +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", "Tab"], "Focus tab list")); + hints.push(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 parrent directory", + )); + } else { + hints.push(Shortcut::new(vec!["h", "Left"], "Go to parrent directory")); + hints.push(get_list_item_shortcut(state)); + if state.selected_item_is_cmd() { + hints.push(Shortcut::new(vec!["p"], "Enable preview")); + } + } + hints.push(Shortcut::new(vec!["Tab"], "Focus tab list")); + }; + 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")); + ShortcutList { + scope_name: "Item list", + hints, + } + } + Focus::TabList => ShortcutList { + scope_name: "Tab list", + hints: vec![ + Shortcut::new(vec!["q", "CTRL-c"], "Exit linutil"), + Shortcut::new(vec!["l", "Right", "Tab", "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"), + ], + }, + Focus::FloatingWindow(ref float) => float.get_shortcut_list(), + } + .draw(frame, area); +} diff --git a/src/main.rs b/src/main.rs index 85c0278f..1759435c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod filter; mod float; mod floating_text; +mod hint; mod running_command; pub mod state; mod tabs; @@ -71,7 +72,6 @@ fn main() -> std::io::Result<()> { fn run(terminal: &mut Terminal, state: &mut AppState) -> io::Result<()> { loop { terminal.draw(|frame| state.draw(frame)).unwrap(); - // Wait for an event if !event::poll(Duration::from_millis(10))? { continue; diff --git a/src/running_command.rs b/src/running_command.rs index f5041ee4..0840f189 100644 --- a/src/running_command.rs +++ b/src/running_command.rs @@ -1,4 +1,7 @@ -use crate::float::FloatContent; +use crate::{ + float::FloatContent, + hint::{Shortcut, ShortcutList}, +}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use oneshot::{channel, Receiver}; use portable_pty::{ @@ -123,6 +126,20 @@ impl FloatContent for RunningCommand { true } } + + fn get_shortcut_list(&self) -> ShortcutList { + if self.is_finished() { + ShortcutList { + scope_name: "Finished command", + hints: vec![Shortcut::new(vec!["Enter", "q"], "Close window")], + } + } else { + ShortcutList { + scope_name: "Running command", + hints: vec![Shortcut::new(vec!["CTRL-c"], "Kill the command")], + } + } + } } impl RunningCommand { diff --git a/src/state.rs b/src/state.rs index 432a5af2..5cf3bd83 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,6 +2,7 @@ use crate::{ filter::{Filter, SearchAction}, float::{Float, FloatContent}, floating_text::FloatingText, + hint::{draw_shortcuts, SHORTCUT_LINES}, running_command::{Command, RunningCommand}, tabs::{ListNode, Tab}, theme::Theme, @@ -21,7 +22,7 @@ pub struct AppState { /// Selected theme theme: Theme, /// Currently focused area - focus: Focus, + pub focus: Focus, /// List of tabs tabs: Vec, /// Current tab @@ -72,13 +73,23 @@ impl AppState { .max() .unwrap_or(0); + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(100), + Constraint::Min(2 + SHORTCUT_LINES as u16), + ]) + .margin(0) + .split(frame.size()); + let horizontal = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Min(longest_tab_display_len as u16 + 5), Constraint::Percentage(100), ]) - .split(frame.size()); + .split(vertical[0]); + let left_chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(1)]) @@ -148,6 +159,8 @@ impl AppState { if let Focus::FloatingWindow(float) = &mut self.focus { float.draw(frame, chunks[1]); } + + draw_shortcuts(self, frame, vertical[1]); } pub fn handle_key(&mut self, key: &KeyEvent) -> bool { match &mut self.focus { @@ -213,7 +226,7 @@ impl AppState { /// Checks ehther the current tree node is the root node (can we go up the tree or no) /// Returns `true` if we can't go up the tree (we are at the tree root) /// else returns `false` - fn at_root(&self) -> bool { + pub fn at_root(&self) -> bool { self.visit_stack.len() == 1 } fn enter_parent_directory(&mut self) { @@ -221,13 +234,10 @@ impl AppState { self.selection.select(Some(0)); self.update_items(); } - fn get_selected_command(&mut self, change_directory: bool) -> Option { + pub fn get_selected_command(&self) -> Option { let mut selected_index = self.selection.selected().unwrap_or(0); if !self.at_root() && selected_index == 0 { - if change_directory { - self.enter_parent_directory(); - } return None; } if !self.at_root() { @@ -237,25 +247,83 @@ impl AppState { if let Some(item) = self.filter.item_list().get(selected_index) { if !item.has_children { return Some(item.node.command.clone()); - } else if change_directory { + } + } + None + } + pub fn go_to_selected_dir(&mut self) { + let mut selected_index = self.selection.selected().unwrap_or(0); + + if !self.at_root() && selected_index == 0 { + self.enter_parent_directory(); + return; + } + + if !self.at_root() { + selected_index = selected_index.saturating_sub(1); + } + + if let Some(item) = self.filter.item_list().get(selected_index) { + if item.has_children { self.visit_stack.push(item.id); self.selection.select(Some(0)); self.update_items(); } } - None + } + pub fn selected_item_is_dir(&self) -> bool { + let mut selected_index = self.selection.selected().unwrap_or(0); + + if !self.at_root() && selected_index == 0 { + return false; + } + + if !self.at_root() { + selected_index = selected_index.saturating_sub(1); + } + + if let Some(item) = self.filter.item_list().get(selected_index) { + item.has_children + } else { + false + } + } + + pub fn selected_item_is_cmd(&self) -> bool { + let mut selected_index = self.selection.selected().unwrap_or(0); + + if !self.at_root() && selected_index == 0 { + return false; + } + + if !self.at_root() { + selected_index = selected_index.saturating_sub(1); + } + + if let Some(item) = self.filter.item_list().get(selected_index) { + !item.has_children + } else { + false + } + } + pub fn selected_item_is_up_dir(&self) -> bool { + let selected_index = self.selection.selected().unwrap_or(0); + + !self.at_root() && selected_index == 0 } fn enable_preview(&mut self) { - if let Some(command) = self.get_selected_command(false) { + if let Some(command) = self.get_selected_command() { if let Some(preview) = FloatingText::from_command(&command) { self.spawn_float(preview, 80, 80); } } } fn handle_enter(&mut self) { - if let Some(cmd) = self.get_selected_command(true) { + if let Some(cmd) = self.get_selected_command() { let command = RunningCommand::new(cmd); self.spawn_float(command, 80, 80); + } else { + self.go_to_selected_dir(); } } fn spawn_float(&mut self, float: T, width: u16, height: u16) { From d5c58768789507c3b4aa281c82da4a084dd4a74d Mon Sep 17 00:00:00 2001 From: Andrii Dokhniak Date: Sat, 7 Sep 2024 16:25:56 +0200 Subject: [PATCH 2/3] Added the credits label --- src/state.rs | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/state.rs b/src/state.rs index 5cf3bd83..36b5ce34 100644 --- a/src/state.rs +++ b/src/state.rs @@ -10,10 +10,10 @@ use crate::{ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; use ego_tree::NodeId; use ratatui::{ - layout::{Constraint, Direction, Layout}, + layout::{Alignment, Constraint, Direction, Layout}, style::{Style, Stylize}, - text::Line, - widgets::{Block, Borders, List, ListState}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListState, Paragraph}, Frame, }; use std::path::Path; @@ -66,12 +66,35 @@ impl AppState { state } pub fn draw(&mut self, frame: &mut Frame) { + let label_block = + Block::default() + .borders(Borders::all()) + .border_set(ratatui::symbols::border::Set { + top_left: " ", + top_right: " ", + bottom_left: " ", + bottom_right: " ", + vertical_left: " ", + vertical_right: " ", + horizontal_top: "*", + horizontal_bottom: "*", + }); + let str1 = "Linutil "; + let str2 = "by Chris Titus"; + let label = Paragraph::new(Line::from(vec![ + Span::styled(str1, Style::default().bold()), + Span::styled(str2, Style::default().italic()), + ])) + .block(label_block) + .alignment(Alignment::Center); + let longest_tab_display_len = self .tabs .iter() .map(|tab| tab.name.len() + self.theme.tab_icon().len()) .max() - .unwrap_or(0); + .unwrap_or(0) + .max(str1.len() + str2.len()); let vertical = Layout::default() .direction(Direction::Vertical) @@ -94,6 +117,7 @@ impl AppState { .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(1)]) .split(horizontal[0]); + frame.render_widget(label, left_chunks[0]); let tabs = self .tabs From d3b3eae2d6d87fa3ff9a2c9a56530e6db9f51977 Mon Sep 17 00:00:00 2001 From: Chris Titus Date: Tue, 10 Sep 2024 14:57:20 -0500 Subject: [PATCH 3/3] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8dcac50e..fcaa6744 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /build rust/target rust/build +Cargo.lock