Rendering optimizations and function refactors

Handle `find_command` inside state itself -> `get_command_by_name`. Move tips to a seperate file for modularity. Pass the whole args to state instead of seperate args. Use const for float and confirmation prompt float sizes. Add the `longest_tab_length` to appstate struct so that it will not be calculated for each frame render use static str instead String for tips. Use function for spawning confirmprompt. Merge command list and task items list rendering a single widget instead of two. Remove redundant keys in handle_key. Optimize scrolling logic. Rename `toggle_task_list_guide` -> `enable_task_list_guide`
This commit is contained in:
Jeevitha Kannan K S 2024-11-13 00:37:26 +05:30
parent df81642c9e
commit 79aae9eb24
No known key found for this signature in database
GPG Key ID: 5904C34A2F7CE333
4 changed files with 202 additions and 292 deletions

View File

@ -36,16 +36,3 @@ pub struct ListNode {
pub task_list: String,
pub multi_select: bool,
}
impl Tab {
pub fn find_command(&self, name: &str) -> Option<Rc<ListNode>> {
self.tree.root().descendants().find_map(|node| {
let value = node.value();
if value.name == name && !node.has_children() {
Some(value.clone())
} else {
None
}
})
}
}

View File

@ -8,6 +8,9 @@ mod running_command;
pub mod state;
mod theme;
#[cfg(feature = "tips")]
mod tips;
use crate::theme::Theme;
use clap::Parser;
use ratatui::{
@ -29,7 +32,7 @@ use std::{
// Linux utility toolbox
#[derive(Debug, Parser)]
struct Args {
pub struct Args {
#[arg(short, long, help = "Path to the configuration file")]
config: Option<PathBuf>,
#[arg(short, long, value_enum)]
@ -53,13 +56,7 @@ struct Args {
fn main() -> io::Result<()> {
let args = Args::parse();
let mut state = AppState::new(
args.config,
args.theme,
args.override_validation,
args.size_bypass,
args.skip_confirmation,
);
let mut state = AppState::new(args);
stdout().execute(EnterAlternateScreen)?;
stdout().execute(EnableMouseCapture)?;

View File

@ -7,24 +7,24 @@ use crate::{
root::check_root_status,
running_command::RunningCommand,
theme::Theme,
Args,
};
use linutil_core::{ego_tree::NodeId, Command, Config, ListNode, TabList};
#[cfg(feature = "tips")]
use rand::Rng;
use ratatui::{
crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind},
layout::{Alignment, Constraint, Direction, Flex, Layout, Position, Rect},
style::{Style, Stylize},
symbols::border,
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListState, Paragraph},
widgets::{Block, List, ListState, Paragraph},
Frame,
};
use std::path::PathBuf;
use std::rc::Rc;
const MIN_WIDTH: u16 = 100;
const MIN_HEIGHT: u16 = 25;
const FLOAT_SIZE: u16 = 80;
const CONFIRM_PROMPT_FLOAT_SIZE: u16 = 40;
const TITLE: &str = concat!(" Linux Toolbox - ", env!("CARGO_PKG_VERSION"), " ");
const ACTIONS_GUIDE: &str = "List of important tasks performed by commands' names:
@ -47,11 +47,12 @@ pub struct AppState {
/// Selected theme
theme: Theme,
/// Currently focused area
pub focus: Focus,
focus: Focus,
/// List of tabs
tabs: TabList,
/// Current tab
current_tab: ListState,
longest_tab_display_len: u16,
/// 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)>,
@ -63,7 +64,7 @@ pub struct AppState {
selected_commands: Vec<Rc<ListNode>>,
drawable: bool,
#[cfg(feature = "tips")]
tip: String,
tip: &'static str,
size_bypass: bool,
skip_confirmation: bool,
}
@ -82,7 +83,7 @@ pub struct ListEntry {
pub has_children: bool,
}
pub struct Areas {
struct Areas {
tab_list: Rect,
list: Rect,
}
@ -95,24 +96,27 @@ enum SelectedItem {
}
impl AppState {
pub fn new(
config_path: Option<PathBuf>,
theme: Theme,
override_validation: bool,
size_bypass: bool,
skip_confirmation: bool,
) -> Self {
let tabs = linutil_core::get_tabs(!override_validation);
pub fn new(args: Args) -> Self {
let tabs = linutil_core::get_tabs(!args.override_validation);
let root_id = tabs[0].tree.root().id();
let auto_execute_commands = config_path.map(|path| Config::from_file(&path).auto_execute);
let auto_execute_commands = args
.config
.map(|path| Config::from_file(&path).auto_execute);
let longest_tab_display_len = tabs
.iter()
.map(|tab| tab.name.len() + args.theme.tab_icon().len())
.max()
.unwrap_or(22) as u16; // 22 is the length of "Linutil by Chris Titus" title
let mut state = Self {
areas: None,
theme,
theme: args.theme,
focus: Focus::List,
tabs,
current_tab: ListState::default().with_selected(Some(0)),
longest_tab_display_len,
visit_stack: vec![(root_id, 0usize)],
selection: ListState::default().with_selected(Some(0)),
filter: Filter::new(),
@ -120,14 +124,14 @@ impl AppState {
selected_commands: Vec::new(),
drawable: false,
#[cfg(feature = "tips")]
tip: get_random_tip(),
size_bypass,
skip_confirmation,
tip: crate::tips::get_random_tip(),
size_bypass: args.size_bypass,
skip_confirmation: args.skip_confirmation,
};
#[cfg(unix)]
if let Some(root_warning) = check_root_status() {
state.spawn_float(root_warning, 60, 40);
state.spawn_float(root_warning, FLOAT_SIZE, FLOAT_SIZE);
}
state.update_items();
@ -138,24 +142,41 @@ impl AppState {
state
}
fn find_command_by_name(&self, name: &str) -> Option<Rc<ListNode>> {
self.tabs.iter().find_map(|tab| {
tab.tree.root().descendants().find_map(|node| {
let node_value = node.value();
(node_value.name == name && !node.has_children()).then_some(node_value.clone())
})
})
}
fn handle_initial_auto_execute(&mut self, auto_execute_commands: &[String]) {
self.selected_commands = auto_execute_commands
.iter()
.filter_map(|name| self.tabs.iter().find_map(|tab| tab.find_command(name)))
.filter_map(|name| self.find_command_by_name(name))
.collect();
if !self.selected_commands.is_empty() {
let cmd_names: Vec<_> = 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));
self.spawn_confirmprompt();
}
}
fn spawn_confirmprompt(&mut self) {
let cmd_names: Vec<_> = 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),
CONFIRM_PROMPT_FLOAT_SIZE,
CONFIRM_PROMPT_FLOAT_SIZE,
));
}
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"])])
@ -229,16 +250,18 @@ impl AppState {
}
}
pub fn draw(&mut self, frame: &mut Frame) {
let terminal_size = frame.area();
fn is_terminal_drawable(&mut self, terminal_size: Rect) -> bool {
!self.size_bypass && (terminal_size.height < MIN_HEIGHT || terminal_size.width < MIN_WIDTH)
}
if !self.size_bypass
&& (terminal_size.height < MIN_HEIGHT || terminal_size.width < MIN_WIDTH)
{
pub fn draw(&mut self, frame: &mut Frame) {
let area = frame.area();
self.drawable = !self.is_terminal_drawable(area);
if !self.drawable {
let warning = Paragraph::new(format!(
"Terminal size too small:\nWidth = {} Height = {}\n\nMinimum size:\nWidth = {} Height = {}",
terminal_size.width,
terminal_size.height,
area.width,
area.height,
MIN_WIDTH,
MIN_HEIGHT,
))
@ -253,80 +276,53 @@ impl AppState {
Constraint::Length(5),
Constraint::Fill(1),
])
.split(terminal_size);
.split(area);
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::ROUNDED)
.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_block = Block::bordered().border_set(border::Set {
top_left: " ",
top_right: " ",
bottom_left: " ",
bottom_right: " ",
vertical_left: " ",
vertical_right: " ",
horizontal_top: "*",
horizontal_bottom: "*",
});
let label = Paragraph::new(Line::from(vec![
Span::styled(str1, Style::default().bold()),
Span::styled(str2, Style::default().italic()),
Span::styled("Linutil ", Style::default().bold()),
Span::styled("by Chris Titus", 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());
.centered();
let (keybind_scope, shortcuts) = self.get_keybinds();
let keybind_render_width = terminal_size.width - 2;
let keybinds_block = Block::default()
let keybinds_block = Block::bordered()
.title(format!(" {} ", keybind_scope))
.borders(Borders::ALL)
.border_set(ratatui::symbols::border::ROUNDED);
.border_set(border::ROUNDED);
let keybind_render_width = keybinds_block.inner(area).width;
let keybinds = create_shortcut_list(shortcuts, keybind_render_width);
let n_lines = keybinds.len() as u16;
let keybind_len = 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 vertical =
Layout::vertical([Constraint::Percentage(0), Constraint::Max(keybind_len + 2)])
.flex(Flex::Legacy)
.split(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 horizontal = Layout::horizontal([
Constraint::Min(self.longest_tab_display_len + 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]);
let left_chunks =
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(horizontal[0]);
frame.render_widget(label, left_chunks[0]);
self.areas = Some(Areas {
@ -346,36 +342,23 @@ impl AppState {
Style::new().fg(self.theme.tab_color())
};
let list = List::new(tabs)
.block(
Block::default()
.borders(Borders::ALL)
.border_set(ratatui::symbols::border::ROUNDED),
)
let tab_list = List::new(tabs)
.block(Block::bordered().border_set(border::ROUNDED))
.highlight_style(tab_hl_style)
.highlight_symbol(self.theme.tab_icon());
frame.render_stateful_widget(list, left_chunks[1], &mut self.current_tab);
frame.render_stateful_widget(tab_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]);
let chunks =
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(horizontal[1]);
self.filter.draw_searchbar(frame, chunks[0], &self.theme);
let mut items: Vec<Line> = Vec::new();
let mut task_items: Vec<Line> = Vec::new();
let mut items: Vec<Line> = Vec::with_capacity(self.filter.item_list().len());
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(
@ -384,60 +367,37 @@ impl AppState {
}| {
let is_selected = self.selected_commands.contains(node);
let (indicator, style) = if is_selected {
(self.theme.multi_select_icon(), Style::default().bold())
(self.theme.multi_select_icon(), Style::new().bold())
} else {
let ms_style = if self.multi_select && !node.multi_select {
Style::default().fg(self.theme.multi_select_disabled_color())
Style::new().fg(self.theme.multi_select_disabled_color())
} else {
Style::new()
};
("", ms_style)
};
if *has_children {
Line::from(format!(
"{} {} {}",
self.theme.dir_icon(),
node.name,
indicator
))
.style(self.theme.dir_color())
Line::styled(
format!("{} {}", self.theme.dir_icon(), node.name,),
self.theme.dir_color(),
)
.patch_style(style)
} else {
Line::from(format!(
"{} {} {}",
self.theme.cmd_icon(),
node.name,
indicator
))
.style(self.theme.cmd_color())
let left_content =
format!("{} {} {}", self.theme.cmd_icon(), node.name, indicator);
let right_content = format!("{} ", node.task_list);
let center_space = " ".repeat(
chunks[1].width as usize - left_content.len() - right_content.len(),
);
Line::styled(
format!("{}{}{}", left_content, center_space, right_content),
self.theme.cmd_color(),
)
.patch_style(style)
}
},
));
task_items.extend(self.filter.item_list().iter().map(
|ListEntry {
node, has_children, ..
}| {
let ms_style = if self.multi_select && !node.multi_select {
Style::default().fg(self.theme.multi_select_disabled_color())
} else {
Style::new()
};
if *has_children {
Line::from(" ")
.style(self.theme.dir_color())
.patch_style(ms_style)
} else {
Line::from(format!("{} ", node.task_list))
.alignment(Alignment::Right)
.style(self.theme.cmd_color())
.bold()
.patch_style(ms_style)
}
},
));
let style = if let Focus::List = self.focus {
Style::default().reversed()
} else {
@ -451,7 +411,10 @@ impl AppState {
};
#[cfg(feature = "tips")]
let bottom_title = Line::from(self.tip.as_str().bold().blue()).right_aligned();
let bottom_title = Line::from(format!(" {} ", self.tip))
.bold()
.blue()
.centered();
#[cfg(not(feature = "tips"))]
let bottom_title = "";
@ -461,23 +424,14 @@ impl AppState {
let list = List::new(items)
.highlight_style(style)
.block(
Block::default()
.borders(Borders::ALL & !Borders::RIGHT)
.border_set(ratatui::symbols::border::ROUNDED)
Block::bordered()
.border_set(border::ROUNDED)
.title(title)
.title(task_list_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)
.border_set(ratatui::symbols::border::ROUNDED)
.title(task_list_title),
);
frame.render_stateful_widget(disclaimer_list, list_chunks[1], &mut self.selection);
frame.render_stateful_widget(list, chunks[1], &mut self.selection);
match &mut self.focus {
Focus::FloatingWindow(float) => float.draw(frame, chunks[1], &self.theme),
@ -566,26 +520,10 @@ impl AppState {
// 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();
}
_ => {}
}
if matches!(self.focus, Focus::TabList | Focus::List)
&& self.handle_tablist_and_list_keys(key)
{
return true;
}
match &mut self.focus {
@ -626,15 +564,9 @@ impl AppState {
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(),
_ => {}
},
@ -645,11 +577,6 @@ impl AppState {
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(),
_ => {}
},
@ -659,32 +586,38 @@ impl AppState {
true
}
fn scroll_down(&mut self) {
let len = self.filter.item_list().len();
if len == 0 {
return;
fn handle_tablist_and_list_keys(&mut self, key: &KeyEvent) -> bool {
match key.code {
KeyCode::Tab => self.scroll_tab_down(),
KeyCode::BackTab => self.scroll_tab_up(),
KeyCode::Char('/') => self.enter_search(),
KeyCode::Char('g') | KeyCode::Char('G') => self.enable_task_list_guide(),
KeyCode::Char('v') | KeyCode::Char('V') => self.toggle_multi_select(),
KeyCode::Char('t') => self.theme.next(),
KeyCode::Char('T') => self.theme.prev(),
_ => return false,
}
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
};
true
}
self.selection.select(Some(next));
fn scroll_down(&mut self) {
if let Some(selected) = self.selection.selected() {
if selected == self.filter.item_list().len() - 1 {
self.selection.select_first();
} else {
self.selection.select_next();
}
}
}
fn scroll_up(&mut self) {
let len = self.filter.item_list().len();
if len == 0 {
return;
if let Some(selected) = self.selection.selected() {
if selected == 0 {
self.selection.select_last();
} else {
self.selection.select_previous();
}
}
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) {
@ -747,11 +680,12 @@ impl AppState {
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 {
return None;
}
if !self.at_root() {
selected_index = selected_index.saturating_sub(1);
if selected_index == 0 {
return None;
} else {
selected_index = selected_index.saturating_sub(1);
}
}
if let Some(item) = self.filter.item_list().get(selected_index) {
@ -793,12 +727,12 @@ impl AppState {
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);
if selected_index == 0 {
return false;
} else {
selected_index = selected_index.saturating_sub(1);
}
}
self.filter
@ -822,7 +756,7 @@ impl AppState {
if let Some(list_node) = self.get_selected_node() {
let preview_title = format!("[Preview] - {}", list_node.name.as_str());
let preview = FloatingText::from_command(&list_node.command, &preview_title, false);
self.spawn_float(preview, 80, 80);
self.spawn_float(preview, FLOAT_SIZE, FLOAT_SIZE);
}
}
@ -831,11 +765,19 @@ impl AppState {
if !command_description.is_empty() {
let description =
FloatingText::new(command_description, "Command Description", true);
self.spawn_float(description, 80, 80);
self.spawn_float(description, FLOAT_SIZE, FLOAT_SIZE);
}
}
}
fn enable_task_list_guide(&mut self) {
self.spawn_float(
FloatingText::new(ACTIONS_GUIDE.to_string(), "Important Actions Guide", true),
FLOAT_SIZE,
FLOAT_SIZE,
);
}
fn get_selected_item_type(&self) -> SelectedItem {
if self.selected_item_is_up_dir() {
SelectedItem::UpDir
@ -862,14 +804,7 @@ impl AppState {
if self.skip_confirmation {
self.handle_confirm_command();
} else {
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));
self.spawn_confirmprompt();
}
}
SelectedItem::None => {}
@ -884,7 +819,7 @@ impl AppState {
.collect();
let command = RunningCommand::new(&commands);
self.spawn_float(command, 80, 80);
self.spawn_float(command, FLOAT_SIZE, FLOAT_SIZE);
self.selected_commands.clear();
}
@ -918,44 +853,21 @@ impl AppState {
self.update_items();
}
fn toggle_task_list_guide(&mut self) {
self.spawn_float(
FloatingText::new(ACTIONS_GUIDE.to_string(), "Important Actions Guide", true),
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));
if self.current_tab.selected().unwrap() == self.tabs.len() - 1 {
self.current_tab.select_first();
} else {
self.current_tab.select_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));
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();
}
}
#[cfg(feature = "tips")]
const TIPS: &str = include_str!("../cool_tips.txt");
#[cfg(feature = "tips")]
fn get_random_tip() -> String {
let tips: Vec<&str> = TIPS.lines().collect();
if tips.is_empty() {
return "".to_string();
}
let mut rng = rand::thread_rng();
let random_index = rng.gen_range(0..tips.len());
format!(" {} ", tips[random_index])
}

14
tui/src/tips.rs Normal file
View File

@ -0,0 +1,14 @@
use rand::Rng;
const TIPS: &str = include_str!("../cool_tips.txt");
pub 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]
}