From 7424aba439edee46f48c63d2740598a3ccd1fd07 Mon Sep 17 00:00:00 2001 From: Lucy Date: Tue, 30 Sep 2025 23:48:53 +0200 Subject: [PATCH] meow --- src/lib.rs | 2 + src/main.rs | 54 +- .../by_name/bi/binutils/cross_toolchain.rs | 285 +++++ src/pkgs/by_name/bi/binutils/mod.rs | 2 + src/pkgs/by_name/bi/binutils/parser.rs | 220 ++++ src/pkgs/by_name/bi/mod.rs | 1 + src/pkgs/by_name/mod.rs | 1 + src/pkgs/mod.rs | 1 + src/tui.rs | 1057 ----------------- src/tui/disk_manager.rs | 437 +++++++ src/tui/downloader.rs | 61 + src/tui/main_menu.rs | 49 + src/tui/mod.rs | 4 + src/tui/settings.rs | 28 + 14 files changed, 1092 insertions(+), 1110 deletions(-) create mode 100644 src/lib.rs create mode 100644 src/pkgs/by_name/bi/binutils/cross_toolchain.rs create mode 100644 src/pkgs/by_name/bi/binutils/mod.rs create mode 100644 src/pkgs/by_name/bi/binutils/parser.rs create mode 100644 src/pkgs/by_name/bi/mod.rs create mode 100644 src/pkgs/by_name/mod.rs create mode 100644 src/pkgs/mod.rs delete mode 100644 src/tui.rs create mode 100644 src/tui/disk_manager.rs create mode 100644 src/tui/downloader.rs create mode 100644 src/tui/main_menu.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/settings.rs diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..04159f2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +pub mod pkgs; +pub mod tui; diff --git a/src/main.rs b/src/main.rs index 3146749..79dd5fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,58 +1,6 @@ -// src/main.rs - Initialize logging -use tracing::{error, info}; -use tracing_appender::rolling::{RollingFileAppender, Rotation}; -use tracing_subscriber::{EnvFilter, fmt, prelude::*}; - mod tui; -mod wget_list; fn main() -> Result<(), Box> { - // Initialize logging - init_logging()?; - - info!("Starting lpkg package manager"); - info!("Version: 0.1.0"); - - // Run the TUI - if let Err(e) = tui::tui_menu() { - error!("TUI error: {}", e); - eprintln!("Error: {}", e); - return Err(e); - } - - info!("lpkg exiting normally"); - Ok(()) -} - -fn init_logging() -> Result<(), Box> { - // Create log directory if it doesn't exist - std::fs::create_dir_all("logs")?; - - // File appender - rotates daily - let file_appender = RollingFileAppender::new(Rotation::DAILY, "logs", "lpkg.log"); - - // Console layer - only shows info and above - let console_layer = fmt::layer() - .with_target(true) - .with_thread_ids(false) - .with_file(true) - .with_line_number(true) - .with_filter(EnvFilter::new("info")); - - // File layer - shows debug and above - let file_layer = fmt::layer() - .with_writer(file_appender) - .with_ansi(false) - .with_target(true) - .with_file(true) - .with_line_number(true) - .with_filter(EnvFilter::new("debug")); - - // Build the subscriber - tracing_subscriber::registry() - .with(console_layer) - .with(file_layer) - .init(); - + tui::disk_manager::DiskManager::run_tui()?; Ok(()) } diff --git a/src/pkgs/by_name/bi/binutils/cross_toolchain.rs b/src/pkgs/by_name/bi/binutils/cross_toolchain.rs new file mode 100644 index 0000000..968f405 --- /dev/null +++ b/src/pkgs/by_name/bi/binutils/cross_toolchain.rs @@ -0,0 +1,285 @@ +// async cross-toolchain runner that uses parser.rs info (no hardcoding) +use crate::pkgs::by_name::bi::binutils::parser::{BinutilsInfo, fetch_page, parse_binutils}; +use reqwest::Client; +use std::{ + error::Error, + ffi::OsStr, + path::{Path, PathBuf}, +}; +use tokio::process::Command; +use tracing::{error, info, warn}; + +/// Configuration object - uses environment if values omitted. +#[derive(Debug, Clone)] +pub struct BinutilsConfig { + pub lfs_root: PathBuf, // where the LFS tree will be (used for $LFS) + pub target: String, // LFS_TGT (e.g. x86_64-lfs-linux-gnu) + pub info: BinutilsInfo, // parsed page info +} + +impl BinutilsConfig { + /// create from env or params. If target is None, tries $LFS_TGT env var. + pub fn new(lfs_root: impl AsRef, target: Option, info: BinutilsInfo) -> Self { + let lfs_root = lfs_root.as_ref().to_path_buf(); + let target = target + .or_else(|| std::env::var("LFS_TGT").ok()) + .unwrap_or_else(|| { + // fallback best-effort + if cfg!(target_os = "linux") { + "x86_64-lfs-linux-gnu".to_string() + } else { + "x86_64-lfs-linux-gnu".to_string() + } + }); + + Self { + lfs_root, + target, + info, + } + } + + /// default places (non-hardcoded) where sources live. + /// If env `BINUTILS_SRC_DIR` is set, use that; else try LFS layout: + /// - $LFS/src/pkgs/by-name/bi/binutils + pub fn source_base_dir(&self) -> PathBuf { + if let Ok(s) = std::env::var("BINUTILS_SRC_DIR") { + PathBuf::from(s) + } else { + self.lfs_root + .join("src") + .join("pkgs") + .join("by-name") + .join("bi") + .join("binutils") + } + } + + /// build directory inside LFS tree (following LFS style) + pub fn build_dir(&self) -> PathBuf { + self.lfs_root.join("build").join("binutils-pass1") + } + + /// install dir (tools) + pub fn install_dir(&self) -> PathBuf { + self.lfs_root.join("tools") + } +} + +/// High-level orchestration. Async. +pub async fn build_binutils_from_page( + page_url: &str, + lfs_root: impl AsRef, + target: Option, +) -> Result<(), Box> { + // 1) fetch page + info!("Fetching page: {}", page_url); + let html = fetch_page(page_url).await?; + let info = parse_binutils(&html)?; + info!("Parsed info: {:?}", info); + + // 2) build config + let cfg = BinutilsConfig::new(lfs_root, target, info.clone()); + + // 3) ensure source base dir exists + let src_base = cfg.source_base_dir(); + if !src_base.exists() { + info!("Creating source base dir: {:?}", src_base); + tokio::fs::create_dir_all(&src_base).await?; + } + + // 4) find extracted source directory (binutils-*) + let mut source_dir: Option = None; + if let Ok(mut rd) = tokio::fs::read_dir(&src_base).await { + while let Some(entry) = rd.next_entry().await? { + let ft = entry.file_type().await?; + if ft.is_dir() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.to_lowercase().contains("binutils") { + source_dir = Some(entry.path()); + break; + } + } + } + } + + // 5) if not found, attempt to download & extract + if source_dir.is_none() { + if let Some(dl) = &cfg.info.download_url { + info!("No extracted source found; will download {}", dl); + + // download file into src_base + let client = Client::new(); + let resp = client.get(dl).send().await?; + if !resp.status().is_success() { + return Err(format!("Download failed: {}", resp.status()).into()); + } + + // pick a filename from URL + let url_path = url::Url::parse(dl)?; + let filename = url_path + .path_segments() + .and_then(|seg| seg.last()) + .and_then(|s| { + if !s.is_empty() { + Some(s.to_string()) + } else { + None + } + }) + .ok_or("Cannot determine filename from URL")?; + + let outpath = src_base.join(&filename); + info!("Saving archive to {:?}", outpath); + let bytes = resp.bytes().await?; + tokio::fs::write(&outpath, &bytes).await?; + + // extract using tar (async spawn). Use absolute path to src_base + info!("Extracting archive {:?}", outpath); + let tar_path = outpath.clone(); + let mut tar_cmd = Command::new("tar"); + tar_cmd.arg("-xf").arg(&tar_path).arg("-C").arg(&src_base); + let status = tar_cmd.status().await?; + if !status.success() { + return Err("tar extraction failed".into()); + } + + // look for extracted dir again + if let Ok(mut rd) = tokio::fs::read_dir(&src_base).await { + while let Some(entry) = rd.next_entry().await? { + let ft = entry.file_type().await?; + if ft.is_dir() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.to_lowercase().contains("binutils") { + source_dir = Some(entry.path()); + break; + } + } + } + } + } else { + warn!("No download URL found on the page and no unpacked source present."); + } + } + + let source_dir = match source_dir { + Some(p) => p, + None => return Err("Could not locate or download/extract Binutils source".into()), + }; + info!("Using source dir: {:?}", source_dir); + + // 6) prepare build dir + let build_dir = cfg.build_dir(); + if !build_dir.exists() { + info!("Creating build dir {:?}", build_dir); + tokio::fs::create_dir_all(&build_dir).await?; + } + + // 7) run configure: use absolute configure script path in source_dir + let configure_path = source_dir.join("configure"); + if !configure_path.exists() { + return Err(format!("configure script not found at {:?}", configure_path).into()); + } + + // If parser produced configure args tokens, use them; otherwise fallback to common flags + let args = if !cfg.info.configure_args.is_empty() { + cfg.info.configure_args.clone() + } else { + vec![ + format!("--prefix={}", cfg.install_dir().display()), + format!("--with-sysroot={}", cfg.lfs_root.display()), + format!("--target={}", cfg.target), + "--disable-nls".to_string(), + "--disable-werror".to_string(), + ] + }; + + // replace $LFS and $LFS_TGT in args + let args: Vec = args + .into_iter() + .map(|a| { + a.replace("$LFS", &cfg.lfs_root.to_string_lossy()) + .replace("$LFS_TGT", &cfg.target) + }) + .collect(); + + info!("Configuring with args: {:?}", args); + + // spawn configure + let mut conf_cmd = Command::new(&configure_path); + conf_cmd.current_dir(&build_dir); + for a in &args { + conf_cmd.arg(a); + } + conf_cmd.stdout(std::process::Stdio::inherit()); + conf_cmd.stderr(std::process::Stdio::inherit()); + let status = conf_cmd.status().await?; + if !status.success() { + return Err("configure step failed".into()); + } + info!("configure completed"); + + // 8) run build commands (make-like) + if !cfg.info.build_cmds.is_empty() { + for b in &cfg.info.build_cmds { + // split into program + args + let mut parts = shell_words::split(b).unwrap_or_else(|_| vec![b.clone()]); + let prog = parts.remove(0); + let mut cmd = Command::new(prog); + if !parts.is_empty() { + cmd.args(parts); + } + cmd.current_dir(&build_dir); + cmd.stdout(std::process::Stdio::inherit()); + cmd.stderr(std::process::Stdio::inherit()); + let status = cmd.status().await?; + if !status.success() { + return Err(format!("build step failed: {:?}", b).into()); + } + } + } else { + // fallback to running `make` + let mut m = Command::new("make"); + m.current_dir(&build_dir); + m.stdout(std::process::Stdio::inherit()); + m.stderr(std::process::Stdio::inherit()); + let status = m.status().await?; + if !status.success() { + return Err("make failed".into()); + } + } + info!("build completed"); + + // 9) run install commands (make install) + if !cfg.info.install_cmds.is_empty() { + for inst in &cfg.info.install_cmds { + let mut parts = shell_words::split(inst).unwrap_or_else(|_| vec![inst.clone()]); + let prog = parts.remove(0); + let mut cmd = Command::new(prog); + if !parts.is_empty() { + cmd.args(parts); + } + cmd.current_dir(&build_dir); + cmd.stdout(std::process::Stdio::inherit()); + cmd.stderr(std::process::Stdio::inherit()); + let status = cmd.status().await?; + if !status.success() { + return Err(format!("install step failed: {:?}", inst).into()); + } + } + } else { + // fallback `make install` + let mut mi = Command::new("make"); + mi.arg("install"); + mi.current_dir(&build_dir); + mi.stdout(std::process::Stdio::inherit()); + mi.stderr(std::process::Stdio::inherit()); + let status = mi.status().await?; + if !status.success() { + return Err("make install failed".into()); + } + } + + info!("install completed. Binutils Pass 1 done."); + Ok(()) +} diff --git a/src/pkgs/by_name/bi/binutils/mod.rs b/src/pkgs/by_name/bi/binutils/mod.rs new file mode 100644 index 0000000..2ba8d67 --- /dev/null +++ b/src/pkgs/by_name/bi/binutils/mod.rs @@ -0,0 +1,2 @@ +pub mod cross_toolchain; +pub mod parser; diff --git a/src/pkgs/by_name/bi/binutils/parser.rs b/src/pkgs/by_name/bi/binutils/parser.rs new file mode 100644 index 0000000..5ec2540 --- /dev/null +++ b/src/pkgs/by_name/bi/binutils/parser.rs @@ -0,0 +1,220 @@ +// async parser for Binutils Pass 1 page +use reqwest::Client; +use scraper::{Html, Selector}; +use std::error::Error; + +#[derive(Debug, Clone)] +pub struct BinutilsInfo { + /// "2.45" or derived version text + pub version: Option, + /// first archive download URL found (.tar.xz or .tar.gz) + pub download_url: Option, + /// tokens for configure flags (everything after ../configure) + pub configure_args: Vec, + /// build commands discovered (e.g. ["make"]) + pub build_cmds: Vec, + /// install commands discovered (e.g. ["make install"]) + pub install_cmds: Vec, + /// optional SBU, disk space + pub sbu: Option, + pub disk_space: Option, +} + +impl Default for BinutilsInfo { + fn default() -> Self { + Self { + version: None, + download_url: None, + configure_args: Vec::new(), + build_cmds: Vec::new(), + install_cmds: Vec::new(), + sbu: None, + disk_space: None, + } + } +} + +/// Fetch page content (async) +pub async fn fetch_page(url: &str) -> Result> { + let client = Client::new(); + let res = client.get(url).send().await?; + let status = res.status(); + if !status.is_success() { + return Err(format!("Failed to fetch {}: {}", url, status).into()); + } + let text = res.text().await?; + Ok(text) +} + +/// Parse the LFS Binutils pass1 page; robust to small formatting changes. +/// - extracts version (from

