mirror of
https://github.com/ChrisTitusTech/linutil.git
synced 2024-12-24 12:29:44 +00:00
Feat: confirmation prompts (#687)
* confirmation prompt * actually implement scrolling * finalize styling * get rid of generics on AppState and Focus * add bottom title as help text * number formatting
This commit is contained in:
parent
6a8987b35a
commit
7cc38df7e1
|
@ -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<Tab> {
|
|||
},
|
||||
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<Entry>) {
|
|||
|
||||
fn create_directory(
|
||||
data: Vec<Entry>,
|
||||
node: &mut NodeMut<ListNode>,
|
||||
node: &mut NodeMut<Rc<ListNode>>,
|
||||
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,
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<ListNode>,
|
||||
pub tree: Tree<Rc<ListNode>>,
|
||||
pub multi_selectable: bool,
|
||||
}
|
||||
|
||||
|
|
126
tui/src/confirmation.rs
Normal file
126
tui/src/confirmation.rs
Normal file
|
@ -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::<String>();
|
||||
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::<Text>();
|
||||
|
||||
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"]),
|
||||
]),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -13,14 +13,14 @@ pub trait FloatContent {
|
|||
fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>);
|
||||
}
|
||||
|
||||
pub struct Float {
|
||||
content: Box<dyn FloatContent>,
|
||||
pub struct Float<Content: FloatContent + ?Sized> {
|
||||
pub content: Box<Content>,
|
||||
width_percent: u16,
|
||||
height_percent: u16,
|
||||
}
|
||||
|
||||
impl Float {
|
||||
pub fn new(content: Box<dyn FloatContent>, width_percent: u16, height_percent: u16) -> Self {
|
||||
impl<Content: FloatContent + ?Sized> Float<Content> {
|
||||
pub fn new(content: Box<Content>, width_percent: u16, height_percent: u16) -> Self {
|
||||
Self {
|
||||
content,
|
||||
width_percent,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
mod confirmation;
|
||||
mod filter;
|
||||
mod float;
|
||||
mod floating_text;
|
||||
|
|
|
@ -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<Command>,
|
||||
selected_commands: Vec<Rc<ListNode>>,
|
||||
drawable: bool,
|
||||
#[cfg(feature = "tips")]
|
||||
tip: &'static str,
|
||||
|
@ -63,11 +66,12 @@ pub enum Focus {
|
|||
Search,
|
||||
TabList,
|
||||
List,
|
||||
FloatingWindow(Float),
|
||||
FloatingWindow(Float<dyn FloatContent>),
|
||||
ConfirmationPrompt(Float<ConfirmPrompt>),
|
||||
}
|
||||
|
||||
pub struct ListEntry {
|
||||
pub node: ListNode,
|
||||
pub node: Rc<ListNode>,
|
||||
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<Rc<ListNode>> {
|
||||
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<Command> {
|
||||
self.get_selected_node().map(|node| node.command.clone())
|
||||
}
|
||||
|
||||
fn get_selected_description(&self) -> Option<String> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<T: FloatContent + 'static>(&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
|
||||
|
|
Loading…
Reference in New Issue
Block a user