diff --git a/Cargo.lock b/Cargo.lock index 58737f92..2319a234 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -444,7 +444,7 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -611,6 +611,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "ctrlc" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881c5d0a13b2f1498e2306e82cbada78390e152d4b1378fb28a84f4dcd0dc4f3" +dependencies = [ + "dispatch", + "nix", + "windows-sys 0.61.2", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -742,6 +753,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "displaydoc" version = "0.2.5" @@ -1537,6 +1554,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.9.1", + "cfg-if 1.0.1", + "cfg_aliases", + "libc", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2007,6 +2036,7 @@ version = "0.1.0" dependencies = [ "anyhow", "blake3 1.5.3", + "ctrlc", "ignore", "object", "pbr", @@ -3164,7 +3194,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -3197,13 +3227,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-result" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -3212,7 +3248,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -3251,6 +3287,15 @@ dependencies = [ "windows-targets 0.53.2", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.42.2" diff --git a/Cargo.toml b/Cargo.toml index 17b6d972..b4ba691b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ doctest = false [dependencies] anyhow = "1" blake3 = "=1.5.3" # 1.5.4 is incompatible with blake3 0.3 dependency from pkgar +ctrlc = { version = "3.5.0", features = ["termination"] } ignore = "0.4" object = { version = "0.36", features = ["build_core"] } pbr = "1.0.2" @@ -36,9 +37,11 @@ serde = { version = "=1.0.197", features = ["derive"] } termion = "4" toml = "0.8" walkdir = "2.3.1" -ratatui = { version = "0.29.0", default-features = false, features = [ - "termion", -] } + +[dependencies.ratatui] +version = "0.29.0" +default-features = false +features = ["termion"] [dev-dependencies] tempfile = "3" diff --git a/config.sh b/config.sh index c3eccb17..f60c503e 100755 --- a/config.sh +++ b/config.sh @@ -60,6 +60,9 @@ function pkgar { function cook { "$ROOT/target/release/cook" "$@" } +function repo { + "$ROOT/target/release/repo" "$@" +} function repo_builder { "$ROOT/target/release/repo_builder" "$@" } diff --git a/fetch.sh b/fetch.sh index 73da2648..16e503fd 100755 --- a/fetch.sh +++ b/fetch.sh @@ -3,4 +3,3 @@ set -e source config.sh -cook --fetch-only ${@:1} diff --git a/repo.sh b/repo.sh index ddc0e779..3ef74d17 100755 --- a/repo.sh +++ b/repo.sh @@ -18,15 +18,12 @@ do elif [ "$arg" == "--nonstop" ] then COOK_OPT+=" --nonstop" - elif [ "$arg" == "--offline" ] - then - COOK_OPT+=" --offline" else recipes+=" $arg" fi done -cook $COOK_OPT $recipes +repo cook $COOK_OPT $recipes repo="$ROOT/repo/$TARGET" mkdir -p "$repo" diff --git a/src/bin/repo.rs b/src/bin/repo.rs index bc73fed4..d72db813 100644 --- a/src/bin/repo.rs +++ b/src/bin/repo.rs @@ -2,7 +2,8 @@ use std::collections::HashMap; use std::io::{BufRead, BufReader, PipeReader, stdout}; use std::path::PathBuf; use std::str::FromStr; -use std::sync::mpsc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, mpsc}; use std::time::Duration; use std::{env, fs}; use std::{process, thread}; @@ -18,11 +19,14 @@ use cookbook::recipe::CookRecipe; use pkg::PackageName; use pkg::package::PackageError; use ratatui::Terminal; -use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::layout::{Constraint, Direction, Layout, Position, Rect}; use ratatui::prelude::TermionBackend; use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; -use termion::screen::{ToAlternateScreen, ToMainScreen}; +use termion::event::{Event, Key, MouseEvent}; +use termion::input::{MouseTerminal, TermRead}; +use termion::raw::RawTerminal; +use termion::screen::{AlternateScreen, ToAlternateScreen, ToMainScreen}; // A repo manager, to replace repo.sh @@ -322,6 +326,10 @@ fn handle_push(recipe: &CookRecipe, config: &CliConfig) -> anyhow::Result<()> { )) } +// +// ------------- TUI SPECIFIC CODE ------------------- +// + #[derive(Debug, Clone, PartialEq)] enum RecipeStatus { Pending, @@ -341,6 +349,8 @@ enum StatusUpdate { CookLog(PackageName, String), Cooked(PackageName), FailCook(PackageName, String), + FetchThreadFinished, + CookThreadFinished, } struct TuiApp { @@ -352,6 +362,15 @@ struct TuiApp { active_fetch: Option, active_cook: Option, logs: HashMap>, + log_scroll: u16, + auto_scroll: bool, + fetch_scroll: u16, + cook_scroll: u16, + fetch_complete: bool, + cook_complete: bool, + fetch_panel_rect: Option, + cook_panel_rect: Option, + log_panel_rect: Option, } impl TuiApp { @@ -369,6 +388,15 @@ impl TuiApp { active_fetch: None, active_cook: None, logs: HashMap::new(), + log_scroll: 0, + auto_scroll: true, + fetch_scroll: 0, + cook_scroll: 0, + fetch_complete: false, + cook_complete: false, + fetch_panel_rect: None, + cook_panel_rect: None, + log_panel_rect: None, } } @@ -377,14 +405,16 @@ impl TuiApp { let (name, new_status) = match update { StatusUpdate::StartFetch(name) => { self.active_fetch = Some(name.clone()); - self.logs.insert(name.clone(), Vec::new()); // Clear old logs + self.logs.insert(name.clone(), Vec::new()); + self.log_scroll = 0; + self.auto_scroll = true; (name.clone(), RecipeStatus::Fetching) } StatusUpdate::Fetched(name) => (name, RecipeStatus::Fetched), StatusUpdate::FailFetch(name, err) => (name, RecipeStatus::Failed(err)), StatusUpdate::StartCook(name) => { - self.active_cook = Some(name.clone()); // Set active cook - self.logs.insert(name.clone(), Vec::new()); // Clear old logs + self.active_cook = Some(name.clone()); + self.logs.insert(name.clone(), Vec::new()); (name.clone(), RecipeStatus::Cooking) } StatusUpdate::CookLog(name, line) => { @@ -396,8 +426,28 @@ impl TuiApp { return; // Should not happen } } - StatusUpdate::Cooked(name) => (name, RecipeStatus::Done), - StatusUpdate::FailCook(name, err) => (name, RecipeStatus::Failed(err)), + StatusUpdate::Cooked(name) => { + if self.active_cook.as_ref() == Some(&name) { + self.active_cook = None; + } + self.auto_scroll = true; + (name.clone(), RecipeStatus::Done) + } + StatusUpdate::FailCook(name, err) => { + if self.active_cook.as_ref() == Some(&name) { + self.active_cook = None; + } + self.auto_scroll = false; + (name.clone(), RecipeStatus::Failed(err)) + } + StatusUpdate::FetchThreadFinished => { + self.fetch_complete = true; + return; + } + StatusUpdate::CookThreadFinished => { + self.cook_complete = true; + return; + } }; if let Some((_, status)) = self.recipes.iter_mut().find(|(r, _)| r.name == name) { @@ -432,26 +482,6 @@ impl TuiApp { } } -fn spawn_log_reader( - mut pipe_reader: PipeReader, - package_name: PackageName, - status_tx: mpsc::Sender, -) { - thread::spawn(move || { - let reader = BufReader::new(&mut pipe_reader); - for line in reader.lines() { - let line_str = line.unwrap_or_else(|e| format!("[IO Error] {}", e)); - if status_tx - .send(StatusUpdate::CookLog(package_name.clone(), line_str)) - .is_err() - { - // TUI thread hung up - break; - } - } - }); -} - fn run_tui_cook(config: CliConfig, recipes: Vec) -> anyhow::Result<()> { let (work_tx, work_rx) = mpsc::channel::<(CookRecipe, PathBuf)>(); let (status_tx, status_rx) = mpsc::channel::(); @@ -475,6 +505,22 @@ fn run_tui_cook(config: CliConfig, recipes: Vec) -> anyhow::Result<( .unwrap(), } } + cooker_status_tx + .send(StatusUpdate::CookThreadFinished) + .unwrap_or_default(); + }); + + // ----- Input Thread ----- + let (input_tx, input_rx) = mpsc::channel::(); + let _input_handle = thread::spawn(move || { + let stdin = std::io::stdin(); + for evt in stdin.events() { + if let Ok(evt) = evt { + if input_tx.send(evt).is_err() { + return; + } + } + } }); // ---- Fetcher Thread ---- @@ -502,30 +548,37 @@ fn run_tui_cook(config: CliConfig, recipes: Vec) -> anyhow::Result<( .send(StatusUpdate::FailFetch(name, e.to_string())) .unwrap(), } + status_tx + .send(StatusUpdate::FetchThreadFinished) + .unwrap_or_default(); } }); print!("{}", ToAlternateScreen); - // enable_raw_mode()?; let mut terminal = Terminal::new(TermionBackend::new(stdout()))?; terminal.clear()?; let mut app = TuiApp::new(recipes); // let total_recipes = app.recipes.len(); - let mut running = true; + let running = Arc::new(AtomicBool::new(true)); + let r = running.clone(); - while running { + ctrlc::set_handler(move || { + r.store(false, Ordering::SeqCst); + }) + .context("Error setting Ctrl-C handler")?; + + while running.load(Ordering::Relaxed) { terminal.draw(|f| { + let mut constraints = Vec::new(); + if !app.fetch_complete { + constraints.push(Constraint::Length(30)); + } + constraints.push(Constraint::Length(30)); + constraints.push(Constraint::Min(20)); // Log panel always exists let chunks = Layout::default() .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Percentage(20), - Constraint::Percentage(20), - Constraint::Percentage(60), - ] - .as_ref(), - ) + .constraints(constraints) .split(f.area()); // Left Pane @@ -586,19 +639,85 @@ fn run_tui_cook(config: CliConfig, recipes: Vec) -> anyhow::Result<( vec!["No active cook job.".to_string()] }; + let log_pane_height = chunks[2].height.saturating_sub(2); + let total_log_lines = log_text.len() as u16; + + if app.auto_scroll { + if total_log_lines > log_pane_height { + app.log_scroll = total_log_lines - log_pane_height; + } else { + app.log_scroll = 0; + } + } else { + if total_log_lines > log_pane_height { + if app.log_scroll > total_log_lines - log_pane_height { + app.log_scroll = total_log_lines - log_pane_height; + } + } else { + app.log_scroll = 0; + } + } + let log_paragraph = Paragraph::new(log_text.join("\n")) .block(Block::default().title(log_title).borders(Borders::ALL)) - .wrap(Wrap { trim: false }); + .wrap(Wrap { trim: false }) + .scroll((app.log_scroll, 0)); f.render_widget(log_paragraph, chunks[2]); - // let footer = Paragraph::new(format!( - // "Done: {}/{} | Failed: {}", - // app.done.len(), - // total_recipes, - // app.failed.len() - // )); - // f.render_widget(footer, f.area()); + while let Ok(event) = input_rx.try_recv() { + match event { + Event::Key(key) => match key { + Key::Up => { + app.auto_scroll = false; + app.log_scroll = app.log_scroll.saturating_sub(1); + } + Key::Down => { + app.auto_scroll = false; + app.log_scroll = app.log_scroll.saturating_add(1); + } + _ => {} + }, + + Event::Mouse(mouse_event) => { + match mouse_event { + MouseEvent::Press(termion::event::MouseButton::WheelUp, x, y) => { + // termion is 1-based, ratatui rects are 0-based + let pos = Position { + x: x.saturating_sub(1), + y: y.saturating_sub(1), + }; + + if app.fetch_panel_rect.map_or(false, |r| r.contains(pos)) { + app.fetch_scroll = app.fetch_scroll.saturating_sub(1); + } else if app.cook_panel_rect.map_or(false, |r| r.contains(pos)) { + app.cook_scroll = app.cook_scroll.saturating_sub(1); + } else if app.log_panel_rect.map_or(false, |r| r.contains(pos)) { + app.auto_scroll = false; + app.log_scroll = app.log_scroll.saturating_sub(1); + } + } + MouseEvent::Press(termion::event::MouseButton::WheelDown, x, y) => { + let pos = Position { + x: x.saturating_sub(1), + y: y.saturating_sub(1), + }; + + if app.fetch_panel_rect.map_or(false, |r| r.contains(pos)) { + app.fetch_scroll = app.fetch_scroll.saturating_add(1); + } else if app.cook_panel_rect.map_or(false, |r| r.contains(pos)) { + app.cook_scroll = app.cook_scroll.saturating_add(1); + } else if app.log_panel_rect.map_or(false, |r| r.contains(pos)) { + app.auto_scroll = false; + app.log_scroll = app.log_scroll.saturating_add(1); + } + } + _ => {} + } + } + _ => {} + } + } })?; while let Ok(update) = status_rx.try_recv() { @@ -607,7 +726,7 @@ fn run_tui_cook(config: CliConfig, recipes: Vec) -> anyhow::Result<( if fetcher_handle.is_finished() && cooker_handle.is_finished() { thread::sleep(Duration::from_secs(5)); - running = false; + running.swap(false, Ordering::SeqCst); } } @@ -620,6 +739,26 @@ fn run_tui_cook(config: CliConfig, recipes: Vec) -> anyhow::Result<( Ok(()) } +fn spawn_log_reader( + mut pipe_reader: PipeReader, + package_name: PackageName, + status_tx: mpsc::Sender, +) { + thread::spawn(move || { + let reader = BufReader::new(&mut pipe_reader); + for line in reader.lines() { + let line_str = line.unwrap_or_else(|e| format!("[IO Error] {}", e)); + if status_tx + .send(StatusUpdate::CookLog(package_name.clone(), line_str)) + .is_err() + { + // TUI thread hung up + break; + } + } + }); +} + fn setup_logger( cooker_status_tx: &mpsc::Sender, name: &PackageName,