1use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
2use ratatui::{
3 DefaultTerminal, Frame,
4 layout::{Direction, Layout},
5 prelude::Constraint,
6};
7use std::fs;
8use std::io::Write;
9use std::path::PathBuf;
10use std::process::Command;
11use std::time::Duration;
12use tokio::runtime::Runtime;
13use tokio::sync::mpsc;
14
15pub mod component_manager;
16pub mod components;
17pub mod config;
18pub mod logging;
19pub mod lua_component;
20pub mod time_utils;
21
22pub use component_manager::ComponentManager;
23pub use components::{LeftBar, MiddleBar, RightBar};
24
25pub fn is_bar_running() -> color_eyre::Result<bool> {
27 let pid_file_path = get_pid_file_path()?;
28
29 if !pid_file_path.exists() {
30 return Ok(false);
31 }
32
33 let pid_content = fs::read_to_string(&pid_file_path)?;
34 let pid: u32 = pid_content
35 .trim()
36 .parse()
37 .map_err(|_| color_eyre::eyre::eyre!("Invalid PID in PID file"))?;
38
39 unsafe {
41 if libc::kill(pid as i32, 0) == 0 {
42 Ok(true) } else {
44 let _ = fs::remove_file(&pid_file_path);
46 Ok(false)
47 }
48 }
49}
50
51fn find_bar_executable() -> color_eyre::Result<std::path::PathBuf> {
53 if let Ok(bar_exe) = which::which("catfood-bar") {
55 return Ok(bar_exe);
56 }
57
58 if let Ok(path) = std::env::var("CARGO_BIN_EXE_catfood-bar") {
60 let path = std::path::PathBuf::from(path);
61 if path.exists() {
62 return Ok(path);
63 }
64 }
65
66 let current_exe = std::env::current_exe()?;
68 let bar_exe = current_exe
69 .parent()
70 .unwrap_or(¤t_exe)
71 .join("catfood-bar");
72
73 if bar_exe.exists() {
74 return Ok(bar_exe);
75 }
76
77 let current_dir = std::env::current_dir()?;
79 let target_debug = current_dir.join("target/debug/catfood-bar");
80 if target_debug.exists() {
81 return Ok(target_debug);
82 }
83
84 let target_release = current_dir.join("target/release/catfood-bar");
85 if target_release.exists() {
86 return Ok(target_release);
87 }
88
89 Err(color_eyre::eyre::eyre!(
90 "Could not find catfood-bar executable.\n\n\
91 Please install catfood-bar with one of these methods:\n\
92 • cargo install catfood-bar\n\
93 • Download from https://github.com/thombruce/catfood/releases\n\n\
94 Or ensure it's available in your PATH if already installed."
95 ))
96}
97
98pub fn spawn_in_panel() {
100 let bar_exe = match find_bar_executable() {
102 Ok(path) => path,
103 Err(e) => {
104 eprintln!("Error: {}", e);
105 std::process::exit(1);
106 }
107 };
108
109 match Command::new("kitten")
112 .arg("panel")
113 .arg("--single-instance")
114 .arg(&bar_exe)
115 .arg("--no-kitten") .spawn()
117 {
118 Ok(_child) => {
119 std::thread::sleep(std::time::Duration::from_millis(500));
122 std::process::exit(0);
123 }
124 Err(e) => {
125 eprintln!("Failed to spawn kitten panel: {}", e);
126 eprintln!(
127 "Make sure Kitty is installed and you're running this in a Kitty environment."
128 );
129 std::process::exit(1);
130 }
131 }
132}
133
134pub fn handle_bar_cli(no_kitten: bool) -> bool {
138 if !no_kitten {
139 if let Ok(true) = is_bar_running() {
141 eprintln!("catfood-bar is already running");
142 std::process::exit(1);
143 }
144
145 spawn_in_panel();
147 unreachable!("spawn_in_panel() should have exited the process")
149 } else {
150 false }
152}
153
154pub fn run_bar() -> color_eyre::Result<()> {
155 color_eyre::install()?;
156
157 if let Err(e) = create_pid_file() {
159 eprintln!("Failed to create PID file: {}", e);
160 return Err(e);
161 }
162
163 let rt = Runtime::new()?;
165
166 let result = rt.block_on(async {
167 let terminal = ratatui::init();
168 let app_result = App::new()?.run_async(terminal).await;
169 ratatui::restore();
170 app_result
171 });
172
173 let _ = remove_pid_file();
175
176 result
177}
178
179#[derive(Debug)]
181pub struct App {
182 running: bool,
184 component_manager: ComponentManager,
185 left_bar: LeftBar,
186 middle_bar: MiddleBar,
187 right_bar: RightBar,
188 reload_rx: mpsc::Receiver<()>,
189}
190
191impl App {
192 pub fn new() -> color_eyre::Result<Self> {
194 let component_manager = ComponentManager::new()?;
195 let (reload_tx, reload_rx) = mpsc::channel(10);
196
197 Self::start_config_watcher(reload_tx)?;
199
200 Ok(Self {
201 running: true,
202 component_manager,
203 left_bar: LeftBar::new()?,
204 middle_bar: MiddleBar::new()?,
205 right_bar: RightBar::new()?,
206 reload_rx,
207 })
208 }
209
210 fn start_config_watcher(reload_tx: mpsc::Sender<()>) -> color_eyre::Result<()> {
212 let config_path =
213 std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
214 .join(".config")
215 .join("catfood")
216 .join("bar.json");
217
218 tokio::spawn(async move {
219 use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher};
220 use std::time::Duration;
221
222 let (tx, mut rx) = tokio::sync::mpsc::channel(10);
223
224 let mut watcher = match RecommendedWatcher::new(
226 move |res| {
227 if let Ok(event) = res {
228 let _ = tx.blocking_send(event);
229 }
230 },
231 NotifyConfig::default().with_poll_interval(Duration::from_secs(1)),
232 ) {
233 Ok(w) => w,
234 Err(e) => {
235 logging::log_file_watcher_error(&format!(
236 "Failed to create file watcher: {}",
237 e
238 ));
239 return;
240 }
241 };
242
243 if let Some(parent) = config_path.parent()
245 && let Err(e) = watcher.watch(parent, RecursiveMode::NonRecursive)
246 {
247 logging::log_file_watcher_error(&format!(
248 "Failed to watch config directory: {}",
249 e
250 ));
251 return;
252 }
253
254 while let Some(event) = rx.recv().await {
255 use notify::EventKind;
256
257 if let Some(path) = event.paths.first()
259 && path == &config_path
260 && matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_))
261 && let Err(e) = reload_tx.send(()).await
262 {
263 logging::log_file_watcher_error(&format!(
264 "Failed to send reload signal: {}",
265 e
266 ));
267 break;
268 }
269 }
270 });
271
272 Ok(())
273 }
274
275 pub async fn run_async(mut self, mut terminal: DefaultTerminal) -> color_eyre::Result<()> {
277 while self.running {
278 tokio::select! {
279 _ = self.reload_rx.recv() => {
280 if let Err(e) = self.component_manager.reload() {
282 logging::log_config_error(&format!("Failed to reload configuration: {}", e));
283 }
284 }
285 _ = tokio::time::sleep(Duration::from_millis(333)) => {
286 self.update_components();
288 terminal.draw(|frame| self.render(frame))?;
289 self.handle_crossterm_events()?;
290 }
291 }
292 }
293 Ok(())
294 }
295
296 fn update_components(&mut self) {
297 if let Err(e) = self.component_manager.update() {
298 logging::log_system_error("Component Manager", &format!("{}", e));
299 }
300 if let Err(e) = self.left_bar.update() {
301 logging::log_system_error("Left Bar", &format!("{}", e));
302 }
303 if let Err(e) = self.middle_bar.update() {
304 logging::log_system_error("Middle Bar", &format!("{}", e));
305 }
306 if let Err(e) = self.right_bar.update() {
307 logging::log_system_error("Right Bar", &format!("{}", e));
308 }
309 }
310
311 fn render(&mut self, frame: &mut Frame) {
313 let layout = Layout::default()
314 .direction(Direction::Horizontal)
315 .constraints(vec![
316 Constraint::Ratio(1, 3),
317 Constraint::Ratio(1, 3),
318 Constraint::Ratio(1, 3),
319 ])
320 .split(frame.area());
321
322 self.left_bar
323 .render(frame, layout[0], &self.component_manager);
324 self.middle_bar
325 .render(frame, layout[1], &self.component_manager);
326 self.right_bar
327 .render(frame, layout[2], &self.component_manager);
328 }
329
330 fn handle_crossterm_events(&mut self) -> color_eyre::Result<()> {
332 if event::poll(Duration::from_millis(333))? {
333 match event::read()? {
334 Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key_event(key),
335 Event::Mouse(_) => {}
336 Event::Resize(_, _) => {}
337 _ => {}
338 }
339 }
340 Ok(())
341 }
342
343 fn on_key_event(&mut self, key: KeyEvent) {
345 match (key.modifiers, key.code) {
346 (_, KeyCode::Esc | KeyCode::Char('q'))
347 | (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => self.quit(),
348 _ => {}
349 }
350 }
351
352 fn quit(&mut self) {
354 self.running = false;
355 }
356}
357
358fn get_pid_file_path() -> color_eyre::Result<PathBuf> {
360 let data_dir = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
361 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
362 format!("{}/.local/share", home)
363 });
364
365 let catfood_dir = PathBuf::from(data_dir).join("catfood");
366 fs::create_dir_all(&catfood_dir)?;
367
368 Ok(catfood_dir.join("bar.pid"))
369}
370
371fn remove_pid_file() -> color_eyre::Result<()> {
373 let pid_file_path = get_pid_file_path()?;
374
375 if pid_file_path.exists() {
376 fs::remove_file(&pid_file_path)?;
377 }
378
379 Ok(())
380}
381
382fn create_pid_file() -> color_eyre::Result<()> {
384 let pid_file_path = get_pid_file_path()?;
385 let pid = std::process::id();
386
387 let mut file = fs::File::create(&pid_file_path)?;
388 writeln!(file, "{}", pid)?;
389
390 Ok(())
391}