From 00572043854416ac3f4ef5b7f1836a2618d1899c Mon Sep 17 00:00:00 2001 From: Chintan Patel <216989679+Chintanpatel24@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:51:45 +0530 Subject: [PATCH] feat: add prebuilt binary release workflows, updated installer, and CLI args - Add GitHub Actions CI (.github/workflows/ci.yml) and Release workflows (.github/workflows/release.yml) with x86_64 and aarch64 cross-build matrix. - Overhaul install.sh to fetch prebuilt binaries to ~/.local/bin without sudo, with ELF verification and cargo build fallback. - Support CLI flags in main.rs (--version, -v, --help, -h) and 'glare update' subcommand. - Clean up unused warnings and clippy lints, format codebase with cargo fmt. - Update README.md with one-liner installer and update instructions. --- .github/workflows/ci.yml | 38 +++++++++++++++ .github/workflows/release.yml | 56 ++++++++++++++++++++++ README.md | 30 ++++++++---- install.sh | 87 ++++++++++++++++++++++++++++++----- src/app.rs | 37 +++++++++++---- src/config.rs | 14 +++--- src/event.rs | 4 +- src/main.rs | 44 ++++++++++++++++++ src/package.rs | 27 ++++++++--- src/pacman.rs | 27 ++++------- src/ui/dashboard.rs | 4 +- src/ui/detail.rs | 19 +++----- src/ui/footer.rs | 12 +++-- src/ui/header.rs | 21 ++++++--- src/ui/mod.rs | 26 +++++------ src/ui/package_list.rs | 10 ++-- src/ui/theme.rs | 26 ++++++++--- 17 files changed, 371 insertions(+), 111 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0b54f82 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + check-and-test: + name: Check, Test & Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + + - name: Cargo Format Check + run: cargo fmt --check + + - name: Clippy Linter + run: cargo clippy -- -D warnings + + - name: Run Tests + run: cargo test --verbose + + - name: Build Release + run: cargo build --release --verbose diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5ea60ba --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build-and-release: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + asset_name: glare-x86_64-linux + use_cross: false + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + asset_name: glare-aarch64-linux + use_cross: true + + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross + if: matrix.use_cross + run: cargo install cross --git https://github.com/cross-rs/cross + + - name: Build binary + run: | + if [ "${{ matrix.use_cross }}" = "true" ]; then + cross build --release --target ${{ matrix.target }} + else + cargo build --release --target ${{ matrix.target }} + fi + + - name: Package asset + run: | + cp "target/${{ matrix.target }}/release/glare" "${{ matrix.asset_name }}" + + - name: Upload binary to release + uses: softprops/action-gh-release@v2 + with: + files: ${{ matrix.asset_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 98409e1..6b07fb0 100644 --- a/README.md +++ b/README.md @@ -16,31 +16,43 @@ Think of it as a **file manager, but for your system state**. - **Dashboard** -- Overview with package stats, disk usage breakdown, and top 10 largest packages with bar chart - **Blazing Fast** -- Pure Rust, single binary, queries pacman live with zero persistent storage - **Adaptive Theme** -- Respects your terminal colors with a clean, minimal aesthetic +## Installation + +Install Glare via the one-liner script (fetches the prebuilt binary directly to `~/.local/bin`, no `sudo` required): +```sh +curl -sSL https://raw.githubusercontent.com/TattvaOrg/Glare/main/install.sh | bash ``` -## Installation +## Updating -### One-Liner +Update Glare to the latest release at any time: -```bash -curl -sL https://raw.githubusercontent.com/TattvaOrg/Glare/main/install.sh | bash +```sh +glare update ``` -### From Source +Or re-run the installer: -```bash +```sh +curl -sSL https://raw.githubusercontent.com/TattvaOrg/Glare/main/install.sh | bash +``` + +## Build from Source + +```sh git clone https://github.com/TattvaOrg/Glare.git cd Glare cargo build --release -sudo install -Dm755 target/release/glare /usr/local/bin/glare +install -Dm755 target/release/glare ~/.local/bin/glare ``` ### Requirements - Arch Linux (or Arch-based: CachyOS, EndeavourOS, Manjaro, etc.) -- `pacman` (comes with Arch) -- Rust toolchain (for building from source) +- `pacman` (standard on Arch Linux) +- `curl` (for prebuilt installer) +- Rust toolchain (only if compiling from source) ## Usage diff --git a/install.sh b/install.sh index 6e3f214..f97da7b 100755 --- a/install.sh +++ b/install.sh @@ -1,19 +1,84 @@ #!/bin/bash set -e -REPO="https://github.com/TattvaOrg/Glare.git" -INSTALL_DIR="/usr/local/bin" -TMP_DIR=$(mktemp -d) +ARCH=$(uname -m) +case $ARCH in + x86_64) + TARGET_ARCH="x86_64" + ;; + aarch64|arm64) + TARGET_ARCH="aarch64" + ;; + *) + echo "Error: Unsupported architecture: $ARCH" + exit 1 + ;; +esac -trap 'rm -rf "$TMP_DIR"' EXIT +BIN_DIR="${CARGO_HOME:-$HOME/.local}/bin" +[ -d "$HOME/.local/bin" ] && BIN_DIR="$HOME/.local/bin" +mkdir -p "$BIN_DIR" -echo ":: Cloning Glare..." -git clone --depth 1 "$REPO" "$TMP_DIR/glare" 2>/dev/null +IS_UPDATE=false +if [ -f "$BIN_DIR/glare" ]; then + IS_UPDATE=true + CURRENT_VERSION=$("$BIN_DIR/glare" --version 2>/dev/null || echo "existing") + echo "==> Existing Glare installation detected ($CURRENT_VERSION)." + echo "==> Updating Glare at $BIN_DIR/glare..." +else + echo "==> Installing Glare to $BIN_DIR/glare..." +fi -echo ":: Building (release)..." -cargo build --release --manifest-path "$TMP_DIR/glare/Cargo.toml" 2>/dev/null +URL="https://github.com/TattvaOrg/Glare/releases/latest/download/glare-$TARGET_ARCH-linux" +TMP_BIN=$(mktemp) +trap 'rm -f "$TMP_BIN"' EXIT -echo ":: Installing to $INSTALL_DIR..." -sudo install -Dm755 "$TMP_DIR/glare/target/release/glare" "$INSTALL_DIR/glare" +echo "==> Fetching prebuilt release binary..." +if curl -sSfL "$URL" -o "$TMP_BIN" 2>/dev/null; then + # Verify file is an executable binary and not an error page + if file "$TMP_BIN" | grep -qE "ELF|executable"; then + # Unlink existing binary first to avoid ETXTBSY if currently running + rm -f "$BIN_DIR/glare" + mv -f "$TMP_BIN" "$BIN_DIR/glare" + chmod +x "$BIN_DIR/glare" + NEW_VER=$("$BIN_DIR/glare" --version 2>/dev/null || echo "") + if [ "$IS_UPDATE" = true ]; then + echo "✅ Glare updated successfully! ($NEW_VER)" + else + echo "✅ Glare installed successfully! ($NEW_VER)" + fi + else + echo "Error: Downloaded file is not a valid binary." + exit 1 + fi +else + echo "Notice: Prebuilt release binary not found at $URL" + if command -v cargo >/dev/null 2>&1; then + echo "==> Compiling and updating from source using cargo..." + cargo install --force --git https://github.com/TattvaOrg/Glare.git --root "${BIN_DIR%/bin}" + NEW_VER=$("$BIN_DIR/glare" --version 2>/dev/null || echo "") + if [ "$IS_UPDATE" = true ]; then + echo "✅ Glare updated successfully via cargo! ($NEW_VER)" + else + echo "✅ Glare installed successfully via cargo! ($NEW_VER)" + fi + else + echo "Error: Could not download prebuilt binary and 'cargo' is not installed." + echo "Please install Rust (https://rustup.rs) or check https://github.com/TattvaOrg/Glare/releases" + exit 1 + fi +fi -echo ":: Done. Run 'glare' to start." +# PATH guidance +case ":$PATH:" in + *":$BIN_DIR:"*) ;; + *) + echo "" + echo "⚠️ Note: $BIN_DIR is not in your PATH." + echo "Add it to your shell configuration (e.g. ~/.bashrc or ~/.zshrc):" + echo " export PATH=\"\$PATH:$BIN_DIR\"" + ;; +esac + +echo "" +echo "Run 'glare' to start the application." diff --git a/src/app.rs b/src/app.rs index e30bfe9..91db497 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,5 @@ use crate::config::Config; -use crate::package::{FileGroup, Package, PackageSource, InstallReason, group_files}; +use crate::package::{group_files, FileGroup, InstallReason, Package, PackageSource}; use crate::pacman; use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -8,6 +8,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(clippy::upper_case_acronyms)] pub enum FilterCategory { All, Explicit, @@ -123,6 +124,7 @@ pub struct App { pub aur_names: HashSet, pub cached_files: HashMap>, pub total_installed_size: u64, + #[allow(dead_code)] pub config: Config, } @@ -144,7 +146,7 @@ impl App { } // Sort by name initially - packages.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + packages.sort_by_key(|a| a.name.to_lowercase()); let total_installed_size: u64 = packages.iter().map(|p| p.installed_size_bytes).sum(); let filtered_indices: Vec = (0..packages.len()).collect(); @@ -354,10 +356,12 @@ impl App { pub fn load_files_for_selected(&mut self) { if let Some(pkg) = self.selected_package() { let name = pkg.name.clone(); - if !self.cached_files.contains_key(&name) { + if let std::collections::hash_map::Entry::Vacant(e) = + self.cached_files.entry(name.clone()) + { if let Ok(files) = pacman::load_package_files(&name) { let grouped = group_files(files); - self.cached_files.insert(name, grouped); + e.insert(grouped); } } } @@ -393,7 +397,8 @@ impl App { fn move_to_end(&mut self) { if !self.filtered_indices.is_empty() { - self.list_state.select(Some(self.filtered_indices.len() - 1)); + self.list_state + .select(Some(self.filtered_indices.len() - 1)); self.detail_scroll = 0; } } @@ -404,11 +409,17 @@ impl App { } pub fn explicit_count(&self) -> usize { - self.packages.iter().filter(|p| p.install_reason == InstallReason::Explicit).count() + self.packages + .iter() + .filter(|p| p.install_reason == InstallReason::Explicit) + .count() } pub fn dependency_count(&self) -> usize { - self.packages.iter().filter(|p| p.install_reason == InstallReason::Dependency).count() + self.packages + .iter() + .filter(|p| p.install_reason == InstallReason::Dependency) + .count() } pub fn aur_count(&self) -> usize { @@ -421,7 +432,15 @@ impl App { pub fn top_largest(&self, n: usize) -> Vec<&Package> { let mut indices: Vec = (0..self.packages.len()).collect(); - indices.sort_by(|&a, &b| self.packages[b].installed_size_bytes.cmp(&self.packages[a].installed_size_bytes)); - indices.into_iter().take(n).map(|i| &self.packages[i]).collect() + indices.sort_by(|&a, &b| { + self.packages[b] + .installed_size_bytes + .cmp(&self.packages[a].installed_size_bytes) + }); + indices + .into_iter() + .take(n) + .map(|i| &self.packages[i]) + .collect() } } diff --git a/src/config.rs b/src/config.rs index 32be089..ba46429 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,7 @@ use serde::Deserialize; use std::path::PathBuf; #[derive(Debug, Deserialize, Clone)] +#[allow(dead_code)] pub struct Config { #[serde(default = "default_aur_helper")] pub aur_helper: String, @@ -11,14 +12,17 @@ pub struct Config { pub default_sort: String, } +#[allow(dead_code)] fn default_aur_helper() -> String { "paru".to_string() } +#[allow(dead_code)] fn default_filter() -> String { "all".to_string() } +#[allow(dead_code)] fn default_sort() -> String { "name".to_string() } @@ -38,12 +42,10 @@ impl Default for Config { pub fn load_config() -> Config { let config_path = get_config_path(); if config_path.exists() { - match std::fs::read_to_string(&config_path) { - Ok(content) => match toml::from_str(&content) { - Ok(config) => return config, - Err(_) => {} - }, - Err(_) => {} + if let Ok(content) = std::fs::read_to_string(&config_path) { + if let Ok(config) = toml::from_str(&content) { + return config; + } } } Config::default() diff --git a/src/event.rs b/src/event.rs index 8cb72ff..941a012 100644 --- a/src/event.rs +++ b/src/event.rs @@ -19,9 +19,7 @@ impl EventHandler { pub fn next(&self) -> Result { if event::poll(self.tick_rate)? { match event::read()? { - Event::Key(key) if key.kind == KeyEventKind::Press => { - Ok(AppEvent::Key(key)) - } + Event::Key(key) if key.kind == KeyEventKind::Press => Ok(AppEvent::Key(key)), _ => Ok(AppEvent::Tick), } } else { diff --git a/src/main.rs b/src/main.rs index bb8b1c4..3e2760e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,51 @@ use config::load_config; use event::{AppEvent, EventHandler}; use std::time::Duration; +fn handle_update() -> anyhow::Result<()> { + println!("==> Updating Glare..."); + let status = std::process::Command::new("bash") + .arg("-c") + .arg("curl -sSL https://raw.githubusercontent.com/TattvaOrg/Glare/main/install.sh | bash") + .status()?; + + if !status.success() { + anyhow::bail!("Update failed with exit code: {:?}", status.code()); + } + Ok(()) +} + fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + if args.len() > 1 { + match args[1].as_str() { + "-v" | "--version" => { + println!("glare {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + "-h" | "--help" => { + println!("glare {}", env!("CARGO_PKG_VERSION")); + println!("A lightweight system state manager TUI for Arch Linux\n"); + println!("USAGE:"); + println!(" glare [OPTIONS] [COMMAND]\n"); + println!("OPTIONS:"); + println!(" -h, --help Print help information"); + println!(" -v, --version Print version information\n"); + println!("COMMANDS:"); + println!(" update Update glare to the latest release"); + return Ok(()); + } + "update" => { + return handle_update(); + } + unknown => { + eprintln!("error: unrecognized argument or command '{}'\n", unknown); + eprintln!("Usage: glare [OPTIONS] [COMMAND]"); + eprintln!("For more information, try '--help'."); + std::process::exit(1); + } + } + } + // Load config let config = load_config(); diff --git a/src/package.rs b/src/package.rs index d91f86b..b5d0833 100644 --- a/src/package.rs +++ b/src/package.rs @@ -18,6 +18,7 @@ impl fmt::Display for InstallReason { } #[derive(Debug, Clone, PartialEq, Eq)] +#[allow(clippy::upper_case_acronyms)] pub enum PackageSource { Official, AUR, @@ -48,12 +49,13 @@ pub struct Package { pub optional_for: Vec, pub conflicts: Vec, pub replaces: Vec, - pub installed_size: String, // raw string like "5.86 MiB" - pub installed_size_bytes: u64, // parsed to bytes for sorting + pub installed_size: String, // raw string like "5.86 MiB" + pub installed_size_bytes: u64, // parsed to bytes for sorting pub packager: String, pub build_date: String, pub install_date: String, pub install_reason: InstallReason, + #[allow(dead_code)] pub validated_by: String, pub source: PackageSource, pub is_orphan: bool, @@ -62,13 +64,14 @@ pub struct Package { #[derive(Debug, Clone)] pub struct FileGroup { pub category: String, + #[allow(dead_code)] pub base_path: String, pub files: Vec, } /// Parse a size string like "5.86 MiB" or "123.45 KiB" to bytes pub fn parse_size_to_bytes(size_str: &str) -> u64 { - let parts: Vec<&str> = size_str.trim().split_whitespace().collect(); + let parts: Vec<&str> = size_str.split_whitespace().collect(); if parts.len() != 2 { return 0; } @@ -132,7 +135,11 @@ pub fn group_files(files: Vec) -> Vec { } fn categorize_path(path: &str) -> (u8, String, String) { - if path.starts_with("/usr/bin/") || path.starts_with("/usr/sbin/") || path.starts_with("/bin/") || path.starts_with("/sbin/") { + if path.starts_with("/usr/bin/") + || path.starts_with("/usr/sbin/") + || path.starts_with("/bin/") + || path.starts_with("/sbin/") + { (0, "Binaries".to_string(), "/usr/bin/".to_string()) } else if path.starts_with("/usr/lib/") || path.starts_with("/lib/") { (1, "Libraries".to_string(), "/usr/lib/".to_string()) @@ -141,9 +148,17 @@ fn categorize_path(path: &str) -> (u8, String, String) { } else if path.starts_with("/usr/share/man/") { (3, "Man Pages".to_string(), "/usr/share/man/".to_string()) } else if path.starts_with("/usr/share/doc/") { - (4, "Documentation".to_string(), "/usr/share/doc/".to_string()) + ( + 4, + "Documentation".to_string(), + "/usr/share/doc/".to_string(), + ) } else if path.starts_with("/usr/share/licenses/") { - (5, "Licenses".to_string(), "/usr/share/licenses/".to_string()) + ( + 5, + "Licenses".to_string(), + "/usr/share/licenses/".to_string(), + ) } else if path.starts_with("/usr/share/") { (6, "Data & Resources".to_string(), "/usr/share/".to_string()) } else if path.starts_with("/etc/") { diff --git a/src/pacman.rs b/src/pacman.rs index ba54e58..e1282be 100644 --- a/src/pacman.rs +++ b/src/pacman.rs @@ -1,13 +1,11 @@ -use crate::package::{Package, InstallReason, PackageSource, parse_size_to_bytes}; +use crate::package::{parse_size_to_bytes, InstallReason, Package, PackageSource}; use anyhow::Result; use std::collections::HashSet; use std::process::Command; /// Load all installed packages by running `pacman -Qi` pub fn load_all_packages() -> Result> { - let output = Command::new("pacman") - .args(["-Qi"]) - .output()?; + let output = Command::new("pacman").args(["-Qi"]).output()?; let stdout = String::from_utf8_lossy(&output.stdout); let packages = parse_qi_output(&stdout); Ok(packages) @@ -15,9 +13,7 @@ pub fn load_all_packages() -> Result> { /// Get names of AUR/foreign packages via `pacman -Qm` pub fn load_aur_names() -> Result> { - let output = Command::new("pacman") - .args(["-Qm"]) - .output()?; + let output = Command::new("pacman").args(["-Qm"]).output()?; let stdout = String::from_utf8_lossy(&output.stdout); let names: HashSet = stdout .lines() @@ -29,9 +25,7 @@ pub fn load_aur_names() -> Result> { /// Get names of orphan packages via `pacman -Qdt` pub fn load_orphan_names() -> Result> { - let output = Command::new("pacman") - .args(["-Qdtq"]) - .output()?; + let output = Command::new("pacman").args(["-Qdtq"]).output()?; let stdout = String::from_utf8_lossy(&output.stdout); let names: HashSet = stdout .lines() @@ -43,9 +37,7 @@ pub fn load_orphan_names() -> Result> { /// Load files for a specific package via `pacman -Ql ` pub fn load_package_files(name: &str) -> Result> { - let output = Command::new("pacman") - .args(["-Ql", name]) - .output()?; + let output = Command::new("pacman").args(["-Ql", name]).output()?; let stdout = String::from_utf8_lossy(&output.stdout); let files: Vec = stdout .lines() @@ -132,9 +124,7 @@ fn build_package(fields: &[(String, String)]) -> Option { if val.is_empty() || val == "None" { Vec::new() } else { - val.split_whitespace() - .map(|s| s.to_string()) - .collect() + val.split_whitespace().map(|s| s.to_string()).collect() } }; @@ -170,7 +160,8 @@ fn build_package(fields: &[(String, String)]) -> Option { let install_reason_str = get("Install Reason"); let install_reason = if install_reason_str.contains("Explicitly") { InstallReason::Explicit - } else if install_reason_str.contains("dependency") || install_reason_str.contains("Dependency") { + } else if install_reason_str.contains("dependency") || install_reason_str.contains("Dependency") + { InstallReason::Dependency } else { InstallReason::Unknown @@ -199,6 +190,6 @@ fn build_package(fields: &[(String, String)]) -> Option { install_reason, validated_by: get("Validated By"), source: PackageSource::Official, // will be overridden - is_orphan: false, // will be overridden + is_orphan: false, // will be overridden }) } diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 196ad4b..194d1c1 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -1,11 +1,11 @@ +use super::theme::get_theme; use crate::app::App; use crate::package::format_bytes; -use super::theme::get_theme; -use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; pub fn render_dashboard(frame: &mut Frame, area: Rect, app: &App) { let theme = get_theme(); diff --git a/src/ui/detail.rs b/src/ui/detail.rs index 4bd9984..d0bfbe2 100644 --- a/src/ui/detail.rs +++ b/src/ui/detail.rs @@ -1,10 +1,10 @@ -use crate::app::{ActivePane, App, DetailTab}; use super::theme::get_theme; -use ratatui::Frame; +use crate::app::{ActivePane, App, DetailTab}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; pub fn render_detail(frame: &mut Frame, area: Rect, app: &App) { let theme = get_theme(); @@ -83,10 +83,7 @@ fn render_tab_bar( .add_modifier(Modifier::BOLD), )); } else { - spans.push(Span::styled( - label, - Style::default().fg(Color::DarkGray), - )); + spans.push(Span::styled(label, Style::default().fg(Color::DarkGray))); } spans.push(Span::raw(" ")); } @@ -125,7 +122,9 @@ fn build_info_lines<'a>( lines.push(Line::from(vec![ Span::styled( &pkg.name, - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), ), Span::raw(" "), Span::styled(&pkg.version, Style::default().fg(Color::White)), @@ -208,11 +207,7 @@ fn build_files_lines<'a>(app: &'a App, theme: &super::theme::Theme) -> Vec