diff --git a/tui/src/floating_text.rs b/tui/src/floating_text.rs index a73af622..09023d38 100644 --- a/tui/src/floating_text.rs +++ b/tui/src/floating_text.rs @@ -29,8 +29,8 @@ pub struct FloatingText { max_line_width: usize, v_scroll: usize, h_scroll: usize, - mode_title: String, frame_height: usize, + title: String, } macro_rules! style { @@ -125,7 +125,7 @@ fn get_lines_owned(s: &str) -> Vec { } impl FloatingText { - pub fn new(text: String, title: &str) -> Self { + pub fn new(text: String, title: String) -> Self { let src = get_lines(&text) .into_iter() .map(|s| s.to_string()) @@ -134,7 +134,7 @@ impl FloatingText { let max_line_width = max_width!(src); Self { src, - mode_title: title.to_string(), + title, max_line_width, v_scroll: 0, h_scroll: 0, @@ -148,6 +148,7 @@ impl FloatingText { // just apply highlights directly (max_width!(get_lines(cmd)), Some(cmd.clone())) } + Command::LocalFile { file, .. } => { // have to read from tmp dir to get cmd src let raw = std::fs::read_to_string(file) @@ -165,7 +166,7 @@ impl FloatingText { Some(Self { src, - mode_title: title, + title, max_line_width, h_scroll: 0, v_scroll: 0, @@ -201,10 +202,12 @@ impl FloatingText { impl FloatContent for FloatingText { fn top_title(&self) -> Option> { - let title_text = format!(" {} ", self.mode_title); + let title_text = format!(" {} ", self.title); + let title_line = Line::from(title_text) .centered() .style(Style::default().reversed()); + Some(title_line) } @@ -279,7 +282,7 @@ impl FloatContent for FloatingText { fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) { ( - &self.mode_title, + &self.title, Box::new([ Shortcut::new("Scroll down", ["j", "Down"]), Shortcut::new("Scroll up", ["k", "Up"]), diff --git a/tui/src/floating_text.rs.orig b/tui/src/floating_text.rs.orig index fd0d9846..798be99d 100644 --- a/tui/src/floating_text.rs.orig +++ b/tui/src/floating_text.rs.orig @@ -29,8 +29,12 @@ pub struct FloatingText { max_line_width: usize, v_scroll: usize, h_scroll: usize, +<<<<<<< HEAD mode_title: String, frame_height: usize, +======= + title: String, +>>>>>>> b83b3a1 (use command names in `FloatContent` titles) } macro_rules! style { @@ -125,7 +129,7 @@ fn get_lines_owned(s: &str) -> Vec { } impl FloatingText { - pub fn new(text: String, title: &str) -> Self { + pub fn new(text: String, title: String) -> Self { let src = get_lines(&text) .into_iter() .map(|s| s.to_string()) @@ -134,7 +138,7 @@ impl FloatingText { let max_line_width = max_width!(src); Self { src, - mode_title: title.to_string(), + title, max_line_width, v_scroll: 0, h_scroll: 0, @@ -148,6 +152,7 @@ impl FloatingText { // just apply highlights directly (max_width!(get_lines(cmd)), Some(cmd.clone())) } + Command::LocalFile { file, .. } => { // have to read from tmp dir to get cmd src let raw = std::fs::read_to_string(file) @@ -165,7 +170,7 @@ impl FloatingText { Some(Self { src, - mode_title: title, + title, max_line_width, h_scroll: 0, v_scroll: 0, @@ -201,10 +206,12 @@ impl FloatingText { impl FloatContent for FloatingText { fn top_title(&self) -> Option> { - let title_text = format!(" {} ", self.mode_title); + let title_text = format!(" {} ", self.title); + let title_line = Line::from(title_text) .centered() .style(Style::default().reversed()); + Some(title_line) } @@ -213,19 +220,7 @@ impl FloatContent for FloatingText { } fn draw(&mut self, frame: &mut Frame, area: Rect) { -<<<<<<< HEAD - self.frame_height = area.height as usize; - - // Define the Block with a border and background color - let block = Block::default() - .borders(Borders::ALL) - .title(self.mode_title.clone()) - .title_alignment(ratatui::layout::Alignment::Center) - .title_style(Style::default().reversed()) - .style(Style::default()); -======= let Rect { height, .. } = area; ->>>>>>> f6db243 (refactor FloatContent interface) let lines = self .src @@ -291,7 +286,7 @@ impl FloatContent for FloatingText { fn get_shortcut_list(&self) -> (&str, Box<[Shortcut]>) { ( - &self.mode_title, + &self.title, Box::new([ Shortcut::new("Scroll down", ["j", "Down"]), Shortcut::new("Scroll up", ["k", "Up"]), diff --git a/tui/src/state.rs b/tui/src/state.rs index be120436..b371af01 100644 --- a/tui/src/state.rs +++ b/tui/src/state.rs @@ -636,11 +636,6 @@ impl AppState { None } - fn get_selected_description(&self) -> Option { - self.get_selected_node() - .map(|node| node.description.clone()) - } - pub fn go_to_selected_dir(&mut self) { let selected_index = self.selection.selected().unwrap_or(0); @@ -693,21 +688,20 @@ impl AppState { } fn enable_preview(&mut self) { - if let Some(list_node) = self.get_selected_node() { - let mut preview_title = "[Preview] - ".to_string(); - preview_title.push_str(list_node.name.as_str()); - if let Some(preview) = FloatingText::from_command(&list_node.command, preview_title) { + if let Some(node) = self.get_selected_node() { + let preview_title = format!("Command Preview - {}", node.name.as_str()); + if let Some(preview) = FloatingText::from_command(&node.command, preview_title) { self.spawn_float(preview, 80, 80); } } } fn enable_description(&mut self) { - if let Some(command_description) = self.get_selected_description() { - if !command_description.is_empty() { - let description = FloatingText::new(command_description, "Command Description"); - self.spawn_float(description, 80, 80); - } + if let Some(node) = self.get_selected_node() { + let desc_title = format!("Command Description - {}", &node.name); + + let description = FloatingText::new(node.description.clone(), desc_title); + self.spawn_float(description, 80, 80); } } @@ -791,7 +785,10 @@ impl AppState { fn toggle_task_list_guide(&mut self) { self.spawn_float( - FloatingText::new(ACTIONS_GUIDE.to_string(), "Important Actions Guide"), + FloatingText::new( + ACTIONS_GUIDE.to_string(), + "Important Actions Guide".to_string(), + ), 80, 80, ); diff --git a/tui/src/state.rs.orig b/tui/src/state.rs.orig new file mode 100644 index 00000000..a45e8467 --- /dev/null +++ b/tui/src/state.rs.orig @@ -0,0 +1,837 @@ +use crate::{ + confirmation::{ConfirmPrompt, ConfirmStatus}, + filter::{Filter, SearchAction}, + float::{Float, FloatContent}, + floating_text::FloatingText, + hint::{create_shortcut_list, Shortcut}, + running_command::RunningCommand, + theme::Theme, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ego_tree::NodeId; +use linutil_core::{ListNode, Tab}; +#[cfg(feature = "tips")] +use rand::Rng; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Flex, Layout}, + style::{Style, Stylize}, + text::{Line, Span, Text}, + widgets::{Block, Borders, List, ListState, Paragraph}, + Frame, +}; +use std::rc::Rc; +use temp_dir::TempDir; + +const MIN_WIDTH: u16 = 100; +const MIN_HEIGHT: u16 = 25; +const TITLE: &str = concat!("Linux Toolbox - ", env!("CARGO_PKG_VERSION")); +const ACTIONS_GUIDE: &str = "List of important tasks performed by commands' names: + +D - disk modifications (ex. partitioning) (privileged) +FI - flatpak installation +FM - file modification +I - installation (privileged) +MP - package manager actions +SI - full system installation +SS - systemd actions (privileged) +RP - package removal + +P* - privileged * +"; + +pub struct AppState { + /// This must be passed to retain the temp dir until the end of the program + _temp_dir: TempDir, + /// Selected theme + theme: Theme, + /// Currently focused area + pub focus: Focus, + /// List of tabs + tabs: Vec, + /// Current tab + current_tab: ListState, + /// This stack keeps track of our "current directory". 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, usize)>, + /// This is the state associated with the list widget, used to display the selection in the + /// widget + selection: ListState, + filter: Filter, + multi_select: bool, + selected_commands: Vec>, + drawable: bool, + #[cfg(feature = "tips")] + tip: &'static str, +} + +pub enum Focus { + Search, + TabList, + List, + FloatingWindow(Float), + ConfirmationPrompt(Float), +} + +pub struct ListEntry { + pub node: Rc, + pub id: NodeId, + pub has_children: bool, +} + +enum SelectedItem { + UpDir, + Directory, + Command, + None, +} + +impl AppState { + pub fn new(theme: Theme, override_validation: bool) -> Self { + let (temp_dir, tabs) = linutil_core::get_tabs(!override_validation); + let root_id = tabs[0].tree.root().id(); + + let mut state = Self { + _temp_dir: temp_dir, + theme, + focus: Focus::List, + tabs, + current_tab: ListState::default().with_selected(Some(0)), + visit_stack: vec![(root_id, 0usize)], + selection: ListState::default().with_selected(Some(0)), + filter: Filter::new(), + multi_select: false, + selected_commands: Vec::new(), + drawable: false, + #[cfg(feature = "tips")] + tip: get_random_tip(), + }; + + state.update_items(); + state + } + + fn get_list_item_shortcut(&self) -> Box<[Shortcut]> { + if self.selected_item_is_dir() { + Box::new([Shortcut::new("Go to selected dir", ["l", "Right", "Enter"])]) + } else { + Box::new([ + Shortcut::new("Run selected command", ["l", "Right", "Enter"]), + Shortcut::new("Enable preview", ["p"]), + Shortcut::new("Command Description", ["d"]), + ]) + } + } + + pub fn get_keybinds(&self) -> (&str, Box<[Shortcut]>) { + match self.focus { + Focus::Search => ( + "Search bar", + Box::new([ + Shortcut::new("Abort search", ["Esc", "CTRL-c"]), + Shortcut::new("Search", ["Enter"]), + ]), + ), + + Focus::List => { + let mut hints = Vec::new(); + hints.push(Shortcut::new("Exit linutil", ["q", "CTRL-c"])); + + if self.at_root() { + hints.push(Shortcut::new("Focus tab list", ["h", "Left"])); + hints.extend(self.get_list_item_shortcut()); + } else if self.selected_item_is_up_dir() { + hints.push(Shortcut::new( + "Go to parent directory", + ["l", "Right", "Enter", "h", "Left"], + )); + } else { + hints.push(Shortcut::new("Go to parent directory", ["h", "Left"])); + hints.extend(self.get_list_item_shortcut()); + } + + hints.push(Shortcut::new("Select item above", ["k", "Up"])); + hints.push(Shortcut::new("Select item below", ["j", "Down"])); + hints.push(Shortcut::new("Next theme", ["t"])); + hints.push(Shortcut::new("Previous theme", ["T"])); + + if self.is_current_tab_multi_selectable() { + hints.push(Shortcut::new("Toggle multi-selection mode", ["v"])); + hints.push(Shortcut::new("Select multiple commands", ["Space"])); + } + + hints.push(Shortcut::new("Next tab", ["Tab"])); + hints.push(Shortcut::new("Previous tab", ["Shift-Tab"])); + hints.push(Shortcut::new("Important actions guide", ["g"])); + + ("Command list", hints.into_boxed_slice()) + } + + Focus::TabList => ( + "Tab list", + Box::new([ + Shortcut::new("Exit linutil", ["q", "CTRL-c"]), + Shortcut::new("Focus action list", ["l", "Right", "Enter"]), + Shortcut::new("Select item above", ["k", "Up"]), + Shortcut::new("Select item below", ["j", "Down"]), + Shortcut::new("Next theme", ["t"]), + Shortcut::new("Previous theme", ["T"]), + Shortcut::new("Next tab", ["Tab"]), + Shortcut::new("Previous tab", ["Shift-Tab"]), + ]), + ), + + Focus::FloatingWindow(ref float) => float.get_shortcut_list(), + Focus::ConfirmationPrompt(ref prompt) => prompt.get_shortcut_list(), + } + } + + pub fn draw(&mut self, frame: &mut Frame) { + let terminal_size = frame.area(); + + if terminal_size.width < MIN_WIDTH || terminal_size.height < MIN_HEIGHT { + let warning = Paragraph::new(format!( + "Terminal size too small:\nWidth = {} Height = {}\n\nMinimum size:\nWidth = {} Height = {}", + terminal_size.width, + terminal_size.height, + MIN_WIDTH, + MIN_HEIGHT, + )) + .alignment(Alignment::Center) + .style(Style::default().fg(ratatui::style::Color::Red).bold()) + .wrap(ratatui::widgets::Wrap { trim: true }); + + let centered_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(5), + Constraint::Fill(1), + ]) + .split(terminal_size); + + self.drawable = false; + return frame.render_widget(warning, centered_layout[1]); + } else { + self.drawable = true; + } + + let label_block = + Block::default() + .borders(Borders::all()) + .border_set(ratatui::symbols::border::Set { + top_left: " ", + top_right: " ", + bottom_left: " ", + bottom_right: " ", + vertical_left: " ", + vertical_right: " ", + horizontal_top: "*", + horizontal_bottom: "*", + }); + let str1 = "Linutil "; + let str2 = "by Chris Titus"; + let label = Paragraph::new(Line::from(vec![ + Span::styled(str1, Style::default().bold()), + Span::styled(str2, Style::default().italic()), + ])) + .block(label_block) + .alignment(Alignment::Center); + + let longest_tab_display_len = self + .tabs + .iter() + .map(|tab| tab.name.len() + self.theme.tab_icon().len()) + .max() + .unwrap_or(0) + .max(str1.len() + str2.len()); + + let (keybind_scope, shortcuts) = self.get_keybinds(); + + let keybind_render_width = terminal_size.width - 2; + + let keybinds_block = Block::default() + .title(format!(" {} ", keybind_scope)) + .borders(Borders::all()); + + let keybinds = create_shortcut_list(shortcuts, keybind_render_width); + let n_lines = keybinds.len() as u16; + + let keybind_para = Paragraph::new(Text::from_iter(keybinds)).block(keybinds_block); + + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(0), + Constraint::Max(n_lines as u16 + 2), + ]) + .flex(Flex::Legacy) + .margin(0) + .split(frame.area()); + + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(longest_tab_display_len as u16 + 5), + Constraint::Percentage(100), + ]) + .split(vertical[0]); + + let left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(1)]) + .split(horizontal[0]); + frame.render_widget(label, left_chunks[0]); + + let tabs = self + .tabs + .iter() + .map(|tab| tab.name.as_str()) + .collect::>(); + + let tab_hl_style = if let Focus::TabList = self.focus { + Style::default().reversed().fg(self.theme.tab_color()) + } else { + Style::new().fg(self.theme.tab_color()) + }; + + let list = List::new(tabs) + .block(Block::default().borders(Borders::ALL)) + .highlight_style(tab_hl_style) + .highlight_symbol(self.theme.tab_icon()); + frame.render_stateful_widget(list, left_chunks[1], &mut self.current_tab); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(1)].as_ref()) + .split(horizontal[1]); + + let list_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(70), Constraint::Percentage(30)].as_ref()) + .split(chunks[1]); + + self.filter.draw_searchbar(frame, chunks[0], &self.theme); + + let mut items: Vec = Vec::new(); + let mut task_items: Vec = Vec::new(); + + if !self.at_root() { + items.push( + Line::from(format!("{} ..", self.theme.dir_icon())).style(self.theme.dir_color()), + ); + task_items.push(Line::from(" ").style(self.theme.dir_color())); + } + + items.extend(self.filter.item_list().iter().map( + |ListEntry { + node, has_children, .. + }| { + let is_selected = self.selected_commands.contains(node); + let (indicator, style) = if is_selected { + (self.theme.multi_select_icon(), Style::default().bold()) + } else { + ("", Style::new()) + }; + if *has_children { + Line::from(format!( + "{} {} {}", + self.theme.dir_icon(), + node.name, + indicator + )) + .style(self.theme.dir_color()) + } else { + Line::from(format!( + "{} {} {}", + self.theme.cmd_icon(), + node.name, + indicator + )) + .style(self.theme.cmd_color()) + .patch_style(style) + } + }, + )); + + task_items.extend(self.filter.item_list().iter().map( + |ListEntry { + node, has_children, .. + }| { + if *has_children { + Line::from(" ").style(self.theme.dir_color()) + } else { + Line::from(format!("{} ", node.task_list)) + .alignment(Alignment::Right) + .style(self.theme.cmd_color()) + .bold() + } + }, + )); + + let style = if let Focus::List = self.focus { + Style::default().reversed() + } else { + Style::new() + }; + + let title = if self.multi_select { + &format!("{} [Multi-Select]", TITLE) + } else { + TITLE + }; + + #[cfg(feature = "tips")] + let bottom_title = Line::from(self.tip.bold().blue()).right_aligned(); + #[cfg(not(feature = "tips"))] + let bottom_title = ""; + + let task_list_title = Line::from("Important Actions ").right_aligned(); + + // Create the list widget with items + let list = List::new(items) + .highlight_style(style) + .block( + Block::default() + .borders(Borders::ALL & !Borders::RIGHT) + .title(title) + .title_bottom(bottom_title), + ) + .scroll_padding(1); + frame.render_stateful_widget(list, list_chunks[0], &mut self.selection); + + let disclaimer_list = List::new(task_items).highlight_style(style).block( + Block::default() + .borders(Borders::ALL & !Borders::LEFT) + .title(task_list_title), + ); + + frame.render_stateful_widget(disclaimer_list, list_chunks[1], &mut self.selection); + + 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]); + } + + pub fn handle_key(&mut self, key: &KeyEvent) -> bool { + // 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 | Focus::ConfirmationPrompt(_) + ) && (key.code == KeyCode::Char('q') + || key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c')) + { + return false; + } + + // If UI is not drawable returning true will mark as the key handled + if !self.drawable { + return true; + } + + // Handle key only when Tablist or List is focused + // Prevents exiting the application even when a command is running + // Add keys here which should work on both TabList and List + if matches!(self.focus, Focus::TabList | Focus::List) { + match key.code { + KeyCode::Tab => { + if self.current_tab.selected().unwrap() == self.tabs.len() - 1 { + self.current_tab.select_first(); + } else { + self.current_tab.select_next(); + } + self.refresh_tab(); + } + KeyCode::BackTab => { + if self.current_tab.selected().unwrap() == 0 { + self.current_tab.select(Some(self.tabs.len() - 1)); + } else { + self.current_tab.select_previous(); + } + self.refresh_tab(); + } + _ => {} + } + } + + match &mut self.focus { + Focus::FloatingWindow(command) => { + if command.handle_key_event(key) { + self.focus = Focus::List; + } + } + + 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(), + SearchAction::None => {} + }, + + Focus::TabList => match key.code { + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.focus = Focus::List, + + KeyCode::Char('j') | KeyCode::Down => self.scroll_tab_down(), + + KeyCode::Char('k') | KeyCode::Up => self.scroll_tab_up(), + + KeyCode::Char('/') => self.enter_search(), + KeyCode::Char('t') => self.theme.next(), + KeyCode::Char('T') => self.theme.prev(), + KeyCode::Char('g') => self.toggle_task_list_guide(), + _ => {} + }, + + Focus::List if key.kind != KeyEventKind::Release => match key.code { + KeyCode::Char('j') | KeyCode::Down => self.scroll_down(), + KeyCode::Char('k') | KeyCode::Up => self.scroll_up(), + KeyCode::Char('p') | KeyCode::Char('P') => self.enable_preview(), + KeyCode::Char('d') | KeyCode::Char('D') => self.enable_description(), + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.handle_enter(), + KeyCode::Char('h') | KeyCode::Left => self.go_back(), + KeyCode::Char('/') => self.enter_search(), + KeyCode::Char('t') => self.theme.next(), + KeyCode::Char('T') => self.theme.prev(), + KeyCode::Char('g') => self.toggle_task_list_guide(), + KeyCode::Char('v') | KeyCode::Char('V') => self.toggle_multi_select(), + KeyCode::Char(' ') if self.multi_select => self.toggle_selection(), + _ => {} + }, + + _ => (), + }; + true + } + + fn scroll_down(&mut self) { + let len = self.filter.item_list().len(); + if len == 0 { + return; + } + let current = self.selection.selected().unwrap_or(0); + let max_index = if self.at_root() { len - 1 } else { len }; + let next = if current + 1 > max_index { + 0 + } else { + current + 1 + }; + + self.selection.select(Some(next)); + } + + fn scroll_up(&mut self) { + let len = self.filter.item_list().len(); + if len == 0 { + return; + } + let current = self.selection.selected().unwrap_or(0); + let max_index = if self.at_root() { len - 1 } else { len }; + let next = if current == 0 { max_index } else { current - 1 }; + + self.selection.select(Some(next)); + } + + fn toggle_multi_select(&mut self) { + if self.is_current_tab_multi_selectable() { + self.multi_select = !self.multi_select; + if !self.multi_select { + self.selected_commands.clear(); + } + } + } + + fn toggle_selection(&mut self) { + if let Some(command) = self.get_selected_node() { + if self.selected_commands.contains(&command) { + self.selected_commands.retain(|c| c != &command); + } else { + self.selected_commands.push(command); + } + } + } + + pub fn is_current_tab_multi_selectable(&self) -> bool { + let index = self.current_tab.selected().unwrap_or(0); + self.tabs + .get(index) + .map_or(false, |tab| tab.multi_selectable) + } + + fn update_items(&mut self) { + self.filter.update_items( + &self.tabs, + self.current_tab.selected().unwrap(), + self.visit_stack.last().unwrap().0, + ); + if !self.is_current_tab_multi_selectable() { + self.multi_select = false; + self.selected_commands.clear(); + } + let len = self.filter.item_list().len(); + if len > 0 { + let current = self.selection.selected().unwrap_or(0); + self.selection.select(Some(current.min(len - 1))); + } else { + self.selection.select(None); + } + } + + /// Checks either 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` + pub fn at_root(&self) -> bool { + self.visit_stack.len() == 1 + } + + fn go_back(&mut self) { + if self.at_root() { + self.focus = Focus::TabList; + } else { + self.enter_parent_directory(); + } + } + + fn enter_parent_directory(&mut self) { + if let Some((_, previous_position)) = self.visit_stack.pop() { + self.selection.select(Some(previous_position)); + self.update_items(); + } + } + + fn get_selected_node(&self) -> Option> { + let mut selected_index = self.selection.selected().unwrap_or(0); + + if !self.at_root() && selected_index == 0 { + return None; + } + if !self.at_root() { + selected_index = selected_index.saturating_sub(1); + } + + if let Some(item) = self.filter.item_list().get(selected_index) { + if !item.has_children { + return Some(item.node.clone()); + } + } + None + } + + pub fn go_to_selected_dir(&mut self) { + let selected_index = self.selection.selected().unwrap_or(0); + + if !self.at_root() && selected_index == 0 { + self.enter_parent_directory(); + return; + } + + let actual_index = if self.at_root() { + selected_index + } else { + selected_index - 1 + }; + + if let Some(item) = self.filter.item_list().get(actual_index) { + if item.has_children { + self.visit_stack.push((item.id, selected_index)); + self.selection.select(Some(0)); + self.update_items(); + } + } + } + + pub fn selected_item_is_dir(&self) -> bool { + let mut selected_index = self.selection.selected().unwrap_or(0); + + if !self.at_root() && selected_index == 0 { + return false; + } + + if !self.at_root() { + selected_index = selected_index.saturating_sub(1); + } + + self.filter + .item_list() + .get(selected_index) + .map_or(false, |item| item.has_children) + } + + pub fn selected_item_is_cmd(&self) -> bool { + // Any item that is not a directory or up directory (..) must be a command + 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(node) = self.get_selected_node() { + let preview_title = format!("Command Preview - {}", node.name.as_str()); + if let Some(preview) = FloatingText::from_command(&node.command, preview_title) { + self.spawn_float(preview, 80, 80); + } + } + } + + fn enable_description(&mut self) { +<<<<<<< HEAD + if let Some(command_description) = self.get_selected_description() { + if !command_description.is_empty() { + let description = FloatingText::new(command_description, "Command Description"); + self.spawn_float(description, 80, 80); + } +======= + if let Some(node) = self.get_selected_node() { + let desc_title = format!("Command Description - {}", &node.name); + + let description = FloatingText::new(node.description.clone(), desc_title); + self.spawn_float(description, 80, 80); +>>>>>>> 37862fb (use command names in `FloatContent` titles) + } + } + + fn get_selected_item_type(&self) -> SelectedItem { + if self.selected_item_is_up_dir() { + SelectedItem::UpDir + } else if self.selected_item_is_dir() { + SelectedItem::Directory + } else if self.selected_item_is_cmd() { + SelectedItem::Command + } else { + SelectedItem::None + } + } + + fn handle_enter(&mut self) { + match self.get_selected_item_type() { + SelectedItem::UpDir => self.enter_parent_directory(), + SelectedItem::Directory => self.go_to_selected_dir(), + SelectedItem::Command => { + if self.selected_commands.is_empty() { + if let Some(node) = self.get_selected_node() { + self.selected_commands.push(node); + } + } + + 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)); + } + SelectedItem::None => {} + } + } + + 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 + .root() + .id(), + 0usize, + )]; + self.selection.select(Some(0)); + self.filter.clear_search(); + self.update_items(); + } + + fn toggle_task_list_guide(&mut self) { + self.spawn_float( + FloatingText::new( + ACTIONS_GUIDE.to_string(), + "Important Actions Guide".to_string(), + ), + 80, + 80, + ); + } + + fn scroll_tab_down(&mut self) { + let len = self.tabs.len(); + let current = self.current_tab.selected().unwrap_or(0); + let next = if current + 1 >= len { 0 } else { current + 1 }; + + self.current_tab.select(Some(next)); + self.refresh_tab(); + } + + fn scroll_tab_up(&mut self) { + let len = self.tabs.len(); + let current = self.current_tab.selected().unwrap_or(0); + let next = if current == 0 { len - 1 } else { current - 1 }; + + self.current_tab.select(Some(next)); + self.refresh_tab(); + } +} + +#[cfg(feature = "tips")] +const TIPS: &str = include_str!("../cool_tips.txt"); + +#[cfg(feature = "tips")] +fn get_random_tip() -> &'static str { + let tips: Vec<&str> = TIPS.lines().collect(); + if tips.is_empty() { + return ""; + } + + let mut rng = rand::thread_rng(); + let random_index = rng.gen_range(0..tips.len()); + tips[random_index] +}