text like "Binutils-2.45 - Pass 1") +/// - finds a download URL ending with .tar.xz/.tar.gz +/// - finds configure pre block(s), builds token list +/// - finds `make` / `make install` pre blocks +pub fn parse_binutils(html: &str) -> Result> { + let document = Html::parse_document(html); + + let mut info = BinutilsInfo::default(); + + // 1) Version from h1.sect1 (contains "Binutils-2.45 - Pass 1") + if let Ok(h1_sel) = Selector::parse("h1.sect1") { + if let Some(h1) = document.select(&h1_sel).next() { + let txt = h1.text().collect::>().join(" "); + // try to pick the token containing "Binutils-" or "binutils-" + if let Some(tok) = txt + .split_whitespace() + .find(|s| s.to_lowercase().contains("binutils")) + { + // extract digits from token, e.g. "Binutils-2.45" + if let Some(pos) = tok.find('-') { + let ver = tok[pos + 1..] + .trim() + .trim_matches(|c: char| !c.is_ascii() && c != '.') + .to_string(); + if !ver.is_empty() { + info.version = Some(ver); + } + } else { + // fallback: try to find "2.45" somewhere in the h1 string + for part in txt.split_whitespace() { + if part.chars().next().map(|c| c.is_digit(10)).unwrap_or(false) { + info.version = Some(part.trim().to_string()); + break; + } + } + } + } + } + } + + // 2) Download URL: look for anchors with href ending .tar.xz/.tar.gz + if let Ok(a_sel) = Selector::parse("a[href]") { + for a in document.select(&a_sel) { + if let Some(href) = a.value().attr("href") { + let href = href.trim(); + if href.ends_with(".tar.xz") || href.ends_with(".tar.gz") || href.ends_with(".tgz") + { + // Make absolute if relative to page; the typical LFS pages use relative links like ../../... or ../.. + // If it's already absolute (starts with http), keep it. + let url = href.to_string(); + info.download_url = Some(url); + break; + } + } + } + } + + // 3) Parse "segmentedlist" entries for SBU and disk space + if let Ok(segtitle_sel) = + Selector::parse("div.package .segmentedlist .seglistitem .seg strong.segtitle") + { + if let Ok(segbody_sel) = + Selector::parse("div.package .segmentedlist .seglistitem .seg span.segbody") + { + for (t, b) in document + .select(&segtitle_sel) + .zip(document.select(&segbody_sel)) + { + let title = t.text().collect::().to_lowercase(); + let body = b.text().collect::().trim().to_string(); + if title.contains("approximate build time") { + info.sbu = Some(body.clone()); + } else if title.contains("required disk space") { + info.disk_space = Some(body.clone()); + } + } + } + } + + // 4) `pre.kbd.command` blocks for configure & make lines + if let Ok(pre_sel) = Selector::parse("div.installation pre.kbd.command, pre.kbd.command") { + for pre in document.select(&pre_sel) { + let text = pre.text().collect::>().join("\n"); + let trimmed = text.trim(); + + // handle configure block (starts with ../configure or ./configure) + if trimmed.starts_with("../configure") + || trimmed.starts_with("./configure") + || trimmed.starts_with(".. /configure") + { + // normalize: remove trailing backslashes and join lines + let mut joined = String::new(); + for line in trimmed.lines() { + let line = line.trim_end(); + if line.ends_with('\\') { + joined.push_str(line.trim_end_matches('\\').trim()); + joined.push(' '); + } else { + joined.push_str(line.trim()); + joined.push(' '); + } + } + // remove leading "../configure" token and split into args + let pieces: Vec<&str> = joined.split_whitespace().collect(); + let mut args = Vec::new(); + let mut started = false; + for p in pieces { + if !started { + if p.ends_with("configure") + || p.ends_with("configure") + || p.contains("configure") + { + started = true; + continue; + } + // skip until configure found + continue; + } else { + args.push(p.to_string()); + } + } + // fallback: if no tokens parsed, try chopping first token + if args.is_empty() { + // attempt to remove the first token (../configure) by index + if let Some(pos) = joined.find("configure") { + let after = &joined[pos + "configure".len()..]; + for t in after.split_whitespace() { + args.push(t.to_string()); + } + } + } + info.configure_args = args + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + continue; + } + + // handle make / make install lines + // consider blocks that are exactly "make" or "make install" or lines containing them + for line in trimmed.lines().map(|l| l.trim()) { + if line == "make" { + if !info.build_cmds.contains(&"make".to_string()) { + info.build_cmds.push("make".to_string()); + } + } else if line == "make install" { + if !info.install_cmds.contains(&"make install".to_string()) { + info.install_cmds.push("make install".to_string()); + } + } else if line.starts_with("make ") { + // e.g., "make -j2" + let t = line.to_string(); + if !info.build_cmds.contains(&t) { + info.build_cmds.push(t); + } + } else if line.starts_with("time {") && line.contains("make") { + // handle the time wrapper line in the note; ignore + // skip + } + } + } + } + + // final sanity: if build_cmds empty but install_cmds contains "make install", add "make" + if info.build_cmds.is_empty() && !info.install_cmds.is_empty() { + info.build_cmds.push("make".to_string()); + } + + Ok(info) +} diff --git a/src/pkgs/by_name/bi/mod.rs b/src/pkgs/by_name/bi/mod.rs new file mode 100644 index 0000000..3148c5b --- /dev/null +++ b/src/pkgs/by_name/bi/mod.rs @@ -0,0 +1 @@ +pub mod binutils; diff --git a/src/pkgs/by_name/mod.rs b/src/pkgs/by_name/mod.rs new file mode 100644 index 0000000..517c3a3 --- /dev/null +++ b/src/pkgs/by_name/mod.rs @@ -0,0 +1 @@ +pub mod bi; diff --git a/src/pkgs/mod.rs b/src/pkgs/mod.rs new file mode 100644 index 0000000..9dacb43 --- /dev/null +++ b/src/pkgs/mod.rs @@ -0,0 +1 @@ +pub mod by_name; diff --git a/src/tui.rs b/src/tui.rs deleted file mode 100644 index 514c5eb..0000000 --- a/src/tui.rs +++ /dev/null @@ -1,1057 +0,0 @@ -// src/tui.rs - Fixed version with themes and settings -use crate::wget_list; -use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use std::{error::Error, io, time::Duration}; -use tracing::{info, warn, error, debug, trace, instrument}; -use tui::{ - Terminal, - backend::CrosstermBackend, - layout::{Alignment, Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Span, Spans}, - widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Wrap}, -}; - -// Theme definitions -#[derive(Debug, Clone, Copy, PartialEq)] -enum Theme { - Default, - Dracula, - Nord, - Gruvbox, - Monokai, - Catppuccin, -} - -impl Theme { - fn name(&self) -> &str { - match self { - Theme::Default => "๐Ÿ’– Default (Cute)", - Theme::Dracula => "๐Ÿฆ‡ Dracula (Dark)", - Theme::Nord => "โ„๏ธ Nord (Cold)", - Theme::Gruvbox => "๐Ÿ”ฅ Gruvbox (Warm)", - Theme::Monokai => "๐ŸŽฎ Monokai (Retro)", - Theme::Catppuccin => "๐ŸŒธ Catppuccin (Pastel)", - } - } - - fn primary_color(&self) -> Color { - match self { - Theme::Default => Color::Cyan, - Theme::Dracula => Color::Magenta, - Theme::Nord => Color::LightBlue, - Theme::Gruvbox => Color::Yellow, - Theme::Monokai => Color::Green, - Theme::Catppuccin => Color::LightMagenta, - } - } - - fn secondary_color(&self) -> Color { - match self { - Theme::Default => Color::Magenta, - Theme::Dracula => Color::Rgb(189, 147, 249), - Theme::Nord => Color::Cyan, - Theme::Gruvbox => Color::Red, - Theme::Monokai => Color::Yellow, - Theme::Catppuccin => Color::Rgb(245, 194, 231), - } - } - - fn accent_color(&self) -> Color { - match self { - Theme::Default => Color::Yellow, - Theme::Dracula => Color::Rgb(255, 121, 198), - Theme::Nord => Color::Rgb(136, 192, 208), - Theme::Gruvbox => Color::Rgb(250, 189, 47), - Theme::Monokai => Color::Rgb(166, 226, 46), - Theme::Catppuccin => Color::Rgb(180, 190, 254), - } - } - - fn success_color(&self) -> Color { - match self { - Theme::Default => Color::Green, - Theme::Dracula => Color::Rgb(80, 250, 123), - Theme::Nord => Color::Rgb(163, 190, 140), - Theme::Gruvbox => Color::Rgb(184, 187, 38), - Theme::Monokai => Color::Rgb(166, 226, 46), - Theme::Catppuccin => Color::Rgb(166, 227, 161), - } - } - - fn all_themes() -> Vec { - vec![ - Theme::Default, - Theme::Dracula, - Theme::Nord, - Theme::Gruvbox, - Theme::Monokai, - Theme::Catppuccin, - ] - } -} - -struct Settings { - theme: Theme, - show_progress_percentage: bool, - auto_scroll: bool, - sound_enabled: bool, -} - -impl Default for Settings { - fn default() -> Self { - Self { - theme: Theme::Default, - show_progress_percentage: true, - auto_scroll: true, - sound_enabled: false, - } - } -} - -#[instrument] -pub fn tui_menu() -> Result<(), Box> { - info!("๐Ÿš€ Initializing TUI menu"); - - enable_raw_mode()?; - debug!("โœ… Raw mode enabled"); - - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - debug!("โœ… Terminal setup complete"); - - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - info!("โœ… Terminal backend initialized"); - - let mut settings = Settings::default(); - let menu_items = vec![ - "๐Ÿš€ Start Package Downloader", - "๐Ÿ“‹ View Download List", - "๐Ÿ” Check System Status", - "โš™๏ธ Settings", - "โŒ Exit" - ]; - let mut selected = 0; - - info!("โœจ Entering main menu loop"); - loop { - trace!("๐ŸŽจ Drawing menu frame, selected={}", selected); - - terminal.draw(|f| { - let size = f.size(); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(5), - Constraint::Length(menu_items.len() as u16 + 2), - Constraint::Min(5), - Constraint::Length(3), - ]) - .split(size); - - let header = vec![ - Spans::from(vec![ - Span::styled("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•", - Style::default().fg(settings.theme.secondary_color())), - ]), - Spans::from(vec![ - Span::styled(" โœจ LFS Package Downloader โœจ", - Style::default().fg(settings.theme.primary_color()).add_modifier(Modifier::BOLD)), - ]), - Spans::from(vec![ - Span::styled("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•", - Style::default().fg(settings.theme.secondary_color())), - ]), - ]; - let header_widget = Paragraph::new(header) - .alignment(Alignment::Center); - f.render_widget(header_widget, chunks[0]); - - let items: Vec = menu_items - .iter() - .enumerate() - .map(|(i, m)| { - let style = if i == selected { - Style::default() - .fg(Color::Black) - .bg(settings.theme.primary_color()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - let prefix = if i == selected { "โ–ถ " } else { " " }; - ListItem::new(format!("{}{}", prefix, m)).style(style) - }) - .collect(); - - let list = List::new(items).block( - Block::default() - .title(" Main Menu ") - .borders(Borders::ALL) - .border_style(Style::default().fg(settings.theme.primary_color())), - ); - f.render_widget(list, chunks[1]); - - let description = match selected { - 0 => "๐Ÿ“ฆ Download all LFS packages from the wget list.\nโœจ Progress will be shown for each file during download.", - 1 => "๐Ÿ“‹ Display the complete list of packages that will be downloaded.\n๐Ÿ” Review all URLs before starting the download process.", - 2 => "๐Ÿ” Check system requirements and verify environment setup.\nโœ… Ensure all dependencies are available for building LFS.", - 3 => "โš™๏ธ Configure application settings, change theme, and adjust preferences.\n๐ŸŽจ Customize your experience!", - 4 => "โŒ Exit the package downloader and return to shell.", - _ => "", - }; - - let desc_widget = Paragraph::new(description) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(settings.theme.accent_color())) - .title(" Description ")) - .wrap(Wrap { trim: true }); - f.render_widget(desc_widget, chunks[2]); - - let footer_text = format!("โ†‘โ†“: Navigate โ”‚ Enter: Select โ”‚ Esc: Exit โ”‚ Theme: {}", settings.theme.name()); - let footer = Paragraph::new(footer_text) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(footer, chunks[3]); - })?; - - if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - debug!("โŒจ๏ธ Key pressed: {:?}", key.code); - - match key.code { - KeyCode::Up => { - if selected > 0 { - selected -= 1; - debug!("โฌ†๏ธ Selection moved up to {}", selected); - } - } - KeyCode::Down => { - if selected < menu_items.len() - 1 { - selected += 1; - debug!("โฌ‡๏ธ Selection moved down to {}", selected); - } - } - KeyCode::Enter => { - info!("โœ… Menu item {} selected: {}", selected, menu_items[selected]); - - match selected { - 0 => { - info!("๐Ÿ“ฆ Starting package downloader"); - if let Err(e) = run_downloader_ui(&mut terminal, &settings) { - error!("โŒ Downloader failed: {}", e); - } else { - info!("โœ… Downloader completed successfully"); - } - - enable_raw_mode()?; - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - } - 1 => { - info!("๐Ÿ“‹ Viewing download list"); - if let Err(e) = view_download_list(&mut terminal, &settings) { - error!("โŒ Failed to view download list: {}", e); - } - - enable_raw_mode()?; - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - } - 2 => { - info!("๐Ÿ” Checking system status"); - if let Err(e) = check_system_status(&mut terminal, &settings) { - error!("โŒ Failed to check system status: {}", e); - } - - enable_raw_mode()?; - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - } - 3 => { - info!("โš™๏ธ Opening settings menu"); - if let Err(e) = settings_menu(&mut terminal, &mut settings) { - error!("โŒ Settings menu failed: {}", e); - } - - enable_raw_mode()?; - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - } - 4 => { - info!("๐Ÿ‘‹ User selected exit"); - break; - } - _ => {} - } - } - KeyCode::Esc => { - info!("๐Ÿ‘‹ Exit requested via Esc key"); - break; - } - _ => { - trace!("๐Ÿคท Unhandled key: {:?}", key.code); - } - } - } - } - } - - info!("๐Ÿงน Cleaning up terminal"); - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - debug!("โœ… Terminal cleanup complete"); - - Ok(()) -} - -#[instrument(skip(terminal, settings))] -fn settings_menu( - terminal: &mut Terminal>, - settings: &mut Settings, -) -> Result<(), Box> { - info!("โš™๏ธ Opening settings menu"); - - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - - enable_raw_mode()?; - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - - let menu_items = vec![ - "๐ŸŽจ Change Theme", - "๐Ÿ“Š Toggle Progress Percentage", - "๐Ÿ“œ Toggle Auto Scroll", - "๐Ÿ”Š Toggle Sound", - "โ†ฉ๏ธ Back to Main Menu", - ]; - let mut selected = 0; - - loop { - terminal.draw(|f| { - let size = f.size(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(5), - Constraint::Length(menu_items.len() as u16 + 2), - Constraint::Min(5), - Constraint::Length(3), - ]) - .split(size); - - let header = vec![ - Spans::from(vec![ - Span::styled("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•", - Style::default().fg(settings.theme.secondary_color())), - ]), - Spans::from(vec![ - Span::styled(" โš™๏ธ Settings Menu โš™๏ธ", - Style::default().fg(settings.theme.primary_color()).add_modifier(Modifier::BOLD)), - ]), - Spans::from(vec![ - Span::styled("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•", - Style::default().fg(settings.theme.secondary_color())), - ]), - ]; - let header_widget = Paragraph::new(header) - .alignment(Alignment::Center); - f.render_widget(header_widget, chunks[0]); - - let items: Vec = menu_items - .iter() - .enumerate() - .map(|(i, m)| { - let value_text = match i { - 0 => format!(" [{}]", settings.theme.name()), - 1 => format!(" [{}]", if settings.show_progress_percentage { "โœ… ON" } else { "โŒ OFF" }), - 2 => format!(" [{}]", if settings.auto_scroll { "โœ… ON" } else { "โŒ OFF" }), - 3 => format!(" [{}]", if settings.sound_enabled { "โœ… ON" } else { "โŒ OFF" }), - _ => String::new(), - }; - - let style = if i == selected { - Style::default() - .fg(Color::Black) - .bg(settings.theme.primary_color()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - let prefix = if i == selected { "โ–ถ " } else { " " }; - ListItem::new(format!("{}{}{}", prefix, m, value_text)).style(style) - }) - .collect(); - - let list = List::new(items).block( - Block::default() - .title(" Settings ") - .borders(Borders::ALL) - .border_style(Style::default().fg(settings.theme.primary_color())), - ); - f.render_widget(list, chunks[1]); - - let description = match selected { - 0 => "๐ŸŽจ Change the color theme of the application.\nโœจ Choose from multiple beautiful themes to customize your experience.", - 1 => "๐Ÿ“Š Show or hide percentage numbers on progress bars.\n๐Ÿ”ข Useful for detailed download tracking.", - 2 => "๐Ÿ“œ Automatically scroll to current downloading file.\n๐Ÿ‘€ Keeps the active download in view.", - 3 => "๐Ÿ”Š Enable or disable sound notifications.\n๐ŸŽต Play sounds when downloads complete (if available).", - 4 => "โ†ฉ๏ธ Return to the main menu.", - _ => "", - }; - - let desc_widget = Paragraph::new(description) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(settings.theme.accent_color())) - .title(" Description ")) - .wrap(Wrap { trim: true }); - f.render_widget(desc_widget, chunks[2]); - - let footer = Paragraph::new("โ†‘โ†“: Navigate โ”‚ Enter: Select/Toggle โ”‚ Esc: Back") - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(footer, chunks[3]); - })?; - - if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Up => { - if selected > 0 { - selected -= 1; - } - } - KeyCode::Down => { - if selected < menu_items.len() - 1 { - selected += 1; - } - } - KeyCode::Enter => { - match selected { - 0 => { - if let Err(e) = theme_selector(terminal, settings) { - error!("โŒ Theme selector failed: {}", e); - } - enable_raw_mode()?; - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - } - 1 => { - settings.show_progress_percentage = !settings.show_progress_percentage; - info!("๐Ÿ“Š Progress percentage: {}", settings.show_progress_percentage); - } - 2 => { - settings.auto_scroll = !settings.auto_scroll; - info!("๐Ÿ“œ Auto scroll: {}", settings.auto_scroll); - } - 3 => { - settings.sound_enabled = !settings.sound_enabled; - info!("๐Ÿ”Š Sound enabled: {}", settings.sound_enabled); - } - 4 => { - info!("โ†ฉ๏ธ Returning to main menu"); - break; - } - _ => {} - } - } - KeyCode::Esc => { - info!("๐Ÿ‘‹ Exiting settings menu"); - break; - } - _ => {} - } - } - } - } - - Ok(()) -} - -#[instrument(skip(terminal, settings))] -fn theme_selector( - terminal: &mut Terminal>, - settings: &mut Settings, -) -> Result<(), Box> { - info!("๐ŸŽจ Opening theme selector"); - - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - - enable_raw_mode()?; - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - - let themes = Theme::all_themes(); - let mut selected = themes.iter().position(|t| *t == settings.theme).unwrap_or(0); - - loop { - terminal.draw(|f| { - let size = f.size(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(5), - Constraint::Length(themes.len() as u16 + 2), - Constraint::Min(5), - Constraint::Length(3), - ]) - .split(size); - - let preview_theme = themes[selected]; - let header = vec![ - Spans::from(vec![ - Span::styled("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•", - Style::default().fg(preview_theme.secondary_color())), - ]), - Spans::from(vec![ - Span::styled(" ๐ŸŽจ Theme Selector ๐ŸŽจ", - Style::default().fg(preview_theme.primary_color()).add_modifier(Modifier::BOLD)), - ]), - Spans::from(vec![ - Span::styled("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•", - Style::default().fg(preview_theme.secondary_color())), - ]), - ]; - let header_widget = Paragraph::new(header) - .alignment(Alignment::Center); - f.render_widget(header_widget, chunks[0]); - - let items: Vec = themes - .iter() - .enumerate() - .map(|(i, theme)| { - let is_selected = i == selected; - let is_current = *theme == settings.theme; - - let style = if is_selected { - Style::default() - .fg(Color::Black) - .bg(theme.primary_color()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.primary_color()) - }; - - let prefix = if is_selected { "โ–ถ " } else { " " }; - let suffix = if is_current { " โœ“" } else { "" }; - - ListItem::new(format!("{}{}{}", prefix, theme.name(), suffix)).style(style) - }) - .collect(); - - let list = List::new(items).block( - Block::default() - .title(" Available Themes ") - .borders(Borders::ALL) - .border_style(Style::default().fg(preview_theme.primary_color())), - ); - f.render_widget(list, chunks[1]); - - let preview_theme = themes[selected]; - let preview_text = vec![ - Spans::from(vec![ - Span::styled("โœจ Preview: ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), - ]), - Spans::from(""), - Spans::from(vec![ - Span::styled("Primary Color ", Style::default().fg(preview_theme.primary_color()).add_modifier(Modifier::BOLD)), - Span::styled("โ— ", Style::default().fg(preview_theme.primary_color())), - ]), - Spans::from(vec![ - Span::styled("Secondary Color ", Style::default().fg(preview_theme.secondary_color()).add_modifier(Modifier::BOLD)), - Span::styled("โ— ", Style::default().fg(preview_theme.secondary_color())), - ]), - Spans::from(vec![ - Span::styled("Accent Color ", Style::default().fg(preview_theme.accent_color()).add_modifier(Modifier::BOLD)), - Span::styled("โ— ", Style::default().fg(preview_theme.accent_color())), - ]), - Spans::from(vec![ - Span::styled("Success Color ", Style::default().fg(preview_theme.success_color()).add_modifier(Modifier::BOLD)), - Span::styled("โ— ", Style::default().fg(preview_theme.success_color())), - ]), - ]; - - let preview_widget = Paragraph::new(preview_text) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(preview_theme.accent_color())) - .title(" Theme Preview ")) - .wrap(Wrap { trim: true }); - f.render_widget(preview_widget, chunks[2]); - - let footer = Paragraph::new("โ†‘โ†“: Navigate โ”‚ Enter: Apply Theme โ”‚ Esc: Cancel") - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(footer, chunks[3]); - })?; - - if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Up => { - if selected > 0 { - selected -= 1; - } - } - KeyCode::Down => { - if selected < themes.len() - 1 { - selected += 1; - } - } - KeyCode::Enter => { - settings.theme = themes[selected]; - info!("๐ŸŽจ Theme changed to: {:?}", settings.theme); - break; - } - KeyCode::Esc => { - info!("โŒ Theme selection cancelled"); - break; - } - _ => {} - } - } - } - } - - Ok(()) -} - -#[instrument(skip(terminal, settings))] -fn run_downloader_ui( - terminal: &mut Terminal>, - settings: &Settings, -) -> Result<(), Box> { - info!("๐Ÿ“ฆ Initializing downloader UI"); - - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - - enable_raw_mode()?; - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - - debug!("๐Ÿ“‹ Fetching wget list"); - let wget_string = wget_list::get_wget_list()?; - - let files: Vec = wget_string.lines().map(|s| s.to_string()).collect(); - let total_files = files.len(); - info!("โœ… Found {} files to download", total_files); - - let mut progress: Vec = vec![0.0; total_files]; - let mut current_file = 0; - - terminal.draw(|f| { - draw_progress_screen(f, &files, &progress, current_file, total_files, settings); - })?; - - for (i, _file) in files.iter().enumerate() { - current_file = i; - let filename = files[i].split('/').last().unwrap_or(&files[i]); - info!("โฌ‡๏ธ Downloading file {}/{}: {}", i + 1, total_files, filename); - - for p in 0..=100 { - progress[i] = p as f64; - - if p % 25 == 0 { - debug!("๐Ÿ“Š Progress for {}: {}%", filename, p); - } - - terminal.draw(|f| { - draw_progress_screen(f, &files, &progress, current_file, total_files, settings); - })?; - - if event::poll(Duration::from_millis(20))? { - if let Event::Key(key) = event::read()? { - if key.code == KeyCode::Esc { - warn!("โš ๏ธ Download cancelled by user at file {}/{}", i + 1, total_files); - show_message(terminal, "โŒ Download Cancelled", "Press any key to continue...", settings)?; - return Ok(()); - } - } - } else { - std::thread::sleep(Duration::from_millis(20)); - } - } - - info!("โœ… Completed downloading: {}", filename); - } - - info!("๐ŸŽ‰ All downloads completed successfully"); - show_message(terminal, "โœจ Download Complete! โœจ", - "All packages downloaded successfully! ๐ŸŽ‰\nPress any key to return to menu...", settings)?; - - Ok(()) -} - -fn draw_progress_screen( - f: &mut tui::Frame>, - files: &[String], - progress: &[f64], - current_file: usize, - total_files: usize, - settings: &Settings, -) { - let size = f.size(); - - let available_height = size.height.saturating_sub(8); - let max_visible = (available_height / 3) as usize; - - let start_idx = if settings.auto_scroll && current_file >= max_visible / 2 { - (current_file - max_visible / 2).min(files.len().saturating_sub(max_visible)) - } else { - 0 - }; - let end_idx = (start_idx + max_visible).min(files.len()); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(5), - Constraint::Min(3), - Constraint::Length(3), - ]) - .split(size); - - let header = Paragraph::new(format!( - "๐Ÿ“ฆ Downloading Packages... [{}/{}]", - current_file + 1, - total_files - )) - .style(Style::default().fg(settings.theme.primary_color()).add_modifier(Modifier::BOLD)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(header, chunks[0]); - - let progress_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints( - (start_idx..end_idx) - .map(|_| Constraint::Length(3)) - .collect::>(), - ) - .split(chunks[1]); - - for (idx, i) in (start_idx..end_idx).enumerate() { - let prog = progress[i]; - let is_current = i == current_file; - - let filename = files[i] - .split('/') - .last() - .unwrap_or(&files[i]); - - let title = if settings.show_progress_percentage { - format!("{} - {:.0}%", filename, prog) - } else { - filename.to_string() - }; - - let gauge = Gauge::default() - .block(Block::default() - .title(title.as_str()) - .borders(Borders::ALL) - .border_style(if is_current { - Style::default().fg(settings.theme.accent_color()) - } else { - Style::default().fg(Color::DarkGray) - })) - .gauge_style(if prog >= 100.0 { - Style::default().fg(settings.theme.success_color()).bg(Color::Black) - } else if is_current { - Style::default().fg(settings.theme.primary_color()).bg(Color::Black) - } else { - Style::default().fg(Color::DarkGray).bg(Color::Black) - }) - .percent(prog as u16); - - if idx < progress_chunks.len() { - f.render_widget(gauge, progress_chunks[idx]); - } - } - - let footer = Paragraph::new("Esc: Cancel Download") - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(footer, chunks[2]); -} - -#[instrument(skip(terminal, settings))] -fn view_download_list( - terminal: &mut Terminal>, - settings: &Settings, -) -> Result<(), Box> { - info!("๐Ÿ“‹ Displaying download list"); - - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - - enable_raw_mode()?; - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - - let wget_string = wget_list::get_wget_list()?; - let files: Vec = wget_string.lines().map(|s| s.to_string()).collect(); - debug!("๐Ÿ“ฆ Loaded {} files for display", files.len()); - - let mut scroll_offset = 0; - - loop { - terminal.draw(|f| { - let size = f.size(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(3), - ]) - .split(size); - - let header = Paragraph::new(format!("๐Ÿ“‹ Download List ({} packages)", files.len())) - .style(Style::default().fg(settings.theme.primary_color()).add_modifier(Modifier::BOLD)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(header, chunks[0]); - - let visible_lines = (chunks[1].height - 2) as usize; - let end_idx = (scroll_offset + visible_lines).min(files.len()); - - let items: Vec = files[scroll_offset..end_idx] - .iter() - .enumerate() - .map(|(i, url)| { - let filename = url.split('/').last().unwrap_or(url); - ListItem::new(format!("{}. ๐Ÿ“ฆ {}", scroll_offset + i + 1, filename)) - .style(Style::default().fg(settings.theme.primary_color())) - }) - .collect(); - - let list = List::new(items) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(settings.theme.accent_color())) - .title(" Packages ")); - f.render_widget(list, chunks[1]); - - let footer = Paragraph::new("โ†‘โ†“: Scroll โ”‚ PgUp/PgDn: Page โ”‚ Enter/Esc: Back") - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(footer, chunks[2]); - })?; - - if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - let visible_lines = terminal.size()?.height.saturating_sub(8) as usize; - - match key.code { - KeyCode::Up => { - if scroll_offset > 0 { - scroll_offset -= 1; - trace!("โฌ†๏ธ Scrolled up to offset {}", scroll_offset); - } - } - KeyCode::Down => { - if scroll_offset + visible_lines < files.len() { - scroll_offset += 1; - trace!("โฌ‡๏ธ Scrolled down to offset {}", scroll_offset); - } - } - KeyCode::PageUp => { - scroll_offset = scroll_offset.saturating_sub(visible_lines); - debug!("๐Ÿ“„ Page up to offset {}", scroll_offset); - } - KeyCode::PageDown => { - scroll_offset = (scroll_offset + visible_lines).min(files.len().saturating_sub(visible_lines)); - debug!("๐Ÿ“„ Page down to offset {}", scroll_offset); - } - KeyCode::Enter | KeyCode::Esc => { - info!("๐Ÿ‘‹ Exiting download list view"); - break; - } - _ => {} - } - } - } - } - - Ok(()) -} - -#[instrument(skip(terminal, settings))] -fn check_system_status( - terminal: &mut Terminal>, - settings: &Settings, -) -> Result<(), Box> { - info!("๐Ÿ” Checking system status"); - - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - - enable_raw_mode()?; - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - - let status_info = vec![ - ("๐Ÿ’ป System", "Ready"), - ("๐Ÿ’พ Disk Space", "Available"), - ("๐ŸŒ Network", "Connected"), - ("๐Ÿ“ฅ wget", "Installed"), - ("๐Ÿ”จ Build Tools", "Ready"), - ]; - - debug!("๐Ÿ” System checks: {:?}", status_info); - - loop { - terminal.draw(|f| { - let size = f.size(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(3), - ]) - .split(size); - - let header = Paragraph::new("๐Ÿ” System Status Check") - .style(Style::default().fg(settings.theme.primary_color()).add_modifier(Modifier::BOLD)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(header, chunks[0]); - - let items: Vec = status_info - .iter() - .map(|(name, status)| { - ListItem::new(format!("โœ“ {}: {}", name, status)) - .style(Style::default().fg(settings.theme.success_color())) - }) - .collect(); - - let list = List::new(items) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(settings.theme.accent_color())) - .title(" Status ")); - f.render_widget(list, chunks[1]); - - let footer = Paragraph::new("Press any key to return to menu") - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(footer, chunks[2]); - })?; - - if event::poll(Duration::from_millis(100))? { - if let Event::Key(_) = event::read()? { - info!("๐Ÿ‘‹ Exiting system status view"); - break; - } - } - } - - Ok(()) -} - -fn show_message( - terminal: &mut Terminal>, - title: &str, - message: &str, - settings: &Settings, -) -> Result<(), Box> { - debug!("๐Ÿ’ฌ Showing message: {}", title); - - terminal.draw(|f| { - let size = f.size(); - let paragraph = Paragraph::new(message) - .style(Style::default().fg(settings.theme.success_color())) - .alignment(Alignment::Center) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(settings.theme.primary_color())) - .title(title)); - f.render_widget(paragraph, size); - })?; - - loop { - if let Event::Key(_) = event::read()? { - break; - } - } - - Ok(()) -} diff --git a/src/tui/disk_manager.rs b/src/tui/disk_manager.rs new file mode 100644 index 0000000..465c2d1 --- /dev/null +++ b/src/tui/disk_manager.rs @@ -0,0 +1,437 @@ +// src/tui/disk_manager.rs +use std::{ + fs::{File, read_dir}, + io::{self, Seek, SeekFrom, Write}, + path::PathBuf, +}; + +use crossterm::event::{self, Event, KeyCode}; +use crossterm::execute; +use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; +use gptman::{GPT, GPTPartitionEntry, PartitionName}; +use tracing::{info, warn}; +use tui::{ + Terminal, + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::Span, + widgets::{Block, Borders, List, ListItem, Paragraph}, +}; +use uuid::Uuid; + +/// DiskManager: interactive TUI to view and create GPT partitions on Linux. +/// +/// Requirements (add to Cargo.toml): +/// tui = "0.19" +/// crossterm = "0.26" +/// gptman = "2.0" +/// uuid = { version = "1", features = ["v4"] } +/// tracing = "0.1" +pub struct DiskManager; + +impl DiskManager { + /// Entrypoint: run the disk manager UI. This initializes the terminal and starts the loop. + pub fn run_tui() -> Result<(), Box> { + // init terminal + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut term = Terminal::new(backend)?; + term.clear()?; + + // collect devices (linux-focused: sd*, nvme*, vd*) + let mut devices: Vec = Vec::new(); + if let Ok(entries) = read_dir("/dev/") { + for e in entries.flatten() { + let path = e.path(); + if let Some(name) = path.file_name().and_then(|s| s.to_str()) { + if name.starts_with("sd") + || name.starts_with("nvme") + || name.starts_with("vd") + || name.starts_with("mmcblk") + { + devices.push(path); + } + } + } + } + + if devices.is_empty() { + // restore terminal before printing + execute!(term.backend_mut(), LeaveAlternateScreen)?; + println!("No block devices found under /dev (sd*, nvme*, vd*, mmcblk*)."); + return Ok(()); + } + + let mut selected_idx = 0usize; + let mut status_msg = + String::from("Select disk. โ†‘/โ†“ to navigate, Enter=view, C=create, Q=quit."); + + loop { + term.draw(|f| { + let size = f.size(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(3), + Constraint::Min(6), + Constraint::Length(3), + ] + .as_ref(), + ) + .split(size); + + // header + let header = Paragraph::new(Span::styled( + "๐Ÿ”ง Disk Manager โ€” Linux GPT (use carefully!)", + Style::default().add_modifier(Modifier::BOLD), + )) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(header, chunks[0]); + + // device list + selection + let items: Vec = devices + .iter() + .enumerate() + .map(|(i, d)| { + let label = format!( + "{} {}", + if i == selected_idx { "โ–ถ" } else { " " }, + d.display() + ); + let mut li = ListItem::new(label); + if i == selected_idx { + li = li.style(Style::default().fg(Color::Yellow)); + } + li + }) + .collect(); + + let list = + List::new(items).block(Block::default().borders(Borders::ALL).title("Disks")); + f.render_widget(list, chunks[1]); + + // status/footer + let footer = Paragraph::new(status_msg.as_str()) + .style(Style::default().fg(Color::Green)) + .block(Block::default().borders(Borders::ALL).title("Status")); + f.render_widget(footer, chunks[2]); + })?; + + // Input handling + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char('q') | KeyCode::Char('Q') => break, + KeyCode::Up => { + if selected_idx > 0 { + selected_idx -= 1; + } + } + KeyCode::Down => { + if selected_idx + 1 < devices.len() { + selected_idx += 1; + } + } + KeyCode::Enter => { + let path = devices[selected_idx].clone(); + match Self::view_partitions_tui(&path, &mut term) { + Ok(m) => status_msg = m, + Err(e) => status_msg = format!("Error reading partitions: {}", e), + } + } + KeyCode::Char('c') | KeyCode::Char('C') => { + let path = devices[selected_idx].clone(); + match Self::create_partition_tui(&path, &mut term) { + Ok(m) => { + info!(target: "disk_manager", "{}", m); + status_msg = m; + } + Err(e) => { + warn!(target: "disk_manager", "create partition error: {:?}", e); + status_msg = format!("Create failed: {}", e); + } + } + } + _ => {} + } + } + } + } + + // restore terminal + execute!(term.backend_mut(), LeaveAlternateScreen)?; + term.show_cursor()?; + Ok(()) + } + + /// Show GPT partitions for the chosen disk in a paged TUI view. + fn view_partitions_tui( + disk: &PathBuf, + term: &mut Terminal>, + ) -> Result> { + // try to open & read GPT (512 sector size) + let mut file = File::open(disk)?; + let gpt = match GPT::read_from(&mut file, 512) { + Ok(g) => g, + Err(_) => match GPT::find_from(&mut file) { + Ok(g) => g, + Err(_) => { + return Ok(format!("No GPT found on {}", disk.display())); + } + }, + }; + + // Create list of lines to display: + let mut lines: Vec = Vec::new(); + lines.push(format!("Partitions on {}:", disk.display())); + for (i, entry_opt) in gpt.partitions.iter().enumerate() { + if let Some(entry) = entry_opt { + let name = entry.partition_name.to_string(); + lines.push(format!( + "{}: {} -> {} (type: {})", + i, + entry.starting_lba, + entry.ending_lba, + // show a short GUID hex for partition type + hex::encode_upper(&entry.partition_type_guid) + )); + lines.push(format!(" name: {}", name)); + } + } + if lines.len() == 1 { + lines.push("No partitions found.".into()); + } + // paged view loop + let mut top = 0usize; + loop { + term.draw(|f| { + let size = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(1), Constraint::Length(1)].as_ref()) + .split(size); + + let page_lines: Vec = lines + .iter() + .skip(top) + .take((chunks[0].height as usize).saturating_sub(2)) + .map(|l| ListItem::new(l.clone())) + .collect(); + + let list = List::new(page_lines).block( + Block::default() + .borders(Borders::ALL) + .title(format!("Partitions: {}", disk.display())), + ); + f.render_widget(list, chunks[0]); + + let footer = Paragraph::new("โ†‘/โ†“ scroll โ€ข q to go back") + .block(Block::default().borders(Borders::ALL)); + f.render_widget(footer, chunks[1]); + })?; + + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(k) = event::read()? { + match k.code { + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Up => { + if top > 0 { + top = top.saturating_sub(1); + } + } + KeyCode::Down => { + if top + 1 < lines.len() { + top = top.saturating_add(1); + } + } + _ => {} + } + } + } + } + + Ok(format!("Viewed partitions on {}", disk.display())) + } + + /// Fully-TUI flow to enter partition name, size (MB), and choose partition type. + /// Writes GPT changes to disk. + fn create_partition_tui( + disk: &PathBuf, + term: &mut Terminal>, + ) -> Result> { + // open file read+write + let mut file = File::options().read(true).write(true).open(disk)?; + + // Read or create GPT + let mut gpt = match GPT::read_from(&mut file, 512) { + Ok(g) => g, + Err(_) => match GPT::find_from(&mut file) { + Ok(g) => g, + Err(_) => { + // If there's no GPT, create one with a random disk GUID + let disk_guid_raw: [u8; 16] = *Uuid::new_v4().as_bytes(); + // new_from requires a Seek+Read; create new GPT structure on-disk + GPT::new_from(&mut file, 512, disk_guid_raw)? + } + }, + }; + + // interactive fields + let mut name = String::from("new_partition"); + let mut size_mb: u64 = 100; // default 100 MB + let mut type_choice = 1usize; // 0 = EFI, 1 = Linux filesystem + + // known GUIDs (string repr) -> will be parsed to raw bytes as required + let efi_guid = Uuid::parse_str("C12A7328-F81F-11D2-BA4B-00A0C93EC93B")?; // EFI System + let linux_fs_guid = Uuid::parse_str("0FC63DAF-8483-4772-8E79-3D69D8477DE4")?; // Linux filesystem + + loop { + // Render UI + term.draw(|f| { + let size = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(3), + ] + .as_ref(), + ) + .split(size); + + let title = Paragraph::new(Span::styled( + format!("Create partition on {}", disk.display()), + Style::default().add_modifier(Modifier::BOLD), + )) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(title, chunks[0]); + + let name_widget = Paragraph::new(format!("Name: {}", name)).block( + Block::default() + .borders(Borders::ALL) + .title("Partition Name"), + ); + f.render_widget(name_widget, chunks[1]); + + let size_widget = Paragraph::new(format!("Size (MB): {}", size_mb)) + .block(Block::default().borders(Borders::ALL).title("Size")); + f.render_widget(size_widget, chunks[2]); + + let types = vec![ + format!( + "{} EFI System Partition", + if type_choice == 0 { "โ–ถ" } else { " " } + ), + format!( + "{} Linux filesystem", + if type_choice == 1 { "โ–ถ" } else { " " } + ), + ]; + let type_items: Vec = types.into_iter().map(ListItem::new).collect(); + let type_list = List::new(type_items).block( + Block::default() + .borders(Borders::ALL) + .title("Partition Type (use โ†/โ†’)"), + ); + f.render_widget(type_list, chunks[3]); + })?; + + // Input + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(k) = event::read()? { + match k.code { + KeyCode::Char('q') | KeyCode::Esc => { + return Ok("Creation cancelled".to_string()); + } + KeyCode::Left => { + type_choice = type_choice.saturating_sub(1); + } + KeyCode::Right => { + type_choice = (type_choice + 1) % 2; + } + KeyCode::Char(c) => { + // typing to name: accept visible characters; digits typed also append + if !c.is_control() { + name.push(c); + } + } + KeyCode::Backspace => { + name.pop(); + } + KeyCode::Up => { + // increase size by 10MB + size_mb = size_mb.saturating_add(10); + } + KeyCode::Down => { + size_mb = size_mb.saturating_sub(10); + } + KeyCode::Enter => { + break; + } + _ => {} + } + } + } + } + + // convert MB -> sectors (512 bytes per sector) + let sectors = (size_mb as u128 * 1024 * 1024 / 512) as u64; + // choose starting LBA: find max ending_lba among existing partitions; align to 2048 + let last_end = gpt + .partitions + .iter() + .filter_map(|p| p.as_ref().map(|e| e.ending_lba)) + .max() + .unwrap_or(2048); + let start = ((last_end + 2048) / 2048) * 2048 + 1; + let end = start + sectors.saturating_sub(1); + + // build partition entry + let mut new_entry = GPTPartitionEntry::empty(); + new_entry.starting_lba = start; + new_entry.ending_lba = end; + new_entry.partition_name = PartitionName::from(name.as_str()); + + // set partition type GUID + let type_guid = if type_choice == 0 { + *efi_guid.as_bytes() + } else { + *linux_fs_guid.as_bytes() + }; + new_entry.partition_type_guid = type_guid; + + // find first empty partition slot + let idx_opt = gpt.partitions.iter().position(|p| p.is_none()); + let idx = match idx_opt { + Some(i) => i, + None => return Err("No free GPT partition entries (maxed out)".into()), + }; + + // assign and write + gpt.partitions[idx] = Some(new_entry); + + // Seek to start (important) + file.seek(SeekFrom::Start(0))?; + gpt.write_into(&mut file) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + Ok(format!( + "Created partition '{}' on {} ({} MB, sectors {}..{})", + name, + disk.display(), + size_mb, + start, + end + )) + } +} diff --git a/src/tui/downloader.rs b/src/tui/downloader.rs new file mode 100644 index 0000000..83ddd5b --- /dev/null +++ b/src/tui/downloader.rs @@ -0,0 +1,61 @@ +use std::io::Stdout; +use tracing::instrument; +use tui::{ + Terminal, + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + style::Style, + text::Spans, + widgets::{Block, Borders, Gauge, List, ListItem}, +}; + +use crate::tui::settings::Settings; + +pub struct Downloader; + +impl Downloader { + #[instrument(skip(terminal, settings))] + pub fn show_downloader( + terminal: &mut Terminal>, + settings: &Settings, + ) -> Result<(), Box> { + let files = vec!["file1.tar.gz", "file2.tar.gz", "file3.tar.gz"]; + let progress = vec![0.3, 0.5, 0.9]; + + loop { + terminal.draw(|f| { + let size = f.size(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([Constraint::Percentage(70), Constraint::Percentage(30)].as_ref()) + .split(size); + + let items: Vec = files + .iter() + .map(|f| ListItem::new(Spans::from(*f))) + .collect(); + let list = List::new(items).block( + Block::default() + .title(Spans::from("Downloads")) + .borders(Borders::ALL) + .border_style(Style::default().fg(settings.theme.secondary_color())), + ); + f.render_widget(list, chunks[0]); + + for (i, prog) in progress.iter().enumerate() { + let gauge = Gauge::default() + .block(Block::default().title(files[i])) + .gauge_style(Style::default().fg(settings.theme.primary_color())) + .ratio(*prog as f64); + f.render_widget(gauge, chunks[1]); + } + })?; + + break; // remove in real async loop + } + + Ok(()) + } +} diff --git a/src/tui/main_menu.rs b/src/tui/main_menu.rs new file mode 100644 index 0000000..978d040 --- /dev/null +++ b/src/tui/main_menu.rs @@ -0,0 +1,49 @@ +use crate::tui::disk_manager::DiskManager; +use crossterm::event::{self, Event, KeyCode}; +use std::error::Error; +use std::io::Stdout; +use tui::{ + Terminal, + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph}, +}; + +pub fn show_main_menu() -> Result<(), Box> { + let mut stdout = std::io::stdout(); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + loop { + terminal.draw(|f| { + let size = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([Constraint::Length(3), Constraint::Length(3)].as_ref()) + .split(size); + + let menu = Paragraph::new("1) Disk Manager\n0) Exit") + .block(Block::default().borders(Borders::ALL)); + f.render_widget(menu, chunks[0]); + + let status = Paragraph::new("Use number keys to select an option") + .style(Style::default().fg(Color::Yellow)) + .block(Block::default().borders(Borders::ALL).title("Status")); + f.render_widget(status, chunks[1]); + })?; + + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char('1') => DiskManager::show_disk_manager(&mut terminal)?, + KeyCode::Char('0') => break, + _ => {} + } + } + } + } + + Ok(()) +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..ad84ace --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,4 @@ +pub mod disk_manager; +pub mod downloader; +pub mod main_menu; +pub mod settings; diff --git a/src/tui/settings.rs b/src/tui/settings.rs new file mode 100644 index 0000000..d258dd8 --- /dev/null +++ b/src/tui/settings.rs @@ -0,0 +1,28 @@ +use std::io::Stdout; +use tracing::instrument; +use tui::{Terminal, backend::CrosstermBackend}; + +pub struct Settings { + pub theme: Theme, +} + +pub struct Theme; + +impl Theme { + pub fn primary_color(&self) -> tui::style::Color { + tui::style::Color::Cyan + } + pub fn secondary_color(&self) -> tui::style::Color { + tui::style::Color::White + } +} + +impl Settings { + #[instrument(skip(terminal))] + pub fn show_settings( + terminal: &mut Terminal>, + ) -> Result<(), Box> { + // Render settings UI here + Ok(()) + } +}