use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::{
DefaultTerminal, Frame,
layout::{Direction, Layout},
prelude::Constraint,
};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
use tokio::runtime::Runtime;
use tokio::sync::mpsc;
pub mod component_manager;
pub mod components;
pub mod config;
pub mod logging;
pub mod lua_component;
pub mod time_utils;
pub use component_manager::ComponentManager;
pub use components::{LeftBar, MiddleBar, RightBar};
pub fn is_bar_running() -> color_eyre::Result<bool> {
let pid_file_path = get_pid_file_path()?;
if !pid_file_path.exists() {
return Ok(false);
}
let pid_content = fs::read_to_string(&pid_file_path)?;
let pid: u32 = pid_content
.trim()
.parse()
.map_err(|_| color_eyre::eyre::eyre!("Invalid PID in PID file"))?;
unsafe {
if libc::kill(pid as i32, 0) == 0 {
Ok(true) } else {
let _ = fs::remove_file(&pid_file_path);
Ok(false)
}
}
}
fn find_bar_executable() -> color_eyre::Result<std::path::PathBuf> {
if let Ok(bar_exe) = which::which("catfood-bar") {
return Ok(bar_exe);
}
if let Ok(path) = std::env::var("CARGO_BIN_EXE_catfood-bar") {
let path = std::path::PathBuf::from(path);
if path.exists() {
return Ok(path);
}
}
let current_exe = std::env::current_exe()?;
let bar_exe = current_exe
.parent()
.unwrap_or(¤t_exe)
.join("catfood-bar");
if bar_exe.exists() {
return Ok(bar_exe);
}
let current_dir = std::env::current_dir()?;
let target_debug = current_dir.join("target/debug/catfood-bar");
if target_debug.exists() {
return Ok(target_debug);
}
let target_release = current_dir.join("target/release/catfood-bar");
if target_release.exists() {
return Ok(target_release);
}
Err(color_eyre::eyre::eyre!(
"Could not find catfood-bar executable.\n\n\
Please install catfood-bar with one of these methods:\n\
• cargo install catfood-bar\n\
• Download from https://github.com/thombruce/catfood/releases\n\n\
Or ensure it's available in your PATH if already installed."
))
}
pub fn spawn_in_panel() {
let bar_exe = match find_bar_executable() {
Ok(path) => path,
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
};
match Command::new("kitten")
.arg("panel")
.arg("--single-instance")
.arg(&bar_exe)
.arg("--no-kitten") .spawn()
{
Ok(_child) => {
std::thread::sleep(std::time::Duration::from_millis(500));
std::process::exit(0);
}
Err(e) => {
eprintln!("Failed to spawn kitten panel: {}", e);
eprintln!(
"Make sure Kitty is installed and you're running this in a Kitty environment."
);
std::process::exit(1);
}
}
}
pub fn handle_bar_cli(no_kitten: bool) -> bool {
if !no_kitten {
if let Ok(true) = is_bar_running() {
eprintln!("catfood-bar is already running");
std::process::exit(1);
}
spawn_in_panel();
unreachable!("spawn_in_panel() should have exited the process")
} else {
false }
}
pub fn run_bar() -> color_eyre::Result<()> {
color_eyre::install()?;
if let Err(e) = create_pid_file() {
eprintln!("Failed to create PID file: {}", e);
return Err(e);
}
let rt = Runtime::new()?;
let result = rt.block_on(async {
let terminal = ratatui::init();
let app_result = App::new()?.run_async(terminal).await;
ratatui::restore();
app_result
});
let _ = remove_pid_file();
result
}
#[derive(Debug)]
pub struct App {
running: bool,
component_manager: ComponentManager,
left_bar: LeftBar,
middle_bar: MiddleBar,
right_bar: RightBar,
reload_rx: mpsc::Receiver<()>,
}
impl App {
pub fn new() -> color_eyre::Result<Self> {
let component_manager = ComponentManager::new()?;
let (reload_tx, reload_rx) = mpsc::channel(10);
Self::start_config_watcher(reload_tx)?;
Ok(Self {
running: true,
component_manager,
left_bar: LeftBar::new()?,
middle_bar: MiddleBar::new()?,
right_bar: RightBar::new()?,
reload_rx,
})
}
fn start_config_watcher(reload_tx: mpsc::Sender<()>) -> color_eyre::Result<()> {
let config_path =
std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
.join(".config")
.join("catfood")
.join("bar.json");
tokio::spawn(async move {
use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher};
use std::time::Duration;
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let mut watcher = match RecommendedWatcher::new(
move |res| {
if let Ok(event) = res {
let _ = tx.blocking_send(event);
}
},
NotifyConfig::default().with_poll_interval(Duration::from_secs(1)),
) {
Ok(w) => w,
Err(e) => {
logging::log_file_watcher_error(&format!(
"Failed to create file watcher: {}",
e
));
return;
}
};
if let Some(parent) = config_path.parent()
&& let Err(e) = watcher.watch(parent, RecursiveMode::NonRecursive)
{
logging::log_file_watcher_error(&format!(
"Failed to watch config directory: {}",
e
));
return;
}
while let Some(event) = rx.recv().await {
use notify::EventKind;
if let Some(path) = event.paths.first()
&& path == &config_path
&& matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_))
&& let Err(e) = reload_tx.send(()).await
{
logging::log_file_watcher_error(&format!(
"Failed to send reload signal: {}",
e
));
break;
}
}
});
Ok(())
}
pub async fn run_async(mut self, mut terminal: DefaultTerminal) -> color_eyre::Result<()> {
while self.running {
tokio::select! {
_ = self.reload_rx.recv() => {
if let Err(e) = self.component_manager.reload() {
logging::log_config_error(&format!("Failed to reload configuration: {}", e));
}
}
_ = tokio::time::sleep(Duration::from_millis(333)) => {
self.update_components();
terminal.draw(|frame| self.render(frame))?;
self.handle_crossterm_events()?;
}
}
}
Ok(())
}
fn update_components(&mut self) {
if let Err(e) = self.component_manager.update() {
logging::log_system_error("Component Manager", &format!("{}", e));
}
if let Err(e) = self.left_bar.update() {
logging::log_system_error("Left Bar", &format!("{}", e));
}
if let Err(e) = self.middle_bar.update() {
logging::log_system_error("Middle Bar", &format!("{}", e));
}
if let Err(e) = self.right_bar.update() {
logging::log_system_error("Right Bar", &format!("{}", e));
}
}
fn render(&mut self, frame: &mut Frame) {
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(frame.area());
self.left_bar
.render(frame, layout[0], &self.component_manager);
self.middle_bar
.render(frame, layout[1], &self.component_manager);
self.right_bar
.render(frame, layout[2], &self.component_manager);
}
fn handle_crossterm_events(&mut self) -> color_eyre::Result<()> {
if event::poll(Duration::from_millis(333))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key_event(key),
Event::Mouse(_) => {}
Event::Resize(_, _) => {}
_ => {}
}
}
Ok(())
}
fn on_key_event(&mut self, key: KeyEvent) {
match (key.modifiers, key.code) {
(_, KeyCode::Esc | KeyCode::Char('q'))
| (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => self.quit(),
_ => {}
}
}
fn quit(&mut self) {
self.running = false;
}
}
fn get_pid_file_path() -> color_eyre::Result<PathBuf> {
let data_dir = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
format!("{}/.local/share", home)
});
let catfood_dir = PathBuf::from(data_dir).join("catfood");
fs::create_dir_all(&catfood_dir)?;
Ok(catfood_dir.join("bar.pid"))
}
fn remove_pid_file() -> color_eyre::Result<()> {
let pid_file_path = get_pid_file_path()?;
if pid_file_path.exists() {
fs::remove_file(&pid_file_path)?;
}
Ok(())
}
fn create_pid_file() -> color_eyre::Result<()> {
let pid_file_path = get_pid_file_path()?;
let pid = std::process::id();
let mut file = fs::File::create(&pid_file_path)?;
writeln!(file, "{}", pid)?;
Ok(())
}