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:
cartercanedy 2024-09-30 14:48:22 -07:00 committed by GitHub
parent 6a8987b35a
commit 7cc38df7e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 224 additions and 41 deletions

View File

@ -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,
});
}));
}
}
}

View File

@ -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
View 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"]),
]),
)
}
}

View File

@ -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,

View File

@ -1,3 +1,4 @@
mod confirmation;
mod filter;
mod float;
mod floating_text;

View File

@ -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