diff --git a/core/src/inner.rs b/core/src/inner.rs index 2e34954e..6cd4356b 100644 --- a/core/src/inner.rs +++ b/core/src/inner.rs @@ -1,13 +1,15 @@ -use crate::{Command, ListNode, Tab}; -use ego_tree::{NodeMut, Tree}; -use include_dir::{include_dir, Dir}; -use serde::Deserialize; use std::{ fs::File, io::{BufRead, BufReader, Read}, os::unix::fs::PermissionsExt, path::{Path, PathBuf}, + rc::Rc, }; + +use crate::{Command, ListNode, Tab}; +use ego_tree::{NodeMut, Tree}; +use include_dir::{include_dir, Dir}; +use serde::Deserialize; use tempdir::TempDir; const TAB_DATA: Dir = include_dir!("$CARGO_MANIFEST_DIR/tabs"); @@ -35,12 +37,12 @@ pub fn get_tabs(validate: bool) -> Vec { }, directory, )| { - let mut tree = Tree::new(ListNode { + let mut tree = Tree::new(Rc::new(ListNode { name: "root".to_string(), description: String::new(), command: Command::None, task_list: String::new(), - }); + })); let mut root = tree.root_mut(); create_directory(data, &mut root, &directory, validate); Tab { @@ -164,28 +166,28 @@ fn filter_entries(entries: &mut Vec) { fn create_directory( data: Vec, - node: &mut NodeMut, + node: &mut NodeMut>, command_dir: &Path, validate: bool, ) { for entry in data { match entry.entry_type { EntryType::Entries(entries) => { - let mut node = node.append(ListNode { + let mut node = node.append(Rc::new(ListNode { name: entry.name, description: entry.description, command: Command::None, task_list: String::new(), - }); + })); create_directory(entries, &mut node, command_dir, validate); } EntryType::Command(command) => { - node.append(ListNode { + node.append(Rc::new(ListNode { name: entry.name, description: entry.description, command: Command::Raw(command), task_list: String::new(), - }); + })); } EntryType::Script(script) => { let script = command_dir.join(script); @@ -194,7 +196,7 @@ fn create_directory( } if let Some((executable, args)) = get_shebang(&script, validate) { - node.append(ListNode { + node.append(Rc::new(ListNode { name: entry.name, description: entry.description, command: Command::LocalFile { @@ -203,7 +205,7 @@ fn create_directory( file: script, }, task_list: entry.task_list, - }); + })); } } } diff --git a/core/src/lib.rs b/core/src/lib.rs index 22ef602b..b7cd631e 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,5 +1,7 @@ mod inner; +use std::rc::Rc; + use ego_tree::Tree; use std::path::PathBuf; @@ -20,7 +22,7 @@ pub enum Command { #[derive(Clone, Hash, Eq, PartialEq)] pub struct Tab { pub name: String, - pub tree: Tree, + pub tree: Tree>, pub multi_selectable: bool, } diff --git a/tui/src/confirmation.rs b/tui/src/confirmation.rs new file mode 100644 index 00000000..28732e35 --- /dev/null +++ b/tui/src/confirmation.rs @@ -0,0 +1,126 @@ +use std::borrow::Cow; + +use crate::{float::FloatContent, hint::Shortcut}; + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::Alignment, + prelude::*, + widgets::{Block, Borders, Clear, List}, +}; + +pub enum ConfirmStatus { + Confirm, + Abort, + None, +} + +pub struct ConfirmPrompt { + pub names: Box<[String]>, + pub status: ConfirmStatus, + scroll: usize, +} + +impl ConfirmPrompt { + pub fn new(names: &[&str]) -> Self { + let max_count_str = format!("{}", names.len()); + let names = names + .iter() + .zip(1..) + .map(|(name, n)| { + let count_str = format!("{n}"); + let space_str = (0..(max_count_str.len() - count_str.len())) + .map(|_| ' ') + .collect::(); + format!("{space_str}{n}. {name}") + }) + .collect(); + + Self { + names, + status: ConfirmStatus::None, + scroll: 0, + } + } + + pub fn scroll_down(&mut self) { + if self.scroll < self.names.len() - 1 { + self.scroll += 1; + } + } + + pub fn scroll_up(&mut self) { + if self.scroll > 0 { + self.scroll -= 1; + } + } +} + +impl FloatContent for ConfirmPrompt { + fn draw(&mut self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Confirm selections ") + .title_bottom(" [y] to continue, [n] to abort ") + .title_alignment(Alignment::Center) + .title_style(Style::default().bold()) + .style(Style::default()); + + frame.render_widget(block.clone(), area); + + let inner_area = block.inner(area); + + let paths_text = self + .names + .iter() + .skip(self.scroll) + .map(|p| { + let span = Span::from(Cow::<'_, str>::Borrowed(p)); + Line::from(span).style(Style::default()) + }) + .collect::(); + + frame.render_widget(Clear, inner_area); + frame.render_widget(List::new(paths_text), inner_area); + } + + fn handle_key_event(&mut self, key: &KeyEvent) -> bool { + use KeyCode::*; + self.status = match key.code { + Char('y') | Char('Y') => ConfirmStatus::Confirm, + Char('n') | Char('N') | Esc => ConfirmStatus::Abort, + Char('j') => { + self.scroll_down(); + ConfirmStatus::None + } + Char('k') => { + self.scroll_up(); + ConfirmStatus::None + } + _ => ConfirmStatus::None, + }; + + false + } + + fn is_finished(&self) -> bool { + use ConfirmStatus::*; + match self.status { + Confirm | Abort => true, + None => false, + } + } + + fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) { + ( + "Confirmation prompt", + Box::new([ + Shortcut::new("Continue", ["Y", "y"]), + Shortcut::new("Abort", ["N", "n"]), + Shortcut::new("Scroll up", ["j"]), + Shortcut::new("Scroll down", ["k"]), + Shortcut::new("Close linutil", ["CTRL-c", "q"]), + ]), + ) + } +} diff --git a/tui/src/float.rs b/tui/src/float.rs index b4ab0344..7b569752 100644 --- a/tui/src/float.rs +++ b/tui/src/float.rs @@ -13,14 +13,14 @@ pub trait FloatContent { fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>); } -pub struct Float { - content: Box, +pub struct Float { + pub content: Box, width_percent: u16, height_percent: u16, } -impl Float { - pub fn new(content: Box, width_percent: u16, height_percent: u16) -> Self { +impl Float { + pub fn new(content: Box, width_percent: u16, height_percent: u16) -> Self { Self { content, width_percent, diff --git a/tui/src/main.rs b/tui/src/main.rs index a26a4306..801e3b1d 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -1,3 +1,4 @@ +mod confirmation; mod filter; mod float; mod floating_text; diff --git a/tui/src/state.rs b/tui/src/state.rs index 0a955c79..ce724868 100644 --- a/tui/src/state.rs +++ b/tui/src/state.rs @@ -1,4 +1,7 @@ +use std::rc::Rc; + use crate::{ + confirmation::{ConfirmPrompt, ConfirmStatus}, filter::{Filter, SearchAction}, float::{Float, FloatContent}, floating_text::{FloatingText, FloatingTextMode}, @@ -8,7 +11,7 @@ use crate::{ }; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ego_tree::NodeId; -use linutil_core::{Command, ListNode, Tab}; +use linutil_core::{ListNode, Tab}; #[cfg(feature = "tips")] use rand::Rng; use ratatui::{ @@ -53,7 +56,7 @@ pub struct AppState { selection: ListState, filter: Filter, multi_select: bool, - selected_commands: Vec, + selected_commands: Vec>, drawable: bool, #[cfg(feature = "tips")] tip: &'static str, @@ -63,11 +66,12 @@ pub enum Focus { Search, TabList, List, - FloatingWindow(Float), + FloatingWindow(Float), + ConfirmationPrompt(Float), } pub struct ListEntry { - pub node: ListNode, + pub node: Rc, pub id: NodeId, pub has_children: bool, } @@ -164,6 +168,7 @@ impl AppState { ), Focus::FloatingWindow(ref float) => float.get_shortcut_list(), + Focus::ConfirmationPrompt(ref prompt) => prompt.get_shortcut_list(), } } @@ -308,7 +313,7 @@ impl AppState { |ListEntry { node, has_children, .. }| { - let is_selected = self.selected_commands.contains(&node.command); + let is_selected = self.selected_commands.contains(node); let (indicator, style) = if is_selected { (self.theme.multi_select_icon(), Style::default().bold()) } else { @@ -389,8 +394,10 @@ impl AppState { frame.render_stateful_widget(disclaimer_list, list_chunks[1], &mut self.selection); - if let Focus::FloatingWindow(float) = &mut self.focus { - float.draw(frame, chunks[1]); + match &mut self.focus { + Focus::FloatingWindow(float) => float.draw(frame, chunks[1]), + Focus::ConfirmationPrompt(prompt) => prompt.draw(frame, chunks[1]), + _ => {} } frame.render_widget(keybind_para, vertical[1]); @@ -400,9 +407,11 @@ impl AppState { // This should be defined first to allow closing // the application even when not drawable ( If terminal is small ) // Exit on 'q' or 'Ctrl-c' input - if matches!(self.focus, Focus::TabList | Focus::List) - && (key.code == KeyCode::Char('q') - || key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c')) + if matches!( + self.focus, + Focus::TabList | Focus::List | Focus::ConfirmationPrompt(_) + ) && (key.code == KeyCode::Char('q') + || key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c')) { return false; } @@ -444,6 +453,22 @@ impl AppState { } } + Focus::ConfirmationPrompt(confirm) => { + confirm.content.handle_key_event(key); + match confirm.content.status { + ConfirmStatus::Abort => { + self.focus = Focus::List; + // selected command was pushed to selection list if multi-select was + // enabled, need to clear it to prevent state corruption + if !self.multi_select { + self.selected_commands.clear() + } + } + ConfirmStatus::Confirm => self.handle_confirm_command(), + ConfirmStatus::None => {} + } + } + Focus::Search => match self.filter.handle_key(key) { SearchAction::Exit => self.exit_search(), SearchAction::Update => self.update_items(), @@ -503,7 +528,7 @@ impl AppState { } fn toggle_selection(&mut self) { - if let Some(command) = self.get_selected_command() { + if let Some(command) = self.get_selected_node() { if self.selected_commands.contains(&command) { self.selected_commands.retain(|c| c != &command); } else { @@ -552,7 +577,7 @@ impl AppState { self.update_items(); } - fn get_selected_node(&self) -> Option<&ListNode> { + fn get_selected_node(&self) -> Option> { let mut selected_index = self.selection.selected().unwrap_or(0); if !self.at_root() && selected_index == 0 { @@ -564,18 +589,17 @@ impl AppState { if let Some(item) = self.filter.item_list().get(selected_index) { if !item.has_children { - return Some(&item.node); + return Some(item.node.clone()); } } None } - pub fn get_selected_command(&self) -> Option { - self.get_selected_node().map(|node| node.command.clone()) - } + fn get_selected_description(&self) -> Option { self.get_selected_node() .map(|node| node.description.clone()) } + pub fn go_to_selected_dir(&mut self) { let mut selected_index = self.selection.selected().unwrap_or(0); @@ -596,6 +620,7 @@ impl AppState { } } } + pub fn selected_item_is_dir(&self) -> bool { let mut selected_index = self.selection.selected().unwrap_or(0); @@ -618,18 +643,23 @@ impl AppState { self.selection.selected().is_some() && !(self.selected_item_is_up_dir() || self.selected_item_is_dir()) } + 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() { - if let Some(preview) = FloatingText::from_command(&command, FloatingTextMode::Preview) { + if let Some(node) = self.get_selected_node() { + if let Some(preview) = + FloatingText::from_command(&node.command, FloatingTextMode::Preview) + { self.spawn_float(preview, 80, 80); } } } + fn enable_description(&mut self) { if let Some(command_description) = self.get_selected_description() { let description = FloatingText::new(command_description, FloatingTextMode::Description); @@ -640,31 +670,53 @@ impl AppState { fn handle_enter(&mut self) { if self.selected_item_is_cmd() { if self.selected_commands.is_empty() { - if let Some(cmd) = self.get_selected_command() { - self.selected_commands.push(cmd); + if let Some(node) = self.get_selected_node() { + self.selected_commands.push(node); } } - let command = RunningCommand::new(self.selected_commands.clone()); - self.spawn_float(command, 80, 80); - self.selected_commands.clear(); + + let cmd_names = self + .selected_commands + .iter() + .map(|node| node.name.as_str()) + .collect::>(); + + let prompt = ConfirmPrompt::new(&cmd_names[..]); + self.focus = Focus::ConfirmationPrompt(Float::new(Box::new(prompt), 40, 40)); } else { self.go_to_selected_dir(); } } + + fn handle_confirm_command(&mut self) { + let commands = self + .selected_commands + .iter() + .map(|node| node.command.clone()) + .collect(); + + let command = RunningCommand::new(commands); + self.spawn_float(command, 80, 80); + self.selected_commands.clear(); + } + fn spawn_float(&mut self, float: T, width: u16, height: u16) { self.focus = Focus::FloatingWindow(Float::new(Box::new(float), width, height)); } + fn enter_search(&mut self) { self.focus = Focus::Search; self.filter.activate_search(); self.selection.select(None); } + fn exit_search(&mut self) { self.selection.select(Some(0)); self.focus = Focus::List; self.filter.deactivate_search(); self.update_items(); } + fn refresh_tab(&mut self) { self.visit_stack = vec![self.tabs[self.current_tab.selected().unwrap()] .tree