From 2666c7d5cbeebc3044576de6b56e2c3c60a030c0 Mon Sep 17 00:00:00 2001 From: Carter Canedy Date: Fri, 27 Sep 2024 14:04:38 -0700 Subject: [PATCH] confirmation prompt --- core/src/inner.rs | 28 ++++++----- core/src/lib.rs | 4 +- tui/src/confirmation.rs | 103 ++++++++++++++++++++++++++++++++++++++++ tui/src/float.rs | 8 ++-- tui/src/main.rs | 1 + tui/src/state.rs | 98 +++++++++++++++++++++++++++----------- 6 files changed, 195 insertions(+), 47 deletions(-) create mode 100644 tui/src/confirmation.rs diff --git a/core/src/inner.rs b/core/src/inner.rs index 2e34954e4..9fb580832 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 22ef602b2..b7cd631e7 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 000000000..5ee660847 --- /dev/null +++ b/tui/src/confirmation.rs @@ -0,0 +1,103 @@ +use std::borrow::Cow; + +use crate::{ + float::FloatContent, + hint::Shortcut +}; + +use ratatui::{prelude::*, widgets::{Block, Borders, Clear, List}}; +use crossterm::event::{KeyCode, KeyEvent}; + +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 names = names + .iter() + .zip(1..) + .map(|(name, n)| format!("{n}. {name}")) + .collect(); + + Self { + names, + status: ConfirmStatus::None, + scroll: 0 + } + } + + pub fn scroll_down(&mut self) { + if self.scroll < self.names.len() { + 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_alignment(ratatui::layout::Alignment::Center) + .title_style(Style::default().reversed()) + .style(Style::default()); + + // Draw the Block first + frame.render_widget(block.clone(), area); + + let inner_area = block.inner(area); + + let paths_text = self.names + .iter() + .skip(self.scroll) + .map(|p| Line::from(Span::from(Cow::<'_, str>::Borrowed(p))).style(Style::default().bold())) + .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, + _ => 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"]) + ]) + ) + } +} diff --git a/tui/src/float.rs b/tui/src/float.rs index 1a12d6b1e..067415a1c 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 a26a4306f..d823dc287 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -3,6 +3,7 @@ mod float; mod floating_text; mod hint; mod running_command; +mod confirmation; pub mod state; mod theme; diff --git a/tui/src/state.rs b/tui/src/state.rs index 3448a3af4..5ac9a6c14 100644 --- a/tui/src/state.rs +++ b/tui/src/state.rs @@ -1,14 +1,11 @@ +use std::rc::Rc; + use crate::{ - filter::{Filter, SearchAction}, - float::{Float, FloatContent}, - floating_text::{FloatingText, FloatingTextMode}, - hint::{create_shortcut_list, Shortcut}, - running_command::RunningCommand, - theme::Theme, + confirmation::{ConfirmPrompt, ConfirmStatus}, filter::{Filter, SearchAction}, float::{Float, FloatContent}, floating_text::{FloatingText, FloatingTextMode}, hint::{create_shortcut_list, Shortcut}, running_command::RunningCommand, theme::Theme }; 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::{ @@ -40,7 +37,7 @@ pub struct AppState { /// Selected theme theme: Theme, /// Currently focused area - pub focus: Focus, + pub focus: Focus, /// List of tabs tabs: Vec, /// Current tab @@ -53,21 +50,22 @@ pub struct AppState { selection: ListState, filter: Filter, multi_select: bool, - selected_commands: Vec, + selected_commands: Vec>, drawable: bool, #[cfg(feature = "tips")] tip: &'static str, } -pub enum Focus { +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 +162,7 @@ impl AppState { ), Focus::FloatingWindow(ref float) => float.get_shortcut_list(), + Focus::ConfirmationPrompt(ref prompt) => prompt.get_shortcut_list(), } } @@ -308,7 +307,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 +388,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,7 +401,7 @@ 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) + 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')) { @@ -444,6 +445,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 +520,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 +569,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 +581,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 +612,7 @@ impl AppState { } } } + pub fn selected_item_is_dir(&self) -> bool { let mut selected_index = self.selection.selected().unwrap_or(0); @@ -617,18 +634,21 @@ impl AppState { // Any item that is not a directory or up directory (..) must be a command !(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); @@ -639,31 +659,51 @@ 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