feat: build Cub CLI and TUI workflows

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-07 20:57:51 +01:00
parent 714aed9610
commit 22ec92723d
16 changed files with 1633 additions and 29 deletions
@@ -3,6 +3,7 @@ resolver = "2"
members = [
"cub-lib",
"cub-cli",
"cub-tui",
]
default-members = [
"cub-cli",
@@ -13,6 +13,13 @@ path = "src/main.rs"
[dependencies]
cub-lib = { path = "../cub-lib" }
cub-tui = { path = "../cub-tui", optional = true }
redox-pkg = { git = "https://gitlab.redox-os.org/redox-os/pkgutils.git", default-features = false, features = ["indicatif"] }
clap = { workspace = true }
pkgar = "0.2.2"
pkgar-core = "0.2.2"
termion = "4.0.6"
[features]
default = ["tui"]
tui = ["cub-tui"]
@@ -8,14 +8,20 @@ use std::process::Command;
use std::rc::Rc;
use std::time::{SystemTime, UNIX_EPOCH};
use clap::{Parser, Subcommand};
use cub::converter::{self, ConversionReport, ConversionResult};
use clap::{CommandFactory, Parser, Subcommand};
use cub::aur::{AurClient, AurPackage};
use cub::cook;
use cub::error::CubError;
use cub::pkgbuild::{self, ConversionReport, ConversionResult};
use cub::rbpkgbuild::RbPkgBuild;
use cub::rbsrcinfo::RbSrcInfo;
use cub::sandbox::SandboxConfig;
use cub::storage::CubStore;
use pkg::callback::IndicatifCallback;
use pkg::{Library, PackageName};
use pkg::{Library, PackageName, PackageState};
use pkgar::ext::EntryExt;
use pkgar::PackageFile;
use pkgar_core::PackageSrc;
const DEFAULT_TARGET: &str = "x86_64-unknown-redox";
const HOST_INSTALL_PATH: &str = "/tmp/pkg_install";
@@ -26,6 +32,8 @@ const DEFAULT_BUR_REPO_URL: &str = "https://gitlab.redox-os.org/redox-os/bur.git
const DEFAULT_AUR_BASE_URL: &str = "https://aur.archlinux.org";
const PUBLIC_KEY_FILE: &str = "id_ed25519.pub.toml";
const DEFAULT_SECRET_KEY_FILE: &str = "id_ed25519.toml";
const PACKAGES_HEAD_DIR: &str = "var/lib/packages";
const AUR_SYNC_STAMP_FILE: &str = "aur-sync.stamp";
struct CookbookAdapter;
@@ -42,7 +50,7 @@ struct PkgbuildConverter;
impl PkgbuildConverter {
fn convert(content: &str) -> Result<ConversionResult, CubError> {
converter::convert_pkgbuild(content)
pkgbuild::convert_pkgbuild(content)
}
}
@@ -66,10 +74,13 @@ impl PackageCreator {
#[command(name = "cub")]
#[command(version)]
#[command(about = "Red Bear OS Package Builder")]
#[command(arg_required_else_help = true)]
struct Cli {
/// Disable the default TUI launcher when no subcommand is provided
#[arg(short = 'T', long = "no-tui", global = true)]
no_tui: bool,
#[command(subcommand)]
command: Commands,
command: Option<Commands>,
}
#[derive(Debug, Subcommand)]
@@ -78,16 +89,32 @@ enum Commands {
Install { package: String },
/// Search packages in the official repo and cached BUR
Search { query: String },
/// Show AUR package details
Info { package: String },
/// Refresh cached package metadata
Sync,
/// Refresh metadata and update installed packages
SystemUpgrade,
/// Build and install a local RBPKGBUILD directory
Build { dir: String },
/// Fetch a BUR recipe into the current directory
Get { package: String },
/// Import an AUR package into ~/.cub/recipes
GetAur { package: String },
/// Inspect an installed package or local RBPKGBUILD
Inspect { target: String },
/// Convert an AUR PKGBUILD into an RBPKGBUILD tree
ImportAur { target: String },
/// Update all installed packages
UpdateAll,
/// Remove an installed package
Remove { package: String },
/// List installed packages
QueryLocal,
/// Show installed package details
QueryInfo { package: String },
/// List files installed by a package
QueryList { package: String },
/// Remove cub and pkg download caches
CleanCache,
}
@@ -143,20 +170,63 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse_from(args);
let context = AppContext::new();
match cli.command {
Commands::Install { package } => install_package(&context, &package)?,
Commands::Search { query } => search_packages(&context, &query)?,
Commands::Build { dir } => build_local_dir(&context, Path::new(&dir))?,
if let Some(command) = cli.command {
run_command(&context, command)?;
} else if cli.no_tui {
print_help_text()?;
} else {
launch_tui_or_help()?;
}
Ok(())
}
fn run_command(
context: &AppContext,
command: Commands,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
Commands::Install { package } => install_package(context, &package)?,
Commands::Search { query } => search_packages(context, &query)?,
Commands::Info { package } => show_aur_info(&package)?,
Commands::Sync => sync_sources()?,
Commands::SystemUpgrade => system_upgrade(context)?,
Commands::Build { dir } => build_local_dir(context, Path::new(&dir))?,
Commands::Get { package } => fetch_bur_recipe(&package)?,
Commands::Inspect { target } => inspect_target(&context, &target)?,
Commands::GetAur { package } => get_aur_recipe(&package)?,
Commands::Inspect { target } => inspect_target(context, &target)?,
Commands::ImportAur { target } => import_aur_target(&target)?,
Commands::UpdateAll => update_all(&context)?,
Commands::UpdateAll => update_all(context)?,
Commands::Remove { package } => remove_package(context, &package)?,
Commands::QueryLocal => query_local_packages(context)?,
Commands::QueryInfo { package } => query_local_info(context, &package)?,
Commands::QueryList { package } => query_local_files(context, &package)?,
Commands::CleanCache => clean_cache()?,
}
Ok(())
}
fn launch_tui_or_help() -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "tui")]
{
cub_tui::run()?;
return Ok(());
}
#[cfg(not(feature = "tui"))]
{
print_help_text()
}
}
fn print_help_text() -> Result<(), Box<dyn std::error::Error>> {
let mut command = Cli::command();
command.print_help()?;
println!();
Ok(())
}
fn rewrite_shortcut_args(
args: impl IntoIterator<Item = OsString>,
) -> Result<Vec<OsString>, Box<dyn std::error::Error>> {
@@ -167,28 +237,77 @@ fn rewrite_shortcut_args(
let binary = collected[0].clone();
let rest = &collected[1..];
let Some(flag) = rest.first().and_then(|value| value.to_str()) else {
let prefix_len = leading_global_flag_count(rest);
let prefix = &rest[..prefix_len];
let tail = &rest[prefix_len..];
let Some(flag) = tail.first().and_then(|value| value.to_str()) else {
return Ok(collected);
};
let rewrite_value = |subcommand: &str, value_name: &str| {
if prefix.is_empty() {
rewrite_value_command(binary.clone(), tail, subcommand, value_name)
} else {
rewrite_value_command_with_prefix(binary.clone(), prefix, tail, subcommand, value_name)
}
};
let rewrite_flag = |subcommand: &str| {
if prefix.is_empty() {
rewrite_flag_command(binary.clone(), tail, subcommand)
} else {
rewrite_flag_command_with_prefix(binary.clone(), prefix, tail, subcommand)
}
};
match flag {
"-S" => rewrite_value_command(binary, rest, "install", "package"),
"-Ss" => rewrite_value_command(binary, rest, "search", "query"),
"-B" => rewrite_value_command(binary, rest, "build", "dir"),
"-G" => rewrite_value_command(binary, rest, "get", "package"),
"-Pi" => rewrite_value_command(binary, rest, "inspect", "target"),
"--import-aur" => rewrite_value_command(binary, rest, "import-aur", "target"),
"-Sua" => rewrite_flag_command(binary, rest, "update-all"),
"-Sc" => rewrite_flag_command(binary, rest, "clean-cache"),
"-S" => rewrite_value("install", "package"),
"-Ss" => rewrite_value("search", "query"),
"-Si" => rewrite_value("info", "package"),
"-Sy" => rewrite_flag("sync"),
"-Syu" => rewrite_flag("system-upgrade"),
"-B" => rewrite_value("build", "dir"),
"-G" => rewrite_value("get-aur", "package"),
"-R" => rewrite_value("remove", "package"),
"-Q" => rewrite_flag("query-local"),
"-Qi" => rewrite_value("query-info", "package"),
"-Ql" => rewrite_value("query-list", "package"),
"-Pi" => rewrite_value("inspect", "target"),
"--import-aur" => rewrite_value("import-aur", "target"),
"-Sua" => rewrite_flag("update-all"),
"-Sc" => rewrite_flag("clean-cache"),
_ => Ok(collected),
}
}
fn leading_global_flag_count(rest: &[OsString]) -> usize {
let mut count = 0;
while let Some(flag) = rest.get(count).and_then(|value| value.to_str()) {
if matches!(flag, "-T" | "--no-tui") {
count += 1;
} else {
break;
}
}
count
}
fn rewrite_value_command(
binary: OsString,
rest: &[OsString],
subcommand: &str,
value_name: &str,
) -> Result<Vec<OsString>, Box<dyn std::error::Error>> {
rewrite_value_command_with_prefix(binary, &[], rest, subcommand, value_name)
}
fn rewrite_value_command_with_prefix(
binary: OsString,
prefix: &[OsString],
rest: &[OsString],
subcommand: &str,
value_name: &str,
) -> Result<Vec<OsString>, Box<dyn std::error::Error>> {
let Some(value) = rest.get(1) else {
return Err(io::Error::new(
@@ -198,7 +317,10 @@ fn rewrite_value_command(
.into());
};
let mut rewritten = vec![binary, OsString::from(subcommand), value.clone()];
let mut rewritten = vec![binary];
rewritten.extend(prefix.iter().cloned());
rewritten.push(OsString::from(subcommand));
rewritten.push(value.clone());
rewritten.extend(rest.iter().skip(2).cloned());
Ok(rewritten)
}
@@ -208,7 +330,18 @@ fn rewrite_flag_command(
rest: &[OsString],
subcommand: &str,
) -> Result<Vec<OsString>, Box<dyn std::error::Error>> {
let mut rewritten = vec![binary, OsString::from(subcommand)];
rewrite_flag_command_with_prefix(binary, &[], rest, subcommand)
}
fn rewrite_flag_command_with_prefix(
binary: OsString,
prefix: &[OsString],
rest: &[OsString],
subcommand: &str,
) -> Result<Vec<OsString>, Box<dyn std::error::Error>> {
let mut rewritten = vec![binary];
rewritten.extend(prefix.iter().cloned());
rewritten.push(OsString::from(subcommand));
rewritten.extend(rest.iter().skip(1).cloned());
Ok(rewritten)
}
@@ -274,7 +407,51 @@ fn search_packages(context: &AppContext, query: &str) -> Result<(), Box<dyn std:
Ok(())
}
fn show_aur_info(package: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = AurClient::new();
let packages = client.info(&[package])?;
let aur_package = packages
.into_iter()
.find(|candidate| candidate.name == package)
.ok_or_else(|| {
CubError::PackageNotFound(format!("{package} not found in AUR info response"))
})?;
print_aur_package(&aur_package);
Ok(())
}
fn sync_sources() -> Result<(), Box<dyn std::error::Error>> {
let bur_dir = sync_bur_repo()?;
let store = init_cub_store()?;
let aur_client = AurClient::new();
let sample_results = aur_client.search("a", Some("name"))?;
let stamp_path = store.sources_dir().join(AUR_SYNC_STAMP_FILE);
fs::write(
&stamp_path,
format!(
"synced_at_unix = {}\naur_sample_results = {}\n",
current_unix_timestamp(),
sample_results.len()
),
)?;
println!("Refreshed BUR cache at {}.", bur_dir.display());
println!(
"Verified live AUR metadata access and wrote sync stamp to {}.",
stamp_path.display()
);
Ok(())
}
fn system_upgrade(context: &AppContext) -> Result<(), Box<dyn std::error::Error>> {
sync_sources()?;
update_all(context)
}
fn build_local_dir(context: &AppContext, dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
cook::cook_available()?;
let rbpkg_path = dir.join("RBPKGBUILD");
let rbpkg = RbPkgBuild::from_file(&rbpkg_path)?;
rbpkg.validate()?;
@@ -349,6 +526,60 @@ fn fetch_bur_recipe(package: &str) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn get_aur_recipe(package: &str) -> Result<(), Box<dyn std::error::Error>> {
let repo_url = aur_repo_url(package);
let clone_dir = create_temp_dir("cub-aur-get")?;
let status = Command::new("git")
.arg("clone")
.arg("--depth")
.arg("1")
.arg(&repo_url)
.arg(&clone_dir)
.status()?;
if !status.success() {
return Err(Box::new(CubError::BuildFailed(format!(
"failed to clone AUR source from {repo_url}"
))));
}
let pkgbuild_path = clone_dir.join("PKGBUILD");
let pkgbuild = fs::read_to_string(&pkgbuild_path)?;
let conversion = PkgbuildConverter::convert(&pkgbuild)?;
let store = init_cub_store()?;
let recipe_path = cub::recipe::save_recipe_to_store(&conversion.rbpkg, &store)?;
let recipe_dir = recipe_path.parent().ok_or_else(|| {
CubError::BuildFailed(format!("invalid saved recipe path {}", recipe_path.display()))
})?;
fs::create_dir_all(recipe_dir.join("patches"))?;
fs::create_dir_all(recipe_dir.join("import"))?;
fs::write(recipe_dir.join("RBPKGBUILD"), conversion.rbpkg.to_toml()?)?;
fs::write(
recipe_dir.join(".RBSRCINFO"),
RbSrcInfo::from_rbpkgbuild(&conversion.rbpkg).to_string(),
)?;
fs::write(recipe_dir.join("import").join("PKGBUILD"), pkgbuild)?;
let srcinfo_path = clone_dir.join(".SRCINFO");
if srcinfo_path.is_file() {
fs::copy(&srcinfo_path, recipe_dir.join("import").join(".SRCINFO"))?;
}
let report = render_conversion_report(&conversion.report);
fs::write(recipe_dir.join("import").join("report.txt"), &report)?;
println!(
"Imported AUR package {} into {}.",
conversion.rbpkg.package.name,
recipe_dir.display()
);
println!("Saved cookbook recipe to {}.", recipe_path.display());
println!("{report}");
Ok(())
}
fn inspect_target(context: &AppContext, target: &str) -> Result<(), Box<dyn std::error::Error>> {
let path = Path::new(target);
if path.exists() {
@@ -358,7 +589,7 @@ fn inspect_target(context: &AppContext, target: &str) -> Result<(), Box<dyn std:
let mut library = context.open_library()?;
let info = library.info(PackageName::new(target.to_string())?)?;
println!("{info:#?}");
print_local_package_info(target, &info);
Ok(())
}
@@ -411,9 +642,90 @@ fn update_all(context: &AppContext) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn remove_package(context: &AppContext, package: &str) -> Result<(), Box<dyn std::error::Error>> {
let package_name = PackageName::new(package.to_string())?;
let mut library = context.open_library()?;
library.uninstall(vec![package_name])?;
let applied = apply_library_changes(&mut library)?;
println!("Removed {} ({} change(s)).", package, applied);
Ok(())
}
fn query_local_packages(context: &AppContext) -> Result<(), Box<dyn std::error::Error>> {
let library = context.open_library()?;
let mut packages = library.get_installed_packages()?;
packages.sort();
if packages.is_empty() {
println!("No packages are currently installed.");
return Ok(());
}
for package in packages {
println!("{}", package);
}
Ok(())
}
fn query_local_info(
context: &AppContext,
package: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let mut library = context.open_library()?;
let info = library.info(PackageName::new(package.to_string())?)?;
print_local_package_info(package, &info);
Ok(())
}
fn query_local_files(
context: &AppContext,
package: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let package_name = PackageName::new(package.to_string())?;
let package_state = PackageState::from_sysroot(&context.install_path)?;
let install_state = package_state.installed.get(&package_name).ok_or_else(|| {
CubError::PackageNotFound(format!("{package} is not installed under {}", context.install_path.display()))
})?;
let repo_key = package_state.pubkeys.get(&install_state.remote).ok_or_else(|| {
CubError::BuildFailed(format!(
"missing repository public key '{}' for installed package {}",
install_state.remote, package
))
})?;
let head_path = context
.install_path
.join(PACKAGES_HEAD_DIR)
.join(format!("{package}.pkgar_head"));
let mut package_file = PackageFile::new(&head_path, &repo_key.pkey)?;
let mut paths = Vec::new();
for entry in package_file.read_entries()? {
let relative = entry.check_path()?;
paths.push(format!("/{}", relative.display()));
}
paths.sort();
if paths.is_empty() {
println!("{} has no recorded installed files.", package);
} else {
for path in paths {
println!("{} {}", package, path);
}
}
Ok(())
}
fn clean_cache() -> Result<(), Box<dyn std::error::Error>> {
remove_dir_if_exists(Path::new(PKG_DOWNLOAD_DIR))?;
remove_dir_if_exists(Path::new(CUB_CACHE_DIR))?;
if let Ok(store) = CubStore::new() {
if let Err(error) = store.cache_clean() {
eprintln!("Failed to clean Cub store sources cache: {error}");
}
}
println!("Removed package caches from {PKG_DOWNLOAD_DIR} and {CUB_CACHE_DIR}.");
Ok(())
}
@@ -430,6 +742,58 @@ fn apply_library_changes(library: &mut Library) -> Result<usize, Box<dyn std::er
}
}
fn print_aur_package(package: &AurPackage) {
println!("Repository : AUR");
println!("Name : {}", package.name);
println!("Version : {}", package.version);
println!("Description : {}", empty_if_blank(&package.description));
println!("URL : {}", empty_if_blank(&package.url));
println!("Licenses : {}", join_strings(&package.license));
println!("Depends On : {}", join_strings(&package.depends));
println!("Make Depends : {}", join_strings(&package.makedepends));
println!("Optional Deps : {}", join_strings(&package.optdepends));
println!("Provides : {}", join_strings(&package.provides));
println!("Conflicts With : {}", join_strings(&package.conflicts));
println!("Votes : {}", package.num_votes);
println!("Popularity : {:.5}", package.popularity);
println!("Last Modified : {}", package.last_modified);
println!(
"Out Of Date : {}",
match package.out_of_date {
Some(true) => "yes",
Some(false) => "no",
None => "unknown",
}
);
}
fn print_local_package_info(package: &str, info: &pkg::PackageInfo) {
println!("Repository : {}", info.package.remote);
println!("Name : {}", info.package.package.name);
println!("Version : {}", empty_if_blank(&info.package.package.version));
println!("Target : {}", empty_if_blank(&info.package.package.target));
println!("Installed : {}", yes_no(info.installed));
println!("Depends On : {}", join_package_names(&info.package.package.depends));
println!("Blake3 : {}", empty_if_blank(&info.package.package.blake3));
println!("Storage Size : {}", info.package.package.storage_size);
println!("Network Size : {}", info.package.package.network_size);
println!(
"Source ID : {}",
empty_if_blank(&info.package.package.source_identifier)
);
println!(
"Commit ID : {}",
empty_if_blank(&info.package.package.commit_identifier)
);
println!(
"Published At : {}",
empty_if_blank(&info.package.package.time_identifier)
);
if info.package.package.name.as_str() != package {
println!("Requested As : {}", package);
}
}
fn inspect_rbpkgbuild_path(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let rbpkg_path = if path.is_dir() {
path.join("RBPKGBUILD")
@@ -613,6 +977,12 @@ fn sync_bur_repo() -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(repo_dir)
}
fn init_cub_store() -> Result<CubStore, Box<dyn std::error::Error>> {
let store = CubStore::new()?;
store.init()?;
Ok(store)
}
fn default_bur_repo_url() -> String {
env::var("CUB_BUR_REPO_URL").unwrap_or_else(|_| DEFAULT_BUR_REPO_URL.to_string())
}
@@ -799,3 +1169,46 @@ fn remove_dir_if_exists(path: &Path) -> Result<(), io::Error> {
}
Ok(())
}
fn current_unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn join_strings(values: &[String]) -> String {
if values.is_empty() {
"None".to_string()
} else {
values.join(", ")
}
}
fn join_package_names(values: &[PackageName]) -> String {
if values.is_empty() {
"None".to_string()
} else {
values
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
}
fn empty_if_blank(value: &str) -> &str {
if value.trim().is_empty() {
"None"
} else {
value
}
}
fn yes_no(value: bool) -> &'static str {
if value {
"yes"
} else {
"no"
}
}
@@ -12,8 +12,30 @@ pub fn map_dependency(arch_name: &str) -> MappedDep {
let (mapped, is_exact) = match base.as_str() {
"glibc" => ("relibc".to_string(), false),
"gcc" | "make" => ("build-base".to_string(), false),
"gcc-libs" => ("gcc".to_string(), false),
"pkg-config" => ("pkg-config".to_string(), true),
"glib2" => ("glib".to_string(), true),
"gtk3" => ("gtk".to_string(), false),
"gtk4" => ("gtk".to_string(), false),
"openssl" => ("openssl3".to_string(), false),
"qt5-base" => ("qtbase".to_string(), false),
"qt6-base" => ("qtbase".to_string(), false),
"sdl2" => ("sdl2".to_string(), true),
"freetype2" => ("freetype".to_string(), true),
"fontconfig" => ("fontconfig".to_string(), true),
"harfbuzz" => ("harfbuzz".to_string(), true),
"libpng" => ("libpng".to_string(), true),
"libjpeg-turbo" => ("libjpeg-turbo".to_string(), true),
"libx11" => ("libx11".to_string(), false),
"libxcb" => ("libxcb".to_string(), false),
"wayland" => ("wayland".to_string(), true),
"wayland-protocols" => ("wayland-protocols".to_string(), true),
"xorg-server" => (String::new(), false),
"xorgproto" => (String::new(), false),
"llvm" => ("llvm".to_string(), true),
"clang" => ("llvm".to_string(), false),
"linux-api-headers" => (String::new(), false),
"linux-firmware" => (String::new(), false),
"zlib" => ("zlib".to_string(), true),
"libffi" => ("libffi".to_string(), true),
"pcre2" => ("pcre2".to_string(), true),
@@ -18,6 +18,12 @@ pub enum CubError {
Conversion(String),
#[error("Dependency resolution failed: {0}")]
Dependency(String),
#[error("AUR error: {0}")]
Aur(String),
#[error("Storage error: {0}")]
Storage(String),
#[error("Network error: {0}")]
Network(String),
#[error("Sandbox error: {0}")]
Sandbox(String),
}
@@ -1,11 +1,16 @@
pub mod rbpkgbuild;
pub mod rbsrcinfo;
pub mod cookbook;
pub mod aur;
pub mod converter;
pub mod cook;
pub mod cookbook;
pub mod deps;
pub mod sandbox;
pub mod error;
#[cfg(feature = "full")]
pub mod package;
pub mod error;
pub mod pkgbuild;
pub mod rbpkgbuild;
pub mod recipe;
pub mod rbsrcinfo;
pub mod sandbox;
pub mod storage;
pub use error::CubError;
@@ -0,0 +1,641 @@
use std::fs;
use std::io::{self, stdout};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::Duration;
use cub::aur::{self, AurClient};
use cub::rbpkgbuild::RbPkgBuild;
use cub::storage::CubStore;
use cub::CubError;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::TermionBackend;
use ratatui::style::Style;
use ratatui::widgets::Block;
use ratatui::Terminal;
use termion::event::Key;
use termion::input::TermRead;
use termion::raw::IntoRawMode;
use termion::screen::IntoAlternateScreen;
use crate::theme::RedBearTheme;
use crate::views;
const DEFAULT_TARGET: &str = "x86_64-unknown-redox";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum View {
Search,
PackageInfo,
Install,
Build,
Query,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ActionKind {
Install,
Build,
}
#[derive(Debug)]
pub(crate) struct QueryEntry {
pub(crate) title: String,
path: PathBuf,
kind: QueryEntryKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum QueryEntryKind {
Recipe,
Package,
}
#[derive(Debug)]
struct ActionUpdate {
kind: ActionKind,
success: bool,
summary: String,
lines: Vec<String>,
}
pub struct CubApp {
pub search_query: String,
pub search_results: Vec<aur::AurPackage>,
pub selected_index: usize,
pub current_view: View,
pub status_message: String,
pub running: bool,
pub store: CubStore,
pub aur_client: Option<AurClient>,
query_entries: Vec<QueryEntry>,
query_details: Vec<String>,
install_log: Vec<String>,
build_log: Vec<String>,
install_running: bool,
build_running: bool,
action_receiver: Option<Receiver<ActionUpdate>>,
active_action: Option<ActionKind>,
tick: usize,
}
impl CubApp {
pub fn new() -> Result<Self, CubError> {
let store = CubStore::new()?;
store.init()?;
let mut app = Self {
search_query: String::new(),
search_results: Vec::new(),
selected_index: 0,
current_view: View::Search,
status_message: "Type a query, press Enter to search AUR, Tab to change views.".into(),
running: true,
store,
aur_client: Some(AurClient::new()),
query_entries: Vec::new(),
query_details: Vec::new(),
install_log: vec![
"Select a package in Search or Package Info and press i to install.".into(),
],
build_log: vec![
"Select a local recipe in Query or a cached package in Info and press b to build."
.into(),
],
install_running: false,
build_running: false,
action_receiver: None,
active_action: None,
tick: 0,
};
app.refresh_query_view()?;
Ok(app)
}
pub fn run(&mut self) -> Result<(), CubError> {
let stdout = stdout();
let stdout = stdout.into_raw_mode()?;
let stdout = stdout.into_alternate_screen()?;
let backend = TermionBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(terminal_error)?;
terminal.clear().map_err(terminal_error)?;
let mut events = termion::async_stdin().keys();
let run_result = (|| -> Result<(), CubError> {
while self.running {
self.tick = self.tick.wrapping_add(1);
self.poll_action_updates();
terminal
.draw(|frame| self.draw(frame))
.map_err(terminal_error)?;
if let Some(event) = events.next() {
match event {
Ok(key) => self.handle_key(key),
Err(error) => {
self.status_message = format!("Input error: {error}");
self.running = false;
}
}
}
thread::sleep(Duration::from_millis(16));
}
Ok(())
})();
let cursor_result = terminal.show_cursor().map_err(terminal_error);
run_result.and(cursor_result)
}
pub fn draw(&self, frame: &mut ratatui::Frame<'_>) {
let theme = RedBearTheme::default();
let area = frame.area();
frame.render_widget(
Block::default().style(Style::default().bg(theme.background).fg(theme.text)),
area,
);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(2),
])
.split(area);
views::render_tabs(frame, layout[0], self, &theme);
match self.current_view {
View::Search => views::search::render(frame, layout[1], self, &theme),
View::PackageInfo => views::info::render(frame, layout[1], self, &theme),
View::Install => views::install::render(frame, layout[1], self, &theme),
View::Build => views::build::render(frame, layout[1], self, &theme),
View::Query => views::query::render(frame, layout[1], self, &theme),
}
views::render_status(frame, layout[2], self, &theme);
}
pub fn handle_key(&mut self, key: Key) {
match key {
Key::Char('q') | Key::Esc => {
self.running = false;
return;
}
Key::Char('/') => {
self.current_view = View::Search;
self.selected_index = self
.selected_index
.min(self.search_results.len().saturating_sub(1));
self.status_message = "Search focused. Type a query and press Enter.".into();
return;
}
Key::Char('\t') => {
self.cycle_view(true);
return;
}
Key::BackTab => {
self.cycle_view(false);
return;
}
_ => {}
}
match self.current_view {
View::Search => views::search::handle_key(self, key),
View::PackageInfo => views::info::handle_key(self, key),
View::Install => views::install::handle_key(self, key),
View::Build => views::build::handle_key(self, key),
View::Query => views::query::handle_key(self, key),
}
}
pub fn selected_package(&self) -> Option<&aur::AurPackage> {
self.search_results.get(self.selected_index)
}
pub(crate) fn query_entries(&self) -> &[QueryEntry] {
&self.query_entries
}
pub(crate) fn query_details(&self) -> &[String] {
&self.query_details
}
pub(crate) fn install_log(&self) -> &[String] {
&self.install_log
}
pub(crate) fn build_log(&self) -> &[String] {
&self.build_log
}
pub(crate) fn install_running(&self) -> bool {
self.install_running
}
pub(crate) fn build_running(&self) -> bool {
self.build_running
}
pub(crate) fn spinner_frame(&self) -> char {
const FRAMES: [char; 4] = ['-', '\\', '|', '/'];
FRAMES[self.tick % FRAMES.len()]
}
pub fn search(&mut self) {
let query = self.search_query.trim();
if query.is_empty() {
self.status_message = "Search query cannot be empty.".into();
self.search_results.clear();
self.selected_index = 0;
return;
}
let Some(client) = self.aur_client.as_ref() else {
self.status_message = "AUR client is unavailable in this build.".into();
return;
};
match client.search(query, None) {
Ok(results) => {
self.search_results = results;
self.selected_index = 0;
self.status_message = format!(
"Found {} AUR package(s) for {:?}.",
self.search_results.len(),
query
);
if self.search_results.is_empty() {
self.current_view = View::Search;
}
}
Err(error) => {
self.search_results.clear();
self.selected_index = 0;
self.status_message = error.to_string();
}
}
}
pub fn start_install_selected(&mut self) {
let Some(package) = self.selected_package() else {
self.status_message = "No package selected to install.".into();
return;
};
if self.install_running || self.build_running {
self.status_message = "Another action is already running.".into();
self.current_view = View::Install;
return;
}
let package_name = package.name.clone();
self.current_view = View::Install;
self.install_running = true;
self.install_log = vec![
format!("Preparing installation for {}", package_name),
format!("Running command: cub install {}", package_name),
];
self.status_message = format!("Installing {}...", package_name);
self.spawn_action(
ActionKind::Install,
"cub",
vec!["install".into(), package_name],
);
}
pub fn start_build_selected(&mut self) {
if self.install_running || self.build_running {
self.status_message = "Another action is already running.".into();
self.current_view = View::Build;
return;
}
let build_target = if self.current_view == View::Query {
self.selected_query_recipe_dir()
} else {
self.selected_package()
.map(|package| self.store.recipes_dir().join(&package.name))
.filter(|path| path.is_dir())
};
let Some(recipe_dir) = build_target else {
self.current_view = View::Build;
self.build_log = vec![
"No local recipe is available for the current selection.".into(),
format!(
"Expected imported recipe under {}",
self.store.recipes_dir().display()
),
];
self.status_message = "Build requires an imported local recipe directory.".into();
return;
};
let display = recipe_dir.display().to_string();
self.current_view = View::Build;
self.build_running = true;
self.build_log = vec![
format!("Preparing build for {}", display),
format!("Running command: cub build {}", display),
];
self.status_message = format!("Building {}...", display);
self.spawn_action(ActionKind::Build, "cub", vec!["build".into(), display]);
}
pub fn open_selected_info(&mut self) {
if self.selected_package().is_some() {
self.current_view = View::PackageInfo;
self.status_message = "Package info view. Press i to install or b to build.".into();
} else {
self.status_message = "Select an AUR package first.".into();
}
}
pub fn move_selection(&mut self, delta: isize) {
let len = match self.current_view {
View::Search | View::PackageInfo => self.search_results.len(),
View::Query => self.query_entries.len(),
View::Install | View::Build => 0,
};
if len == 0 {
self.selected_index = 0;
return;
}
if delta.is_negative() {
let amount = delta.unsigned_abs();
self.selected_index = self.selected_index.saturating_sub(amount);
} else {
self.selected_index = self
.selected_index
.saturating_add(delta as usize)
.min(len - 1);
}
if self.current_view == View::Query {
self.refresh_query_details();
}
}
pub fn refresh_query_view(&mut self) -> Result<(), CubError> {
let recipe_dirs = self.store.list_recipes()?;
let pkgars = self.store.list_pkgars(DEFAULT_TARGET)?;
self.query_entries = recipe_dirs
.into_iter()
.map(|path| QueryEntry {
title: file_name_or_display(&path),
path,
kind: QueryEntryKind::Recipe,
})
.chain(pkgars.into_iter().map(|path| QueryEntry {
title: file_name_or_display(&path),
path,
kind: QueryEntryKind::Package,
}))
.collect();
self.selected_index = self
.selected_index
.min(self.query_entries.len().saturating_sub(1));
self.refresh_query_details();
Ok(())
}
pub fn refresh_query_details(&mut self) {
let Some(entry) = self.query_entries.get(self.selected_index) else {
self.query_details = vec![
format!("Store root: {}", self.store.root_dir.display()),
"No local recipes or cached pkgars found yet.".into(),
];
return;
};
self.query_details = match entry.kind {
QueryEntryKind::Recipe => describe_recipe_dir(&entry.path),
QueryEntryKind::Package => describe_pkgar(&entry.path),
};
}
fn cycle_view(&mut self, forward: bool) {
self.current_view = match (self.current_view, forward) {
(View::Search, true) => View::PackageInfo,
(View::PackageInfo, true) => View::Install,
(View::Install, true) => View::Build,
(View::Build, true) => View::Query,
(View::Query, true) => View::Search,
(View::Search, false) => View::Query,
(View::PackageInfo, false) => View::Search,
(View::Install, false) => View::PackageInfo,
(View::Build, false) => View::Install,
(View::Query, false) => View::Build,
};
if self.current_view == View::Query {
if let Err(error) = self.refresh_query_view() {
self.status_message = error.to_string();
}
}
}
fn spawn_action(&mut self, kind: ActionKind, program: &str, args: Vec<String>) {
let (tx, rx) = mpsc::channel();
let program = program.to_string();
self.active_action = Some(kind);
self.action_receiver = Some(rx);
thread::spawn(move || {
let output = Command::new(&program).args(&args).output();
let update = match output {
Ok(output) => {
let mut lines = vec![format!("Command: {} {}", program, args.join(" "))];
let stdout_lines = output_string_lines(&output.stdout);
let stderr_lines = output_string_lines(&output.stderr);
if !stdout_lines.is_empty() {
lines.push("stdout:".into());
lines.extend(stdout_lines);
}
if !stderr_lines.is_empty() {
lines.push("stderr:".into());
lines.extend(stderr_lines);
}
let success = output.status.success();
let summary = if success {
format!("{} completed successfully.", action_label(kind))
} else {
format!(
"{} failed with status {:?}.",
action_label(kind),
output.status.code()
)
};
ActionUpdate {
kind,
success,
summary,
lines,
}
}
Err(error) => ActionUpdate {
kind,
success: false,
summary: format!("{} failed to start.", action_label(kind)),
lines: vec![format!("Failed to launch {}: {error}", program)],
},
};
let _ = tx.send(update);
});
}
fn poll_action_updates(&mut self) {
let Some(receiver) = self.action_receiver.take() else {
return;
};
match receiver.try_recv() {
Ok(update) => {
self.active_action = None;
match update.kind {
ActionKind::Install => {
self.install_running = false;
self.install_log = update.lines;
self.current_view = View::Install;
}
ActionKind::Build => {
self.build_running = false;
self.build_log = update.lines;
self.current_view = View::Build;
}
}
self.status_message = update.summary;
if update.success && matches!(update.kind, ActionKind::Build) {
let _ = self.refresh_query_view();
}
}
Err(mpsc::TryRecvError::Empty) => {
self.action_receiver = Some(receiver);
}
Err(mpsc::TryRecvError::Disconnected) => {
self.active_action = None;
self.install_running = false;
self.build_running = false;
self.status_message = "Background action channel closed unexpectedly.".into();
}
}
}
fn selected_query_recipe_dir(&self) -> Option<PathBuf> {
self.query_entries
.get(self.selected_index)
.filter(|entry| entry.kind == QueryEntryKind::Recipe)
.map(|entry| entry.path.clone())
}
}
fn terminal_error(error: io::Error) -> CubError {
CubError::Io(error)
}
fn action_label(kind: ActionKind) -> &'static str {
match kind {
ActionKind::Install => "Install",
ActionKind::Build => "Build",
}
}
fn output_string_lines(bytes: &[u8]) -> Vec<String> {
let output = String::from_utf8_lossy(bytes);
output
.lines()
.map(str::trim_end)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn file_name_or_display(path: &Path) -> String {
path.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned)
.unwrap_or_else(|| path.display().to_string())
}
fn describe_recipe_dir(path: &Path) -> Vec<String> {
let rbpkg_path = path.join("RBPKGBUILD");
if rbpkg_path.is_file() {
match RbPkgBuild::from_file(&rbpkg_path) {
Ok(rbpkg) => {
return vec![
format!("Recipe: {}", rbpkg.package.name),
format!(
"Version: {}-{}",
rbpkg.package.version, rbpkg.package.release
),
format!("Path: {}", path.display()),
format!("Description: {}", rbpkg.package.description),
format!("Build deps: {}", join_or_none(&rbpkg.dependencies.build)),
format!(
"Runtime deps: {}",
join_or_none(&rbpkg.dependencies.runtime)
),
format!(
"Optional deps: {}",
join_or_none(&rbpkg.dependencies.optional)
),
];
}
Err(error) => {
return vec![
format!("Recipe path: {}", path.display()),
format!("RBPKGBUILD parse error: {error}"),
];
}
}
}
let recipe_toml = path.join("recipe.toml");
vec![
format!("Recipe path: {}", path.display()),
format!("RBPKGBUILD: {}", existence_text(&rbpkg_path)),
format!("recipe.toml: {}", existence_text(&recipe_toml)),
]
}
fn describe_pkgar(path: &Path) -> Vec<String> {
let mut lines = vec![format!("Package archive: {}", path.display())];
match fs::metadata(path) {
Ok(metadata) => {
lines.push(format!("Size: {} bytes", metadata.len()));
}
Err(error) => {
lines.push(format!("Failed to read metadata: {error}"));
}
}
lines
}
fn existence_text(path: &Path) -> &'static str {
if path.exists() {
"present"
} else {
"missing"
}
}
fn join_or_none(values: &[String]) -> String {
if values.is_empty() {
"none".into()
} else {
values.join(", ")
}
}
@@ -0,0 +1,84 @@
use std::io::{self, Write};
use termion::clear;
use termion::cursor;
use termion::event::Key;
use termion::input::TermRead;
use termion::raw::IntoRawMode;
use termion::screen::IntoAlternateScreen;
const SHORTCUTS: &[(&str, &str)] = &[
("-S <pkg>", "Install a package from the official repo or BUR"),
("-Ss <query>", "Search the official repo and cached BUR"),
("-Si <pkg>", "Show AUR package information"),
("-Sy", "Refresh BUR cache and verify AUR metadata access"),
("-Syu", "Refresh metadata and update installed packages"),
("-G <pkg>", "Import an AUR package into ~/.cub/recipes"),
("-B <dir>", "Build and install a local RBPKGBUILD directory"),
("-R <pkg>", "Remove an installed package"),
("-Q", "List installed packages"),
("-Qi <pkg>", "Show installed package details"),
("-Ql <pkg>", "List files installed by a package"),
("-Sc", "Clean Cub and pkg download caches"),
];
const COMMANDS: &[(&str, &str)] = &[
("install <pkg>", "Install from repo or BUR"),
("search <query>", "Search official repo and BUR cache"),
("info <pkg>", "Query AUR metadata"),
("sync", "Refresh BUR cache and AUR sync stamp"),
("system-upgrade", "Sync first, then update installed packages"),
("build <dir>", "Build and install a local recipe tree"),
("get <pkg>", "Copy a BUR recipe into the current directory"),
("get-aur <pkg>", "Convert an AUR PKGBUILD into ~/.cub/recipes"),
("inspect <target>", "Inspect a local RBPKGBUILD or package metadata"),
("import-aur <target>", "Convert an AUR PKGBUILD into the current directory"),
("update-all", "Update installed packages"),
("remove <pkg>", "Remove an installed package"),
("query-local", "List installed packages"),
("query-info <pkg>", "Show installed package details"),
("query-list <pkg>", "List installed package files"),
("clean-cache", "Remove caches"),
];
pub fn run() -> io::Result<()> {
let stdin = io::stdin();
let stdout = io::stdout().into_raw_mode()?;
let mut screen = stdout.into_alternate_screen()?;
render(&mut screen)?;
for key in stdin.keys() {
match key? {
Key::Char('q') | Key::Esc => break,
Key::Char('r') | Key::Ctrl('l') => render(&mut screen)?,
_ => {}
}
}
write!(screen, "{}{}{}", clear::All, cursor::Goto(1, 1), cursor::Show)?;
screen.flush()
}
fn render(screen: &mut impl Write) -> io::Result<()> {
write!(screen, "{}{}{}", clear::All, cursor::Goto(1, 1), cursor::Hide)?;
writeln!(screen, "cub — Red Bear OS Package Builder")?;
writeln!(screen)?;
writeln!(screen, "Default TUI mode is active because no subcommand was provided.")?;
writeln!(screen, "Run `cub --no-tui` to see clap help instead.")?;
writeln!(screen)?;
writeln!(screen, "Arch-style shortcuts")?;
writeln!(screen, "-------------------")?;
for (shortcut, description) in SHORTCUTS {
writeln!(screen, " {:<14} {}", shortcut, description)?;
}
writeln!(screen)?;
writeln!(screen, "Named commands")?;
writeln!(screen, "--------------")?;
for (command, description) in COMMANDS {
writeln!(screen, " {:<18} {}", command, description)?;
}
writeln!(screen)?;
writeln!(screen, "Controls: q / Esc = quit, r / Ctrl-L = redraw")?;
screen.flush()
}
@@ -0,0 +1,59 @@
use ratatui::style::{Color, Modifier, Style};
#[derive(Clone, Copy, Debug)]
pub struct RedBearTheme {
pub background: Color,
pub surface: Color,
pub accent: Color,
pub text: Color,
pub muted: Color,
pub success: Color,
pub warning: Color,
pub error: Color,
}
impl Default for RedBearTheme {
fn default() -> Self {
Self {
background: Color::Rgb(14, 14, 18),
surface: Color::Rgb(24, 24, 28),
accent: Color::Rgb(181, 36, 48),
text: Color::Rgb(244, 244, 244),
muted: Color::Rgb(170, 170, 176),
success: Color::Rgb(116, 199, 147),
warning: Color::Rgb(255, 191, 87),
error: Color::Rgb(239, 83, 80),
}
}
}
impl RedBearTheme {
pub fn base_style(self) -> Style {
Style::default().bg(self.background).fg(self.text)
}
pub fn muted_style(self) -> Style {
Style::default().bg(self.background).fg(self.muted)
}
pub fn status_style(self) -> Style {
Style::default().bg(self.surface).fg(self.text)
}
pub fn border_style(self) -> Style {
Style::default().fg(self.muted)
}
pub fn focused_border_style(self) -> Style {
Style::default()
.fg(self.accent)
.add_modifier(Modifier::BOLD)
}
pub fn selected_style(self) -> Style {
Style::default()
.bg(self.accent)
.fg(self.text)
.add_modifier(Modifier::BOLD)
}
}
@@ -0,0 +1,27 @@
use ratatui::layout::Rect;
use termion::event::Key;
use crate::app::CubApp;
use crate::theme::RedBearTheme;
use crate::widgets;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let title = if app.build_running() {
format!("Build {} running", app.spinner_frame())
} else {
"Build Progress".to_string()
};
let body = widgets::log_paragraph(app.build_log(), &title, theme, true);
frame.render_widget(body, area);
}
pub fn handle_key(app: &mut CubApp, key: Key) {
match key {
Key::Char('b') => app.start_build_selected(),
Key::Left => {
app.current_view = crate::app::View::PackageInfo;
app.status_message = "Returned to package info.".into();
}
_ => {}
}
}
@@ -0,0 +1,70 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::Line;
use ratatui::widgets::{List, ListItem, Paragraph};
use termion::event::Key;
use crate::app::CubApp;
use crate::theme::RedBearTheme;
use crate::widgets;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(2)])
.split(area);
let Some(package) = app.selected_package() else {
let empty = Paragraph::new("Select a package in Search to inspect its details.")
.style(theme.base_style())
.block(widgets::block("Package Info", theme, true));
frame.render_widget(empty, layout[0]);
return;
};
let items = vec![
ListItem::new(Line::from(format!("Name: {}", package.name))),
ListItem::new(Line::from(format!("Version: {}", package.version))),
ListItem::new(Line::from(format!("Description: {}", package.description))),
ListItem::new(Line::from(format!(
"Depends: {}",
join_or_none(&package.depends)
))),
ListItem::new(Line::from(format!(
"Make depends: {}",
join_or_none(&package.makedepends)
))),
ListItem::new(Line::from(format!(
"Optional depends: {}",
join_or_none(&package.optdepends)
))),
];
let list = List::new(items).block(widgets::block("Package Info", theme, true));
frame.render_widget(list, layout[0]);
let hints = Paragraph::new("i install • b build local recipe • ← return to search")
.style(theme.muted_style());
frame.render_widget(hints, layout[1]);
}
pub fn handle_key(app: &mut CubApp, key: Key) {
match key {
Key::Left => {
app.current_view = crate::app::View::Search;
app.status_message = "Search view focused.".into();
}
Key::Up | Key::Char('k') => app.move_selection(-1),
Key::Down | Key::Char('j') => app.move_selection(1),
Key::Char('i') => app.start_install_selected(),
Key::Char('b') => app.start_build_selected(),
_ => {}
}
}
fn join_or_none(values: &[String]) -> String {
if values.is_empty() {
"none".into()
} else {
values.join(", ")
}
}
@@ -0,0 +1,27 @@
use ratatui::layout::Rect;
use termion::event::Key;
use crate::app::CubApp;
use crate::theme::RedBearTheme;
use crate::widgets;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let title = if app.install_running() {
format!("Install {} running", app.spinner_frame())
} else {
"Install Progress".to_string()
};
let body = widgets::log_paragraph(app.install_log(), &title, theme, true);
frame.render_widget(body, area);
}
pub fn handle_key(app: &mut CubApp, key: Key) {
match key {
Key::Char('i') => app.start_install_selected(),
Key::Left => {
app.current_view = crate::app::View::PackageInfo;
app.status_message = "Returned to package info.".into();
}
_ => {}
}
}
@@ -0,0 +1,67 @@
pub mod build;
pub mod info;
pub mod install;
pub mod query;
pub mod search;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Tabs};
use crate::app::{CubApp, View};
use crate::theme::RedBearTheme;
use crate::widgets;
pub fn render_tabs(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let titles = [
View::Search,
View::PackageInfo,
View::Install,
View::Build,
View::Query,
]
.into_iter()
.map(view_title)
.map(Line::from)
.collect::<Vec<_>>();
let selected = match app.current_view {
View::Search => 0,
View::PackageInfo => 1,
View::Install => 2,
View::Build => 3,
View::Query => 4,
};
let tabs = Tabs::new(titles)
.block(widgets::block("cub-tui", theme, true))
.style(theme.base_style())
.highlight_style(theme.selected_style())
.select(selected)
.divider("|");
frame.render_widget(tabs, area);
}
pub fn render_status(
frame: &mut ratatui::Frame<'_>,
area: Rect,
app: &CubApp,
theme: &RedBearTheme,
) {
let text = format!(
"{} [q/esc quit] [Tab views] [/ search]",
app.status_message
);
let status = Paragraph::new(text).style(theme.status_style());
frame.render_widget(status, area);
}
fn view_title(view: View) -> &'static str {
match view {
View::Search => " Search ",
View::PackageInfo => " Info ",
View::Install => " Install ",
View::Build => " Build ",
View::Query => " Query ",
}
}
@@ -0,0 +1,57 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::widgets::{List, ListItem, ListState};
use termion::event::Key;
use crate::app::CubApp;
use crate::theme::RedBearTheme;
use crate::widgets;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(38), Constraint::Percentage(62)])
.split(area);
let items = if app.query_entries().is_empty() {
vec![ListItem::new("No local recipes or cached pkgars.")]
} else {
app.query_entries()
.iter()
.map(|entry| ListItem::new(entry.title.clone()))
.collect()
};
let mut state = ListState::default();
if !app.query_entries().is_empty() {
state.select(Some(
app.selected_index
.min(app.query_entries().len().saturating_sub(1)),
));
}
let list = List::new(items)
.block(widgets::block("Local Query", theme, true))
.highlight_style(theme.selected_style())
.highlight_symbol("» ");
frame.render_stateful_widget(list, layout[0], &mut state);
let details = widgets::log_paragraph(app.query_details(), "Details", theme, false);
frame.render_widget(details, layout[1]);
}
pub fn handle_key(app: &mut CubApp, key: Key) {
match key {
Key::Up | Key::Char('k') => app.move_selection(-1),
Key::Down | Key::Char('j') => app.move_selection(1),
Key::Char('r') => match app.refresh_query_view() {
Ok(()) => {
app.status_message = "Refreshed local cub store view.".into();
}
Err(error) => {
app.status_message = error.to_string();
}
},
Key::Char('b') => app.start_build_selected(),
_ => {}
}
}
@@ -0,0 +1,81 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::Line;
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use termion::event::Key;
use crate::app::CubApp;
use crate::theme::RedBearTheme;
use crate::widgets;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(2),
])
.split(area);
let search_bar = Paragraph::new(app.search_query.as_str())
.style(theme.base_style())
.block(widgets::block("AUR Search", theme, true));
frame.render_widget(search_bar, layout[0]);
let items = if app.search_results.is_empty() {
vec![ListItem::new(
"No AUR results yet. Type a query and press Enter.",
)]
} else {
app.search_results
.iter()
.map(|package| {
let description = if package.description.is_empty() {
"No description"
} else {
package.description.as_str()
};
ListItem::new(vec![
Line::from(format!("{} {}", package.name, package.version)),
Line::from(description.to_string()),
])
})
.collect()
};
let mut state = ListState::default();
if !app.search_results.is_empty() {
state.select(Some(
app.selected_index
.min(app.search_results.len().saturating_sub(1)),
));
}
let results = List::new(items)
.block(widgets::block("Results", theme, false))
.highlight_style(theme.selected_style())
.highlight_symbol("» ");
frame.render_stateful_widget(results, layout[1], &mut state);
let hints = Paragraph::new("Enter search • ↑/↓ move • i info • I install • b build")
.style(theme.muted_style());
frame.render_widget(hints, layout[2]);
}
pub fn handle_key(app: &mut CubApp, key: Key) {
match key {
Key::Char('\n') => app.search(),
Key::Char('i') | Key::Right => app.open_selected_info(),
Key::Char('I') => app.start_install_selected(),
Key::Char('b') => app.start_build_selected(),
Key::Up | Key::Char('k') => app.move_selection(-1),
Key::Down | Key::Char('j') => app.move_selection(1),
Key::Backspace => {
app.search_query.pop();
}
Key::Char(ch) if !ch.is_control() => {
app.search_query.push(ch);
}
_ => {}
}
}
@@ -0,0 +1,37 @@
use ratatui::style::Style;
use ratatui::text::Text;
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use crate::theme::RedBearTheme;
pub fn block<'a>(title: &'a str, theme: &RedBearTheme, focused: bool) -> Block<'a> {
let border_style = if focused {
theme.focused_border_style()
} else {
theme.border_style()
};
Block::default()
.title(title)
.borders(Borders::ALL)
.style(Style::default().bg(theme.background).fg(theme.text))
.border_style(border_style)
}
pub fn log_paragraph<'a>(
lines: &[String],
title: &'a str,
theme: &RedBearTheme,
focused: bool,
) -> Paragraph<'a> {
let body = if lines.is_empty() {
Text::from("No output yet.")
} else {
Text::from(lines.join("\n"))
};
Paragraph::new(body)
.wrap(Wrap { trim: false })
.style(theme.base_style())
.block(block(title, theme, focused))
}