mirror of
https://github.com/ChrisTitusTech/linutil.git
synced 2024-11-05 21:28:48 +00:00
Merge branch 'ChrisTitusTech:main' into main
This commit is contained in:
commit
e1ccd4f9a4
56
CONTRIBUTING.md
Normal file
56
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,56 @@
|
|||
# Contributing Guidelines for Linutil
|
||||
|
||||
Thank you for considering contributing to Linutil! We appreciate your effort in helping improve this project. To ensure that your contributions align with the goals and quality standards of Linutil, please follow these guidelines:
|
||||
|
||||
## 1. Setting Up Your Development Environment
|
||||
|
||||
1. **Clone the repo**: Start by cloning the Linutil repository to your local machine.
|
||||
```bash
|
||||
git clone https://github.com/christitustech/linutil.git
|
||||
cd linutil
|
||||
```
|
||||
2. **Install Rust**: Make sure you have Rust installed on your machine. If you don't, you can install it by following the instructions at [rust-lang.org](https://www.rust-lang.org/tools/install).
|
||||
3. **Edit the files you want to change**: Make your changes to the relevant files.
|
||||
4. **Test your changes**: Run `cargo run` to test your modifications in a local environment and ensure everything works as expected.
|
||||
|
||||
## 2. Make Meaningful Changes
|
||||
|
||||
- **Have a clear reason**: Don’t change the way things are done without a valid reason. If you propose an alteration, be prepared to explain why it’s necessary and how it improves the project.
|
||||
- **Respect existing conventions**: Changes should align with the existing code style, design patterns, and overall project philosophy. If you want to introduce a new way of doing things, justify it clearly.
|
||||
|
||||
## 3. Learn from Past Pull Requests (PRs)
|
||||
|
||||
- **Check merged PRs**: Reviewing merged pull requests can give you an idea of what kind of contributions are accepted and how they are implemented.
|
||||
- **Study rejected PRs**: This is especially important as it helps you avoid making similar mistakes or proposing changes that have already been considered and declined.
|
||||
|
||||
## 4. Write Clean, Descriptive Commit Messages
|
||||
|
||||
- **Be descriptive**: Your commit messages should clearly describe what the change does and why it was made.
|
||||
- **Use the imperative mood**: For example, "Add feature X" or "Fix bug in Y", rather than "Added feature X" or "Fixed bug in Y".
|
||||
- **Keep commits clean**: Avoid committing a change and then immediately following it with a fix for that change. Instead, amend your commit or squash it if needed.
|
||||
|
||||
## 5. Keep Your Pull Requests (PRs) Small and Focused
|
||||
|
||||
- **Make small, targeted PRs**: Focus on one feature or fix per pull request. This makes it easier to review and increases the likelihood of acceptance.
|
||||
- **Avoid combining unrelated changes**: PRs that tackle multiple unrelated issues are harder to review and might be rejected because of a single problem.
|
||||
|
||||
## 6. Code Review and Feedback
|
||||
|
||||
- **Expect feedback**: PRs will undergo code review. Be open to feedback and willing to make adjustments as needed.
|
||||
- **Participate in reviews**: If you feel comfortable, review other contributors' PRs as well. Peer review is a great way to learn and ensure high-quality contributions.
|
||||
|
||||
## 7. Contributing Is More Than Just Code
|
||||
|
||||
- **Test the tool**: Running tests and providing feedback on how the tool works in different environments is a valuable contribution.
|
||||
- **Write well-formed issues**: Clearly describe bugs or problems you encounter, providing as much detail as possible, including steps to reproduce the issue.
|
||||
- **Propose reasonable feature requests**: When suggesting new features, ensure they fit within the scope, style, and design of the project. Provide clear reasoning and use cases.
|
||||
|
||||
## 8. Documentation
|
||||
|
||||
- **Update the documentation**: If your change affects the functionality, please update the relevant documentation files to reflect this.
|
||||
|
||||
## 9. License
|
||||
|
||||
- **Agree to the license**: By contributing to Linutil, you agree that your contributions will be licensed under the project's MIT license.
|
||||
|
||||
We appreciate your contributions and look forward to collaborating with you to make Linutil better!
|
27
README.md
27
README.md
|
@ -5,27 +5,34 @@
|
|||
|
||||
![Preview](docs/assets/preview.png)
|
||||
|
||||
A distro-agnostic* toolbox which helps with everyday Linux tasks. It can help you set up applications and your system for specific use cases! Written with Rust 🦀
|
||||
**Linutil** is a distro-agnostic toolbox designed to simplify everyday Linux tasks. It helps you set up applications and optimize your system for specific use cases. The utility is actively developed in Rust 🦀, providing performance and reliability.
|
||||
|
||||
\* — The project is in active development, so there could be some issues. Please consider [submitting feedback](https://github.com/ChrisTitusTech/linutil/issues).
|
||||
*Note:* Since the project is still in active development, you may encounter some issues. Please consider [submitting feedback](https://github.com/ChrisTitusTech/linutil/issues) if you do.
|
||||
|
||||
## 💡 Usage
|
||||
|
||||
Open your terminal and paste this command:
|
||||
To get started, open your terminal and run the following command:
|
||||
```bash
|
||||
curl -fsSL https://christitus.com/linux | sh
|
||||
```
|
||||
## 💖 Support
|
||||
|
||||
If you find Linutil helpful, please consider giving it a ⭐️ to show your support!
|
||||
|
||||
## 🎓 Documentation
|
||||
### [LinUtil Official Documentation](https://christitustech.github.io/linutil/)
|
||||
|
||||
## 💖 Support
|
||||
To morally and mentally support the project, make sure to leave a ⭐️!
|
||||
For comprehensive information on how to use Linutil, visit the [Linutil Official Documentation](https://christitustech.github.io/linutil/).
|
||||
|
||||
## 🏅 Thanks to all Contributors
|
||||
Thanks a lot for spending your time helping Linutil grow. Keep rocking 🍻.
|
||||
## 🛠 Contributing
|
||||
|
||||
We welcome contributions from the community! Before you start, please review our [Contributing Guidelines](CONTRIBUTING.md) to understand how to make the most effective and efficient contributions.
|
||||
|
||||
## 🏅 Thanks to All Contributors
|
||||
|
||||
Thank you to everyone who has contributed to the development of Linutil. Your efforts are greatly appreciated, and you’re helping make this tool better for everyone!
|
||||
|
||||
[![Contributors](https://contrib.rocks/image?repo=ChrisTitusTech/linutil)](https://github.com/ChrisTitusTech/linutil/graphs/contributors)
|
||||
|
||||
## Credits
|
||||
Rust Shell written by [@JustLinuxUser](https://github.com/JustLinuxUser)
|
||||
## 📜 Credits
|
||||
|
||||
Linutil’s Rust shell was developed by [@JustLinuxUser](https://github.com/JustLinuxUser).
|
||||
|
|
BIN
build/linutil
BIN
build/linutil
Binary file not shown.
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 57 KiB |
|
@ -1,3 +0,0 @@
|
|||
# Update Log
|
||||
|
||||
#
|
|
@ -5,7 +5,6 @@ nav:
|
|||
- Introduction: 'index.md'
|
||||
- User Guide: 'userguide.md'
|
||||
- Contribute: 'contribute.md'
|
||||
- Updates: 'updates.md'
|
||||
- Known Issues: 'KnownIssues.md'
|
||||
- FAQ: 'faq.md'
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ setupAlacritty() {
|
|||
if ! command_exists alacritty; then
|
||||
case ${PACKAGER} in
|
||||
pacman)
|
||||
sudo ${PACKAGER} -S --noconfirm alacritty
|
||||
sudo ${PACKAGER} -S --needed --noconfirm alacritty
|
||||
;;
|
||||
*)
|
||||
sudo ${PACKAGER} install -y alacritty
|
||||
|
|
|
@ -13,7 +13,7 @@ setupDWM() {
|
|||
echo "Installing DWM-Titus if not already installed"
|
||||
case "$PACKAGER" in # Install pre-Requisites
|
||||
pacman)
|
||||
sudo "$PACKAGER" -S --noconfirm --needed base-devel libx11 libxinerama libxft imlib2
|
||||
sudo "$PACKAGER" -S --needed --noconfirm base-devel libx11 libxinerama libxft imlib2
|
||||
;;
|
||||
*)
|
||||
sudo "$PACKAGER" install -y build-essential libx11-dev libxinerama-dev libxft-dev libimlib2-dev
|
||||
|
|
|
@ -7,7 +7,7 @@ setupKitty() {
|
|||
if ! command_exists kitty; then
|
||||
case ${PACKAGER} in
|
||||
pacman)
|
||||
sudo "${PACKAGER}" -S --noconfirm kitty
|
||||
sudo "${PACKAGER}" -S --needed --noconfirm kitty
|
||||
;;
|
||||
*)
|
||||
sudo "${PACKAGER}" install -y kitty
|
||||
|
|
|
@ -7,7 +7,7 @@ setupRofi() {
|
|||
if ! command_exists rofi; then
|
||||
case "$PACKAGER" in
|
||||
pacman)
|
||||
sudo "$PACKAGER" -S --noconfirm rofi
|
||||
sudo "$PACKAGER" -S --needed --noconfirm rofi
|
||||
;;
|
||||
*)
|
||||
sudo "$PACKAGER" install -y rofi
|
||||
|
|
|
@ -8,7 +8,7 @@ install_zsh() {
|
|||
if ! command_exists zsh; then
|
||||
case "$PACKAGER" in
|
||||
pacman)
|
||||
sudo "$PACKAGER" -Sy --noconfirm zsh
|
||||
sudo "$PACKAGER" -S --needed --noconfirm zsh
|
||||
;;
|
||||
*)
|
||||
sudo "$PACKAGER" install -y zsh
|
||||
|
|
|
@ -7,7 +7,7 @@ installPkg() {
|
|||
if ! command_exists ufw; then
|
||||
case ${PACKAGER} in
|
||||
pacman)
|
||||
sudo "${PACKAGER}" -Sy --noconfirm ufw
|
||||
sudo "${PACKAGER}" -S --needed --noconfirm ufw
|
||||
;;
|
||||
*)
|
||||
sudo "${PACKAGER}" install -y ufw
|
||||
|
|
|
@ -33,13 +33,13 @@ installDepend() {
|
|||
if ! grep -q "^\s*\[multilib\]" /etc/pacman.conf; then
|
||||
echo "[multilib]" | sudo tee -a /etc/pacman.conf
|
||||
echo "Include = /etc/pacman.d/mirrorlist" | sudo tee -a /etc/pacman.conf
|
||||
sudo "$PACKAGER" -Sy
|
||||
sudo "$PACKAGER" -Syu
|
||||
else
|
||||
echo "Multilib is already enabled."
|
||||
fi
|
||||
if ! command_exists yay && ! command_exists paru; then
|
||||
echo "Installing yay as AUR helper..."
|
||||
sudo "$PACKAGER" --noconfirm -S base-devel
|
||||
sudo "$PACKAGER" -S --needed --noconfirm base-devel
|
||||
cd /opt && sudo git clone https://aur.archlinux.org/yay-git.git && sudo chown -R "$USER":"$USER" ./yay-git
|
||||
cd yay-git && makepkg --noconfirm -si
|
||||
else
|
||||
|
@ -53,7 +53,7 @@ installDepend() {
|
|||
echo "No AUR helper found. Please install yay or paru."
|
||||
exit 1
|
||||
fi
|
||||
"$AUR_HELPER" --noconfirm -S "$DEPENDENCIES"
|
||||
"$AUR_HELPER" -S --needed --noconfirm "$DEPENDENCIES"
|
||||
;;
|
||||
apt-get|nala)
|
||||
COMPILEDEPS='build-essential'
|
||||
|
|
|
@ -9,13 +9,13 @@ installDepend() {
|
|||
if ! grep -q "^\s*\[multilib\]" /etc/pacman.conf; then
|
||||
echo "[multilib]" | sudo tee -a /etc/pacman.conf
|
||||
echo "Include = /etc/pacman.d/mirrorlist" | sudo tee -a /etc/pacman.conf
|
||||
sudo ${PACKAGER} -Sy
|
||||
sudo ${PACKAGER} -Syu
|
||||
else
|
||||
echo "Multilib is already enabled."
|
||||
fi
|
||||
if ! command_exists yay && ! command_exists paru; then
|
||||
echo "Installing yay as AUR helper..."
|
||||
sudo ${PACKAGER} --noconfirm -S base-devel
|
||||
sudo ${PACKAGER} -S --needed --noconfirm base-devel
|
||||
cd /opt && sudo git clone https://aur.archlinux.org/yay-git.git && sudo chown -R ${USER}:${USER} ./yay-git
|
||||
cd yay-git && makepkg --noconfirm -si
|
||||
else
|
||||
|
@ -29,7 +29,7 @@ installDepend() {
|
|||
echo "No AUR helper found. Please install yay or paru."
|
||||
exit 1
|
||||
fi
|
||||
${AUR_HELPER} --noconfirm -S wine giflib lib32-giflib libpng lib32-libpng libldap lib32-libldap gnutls lib32-gnutls \
|
||||
${AUR_HELPER} -S --needed --noconfirm wine giflib lib32-giflib libpng lib32-libpng libldap lib32-libldap gnutls lib32-gnutls \
|
||||
mpg123 lib32-mpg123 openal lib32-openal v4l-utils lib32-v4l-utils libpulse lib32-libpulse libgpg-error \
|
||||
lib32-libgpg-error alsa-plugins lib32-alsa-plugins alsa-lib lib32-alsa-lib libjpeg-turbo lib32-libjpeg-turbo \
|
||||
sqlite lib32-sqlite libxcomposite lib32-libxcomposite libxinerama lib32-libgcrypt libgcrypt lib32-libxinerama \
|
||||
|
|
|
@ -29,8 +29,7 @@ install_theme_tools() {
|
|||
sudo dnf install -y qt6ct kvantum
|
||||
;;
|
||||
pacman)
|
||||
sudo pacman -Sy
|
||||
sudo pacman --noconfirm -S qt6ct kvantum
|
||||
sudo pacman -S --needed --noconfirm qt6ct kvantum
|
||||
;;
|
||||
*)
|
||||
printf "${RED}Unsupported package manager. Please install qt6ct and kvantum manually.${RC}\n"
|
||||
|
|
25
src/commands/system-setup/arch/paru-setup.sh
Normal file
25
src/commands/system-setup/arch/paru-setup.sh
Normal file
|
@ -0,0 +1,25 @@
|
|||
#!/bin/sh -e
|
||||
|
||||
. "$(dirname "$0")/../../common-script.sh"
|
||||
|
||||
installDepend() {
|
||||
case $PACKAGER in
|
||||
pacman)
|
||||
if ! command_exists paru; then
|
||||
echo "Installing paru as AUR helper..."
|
||||
sudo "$PACKAGER" -S --needed --noconfirm base-devel
|
||||
cd /opt && sudo git clone https://aur.archlinux.org/paru.git && sudo chown -R "$USER": ./paru
|
||||
cd paru && makepkg --noconfirm -si
|
||||
echo "Paru installed"
|
||||
else
|
||||
echo "Paru already installed"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported package manager: $PACKAGER"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
checkEnv
|
||||
installDepend
|
25
src/commands/system-setup/arch/yay-setup.sh
Normal file
25
src/commands/system-setup/arch/yay-setup.sh
Normal file
|
@ -0,0 +1,25 @@
|
|||
#!/bin/sh -e
|
||||
|
||||
. "$(dirname "$0")/../../common-script.sh"
|
||||
|
||||
installDepend() {
|
||||
case $PACKAGER in
|
||||
pacman)
|
||||
if ! command_exists yay; then
|
||||
echo "Installing yay as AUR helper..."
|
||||
sudo "$PACKAGER" -S --needed --noconfirm base-devel
|
||||
cd /opt && sudo git clone https://aur.archlinux.org/yay-git.git && sudo chown -R "$USER": ./yay-git
|
||||
cd yay-git && makepkg --noconfirm -si
|
||||
echo "Yay installed"
|
||||
else
|
||||
echo "Aur helper already installed"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported package manager: $PACKAGER"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
checkEnv
|
||||
installDepend
|
|
@ -7,7 +7,7 @@ fastUpdate() {
|
|||
pacman)
|
||||
if ! command_exists yay && ! command_exists paru; then
|
||||
echo "Installing yay as AUR helper..."
|
||||
sudo ${PACKAGER} --noconfirm -S base-devel || { echo -e "${RED}Failed to install base-devel${RC}"; exit 1; }
|
||||
sudo ${PACKAGER} -S --needed --noconfirm base-devel || { echo -e "${RED}Failed to install base-devel${RC}"; exit 1; }
|
||||
cd /opt && sudo git clone https://aur.archlinux.org/yay-git.git && sudo chown -R ${USER}:${USER} ./yay-git
|
||||
cd yay-git && makepkg --noconfirm -si || { echo -e "${RED}Failed to install yay${RC}"; exit 1; }
|
||||
else
|
||||
|
@ -21,7 +21,7 @@ fastUpdate() {
|
|||
echo "No AUR helper found. Please install yay or paru."
|
||||
exit 1
|
||||
fi
|
||||
${AUR_HELPER} --noconfirm -S rate-mirrors-bin
|
||||
${AUR_HELPER} -S --needed --noconfirm rate-mirrors-bin
|
||||
if [ -s /etc/pacman.d/mirrorlist ]; then
|
||||
sudo cp /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak
|
||||
fi
|
||||
|
|
|
@ -75,7 +75,7 @@ prompt_for_mac() {
|
|||
fi
|
||||
|
||||
# Display devices with numbers
|
||||
IFS=$'\n' read -rd '' -a device_list <<<"$devices"
|
||||
IFS=$'\n' read -r -a device_list <<<"$devices"
|
||||
for i in "${!device_list[@]}"; do
|
||||
echo "$((i+1)). ${device_list[$i]}"
|
||||
done
|
||||
|
|
|
@ -101,7 +101,7 @@ prompt_for_network() {
|
|||
fi
|
||||
|
||||
# Display networks with numbers
|
||||
IFS=$'\n' read -rd '' -a network_list <<<"$networks"
|
||||
IFS=$'\n' read -r -a network_list <<<"$networks"
|
||||
for i in "${!network_list[@]}"; do
|
||||
ssid=$(echo "${network_list[$i]}" | awk -F: '{print $1}')
|
||||
echo "$((i+1)). SSID: $ssid"
|
||||
|
|
41
src/float.rs
41
src/float.rs
|
@ -10,16 +10,16 @@ pub trait FloatContent {
|
|||
fn is_finished(&self) -> bool;
|
||||
}
|
||||
|
||||
pub struct Float<T: FloatContent> {
|
||||
content: Option<T>,
|
||||
pub struct Float {
|
||||
content: Box<dyn FloatContent>,
|
||||
width_percent: u16,
|
||||
height_percent: u16,
|
||||
}
|
||||
|
||||
impl<T: FloatContent> Float<T> {
|
||||
pub fn new(width_percent: u16, height_percent: u16) -> Self {
|
||||
impl Float {
|
||||
pub fn new(content: Box<dyn FloatContent>, width_percent: u16, height_percent: u16) -> Self {
|
||||
Self {
|
||||
content: None,
|
||||
content,
|
||||
width_percent,
|
||||
height_percent,
|
||||
}
|
||||
|
@ -48,7 +48,6 @@ impl<T: FloatContent> Float<T> {
|
|||
pub fn draw(&mut self, frame: &mut Frame, parent_area: Rect) {
|
||||
let popup_area = self.floating_window(parent_area);
|
||||
|
||||
if let Some(content) = &mut self.content {
|
||||
let content_area = Rect {
|
||||
x: popup_area.x,
|
||||
y: popup_area.y,
|
||||
|
@ -56,36 +55,18 @@ impl<T: FloatContent> Float<T> {
|
|||
height: popup_area.height,
|
||||
};
|
||||
|
||||
content.draw(frame, content_area);
|
||||
}
|
||||
self.content.draw(frame, content_area);
|
||||
}
|
||||
|
||||
// Returns true if the key was processed by this Float.
|
||||
// Returns true if the floating window is finished.
|
||||
pub fn handle_key_event(&mut self, key: &KeyEvent) -> bool {
|
||||
if let Some(content) = &mut self.content {
|
||||
match key.code {
|
||||
KeyCode::Enter | KeyCode::Char('p') | KeyCode::Esc | KeyCode::Char('q') => {
|
||||
if content.is_finished() {
|
||||
self.content = None;
|
||||
} else {
|
||||
content.handle_key_event(key);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
content.handle_key_event(key);
|
||||
}
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('p') | KeyCode::Esc | KeyCode::Char('q')
|
||||
if self.content.is_finished() =>
|
||||
{
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
_ => self.content.handle_key_event(key),
|
||||
}
|
||||
|
||||
pub fn get_content(&self) -> &Option<T> {
|
||||
&self.content
|
||||
}
|
||||
|
||||
pub fn set_content(&mut self, content: Option<T>) {
|
||||
self.content = content;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::float::FloatContent;
|
||||
use crate::{float::FloatContent, running_command::Command};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
|
@ -7,6 +7,7 @@ use ratatui::{
|
|||
widgets::{Block, Borders, List},
|
||||
Frame,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct FloatingText {
|
||||
text: Vec<String>,
|
||||
|
@ -18,6 +19,26 @@ impl FloatingText {
|
|||
Self { text, scroll: 0 }
|
||||
}
|
||||
|
||||
pub fn from_command(command: &Command, mut full_path: PathBuf) -> Option<Self> {
|
||||
let lines = match 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) => {
|
||||
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 None,
|
||||
};
|
||||
Some(Self::new(lines))
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self) {
|
||||
if self.scroll + 1 < self.text.len() {
|
||||
self.scroll += 1;
|
||||
|
@ -64,16 +85,11 @@ impl FloatContent for FloatingText {
|
|||
|
||||
fn handle_key_event(&mut self, key: &KeyEvent) -> bool {
|
||||
match key.code {
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.scroll_down();
|
||||
true
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.scroll_up();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
KeyCode::Down | KeyCode::Char('j') => self.scroll_down(),
|
||||
KeyCode::Up | KeyCode::Char('k') => self.scroll_up(),
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_finished(&self) -> bool {
|
||||
|
|
444
src/list.rs
444
src/list.rs
|
@ -1,444 +0,0 @@
|
|||
use crate::{float::Float, floating_text::FloatingText, running_command::Command, state::AppState};
|
||||
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,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ListNode {
|
||||
name: &'static str,
|
||||
command: Command,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
// This stores the current search query
|
||||
filter_query: String,
|
||||
// This stores the filtered tree
|
||||
filtered_items: Vec<ListNode>,
|
||||
// This is the preview window for the commands
|
||||
preview_float: Float<FloatingText>,
|
||||
}
|
||||
|
||||
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",
|
||||
command: Command::None,
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Applications Setup",
|
||||
command: Command::None
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Alacritty",
|
||||
command: Command::LocalFile("applications-setup/alacritty-setup.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Bash Prompt",
|
||||
command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/mybash/main/setup.sh)\""),
|
||||
},
|
||||
ListNode {
|
||||
name: "DWM-Titus",
|
||||
command: Command::LocalFile("applications-setup/dwmtitus-setup.sh")
|
||||
},
|
||||
ListNode {
|
||||
name: "Kitty",
|
||||
command: Command::LocalFile("applications-setup/kitty-setup.sh")
|
||||
},
|
||||
ListNode {
|
||||
name: "Neovim",
|
||||
command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/neovim/main/setup.sh)\""),
|
||||
},
|
||||
ListNode {
|
||||
name: "Rofi",
|
||||
command: Command::LocalFile("applications-setup/rofi-setup.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "ZSH Prompt",
|
||||
command: Command::LocalFile("applications-setup/zsh-setup.sh"),
|
||||
}
|
||||
|
||||
},
|
||||
ListNode {
|
||||
name: "Security",
|
||||
command: Command::None
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Firewall Baselines (CTT)",
|
||||
command: Command::LocalFile("security/firewall-baselines.sh"),
|
||||
}
|
||||
},
|
||||
ListNode {
|
||||
name: "System Setup",
|
||||
command: Command::None,
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Build Prerequisites",
|
||||
command: Command::LocalFile("system-setup/1-compile-setup.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Gaming Dependencies",
|
||||
command: Command::LocalFile("system-setup/2-gaming-setup.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Global Theme",
|
||||
command: Command::LocalFile("system-setup/3-global-theme.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Remove Snaps",
|
||||
command: Command::LocalFile("system-setup/4-remove-snaps.sh"),
|
||||
},
|
||||
},
|
||||
ListNode {
|
||||
name: "Utilities",
|
||||
command: Command::None
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Wifi Manager",
|
||||
command: Command::LocalFile("utils/wifi-control.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Bluetooth Manager",
|
||||
command: Command::LocalFile("utils/bluetooth-control.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "MonitorControl(xorg)",
|
||||
command: Command::None,
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Set Resolution",
|
||||
command: Command::LocalFile("utils/monitor-control/set_resolutions.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Duplicate Displays",
|
||||
command: Command::LocalFile("utils/monitor-control/duplicate_displays.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Extend Displays",
|
||||
command: Command::LocalFile("utils/monitor-control/extend_displays.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Auto Detect Displays",
|
||||
command: Command::LocalFile("utils/monitor-control/auto_detect_displays.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Enable Monitor",
|
||||
command: Command::LocalFile("utils/monitor-control/enable_monitor.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Disable Monitor",
|
||||
command: Command::LocalFile("utils/monitor-control/disable_monitor.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Set Primary Monitor",
|
||||
command: Command::LocalFile("utils/monitor-control/set_primary_monitor.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Change Orientation",
|
||||
command: Command::LocalFile("utils/monitor-control/change_orientation.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Manage Arrangement",
|
||||
command: Command::LocalFile("utils/monitor-control/manage_arrangement.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Scale Monitors",
|
||||
command: Command::LocalFile("utils/monitor-control/scale_monitor.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Reset Scaling",
|
||||
command: Command::LocalFile("utils/monitor-control/reset_scaling.sh"),
|
||||
},
|
||||
},
|
||||
},
|
||||
ListNode {
|
||||
name: "Full System Update",
|
||||
command: Command::LocalFile("system-update.sh"),
|
||||
},
|
||||
});
|
||||
// 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)),
|
||||
filter_query: String::new(),
|
||||
filtered_items: vec![],
|
||||
preview_float: Float::new(80, 80),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw our custom widget to the frame
|
||||
pub fn draw(&mut self, frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let item_list: Vec<Line> = if self.filter_query.is_empty() {
|
||||
let mut items: Vec<Line> = 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
|
||||
// icons:
|
||||
if !self.at_root() {
|
||||
items.push(
|
||||
Line::from(format!("{} ..", state.theme.dir_icon))
|
||||
.style(state.theme.dir_color),
|
||||
);
|
||||
}
|
||||
|
||||
// Get the last element in the `visit_stack` vec
|
||||
let curr = self
|
||||
.inner_tree
|
||||
.get(*self.visit_stack.last().unwrap())
|
||||
.unwrap();
|
||||
|
||||
// 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),
|
||||
);
|
||||
} else {
|
||||
items.push(
|
||||
Line::from(format!("{} {}", state.theme.cmd_icon, node.value().name))
|
||||
.style(state.theme.cmd_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
items
|
||||
} else {
|
||||
self.filtered_items
|
||||
.iter()
|
||||
.map(|node| {
|
||||
Line::from(format!("{} {}", state.theme.cmd_icon, node.name))
|
||||
.style(state.theme.cmd_color)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// create the normal list widget containing only item in our "working directory" / tree
|
||||
// node
|
||||
let list = List::new(item_list)
|
||||
.highlight_style(Style::default().reversed())
|
||||
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||
"Linux Toolbox - {}",
|
||||
chrono::Local::now().format("%Y-%m-%d")
|
||||
)))
|
||||
.scroll_padding(1);
|
||||
|
||||
// Render it
|
||||
frame.render_stateful_widget(list, area, &mut self.list_state);
|
||||
|
||||
//Render the preview window
|
||||
self.preview_float.draw(frame, area);
|
||||
}
|
||||
|
||||
pub fn filter(&mut self, query: String) {
|
||||
self.filter_query.clone_from(&query);
|
||||
self.filtered_items.clear();
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let mut stack = vec![self.inner_tree.root().id()];
|
||||
|
||||
while let Some(node_id) = stack.pop() {
|
||||
let node = self.inner_tree.get(node_id).unwrap();
|
||||
|
||||
if node.value().name.to_lowercase().contains(&query_lower) && !node.has_children() {
|
||||
self.filtered_items.push(node.value().clone());
|
||||
}
|
||||
|
||||
for child in node.children() {
|
||||
stack.push(child.id());
|
||||
}
|
||||
}
|
||||
self.filtered_items.sort_by(|a, b| a.name.cmp(b.name));
|
||||
}
|
||||
|
||||
/// Resets the selection to the first item
|
||||
pub fn reset_selection(&mut self) {
|
||||
if !self.filtered_items.is_empty() {
|
||||
self.list_state.select(Some(0));
|
||||
} else {
|
||||
self.list_state.select(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle key events, we are only interested in `Press` and `Repeat` events
|
||||
pub fn handle_key(&mut self, event: KeyEvent, state: &AppState) -> Option<Command> {
|
||||
if event.kind == KeyEventKind::Release {
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.preview_float.handle_key_event(&event) {
|
||||
return None; // If the key event was handled by the preview, don't propagate it further
|
||||
}
|
||||
|
||||
match event.code {
|
||||
// Damm you Up arrow, use vim lol
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
self.list_state.select_next();
|
||||
None
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
self.list_state.select_previous();
|
||||
None
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
self.toggle_preview_window(state);
|
||||
None
|
||||
}
|
||||
|
||||
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
|
||||
if self.preview_float.get_content().is_none() {
|
||||
self.handle_enter()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
KeyCode::Left | KeyCode::Char('h') if !self.at_root() => self.enter_parent_directory(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_command(&self) -> Option<Command> {
|
||||
let selected_index = self.list_state.selected().unwrap_or(0);
|
||||
|
||||
if self.filter_query.is_empty() {
|
||||
// No filter query, use the regular tree navigation
|
||||
let curr = self
|
||||
.inner_tree
|
||||
.get(*self.visit_stack.last().unwrap())
|
||||
.unwrap();
|
||||
|
||||
if !self.at_root() && selected_index == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut actual_index = selected_index;
|
||||
if !self.at_root() {
|
||||
actual_index -= 1; // Adjust for the ".." item if not at root
|
||||
}
|
||||
|
||||
for (idx, node) in curr.children().enumerate() {
|
||||
if idx == actual_index {
|
||||
return Some(node.value().command.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Filter query is active, use the filtered items
|
||||
if let Some(filtered_node) = self.filtered_items.get(selected_index) {
|
||||
return Some(filtered_node.command.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn enter_parent_directory(&mut self) -> Option<Command> {
|
||||
self.visit_stack.pop();
|
||||
self.list_state.select(Some(0));
|
||||
None
|
||||
}
|
||||
|
||||
/// 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
|
||||
///
|
||||
/// Returns `Some(command)` when command is selected, othervise we returns `None`
|
||||
fn handle_enter(&mut self) -> Option<Command> {
|
||||
let selected_index = self.list_state.selected().unwrap_or(0);
|
||||
|
||||
if self.filter_query.is_empty() {
|
||||
// No filter query, use the regular tree navigation
|
||||
let curr = self
|
||||
.inner_tree
|
||||
.get(*self.visit_stack.last().unwrap())
|
||||
.unwrap();
|
||||
|
||||
if !self.at_root() && selected_index == 0 {
|
||||
return self.enter_parent_directory();
|
||||
}
|
||||
|
||||
let mut actual_index = selected_index;
|
||||
if !self.at_root() {
|
||||
actual_index -= 1; // Adjust for the ".." item if not at root
|
||||
}
|
||||
|
||||
for (idx, node) in curr.children().enumerate() {
|
||||
if idx == actual_index {
|
||||
if node.has_children() {
|
||||
self.visit_stack.push(node.id());
|
||||
self.list_state.select(Some(0));
|
||||
return None;
|
||||
} else {
|
||||
return Some(node.value().command.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Filter query is active, use the filtered items
|
||||
if let Some(filtered_node) = self.filtered_items.get(selected_index) {
|
||||
return Some(filtered_node.command.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn toggle_preview_window(&mut self, state: &AppState) {
|
||||
if self.preview_float.get_content().is_some() {
|
||||
// If the preview window is active, disable it
|
||||
self.preview_float.set_content(None);
|
||||
} else {
|
||||
// If the preview window is not active, show it
|
||||
|
||||
// Get the selected command
|
||||
if let Some(selected_command) = self.get_selected_command() {
|
||||
let lines = match selected_command {
|
||||
Command::Raw(cmd) => cmd.lines().map(|line| line.to_string()).collect(),
|
||||
Command::LocalFile(file_path) => {
|
||||
if file_path.is_empty() {
|
||||
return;
|
||||
}
|
||||
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()
|
||||
}
|
||||
Command::None => return,
|
||||
};
|
||||
|
||||
self.preview_float
|
||||
.set_content(Some(FloatingText::new(lines)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
111
src/main.rs
111
src/main.rs
|
@ -1,8 +1,8 @@
|
|||
mod float;
|
||||
mod floating_text;
|
||||
mod list;
|
||||
mod running_command;
|
||||
pub mod state;
|
||||
mod tabs;
|
||||
mod theme;
|
||||
|
||||
use std::{
|
||||
|
@ -13,23 +13,16 @@ use std::{
|
|||
use clap::Parser;
|
||||
use crossterm::{
|
||||
cursor::RestorePosition,
|
||||
event::{self, DisableMouseCapture, Event, KeyCode, KeyEventKind},
|
||||
event::{self, DisableMouseCapture, Event, KeyEventKind},
|
||||
style::ResetColor,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
ExecutableCommand,
|
||||
};
|
||||
use float::Float;
|
||||
use include_dir::include_dir;
|
||||
use list::CustomList;
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Style},
|
||||
text::Span,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Terminal,
|
||||
};
|
||||
use running_command::RunningCommand;
|
||||
use state::AppState;
|
||||
use tempdir::TempDir;
|
||||
use theme::THEMES;
|
||||
|
@ -56,17 +49,14 @@ fn main() -> std::io::Result<()> {
|
|||
.extract(temp_dir.path())
|
||||
.expect("Failed to extract the saved directory");
|
||||
|
||||
let state = AppState {
|
||||
theme,
|
||||
temp_path: temp_dir.path().to_owned(),
|
||||
};
|
||||
let mut state = AppState::new(theme, temp_dir.path().to_owned());
|
||||
|
||||
stdout().execute(EnterAlternateScreen)?;
|
||||
enable_raw_mode()?;
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
|
||||
terminal.clear()?;
|
||||
|
||||
run(&mut terminal, &state)?;
|
||||
run(&mut terminal, &mut state)?;
|
||||
|
||||
// restore terminal
|
||||
disable_raw_mode()?;
|
||||
|
@ -78,55 +68,9 @@ fn main() -> std::io::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn run<B: Backend>(terminal: &mut Terminal<B>, state: &AppState) -> io::Result<()> {
|
||||
//Create the search field
|
||||
let mut search_input = String::new();
|
||||
//Create the command list
|
||||
let mut custom_list = CustomList::new();
|
||||
//Create the float to hold command output
|
||||
let mut command_float = Float::new(60, 60);
|
||||
let mut in_search_mode = false;
|
||||
|
||||
fn run<B: Backend>(terminal: &mut Terminal<B>, state: &mut AppState) -> io::Result<()> {
|
||||
loop {
|
||||
// Always redraw
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
//Split the terminal into 2 vertical chunks
|
||||
//One for the search bar and one for the command list
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(1)].as_ref())
|
||||
.split(frame.size());
|
||||
|
||||
//Set the search bar text (If empty use the placeholder)
|
||||
let display_text = if search_input.is_empty() {
|
||||
if in_search_mode {
|
||||
Span::raw("")
|
||||
} else {
|
||||
Span::raw("Press / to search")
|
||||
}
|
||||
} else {
|
||||
Span::raw(&search_input)
|
||||
};
|
||||
|
||||
//Create the search bar widget
|
||||
let mut search_bar = Paragraph::new(display_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Search"))
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
|
||||
//Change the color if in search mode
|
||||
if in_search_mode {
|
||||
search_bar = search_bar.clone().style(Style::default().fg(Color::Blue));
|
||||
}
|
||||
|
||||
//Render the search bar (First chunk of the screen)
|
||||
frame.render_widget(search_bar, chunks[0]);
|
||||
//Render the command list (Second chunk of the screen)
|
||||
custom_list.draw(frame, chunks[1], state);
|
||||
//Render the command float in the custom_list chunk
|
||||
command_float.draw(frame, chunks[1]);
|
||||
})
|
||||
.unwrap();
|
||||
terminal.draw(|frame| state.draw(frame)).unwrap();
|
||||
|
||||
// Wait for an event
|
||||
if !event::poll(Duration::from_millis(10))? {
|
||||
|
@ -141,47 +85,8 @@ fn run<B: Backend>(terminal: &mut Terminal<B>, state: &AppState) -> io::Result<(
|
|||
continue;
|
||||
}
|
||||
|
||||
//Send the key to the float
|
||||
//If we receive true, then the float processed the input
|
||||
//If that's the case, don't propagate input to other widgets
|
||||
if !command_float.handle_key_event(&key) {
|
||||
//Insert user input into the search bar
|
||||
if in_search_mode {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => {
|
||||
search_input.push(c);
|
||||
custom_list.filter(search_input.clone());
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
search_input.pop();
|
||||
custom_list.filter(search_input.clone());
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
search_input = String::new();
|
||||
custom_list.filter(search_input.clone());
|
||||
in_search_mode = false
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
in_search_mode = false;
|
||||
custom_list.reset_selection();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if let Some(cmd) = custom_list.handle_key(key, state) {
|
||||
command_float.set_content(Some(RunningCommand::new(cmd, state)));
|
||||
} else {
|
||||
// Handle keys while not in search mode
|
||||
match key.code {
|
||||
// Exit the program
|
||||
KeyCode::Char('q') => return Ok(()),
|
||||
//Activate search mode if the forward slash key gets pressed
|
||||
KeyCode::Char('/') => {
|
||||
in_search_mode = true;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !state.handle_key(&key) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::{float::FloatContent, state::AppState};
|
||||
use crate::float::FloatContent;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use oneshot::{channel, Receiver};
|
||||
use portable_pty::{
|
||||
|
@ -13,6 +13,7 @@ use ratatui::{
|
|||
};
|
||||
use std::{
|
||||
io::Write,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
thread::JoinHandle,
|
||||
};
|
||||
|
@ -125,7 +126,7 @@ impl FloatContent for RunningCommand {
|
|||
}
|
||||
|
||||
impl RunningCommand {
|
||||
pub fn new(command: Command, state: &AppState) -> Self {
|
||||
pub fn new(command: Command, temp_path: &Path) -> Self {
|
||||
let pty_system = NativePtySystem::default();
|
||||
|
||||
// Build the command based on the provided Command enum variant
|
||||
|
@ -141,7 +142,7 @@ impl RunningCommand {
|
|||
Command::None => panic!("Command::None was treated as a command"),
|
||||
}
|
||||
|
||||
cmd.cwd(&state.temp_path);
|
||||
cmd.cwd(temp_path);
|
||||
|
||||
// Open a pseudo-terminal with initial size
|
||||
let pair = pty_system
|
||||
|
|
81
src/search.rs
Normal file
81
src/search.rs
Normal file
|
@ -0,0 +1,81 @@
|
|||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::Span,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub struct SearchBar {
|
||||
search_input: String,
|
||||
in_search_mode: bool,
|
||||
}
|
||||
|
||||
impl SearchBar {
|
||||
pub fn new() -> Self {
|
||||
SearchBar {
|
||||
search_input: String::new(),
|
||||
in_search_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate_search(&mut self) {
|
||||
self.in_search_mode = true;
|
||||
}
|
||||
|
||||
pub fn deactivate_search(&mut self) {
|
||||
self.in_search_mode = false;
|
||||
}
|
||||
|
||||
pub fn is_search_active(&self) -> bool {
|
||||
self.in_search_mode
|
||||
}
|
||||
|
||||
pub fn draw(&self, frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
//Set the search bar text (If empty use the placeholder)
|
||||
let display_text = if !self.in_search_mode && self.search_input.is_empty() {
|
||||
Span::raw("Press / to search")
|
||||
} else {
|
||||
Span::raw(&self.search_input)
|
||||
};
|
||||
|
||||
//Create the search bar widget
|
||||
let mut search_bar = Paragraph::new(display_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Search"))
|
||||
.style(Style::default().fg(state.theme.unfocused_color));
|
||||
|
||||
//Change the color if in search mode
|
||||
if self.in_search_mode {
|
||||
search_bar = search_bar
|
||||
.clone()
|
||||
.style(Style::default().fg(state.theme.focused_color));
|
||||
}
|
||||
|
||||
//Render the search bar (First chunk of the screen)
|
||||
frame.render_widget(search_bar, area);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, event: KeyEvent) -> String {
|
||||
//Insert user input into the search bar
|
||||
match event.code {
|
||||
KeyCode::Char(c) => {
|
||||
self.search_input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.search_input.pop();
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.search_input = String::new();
|
||||
self.in_search_mode = false;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.in_search_mode = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.search_input.clone()
|
||||
}
|
||||
}
|
321
src/state.rs
321
src/state.rs
|
@ -1,9 +1,326 @@
|
|||
use crate::theme::Theme;
|
||||
use crate::{
|
||||
float::{Float, FloatContent},
|
||||
floating_text::FloatingText,
|
||||
running_command::{Command, RunningCommand},
|
||||
tabs::{ListNode, TABS},
|
||||
theme::Theme,
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
|
||||
use ego_tree::NodeId;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AppState {
|
||||
/// Selected theme
|
||||
pub theme: Theme,
|
||||
/// Path to the root of the unpacked files in /tmp
|
||||
pub temp_path: PathBuf,
|
||||
temp_path: PathBuf,
|
||||
/// Currently focused area
|
||||
focus: Focus,
|
||||
/// Current tab
|
||||
current_tab: ListState,
|
||||
/// Current search query
|
||||
search_query: String,
|
||||
/// Current items
|
||||
items: Vec<ListEntry>,
|
||||
/// 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
|
||||
selection: ListState,
|
||||
}
|
||||
|
||||
pub enum Focus {
|
||||
Search,
|
||||
TabList,
|
||||
List,
|
||||
FloatingWindow(Float),
|
||||
}
|
||||
|
||||
struct ListEntry {
|
||||
node: ListNode,
|
||||
id: NodeId,
|
||||
has_children: bool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(theme: Theme, temp_path: PathBuf) -> Self {
|
||||
let root_id = TABS[0].tree.root().id();
|
||||
let mut state = Self {
|
||||
theme,
|
||||
temp_path,
|
||||
focus: Focus::List,
|
||||
current_tab: ListState::default().with_selected(Some(0)),
|
||||
search_query: String::new(),
|
||||
items: vec![],
|
||||
visit_stack: vec![root_id],
|
||||
selection: ListState::default().with_selected(Some(0)),
|
||||
};
|
||||
state.update_items();
|
||||
state
|
||||
}
|
||||
pub fn draw(&mut self, frame: &mut Frame) {
|
||||
let longest_tab_display_len = TABS
|
||||
.iter()
|
||||
.map(|tab| tab.name.len() + self.theme.tab_icon.len())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let horizontal = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Min(longest_tab_display_len as u16 + 5),
|
||||
Constraint::Percentage(100),
|
||||
])
|
||||
.split(frame.size());
|
||||
let left_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(1)])
|
||||
.split(horizontal[0]);
|
||||
|
||||
let tabs = TABS.iter().map(|tab| tab.name).collect::<Vec<_>>();
|
||||
|
||||
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]);
|
||||
|
||||
// Render search bar
|
||||
let search_text = match self.focus {
|
||||
Focus::Search => Span::raw(&self.search_query),
|
||||
_ if !self.search_query.is_empty() => Span::raw(&self.search_query),
|
||||
_ => Span::raw("Press / to search"),
|
||||
};
|
||||
let search_bar = Paragraph::new(search_text)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.style(Style::default().fg(if let Focus::Search = self.focus {
|
||||
Color::Blue
|
||||
} else {
|
||||
Color::DarkGray
|
||||
}));
|
||||
frame.render_widget(search_bar, chunks[0]);
|
||||
|
||||
let mut items: Vec<Line> = Vec::new();
|
||||
if !self.at_root() {
|
||||
items.push(
|
||||
Line::from(format!("{} ..", self.theme.dir_icon)).style(self.theme.dir_color),
|
||||
);
|
||||
}
|
||||
|
||||
items.extend(self.items.iter().map(
|
||||
|ListEntry {
|
||||
node, has_children, ..
|
||||
}| {
|
||||
if *has_children {
|
||||
Line::from(format!("{} {}", self.theme.dir_icon, node.name))
|
||||
.style(self.theme.dir_color)
|
||||
} else {
|
||||
Line::from(format!("{} {}", self.theme.cmd_icon, node.name))
|
||||
.style(self.theme.cmd_color)
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
// Create the list widget with items
|
||||
let list = List::new(items)
|
||||
.highlight_style(if let Focus::List = self.focus {
|
||||
Style::default().reversed()
|
||||
} else {
|
||||
Style::new()
|
||||
})
|
||||
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||
"Linux Toolbox - {}",
|
||||
chrono::Local::now().format("%Y-%m-%d")
|
||||
)))
|
||||
.scroll_padding(1);
|
||||
frame.render_stateful_widget(list, chunks[1], &mut self.selection);
|
||||
|
||||
if let Focus::FloatingWindow(float) = &mut self.focus {
|
||||
float.draw(frame, chunks[1]);
|
||||
}
|
||||
}
|
||||
pub fn handle_key(&mut self, key: &KeyEvent) -> bool {
|
||||
match &mut self.focus {
|
||||
Focus::FloatingWindow(command) => {
|
||||
if command.handle_key_event(key) {
|
||||
self.focus = Focus::List;
|
||||
}
|
||||
}
|
||||
Focus::Search => {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => self.search_query.push(c),
|
||||
KeyCode::Backspace => {
|
||||
self.search_query.pop();
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.search_query = String::new();
|
||||
self.exit_search();
|
||||
}
|
||||
KeyCode::Enter => self.exit_search(),
|
||||
_ => return true,
|
||||
}
|
||||
self.update_items();
|
||||
}
|
||||
_ if key.code == KeyCode::Char('q') => return false,
|
||||
Focus::TabList => match key.code {
|
||||
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => {
|
||||
self.focus = Focus::List
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down
|
||||
if self.current_tab.selected().unwrap() + 1 < TABS.len() =>
|
||||
{
|
||||
self.current_tab.select_next();
|
||||
self.refresh_tab();
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
self.current_tab.select_previous();
|
||||
self.refresh_tab();
|
||||
}
|
||||
KeyCode::Char('/') => self.enter_search(),
|
||||
_ => {}
|
||||
},
|
||||
Focus::List if key.kind != KeyEventKind::Release => match key.code {
|
||||
KeyCode::Char('j') | KeyCode::Down => self.selection.select_next(),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.selection.select_previous(),
|
||||
KeyCode::Char('p') => self.enable_preview(),
|
||||
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.handle_enter(),
|
||||
KeyCode::Char('h') | KeyCode::Left => {
|
||||
if self.at_root() {
|
||||
self.focus = Focus::TabList;
|
||||
} else {
|
||||
self.enter_parent_directory();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('/') => self.enter_search(),
|
||||
KeyCode::Tab => self.focus = Focus::TabList,
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
};
|
||||
true
|
||||
}
|
||||
pub fn update_items(&mut self) {
|
||||
if self.search_query.is_empty() {
|
||||
let curr = TABS[self.current_tab.selected().unwrap()]
|
||||
.tree
|
||||
.get(*self.visit_stack.last().unwrap())
|
||||
.unwrap();
|
||||
|
||||
self.items = curr
|
||||
.children()
|
||||
.map(|node| ListEntry {
|
||||
node: node.value().clone(),
|
||||
id: node.id(),
|
||||
has_children: node.has_children(),
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
self.items.clear();
|
||||
|
||||
let query_lower = self.search_query.to_lowercase();
|
||||
for tab in TABS.iter() {
|
||||
let mut stack = vec![tab.tree.root().id()];
|
||||
while let Some(node_id) = stack.pop() {
|
||||
let node = tab.tree.get(node_id).unwrap();
|
||||
|
||||
if node.value().name.to_lowercase().contains(&query_lower)
|
||||
&& !node.has_children()
|
||||
{
|
||||
self.items.push(ListEntry {
|
||||
node: node.value().clone(),
|
||||
id: node.id(),
|
||||
has_children: false,
|
||||
});
|
||||
}
|
||||
|
||||
stack.extend(node.children().map(|child| child.id()));
|
||||
}
|
||||
}
|
||||
self.items.sort_by(|a, b| a.node.name.cmp(b.node.name));
|
||||
}
|
||||
}
|
||||
/// Checks ehther 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
|
||||
}
|
||||
fn enter_parent_directory(&mut self) {
|
||||
self.visit_stack.pop();
|
||||
self.selection.select(Some(0));
|
||||
self.update_items();
|
||||
}
|
||||
fn get_selected_command(&mut self, change_directory: bool) -> Option<Command> {
|
||||
let mut selected_index = self.selection.selected().unwrap_or(0);
|
||||
|
||||
if !self.at_root() && selected_index == 0 {
|
||||
if change_directory {
|
||||
self.enter_parent_directory();
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if !self.at_root() {
|
||||
selected_index = selected_index.saturating_sub(1);
|
||||
}
|
||||
|
||||
if let Some(item) = self.items.get(selected_index) {
|
||||
if !item.has_children {
|
||||
return Some(item.node.command.clone());
|
||||
} else if change_directory {
|
||||
self.visit_stack.push(item.id);
|
||||
self.selection.select(Some(0));
|
||||
self.update_items();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn enable_preview(&mut self) {
|
||||
if let Some(command) = self.get_selected_command(false) {
|
||||
if let Some(preview) = FloatingText::from_command(&command, self.temp_path.clone()) {
|
||||
self.spawn_float(preview, 80, 80);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn handle_enter(&mut self) {
|
||||
if let Some(cmd) = self.get_selected_command(true) {
|
||||
let command = RunningCommand::new(cmd, &self.temp_path);
|
||||
self.spawn_float(command, 80, 80);
|
||||
}
|
||||
}
|
||||
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.selection.select(None);
|
||||
}
|
||||
fn exit_search(&mut self) {
|
||||
self.selection.select(Some(0));
|
||||
self.focus = Focus::List;
|
||||
self.update_items();
|
||||
}
|
||||
fn refresh_tab(&mut self) {
|
||||
self.visit_stack = vec![TABS[self.current_tab.selected().unwrap()].tree.root().id()];
|
||||
self.selection.select(Some(0));
|
||||
self.update_items();
|
||||
}
|
||||
}
|
||||
|
|
175
src/tabs.rs
Normal file
175
src/tabs.rs
Normal file
|
@ -0,0 +1,175 @@
|
|||
use std::sync::LazyLock;
|
||||
|
||||
use ego_tree::{tree, Tree};
|
||||
|
||||
use crate::running_command::Command;
|
||||
|
||||
pub struct Tab {
|
||||
pub name: &'static str,
|
||||
pub tree: Tree<ListNode>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ListNode {
|
||||
pub name: &'static str,
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
pub static TABS: LazyLock<Vec<Tab>> = LazyLock::new(|| {
|
||||
vec![
|
||||
Tab {
|
||||
name: "System Setup",
|
||||
tree: tree!(ListNode {
|
||||
name: "root",
|
||||
command: Command::None,
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Arch Linux",
|
||||
command: Command::None,
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Yay AUR Helper",
|
||||
command: Command::LocalFile("system-setup/arch/yay-setup.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Paru AUR Helper",
|
||||
command: Command::LocalFile("system-setup/arch/paru-setup.sh"),
|
||||
}
|
||||
},
|
||||
ListNode {
|
||||
name: "Full System Update",
|
||||
command: Command::LocalFile("system-update.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Build Prerequisites",
|
||||
command: Command::LocalFile("system-setup/1-compile-setup.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Gaming Dependencies",
|
||||
command: Command::LocalFile("system-setup/2-gaming-setup.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Global Theme",
|
||||
command: Command::LocalFile("system-setup/3-global-theme.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Remove Snaps",
|
||||
command: Command::LocalFile("system-setup/4-remove-snaps.sh"),
|
||||
}
|
||||
}),
|
||||
},
|
||||
Tab {
|
||||
name: "Applications Setup",
|
||||
tree: tree!(ListNode {
|
||||
name: "root",
|
||||
command: Command::None,
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Alacritty",
|
||||
command: Command::LocalFile("applications-setup/alacritty-setup.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Bash Prompt",
|
||||
command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/mybash/main/setup.sh)\""),
|
||||
},
|
||||
ListNode {
|
||||
name: "DWM-Titus",
|
||||
command: Command::LocalFile("applications-setup/dwmtitus-setup.sh")
|
||||
},
|
||||
ListNode {
|
||||
name: "Kitty",
|
||||
command: Command::LocalFile("applications-setup/kitty-setup.sh")
|
||||
},
|
||||
ListNode {
|
||||
name: "Neovim",
|
||||
command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/neovim/main/setup.sh)\""),
|
||||
},
|
||||
ListNode {
|
||||
name: "Rofi",
|
||||
command: Command::LocalFile("applications-setup/rofi-setup.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "ZSH Prompt",
|
||||
command: Command::LocalFile("applications-setup/zsh-setup.sh"),
|
||||
}
|
||||
}),
|
||||
},
|
||||
Tab {
|
||||
name: "Security",
|
||||
tree: tree!(ListNode {
|
||||
name: "root",
|
||||
command: Command::None,
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Firewall Baselines (CTT)",
|
||||
command: Command::LocalFile("security/firewall-baselines.sh"),
|
||||
}
|
||||
}),
|
||||
},
|
||||
Tab {
|
||||
name: "Utilities",
|
||||
tree: tree!(ListNode {
|
||||
name: "root",
|
||||
command: Command::None,
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Wifi Manager",
|
||||
command: Command::LocalFile("utils/wifi-control.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Bluetooth Manager",
|
||||
command: Command::LocalFile("utils/bluetooth-control.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "MonitorControl(xorg)",
|
||||
command: Command::None,
|
||||
} => {
|
||||
ListNode {
|
||||
name: "Set Resolution",
|
||||
command: Command::LocalFile("utils/monitor-control/set_resolutions.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Duplicate Displays",
|
||||
command: Command::LocalFile("utils/monitor-control/duplicate_displays.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Extend Displays",
|
||||
command: Command::LocalFile("utils/monitor-control/extend_displays.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Auto Detect Displays",
|
||||
command: Command::LocalFile("utils/monitor-control/auto_detect_displays.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Enable Monitor",
|
||||
command: Command::LocalFile("utils/monitor-control/enable_monitor.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Disable Monitor",
|
||||
command: Command::LocalFile("utils/monitor-control/disable_monitor.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Set Primary Monitor",
|
||||
command: Command::LocalFile("utils/monitor-control/set_primary_monitor.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Change Orientation",
|
||||
command: Command::LocalFile("utils/monitor-control/change_orientation.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Manage Arrangement",
|
||||
command: Command::LocalFile("utils/monitor-control/manage_arrangement.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Scale Monitors",
|
||||
command: Command::LocalFile("utils/monitor-control/scale_monitor.sh"),
|
||||
},
|
||||
ListNode {
|
||||
name: "Reset Scaling",
|
||||
command: Command::LocalFile("utils/monitor-control/reset_scaling.sh"),
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
});
|
12
src/theme.rs
12
src/theme.rs
|
@ -4,27 +4,39 @@ use ratatui::style::Color;
|
|||
pub struct Theme {
|
||||
pub dir_color: Color,
|
||||
pub cmd_color: Color,
|
||||
pub tab_color: Color,
|
||||
pub dir_icon: &'static str,
|
||||
pub cmd_icon: &'static str,
|
||||
pub tab_icon: &'static str,
|
||||
pub success_color: Color,
|
||||
pub fail_color: Color,
|
||||
pub focused_color: Color,
|
||||
pub unfocused_color: Color,
|
||||
}
|
||||
|
||||
pub const THEMES: [Theme; 2] = [
|
||||
Theme {
|
||||
dir_color: Color::Blue,
|
||||
cmd_color: Color::LightGreen,
|
||||
tab_color: Color::Yellow,
|
||||
dir_icon: "[DIR]",
|
||||
cmd_icon: "[CMD]",
|
||||
tab_icon: ">> ",
|
||||
success_color: Color::Green,
|
||||
fail_color: Color::Red,
|
||||
focused_color: Color::LightBlue,
|
||||
unfocused_color: Color::Gray,
|
||||
},
|
||||
Theme {
|
||||
dir_color: Color::Blue,
|
||||
cmd_color: Color::Rgb(204, 224, 208),
|
||||
tab_color: Color::Rgb(255, 255, 85),
|
||||
dir_icon: " ",
|
||||
cmd_icon: " ",
|
||||
tab_icon: " ",
|
||||
fail_color: Color::Rgb(199, 55, 44),
|
||||
success_color: Color::Rgb(5, 255, 55),
|
||||
focused_color: Color::LightBlue,
|
||||
unfocused_color: Color::Gray,
|
||||
},
|
||||
];
|
||||
|
|
Loading…
Reference in New Issue
Block a user