catfood_bar/
lib.rs

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
25/// Check if bar is already running by checking PID file
26pub 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    // Check if process exists by sending signal 0
40    unsafe {
41        if libc::kill(pid as i32, 0) == 0 {
42            Ok(true) // Process exists and is alive
43        } else {
44            // Process doesn't exist, remove stale PID file
45            let _ = fs::remove_file(&pid_file_path);
46            Ok(false)
47        }
48    }
49}
50
51/// Find the catfood-bar executable using multiple strategies
52fn find_bar_executable() -> color_eyre::Result<std::path::PathBuf> {
53    // Strategy 1: Try PATH first (works for installed packages)
54    if let Ok(bar_exe) = which::which("catfood-bar") {
55        return Ok(bar_exe);
56    }
57
58    // Strategy 2: Try CARGO_BIN_EXE (works during development with cargo run)
59    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    // Strategy 3: Try relative to current executable (development fallback)
67    let current_exe = std::env::current_exe()?;
68    let bar_exe = current_exe
69        .parent()
70        .unwrap_or(&current_exe)
71        .join("catfood-bar");
72
73    if bar_exe.exists() {
74        return Ok(bar_exe);
75    }
76
77    // Strategy 4: Try target directories (development fallback)
78    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
98/// Spawn bar executable in a kitten panel
99pub fn spawn_in_panel() {
100    // Find the bar executable using robust discovery
101    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    // Spawn kitten panel directly with proper arguments for security
110    // This avoids shell injection risks from special characters in paths
111    match Command::new("kitten")
112        .arg("panel")
113        .arg("--single-instance")
114        .arg(&bar_exe)
115        .arg("--no-kitten") // Required to prevent spawning additional panels
116        .spawn()
117    {
118        Ok(_child) => {
119            // Give panel a moment to start then exit parent
120            // The child process continues running independently
121            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
134/// Handle common bar CLI logic: check if running and optionally spawn in panel
135/// Returns true if spawning in panel (process will exit via spawn_in_panel),
136/// false if should continue with direct execution
137pub fn handle_bar_cli(no_kitten: bool) -> bool {
138    if !no_kitten {
139        // Check if already running
140        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 - this function will exit the process
146        spawn_in_panel();
147        // This line is unreachable, but required for type compatibility
148        unreachable!("spawn_in_panel() should have exited the process")
149    } else {
150        false // Continue with direct execution (--no-kitten case)
151    }
152}
153
154pub fn run_bar() -> color_eyre::Result<()> {
155    color_eyre::install()?;
156
157    // Create PID file at bar startup (not in parent)
158    if let Err(e) = create_pid_file() {
159        eprintln!("Failed to create PID file: {}", e);
160        return Err(e);
161    }
162
163    // Initialize Tokio runtime
164    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    // Clean up PID file on exit
174    let _ = remove_pid_file();
175
176    result
177}
178
179/// The main application which holds the state and logic of the application.
180#[derive(Debug)]
181pub struct App {
182    /// Is the application running?
183    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    /// Construct a new instance of [`App`].
193    pub fn new() -> color_eyre::Result<Self> {
194        let component_manager = ComponentManager::new()?;
195        let (reload_tx, reload_rx) = mpsc::channel(10);
196
197        // Start file watcher
198        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    /// Start the configuration file watcher
211    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            // Create watcher with proper error handling
225            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            // Watch the config directory
244            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                // Check if the event is related to our config file
258                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    /// Run the application's main loop.
276    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                    // Handle config reload
281                    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                    // Normal update cycle
287                    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    /// Renders the user interface.
312    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    /// Reads the crossterm events and updates the state of [`App`].
331    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    /// Handles the key events and updates the state of [`App`].
344    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    /// Set running to false to quit the application.
353    fn quit(&mut self) {
354        self.running = false;
355    }
356}
357
358/// Get the PID file path (same as in catfood crate)
359fn 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
371/// Remove PID file
372fn 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
382/// Create PID file with current process ID
383fn 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}