linutil/src/list.rs

400 lines
15 KiB
Rust
Raw Normal View History

2024-07-28 16:31:20 +01:00
use crate::{float::floating_window, running_command::Command, state::AppState};
2024-06-06 23:56:45 +01:00
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use ego_tree::{tree, NodeId};
use ratatui::{
layout::Rect,
style::{Style, Stylize},
text::Line,
widgets::{Block, Borders, List, ListState},
Frame,
};
struct ListNode {
name: &'static str,
2024-07-28 16:31:20 +01:00
command: Command,
2024-06-06 23:56:45 +01:00
}
/// This is a data structure that has everything necessary to draw and manage a menu of commands
pub struct CustomList {
/// The tree data structure, to represent regular items
/// and "directories"
inner_tree: ego_tree::Tree<ListNode>,
/// This stack keeps track of our "current dirrectory". You can think of it as `pwd`. but not
/// just the current directory, all paths that took us here, so we can "cd .."
visit_stack: Vec<NodeId>,
/// This is the state asociated with the list widget, used to display the selection in the
/// widget
list_state: ListState,
2024-07-15 18:59:09 +01:00
/// This stores the preview windows state. If it is None, it will not be displayed.
/// If it is Some, we show it with the content of the selected item
preview_window_state: Option<PreviewWindowState>,
}
/// This struct stores the preview window state
struct PreviewWindowState {
/// The text inside the window
text: Vec<String>,
/// The current line scroll
scroll: usize,
}
impl PreviewWindowState {
/// Create a new PreviewWindowState
pub fn new(text: Vec<String>) -> Self {
Self { text, scroll: 0 }
}
2024-06-06 23:56:45 +01:00
}
impl CustomList {
pub fn new() -> Self {
// When a function call ends with an exclamation mark, it means it's a macro, like in this
// case the tree! macro expands to `ego-tree::tree` data structure
let tree = tree!(ListNode {
name: "root",
2024-07-28 16:31:20 +01:00
command: Command::None,
2024-06-06 23:56:45 +01:00
} => {
ListNode {
2024-07-13 02:57:34 +01:00
name: "Full System Update",
2024-07-28 16:31:20 +01:00
command: Command::LocalFile("system-update.sh"),
},
2024-06-06 23:56:45 +01:00
ListNode {
name: "Setup Bash Prompt",
2024-07-28 16:31:20 +01:00
command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/mybash/main/setup.sh)\""),
2024-06-06 23:56:45 +01:00
},
ListNode {
name: "Setup Neovim",
2024-07-28 16:31:20 +01:00
command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/neovim/main/setup.sh)\""),
2024-06-06 23:56:45 +01:00
},
ListNode {
2024-07-13 02:57:34 +01:00
name: "System Setup",
2024-07-28 16:31:20 +01:00
command: Command::None,
2024-06-06 23:56:45 +01:00
} => {
ListNode {
2024-07-13 02:57:34 +01:00
name: "Build Prerequisites",
2024-07-28 16:31:20 +01:00
command: Command::LocalFile("system-setup/1-compile-setup.sh"),
2024-06-06 23:56:45 +01:00
},
ListNode {
2024-07-13 02:57:34 +01:00
name: "Gaming Dependencies",
2024-07-28 16:31:20 +01:00
command: Command::LocalFile("system-setup/2-gaming-setup.sh"),
2024-06-06 23:56:45 +01:00
},
2024-07-23 22:14:17 +01:00
ListNode {
name: "Global Theme",
2024-07-28 16:31:20 +01:00
command: Command::LocalFile("system-setup/3-global-theme.sh"),
2024-06-06 23:56:45 +01:00
},
2024-07-13 15:09:35 +01:00
},
ListNode {
name: "Titus Dotfiles",
2024-07-28 16:31:20 +01:00
command: Command::None
2024-07-13 15:09:35 +01:00
} => {
2024-07-15 20:00:04 +01:00
ListNode {
name: "Alacritty Setup",
2024-07-28 16:31:20 +01:00
command: Command::LocalFile("dotfiles/alacritty-setup.sh"),
2024-07-15 20:00:04 +01:00
},
2024-07-13 22:44:09 +01:00
ListNode {
name: "Kitty Setup",
2024-07-28 16:31:20 +01:00
command: Command::LocalFile("dotfiles/kitty-setup.sh"),
2024-07-13 22:44:09 +01:00
},
2024-07-13 15:09:35 +01:00
ListNode {
name: "Rofi Setup",
2024-07-28 16:31:20 +01:00
command: Command::LocalFile("dotfiles/rofi-setup.sh"),
},
},
ListNode {
name: "Testing category",
command: Command::None,
} => {
ListNode {
name: "Complex command",
command: Command::Raw("sleep 1 && ls -la && sleep 1 && ls -la && echo Bonus eza comming... && sleep 1 && ls -la"),
},
ListNode {
name: "Neovim",
command: Command::Raw("nvim"),
},
ListNode {
name: "Full bash",
command: Command::Raw("bash"),
},
ListNode {
name: "Running file with `source`",
command: Command::LocalFile("test/main.sh"),
2024-06-06 23:56:45 +01:00
},
}
});
// We don't get a reference, but rather an id, because references are siginficantly more
// paintfull to manage
let root_id = tree.root().id();
Self {
inner_tree: tree,
visit_stack: vec![root_id],
list_state: ListState::default().with_selected(Some(0)),
2024-07-15 18:59:09 +01:00
// By default the PreviewWindowState is set to None, so it is not being shown
preview_window_state: None,
2024-06-06 23:56:45 +01:00
}
}
/// Draw our custom widget to the frame
pub fn draw(&mut self, frame: &mut Frame, area: Rect, state: &AppState) {
2024-06-06 23:56:45 +01:00
// Get the last element in the `visit_stack` vec
let curr = self
.inner_tree
.get(*self.visit_stack.last().unwrap())
.unwrap();
let mut items = vec![];
// If we are not at the root of our filesystem tree, we need to add `..` path, to be able
// to go up the tree
if !self.at_root() {
2024-07-28 16:31:20 +01:00
items.push(
Line::from(format!("{} ..", state.theme.dir_icon)).style(state.theme.dir_color),
);
2024-06-06 23:56:45 +01:00
}
// Iterate through all the children
for node in curr.children() {
// The difference between a "directory" and a "command" is simple: if it has children,
// it's a directory and will be handled as such
if node.has_children() {
items.push(
Line::from(format!("{} {}", state.theme.dir_icon, node.value().name))
.style(state.theme.dir_color),
2024-06-06 23:56:45 +01:00
);
} else {
items.push(
Line::from(format!("{} {}", state.theme.cmd_icon, node.value().name))
.style(state.theme.cmd_color),
2024-06-06 23:56:45 +01:00
);
}
}
// create the normal list widget containing only item in our "working directory" / tree
// node
let list = List::new(items)
.highlight_style(Style::default().reversed())
2024-07-19 14:56:09 +01:00
.block(Block::default().borders(Borders::ALL).title(format!(
"Linux Toolbox - {}",
chrono::Local::now().format("%Y-%m-%d")
)))
2024-06-06 23:56:45 +01:00
.scroll_padding(1);
// Render it
frame.render_stateful_widget(list, area, &mut self.list_state);
2024-07-15 18:59:09 +01:00
// Draw the preview window if it's active
if let Some(pw_state) = &self.preview_window_state {
// Set the window to be floating
let floating_area = floating_window(area);
// Draw the preview windows lines
let lines: Vec<Line> = pw_state
.text
.iter()
.skip(pw_state.scroll)
.take(floating_area.height as usize)
.map(|line| Line::from(line.as_str()))
.collect();
// Create list widget
let list = List::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title("Action preview"),
)
.highlight_style(Style::default().reversed());
// Finally render the preview window
frame.render_widget(list, floating_area);
}
2024-06-06 23:56:45 +01:00
}
/// Handle key events, we are only interested in `Press` and `Repeat` events
2024-07-28 16:31:20 +01:00
pub fn handle_key(&mut self, event: KeyEvent, state: &AppState) -> Option<Command> {
2024-06-06 23:56:45 +01:00
if event.kind == KeyEventKind::Release {
return None;
}
match event.code {
// Damm you Up arrow, use vim lol
KeyCode::Char('j') | KeyCode::Down => {
2024-07-15 18:59:09 +01:00
// If the preview window is active, scroll down and consume the scroll action,
// so the scroll does not happen in the main window as well
if self.preview_window_state.is_some() {
self.scroll_preview_window_down();
return None;
}
2024-06-06 23:56:45 +01:00
self.try_scroll_down();
None
}
KeyCode::Char('k') | KeyCode::Up => {
2024-07-15 18:59:09 +01:00
// If the preview window is active, scroll up and consume the scroll action,
// so the scroll does not happen in the main window as well
if self.preview_window_state.is_some() {
self.scroll_preview_window_up();
return None;
}
2024-06-06 23:56:45 +01:00
self.try_scroll_up();
None
}
2024-07-15 18:59:09 +01:00
// The 'p' key toggles the preview on and off
KeyCode::Char('p') => {
2024-07-28 16:31:20 +01:00
self.toggle_preview_window(state);
2024-07-15 18:59:09 +01:00
None
}
2024-06-06 23:56:45 +01:00
KeyCode::Enter => self.handle_enter(),
_ => None,
}
}
2024-07-28 16:31:20 +01:00
fn toggle_preview_window(&mut self, state: &AppState) {
2024-07-15 18:59:09 +01:00
// If the preview window is active, disable it
if self.preview_window_state.is_some() {
self.preview_window_state = None;
} else {
// If the preview windows is not active, show it
// Get the selected command
if let Some(selected_command) = self.get_selected_command() {
2024-07-28 16:31:20 +01:00
let lines = match selected_command {
Command::Raw(cmd) => {
// Reconstruct the line breaks and file formatting after the
// 'include_str!()' call in the node
cmd.lines().map(|line| line.to_string()).collect()
}
Command::LocalFile(file_path) => {
let mut full_path = state.temp_path.clone();
full_path.push(file_path);
let file_contents = std::fs::read_to_string(&full_path)
.map_err(|_| format!("File not found: {:?}", &full_path))
.unwrap();
file_contents.lines().map(|line| line.to_string()).collect()
}
// If command is a folder, we don't display a preview
Command::None => return,
};
2024-07-15 18:59:09 +01:00
// Show the preview window with the text lines
self.preview_window_state = Some(PreviewWindowState::new(lines));
}
}
}
2024-06-06 23:56:45 +01:00
fn try_scroll_up(&mut self) {
self.list_state
.select(Some(self.list_state.selected().unwrap().saturating_sub(1)));
}
fn try_scroll_down(&mut self) {
let curr = self
.inner_tree
.get(*self.visit_stack.last().unwrap())
.unwrap();
let count = curr.children().count();
let curr_selection = self.list_state.selected().unwrap();
if self.at_root() {
self.list_state
.select(Some((curr_selection + 1).min(count - 1)));
} else {
// When we are not at the root, we have to account for 1 more "virtual" node, `..`. So
// the count is 1 bigger (select is 0 based, because it's an index)
self.list_state
.select(Some((curr_selection + 1).min(count)));
}
}
2024-07-15 18:59:09 +01:00
/// Scroll the preview window down
fn scroll_preview_window_down(&mut self) {
if let Some(pw_state) = &mut self.preview_window_state {
if pw_state.scroll + 1 < pw_state.text.len() {
pw_state.scroll += 1;
}
}
}
/// Scroll the preview window up
fn scroll_preview_window_up(&mut self) {
if let Some(pw_state) = &mut self.preview_window_state {
if pw_state.scroll > 0 {
pw_state.scroll = pw_state.scroll.saturating_sub(1);
}
}
}
/// This method return the currently selected command, or None if no command is selected.
/// It was extracted from the 'handle_enter()'
///
/// This could probably be integrated into the 'handle_enter()' method as to avoid code
/// duplication, but I don't want to make too major changes to the codebase.
2024-07-28 16:31:20 +01:00
fn get_selected_command(&self) -> Option<Command> {
2024-07-15 18:59:09 +01:00
let curr = self
.inner_tree
.get(*self.visit_stack.last().unwrap())
.unwrap();
let selected = self.list_state.selected().unwrap();
// If we are not at the root and the first item is selected, it's the `..` item
if !self.at_root() && selected == 0 {
return None;
}
for (mut idx, node) in curr.children().enumerate() {
if !self.at_root() {
idx += 1;
}
if idx == selected {
2024-07-28 16:31:20 +01:00
return Some(node.value().command.clone());
2024-07-15 18:59:09 +01:00
}
}
None
}
2024-06-06 23:56:45 +01:00
/// Handles the <Enter> key. This key can do 3 things:
/// - Run a command, if it is the currently selected item,
/// - Go up a directory
/// - Go down into a directory
2024-07-28 16:31:20 +01:00
///
2024-06-06 23:56:45 +01:00
/// Returns `Some(command)` when command is selected, othervise we returns `None`
2024-07-28 16:31:20 +01:00
fn handle_enter(&mut self) -> Option<Command> {
2024-06-06 23:56:45 +01:00
// Get the current node (current directory)
let curr = self
.inner_tree
.get(*self.visit_stack.last().unwrap())
.unwrap();
let selected = self.list_state.selected().unwrap();
// if we are not at the root, and the first element is selected,
// we can be sure it's '..', so we go up the directory
if !self.at_root() && selected == 0 {
self.visit_stack.pop();
self.list_state.select(Some(0));
return None;
}
for (mut idx, node) in curr.children().enumerate() {
// at this point, we know that we are not on the .. item, and our indexes of the items never had ..
// item. so to balance it out, in case the selection index contains .., se add 1 to our node index
if !self.at_root() {
idx += 1;
}
if idx == selected {
if node.has_children() {
self.visit_stack.push(node.id());
self.list_state.select(Some(0));
return None;
} else {
2024-07-28 16:31:20 +01:00
return Some(node.value().command.clone());
2024-06-06 23:56:45 +01:00
}
}
}
None
}
/// Checks weather 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 {
self.visit_stack.len() == 1
}
2024-07-19 14:56:09 +01:00
}