From cbba9df8c06dc5ef64613412497ef59c2cd8faac Mon Sep 17 00:00:00 2001 From: Evan Lynch Date: Wed, 22 Jul 2026 12:42:46 -0400 Subject: [PATCH] add Omarchy 4.0+ Quickshell bar/plugin support Detects Omarchy 4.0+ ("Quattro") by binary presence (omarchy-shell + omarchy-bar) and swaps the Browse TUI's Waybar/Walker tabs for Bar and Plugins tabs on those installs, leaving 3.x untouched. Adds `theme-manager bar list|use|reset|defaults|position|transparent|plugin` and `theme-manager plugin list|enable|disable` (plus passthrough for add/update/remove/rescan/clone/edit/validate), wrapping the corresponding `omarchy bar`/`omarchy plugin` commands. The Plugins tab toggles enable/disable immediately on Enter, mirroring the Presets tab's immediate-action model rather than deferring to Review. Also fixes test isolation: setup_env() now shadows the host's real `omarchy` binary with a 127-exit stub, since running the suite on an actual Omarchy machine let the live unified CLI silently intercept subcommands meant to exercise the legacy-script fallback. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XqqAgiAUsbZpc95dxWJqE7 --- CHANGELOG.md | 11 + rust/Cargo.toml | 1 + rust/src/bar.rs | 178 +++++++++++++++ rust/src/cli.rs | 101 +++++++++ rust/src/config.rs | 8 + rust/src/lib.rs | 69 +++++- rust/src/omarchy.rs | 9 + rust/src/plugins.rs | 120 ++++++++++ rust/src/tui.rs | 465 +++++++++++++++++++++++++++++++++----- rust/tests/support/mod.rs | 7 + 10 files changed, 910 insertions(+), 59 deletions(-) create mode 100644 rust/src/bar.rs create mode 100644 rust/src/plugins.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f26b93..417cb29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project are documented in this file. ## Unreleased +- Added Omarchy 4.0+ ("Quattro") Quickshell bar support: + - Capability-based detection (`omarchy::shell_available()`) instead of parsing `omarchy-version`. + - `theme-manager bar list|use|reset|defaults|position|transparent`, wrapping `omarchy bar ...`. + - `print-config` reports `OMARCHY_SHELL_AVAILABLE`. + - Browse TUI: on 4.0+, the Waybar and Walker tabs are replaced by a single Bar tab (both are superseded by the Quickshell bar/launcher); 3.x installs are unaffected. +- Added Omarchy 4.0+ Quickshell plugin management: + - `theme-manager plugin list|enable|disable`, plus passthrough for `add|update|remove|rescan|clone|edit|validate` straight to `omarchy-plugin` with interactive stdio. + - Browse TUI: a Plugins tab (4.0+ only) listing every discovered shell plugin (services, overlays, panels, bar widgets, ...) with live enabled state; Enter toggles enable/disable immediately, independent of the theme-apply pipeline. +- Added `theme-manager bar plugin add|move|remove|set|replace ...`, a passthrough to `omarchy-bar-plugin` (with interactive stdio) for editing the bar's widget layout (`shell.json`'s `bar.layout.{left,center,right}`). +- Fixed test isolation: `setup_env()` now shadows the host's real `omarchy` unified CLI with a stub that exits 127, so running the test suite on an actual Omarchy machine no longer lets the live `omarchy` binary silently intercept subcommands meant to exercise the legacy-script fallback (was causing `set_generates_templates_from_colors` and `bg_next_runs_command` to fail). + ## 0.3.6 - Added Omarchy 3.7 unlock theme support: diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 143f25c..18b6b3d 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -16,6 +16,7 @@ clap = { version = "4.5.4", features = ["derive"] } crossterm = "0.28.1" ratatui = "0.28.1" serde = { version = "1.0.203", features = ["derive"] } +serde_json = "1.0.120" toml = "0.8.14" walkdir = "2.5.0" which = "6.0.1" diff --git a/rust/src/bar.rs b/rust/src/bar.rs new file mode 100644 index 0000000..25e73cb --- /dev/null +++ b/rust/src/bar.rs @@ -0,0 +1,178 @@ +use anyhow::{anyhow, Result}; +use serde::Deserialize; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use crate::omarchy; + +#[derive(Debug, Clone)] +pub struct BarOption { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Debug, Deserialize)] +struct CatalogEntry { + id: String, + #[serde(default)] + name: String, + #[serde(default)] + description: String, + #[serde(default)] + kinds: Vec, + #[serde(rename = "barPath")] + bar_path: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct ShellConfig { + bar: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct BarSection { + id: Option, +} + +fn require_shell() -> Result<()> { + if omarchy::shell_available() { + return Ok(()); + } + Err(anyhow!( + "bar management requires Omarchy 4.0+ (the Quickshell-based omarchy-shell); \ + omarchy-shell/omarchy-bar not found in PATH" + )) +} + +fn shell_config_path() -> Result { + let home = env::var("HOME").map_err(|_| anyhow!("HOME is not set"))?; + Ok(PathBuf::from(home).join(".config/omarchy/shell.json")) +} + +/// The active bar's plugin id, or `None` when the built-in `omarchy.bar` is active. +pub fn current_bar_id() -> Result> { + let path = shell_config_path()?; + if !path.is_file() { + return Ok(None); + } + let raw = fs::read_to_string(&path)?; + if raw.trim().is_empty() { + return Ok(None); + } + let parsed: ShellConfig = serde_json::from_str(&raw) + .map_err(|err| anyhow!("failed to parse {}: {err}", path.to_string_lossy()))?; + Ok(parsed.bar.and_then(|bar| bar.id)) +} + +/// Every installed bar-kind plugin (full alternate bar layouts), built-in and +/// user-installed, sourced from `omarchy-plugin-catalog` (manifest walking, +/// works even when the shell isn't running). +pub fn list_bar_options() -> Result> { + require_shell()?; + let output = Command::new("omarchy-plugin-catalog") + .output() + .map_err(|err| anyhow!("failed to run omarchy-plugin-catalog: {err}"))?; + if !output.status.success() { + return Err(anyhow!( + "omarchy-plugin-catalog exited with {}", + output.status + )); + } + let raw = String::from_utf8_lossy(&output.stdout); + let entries: Vec = serde_json::from_str(&raw) + .map_err(|err| anyhow!("failed to parse omarchy-plugin-catalog output: {err}"))?; + + let mut options: Vec = entries + .into_iter() + .filter(|entry| entry.kinds.iter().any(|kind| kind == "bar") && entry.bar_path.is_some()) + .map(|entry| BarOption { + id: entry.id.clone(), + name: if entry.name.is_empty() { + entry.id + } else { + entry.name + }, + description: entry.description, + }) + .collect(); + + options.sort_by(|a, b| { + // Keep the built-in bar pinned first, like Waybar/Walker tabs pin + // `Omarchy-Default`. + let a_builtin = a.id == "omarchy.bar"; + let b_builtin = b.id == "omarchy.bar"; + b_builtin.cmp(&a_builtin).then_with(|| a.name.cmp(&b.name)) + }); + + Ok(options) +} + +pub fn cmd_bar_list() -> Result<()> { + let current = current_bar_id()?; + for option in list_bar_options()? { + let is_current = match ¤t { + Some(id) => id == &option.id, + None => option.id == "omarchy.bar", + }; + let marker = if is_current { "*" } else { " " }; + println!("{marker} {} ({})", option.name, option.id); + } + Ok(()) +} + +pub fn cmd_bar_use(id: &str, quiet: bool) -> Result<()> { + require_shell()?; + omarchy::run_omarchy_required("bar", "use", &[id], quiet) +} + +pub fn cmd_bar_reset(quiet: bool) -> Result<()> { + require_shell()?; + omarchy::run_omarchy_required("bar", "reset", &[], quiet) +} + +pub fn cmd_bar_defaults(quiet: bool) -> Result<()> { + require_shell()?; + omarchy::run_omarchy_required("bar", "defaults", &[], quiet) +} + +pub fn cmd_bar_position(position: &str, quiet: bool) -> Result<()> { + require_shell()?; + if !matches!(position, "top" | "bottom" | "left" | "right") { + return Err(anyhow!( + "position must be one of top, bottom, left, right (got '{position}')" + )); + } + omarchy::run_omarchy_required("bar", "position", &[position], quiet) +} + +pub fn cmd_bar_transparent(value: &str, quiet: bool) -> Result<()> { + require_shell()?; + if !matches!(value, "true" | "false") { + return Err(anyhow!( + "transparent must be 'true' or 'false' (got '{value}')" + )); + } + omarchy::run_omarchy_required("bar", "transparent", &[value], quiet) +} + +/// Passthrough for widget-layout editing (add/move/remove/set/replace): +/// these accept a mix of positional args and `--section`/`--index`/ +/// `--before`/`--after`/`--json` flags that aren't worth re-modeling in +/// clap, so exec `omarchy-bar-plugin` directly with inherited stdio. +pub fn cmd_bar_plugin_passthrough(args: &[String]) -> Result<()> { + require_shell()?; + let status = Command::new("omarchy-bar-plugin") + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|err| anyhow!("failed to run omarchy-bar-plugin: {err}"))?; + if !status.success() { + return Err(anyhow!("omarchy-bar-plugin exited with {status}")); + } + Ok(()) +} diff --git a/rust/src/cli.rs b/rust/src/cli.rs index b0fe295..75cd0d3 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -36,6 +36,8 @@ pub enum Command { Hyprlock(HyprlockArgs), Unlock(UnlockArgs), Starship(StarshipArgs), + Bar(BarArgs), + Plugin(PluginArgs), } #[derive(Parser, Debug)] @@ -182,3 +184,102 @@ pub struct StarshipArgs { #[arg(short = 'q', long = "quiet")] pub quiet: bool, } + +#[derive(Parser, Debug)] +#[command( + about = "Manage the Omarchy 4.0+ Quickshell bar (requires omarchy-shell)" +)] +pub struct BarArgs { + #[command(subcommand)] + pub command: BarCommand, +} + +#[derive(Subcommand, Debug)] +pub enum BarCommand { + /// List installed bar options (built-in and third-party) + List, + /// Switch the active bar to the given plugin id + Use(BarUseArgs), + /// Return to the built-in Omarchy bar + Reset(BarQuietArgs), + /// Restore the default bar layout and service widgets + Defaults(BarQuietArgs), + /// Set the bar position + Position(BarPositionArgs), + /// Set bar transparency + Transparent(BarTransparentArgs), + /// Add, move, remove, set, or replace bar widgets in the layout + /// (passed straight through to `omarchy-bar-plugin` with interactive stdio) + Plugin(BarPluginArgs), +} + +#[derive(Parser, Debug)] +pub struct BarPluginArgs { + #[command(subcommand)] + pub command: BarPluginCommand, +} + +#[derive(Subcommand, Debug)] +pub enum BarPluginCommand { + /// Anything (add/move/remove/set/replace) is passed straight through + /// to `omarchy-bar-plugin` with interactive stdio + #[command(external_subcommand)] + Other(Vec), +} + +#[derive(Parser, Debug)] +pub struct BarUseArgs { + pub id: String, + #[arg(short = 'q', long = "quiet")] + pub quiet: bool, +} + +#[derive(Parser, Debug)] +pub struct BarQuietArgs { + #[arg(short = 'q', long = "quiet")] + pub quiet: bool, +} + +#[derive(Parser, Debug)] +pub struct BarPositionArgs { + #[arg(value_name = "top|bottom|left|right")] + pub position: String, + #[arg(short = 'q', long = "quiet")] + pub quiet: bool, +} + +#[derive(Parser, Debug)] +pub struct BarTransparentArgs { + #[arg(value_name = "true|false")] + pub value: String, + #[arg(short = 'q', long = "quiet")] + pub quiet: bool, +} + +#[derive(Parser, Debug)] +#[command(about = "Manage Omarchy 4.0+ Quickshell plugins (requires omarchy-shell)")] +pub struct PluginArgs { + #[command(subcommand)] + pub command: PluginCommand, +} + +#[derive(Subcommand, Debug)] +pub enum PluginCommand { + /// List discovered shell plugins + List, + /// Enable a plugin by id + Enable(PluginIdArgs), + /// Disable a plugin by id + Disable(PluginIdArgs), + /// Anything else (add/update/remove/rescan/clone/edit/validate) is passed + /// straight through to `omarchy-plugin` with interactive stdio + #[command(external_subcommand)] + Other(Vec), +} + +#[derive(Parser, Debug)] +pub struct PluginIdArgs { + pub id: String, + #[arg(short = 'q', long = "quiet")] + pub quiet: bool, +} diff --git a/rust/src/config.rs b/rust/src/config.rs index 5673a28..0f38ee6 100644 --- a/rust/src/config.rs +++ b/rust/src/config.rs @@ -521,6 +521,14 @@ pub fn print_config(config: &ResolvedConfig) { .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default() ); + println!( + "OMARCHY_SHELL_AVAILABLE={}", + if crate::omarchy::shell_available() { + "1" + } else { + "" + } + ); println!("WAYBAR_DIR={}", config.waybar_dir.to_string_lossy()); println!( "WAYBAR_THEMES_DIR={}", diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 5451af1..5dcb23f 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,8 +1,10 @@ use anyhow::{anyhow, Result}; use std::path::{Path, PathBuf}; +pub mod bar; pub mod cli; pub mod config; +pub mod plugins; pub mod git_ops; pub mod hyprlock; pub mod omarchy; @@ -18,7 +20,7 @@ pub mod unlock; pub mod walker; pub mod waybar; -use cli::{Command, PresetCommand, UnlockCommand}; +use cli::{BarCommand, Command, PluginCommand, PresetCommand, UnlockCommand}; use config::ResolvedConfig; use theme_ops::{ hyprlock_from_defaults, starship_from_defaults, walker_from_defaults, waybar_from_defaults, @@ -163,6 +165,15 @@ pub fn run(cli: cli::Cli) -> Result<()> { unlock::cmd_unlock_set(&config, &name, quiet)?; } } + match selection.bar { + tui::BarSelection::NoChange => {} + tui::BarSelection::Named(id) => { + if !quiet { + output::apply_step("Bar", format!("Applying {id}")); + } + bar::cmd_bar_use(&id, quiet)?; + } + } if !quiet { output::apply_done(); } @@ -303,6 +314,52 @@ pub fn run(cli: cli::Cli) -> Result<()> { let quiet = args.quiet || config.quiet_default; apply_starship_only(&config, starship_mode, quiet, skip_apps, cli.debug_awww)?; } + Command::Bar(args) => match args.command { + BarCommand::List => { + bar::cmd_bar_list()?; + } + BarCommand::Use(use_args) => { + let quiet = use_args.quiet || config.quiet_default; + bar::cmd_bar_use(&use_args.id, quiet)?; + } + BarCommand::Reset(reset_args) => { + let quiet = reset_args.quiet || config.quiet_default; + bar::cmd_bar_reset(quiet)?; + } + BarCommand::Defaults(defaults_args) => { + let quiet = defaults_args.quiet || config.quiet_default; + bar::cmd_bar_defaults(quiet)?; + } + BarCommand::Position(position_args) => { + let quiet = position_args.quiet || config.quiet_default; + bar::cmd_bar_position(&position_args.position, quiet)?; + } + BarCommand::Transparent(transparent_args) => { + let quiet = transparent_args.quiet || config.quiet_default; + bar::cmd_bar_transparent(&transparent_args.value, quiet)?; + } + BarCommand::Plugin(plugin_args) => match plugin_args.command { + cli::BarPluginCommand::Other(args) => { + bar::cmd_bar_plugin_passthrough(&args)?; + } + }, + }, + Command::Plugin(args) => match args.command { + PluginCommand::List => { + plugins::cmd_plugin_list()?; + } + PluginCommand::Enable(enable_args) => { + let quiet = enable_args.quiet || config.quiet_default; + plugins::cmd_plugin_enable(&enable_args.id, quiet)?; + } + PluginCommand::Disable(disable_args) => { + let quiet = disable_args.quiet || config.quiet_default; + plugins::cmd_plugin_disable(&disable_args.id, quiet)?; + } + PluginCommand::Other(args) => { + plugins::cmd_plugin_passthrough(&args)?; + } + }, } Ok(()) @@ -332,6 +389,16 @@ fn print_apply_summary(selection: &tui::BrowseSelection) { if let Some(value) = unlock_summary(&selection.unlock) { output::apply_item("Unlock", value); } + if let Some(value) = bar_summary(&selection.bar) { + output::apply_item("Bar", value); + } +} + +fn bar_summary(selection: &tui::BarSelection) -> Option { + match selection { + tui::BarSelection::NoChange => None, + tui::BarSelection::Named(id) => Some(id.clone()), + } } fn waybar_summary(selection: &tui::WaybarSelection) -> Option { diff --git a/rust/src/omarchy.rs b/rust/src/omarchy.rs index 0de95d7..74c8351 100644 --- a/rust/src/omarchy.rs +++ b/rust/src/omarchy.rs @@ -28,6 +28,15 @@ pub fn command_exists(cmd: &str) -> bool { which::which(cmd).is_ok() } +/// Detects Omarchy 4.0+ ("Quattro"): Waybar/Walker/Mako/swayosd are replaced +/// by a single Quickshell process ("omarchy-shell") with its own `omarchy +/// bar`/`omarchy plugin` command groups. Checked by binary presence rather +/// than parsing `omarchy-version` (which varies wildly: "4.0.0.alpha", +/// "4.0.0.r1193.g0526ebe-1", "dev (hash)"). +pub fn shell_available() -> bool { + command_exists("omarchy-shell") && command_exists("omarchy-bar") +} + pub fn detect_omarchy_root(config: &ResolvedConfig) -> Option { if let Ok(path) = env::var("OMARCHY_PATH") { let trimmed = path.trim(); diff --git a/rust/src/plugins.rs b/rust/src/plugins.rs new file mode 100644 index 0000000..741ca8c --- /dev/null +++ b/rust/src/plugins.rs @@ -0,0 +1,120 @@ +use anyhow::{anyhow, Result}; +use serde::Deserialize; +use std::process::{Command, Stdio}; + +use crate::omarchy; + +#[derive(Debug, Clone)] +pub struct PluginInfo { + pub id: String, + pub name: String, + pub enabled: bool, + pub first_party: bool, + pub kinds: Vec, +} + +#[derive(Debug, Deserialize)] +struct PluginJson { + id: String, + #[serde(default)] + name: String, + #[serde(default)] + enabled: bool, + #[serde(default, rename = "firstParty")] + first_party: bool, + #[serde(default)] + kinds: Vec, +} + +fn require_shell() -> Result<()> { + if omarchy::shell_available() { + return Ok(()); + } + Err(anyhow!( + "plugin management requires Omarchy 4.0+ (the Quickshell-based omarchy-shell); \ + omarchy-shell/omarchy-plugin not found in PATH" + )) +} + +/// Every discovered shell plugin (bar options, bar widgets, services, +/// overlays, panels, ...) with live enabled state. Requires omarchy-shell to +/// actually be running (unlike `bar::list_bar_options`, which only walks +/// manifests on disk). +pub fn list_plugins() -> Result> { + require_shell()?; + let output = Command::new("omarchy-plugin") + .args(["list", "--json"]) + .output() + .map_err(|err| anyhow!("failed to run omarchy-plugin: {err}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("omarchy-plugin list failed: {}", stderr.trim())); + } + let raw = String::from_utf8_lossy(&output.stdout); + let entries: Vec = serde_json::from_str(&raw) + .map_err(|err| anyhow!("failed to parse omarchy-plugin output: {err}"))?; + + let mut plugins: Vec = entries + .into_iter() + .map(|entry| PluginInfo { + id: entry.id, + name: entry.name, + enabled: entry.enabled, + first_party: entry.first_party, + kinds: entry.kinds, + }) + .collect(); + plugins.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(plugins) +} + +pub fn cmd_plugin_list() -> Result<()> { + for plugin in list_plugins()? { + let state = if plugin.enabled { "enabled" } else { "disabled" }; + let source = if plugin.first_party { + "first-party" + } else { + "third-party" + }; + let name = if plugin.name.is_empty() { + &plugin.id + } else { + &plugin.name + }; + println!( + "{} [{state}] {source} ({}) - {name}", + plugin.id, + plugin.kinds.join(",") + ); + } + Ok(()) +} + +pub fn cmd_plugin_enable(id: &str, quiet: bool) -> Result<()> { + require_shell()?; + omarchy::run_omarchy_required("plugin", "enable", &[id], quiet) +} + +pub fn cmd_plugin_disable(id: &str, quiet: bool) -> Result<()> { + require_shell()?; + omarchy::run_omarchy_required("plugin", "disable", &[id], quiet) +} + +/// Passthrough for everything else (add/update/remove/rescan/clone/edit/ +/// validate): these need real interactive stdio (git clone progress, `gum +/// confirm` prompts, diff review), so exec `omarchy-plugin` directly with +/// inherited stdio instead of capturing output like the other commands here. +pub fn cmd_plugin_passthrough(args: &[String]) -> Result<()> { + require_shell()?; + let status = Command::new("omarchy-plugin") + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|err| anyhow!("failed to run omarchy-plugin: {err}"))?; + if !status.success() { + return Err(anyhow!("omarchy-plugin exited with {status}")); + } + Ok(()) +} diff --git a/rust/src/tui.rs b/rust/src/tui.rs index ec1d6ab..8e4665b 100644 --- a/rust/src/tui.rs +++ b/rust/src/tui.rs @@ -27,9 +27,12 @@ use syntect::parsing::SyntaxSet; use syntect::util::as_24_bit_terminal_escaped; use tempfile::TempDir; +use crate::bar; use crate::config::ResolvedConfig; use crate::hyprlock; +use crate::omarchy; use crate::paths::{normalize_theme_name, title_case_theme}; +use crate::plugins; use crate::presets; use crate::preview; use crate::starship; @@ -53,6 +56,8 @@ enum BrowseTab { Theme, Waybar, Walker, + Bar, + Plugins, Hyprlock, Unlock, Starship, @@ -66,6 +71,7 @@ pub struct BrowseSelection { pub no_theme_change: bool, pub waybar: WaybarSelection, pub walker: WalkerSelection, + pub bar: BarSelection, pub hyprlock: HyprlockSelection, pub unlock: UnlockSelection, pub starship: StarshipSelection, @@ -87,6 +93,12 @@ pub enum WalkerSelection { Named(String), } +#[derive(Debug)] +pub enum BarSelection { + NoChange, + Named(String), +} + #[derive(Debug)] pub enum HyprlockSelection { NoChange, @@ -355,9 +367,9 @@ pub fn browse(config: &ResolvedConfig, quiet: bool) -> Result = tab_order.iter().map(|t| tab_label(*t)).collect(); let mut tab_ranges: Vec<(u16, u16, usize)> = Vec::new(); let mut active_search_area = Rect::ZERO; let mut active_list_inner = Rect::ZERO; @@ -387,16 +399,30 @@ pub fn browse(config: &ResolvedConfig, quiet: bool) -> Result Result Result { + let areas = render_picker( + frame, + content_area, + "Select Bar", + "Bar Info", + &bar_items, + &mut bar_state, + &backend, + |idx| build_bar_code_preview(&bar_items[idx]), + |_idx| None, + |_idx| None, + false, + if status_active && status_tab == BrowseTab::Bar { + Some(status_message.as_str()) + } else { + None + }, + ); + active_search_area = areas.search_area; + active_list_inner = areas.list_inner; + active_code_inner = areas.code_inner; + active_code_area = areas.code_area; + } + BrowseTab::Plugins => { + let areas = render_picker( + frame, + content_area, + "Shell Plugins", + "Plugin Info", + &plugin_items, + &mut plugin_state, + &backend, + |idx| build_plugin_code_preview(&plugin_items[idx]), + |_idx| None, + |_idx| None, + false, + if status_active && status_tab == BrowseTab::Plugins { + Some(status_message.as_str()) + } else { + None + }, + ); + active_search_area = areas.search_area; + active_list_inner = areas.list_inner; + active_code_inner = areas.code_inner; + active_code_area = areas.code_area; + } BrowseTab::Hyprlock => { let areas = render_picker( frame, @@ -615,12 +689,22 @@ pub fn browse(config: &ResolvedConfig, quiet: bool) -> Result Result Result Result Result Result Result Result Result Result Result { + plugin_items = build_plugin_items().unwrap_or_default(); + rebuild_filtered(&mut plugin_state, &plugin_items); + status_message = format!("{} toggled", item.label.trim()); + } + Err(err) => { + status_message = err.to_string(); + } + } + } else { + status_message = "No plugins found".to_string(); + } + if !event::poll(Duration::from_millis(0))? { + break 'event_loop; + } + continue 'event_loop; + } if key.code == KeyCode::Enter && tab != BrowseTab::Review { status_tab = tab; status_at = Instant::now(); @@ -999,6 +1142,8 @@ pub fn browse(config: &ResolvedConfig, quiet: bool) -> Result "Theme selected".to_string(), BrowseTab::Waybar => "Waybar selected".to_string(), BrowseTab::Walker => "Walker selected".to_string(), + BrowseTab::Bar => "Bar selected".to_string(), + BrowseTab::Plugins => "Plugins selected".to_string(), BrowseTab::Hyprlock => "Hyprlock selected".to_string(), BrowseTab::Unlock => "Unlock selected".to_string(), BrowseTab::Starship => "Starship selected".to_string(), @@ -1011,18 +1156,22 @@ pub fn browse(config: &ResolvedConfig, quiet: bool) -> Result Result theme_state.filtered_indices.len(), BrowseTab::Waybar => waybar_state.filtered_indices.len(), BrowseTab::Walker => walker_state.filtered_indices.len(), + BrowseTab::Bar => bar_state.filtered_indices.len(), + BrowseTab::Plugins => plugin_state.filtered_indices.len(), BrowseTab::Hyprlock => hyprlock_state.filtered_indices.len(), BrowseTab::Unlock => unlock_state.filtered_indices.len(), BrowseTab::Starship => starship_state.filtered_indices.len(), @@ -1044,6 +1195,8 @@ pub fn browse(config: &ResolvedConfig, quiet: bool) -> Result Result theme_state.filtered_indices.len(), BrowseTab::Waybar => waybar_state.filtered_indices.len(), BrowseTab::Walker => walker_state.filtered_indices.len(), + BrowseTab::Bar => bar_state.filtered_indices.len(), + BrowseTab::Plugins => plugin_state.filtered_indices.len(), BrowseTab::Hyprlock => hyprlock_state.filtered_indices.len(), BrowseTab::Unlock => unlock_state.filtered_indices.len(), BrowseTab::Starship => starship_state.filtered_indices.len(), @@ -1077,6 +1232,8 @@ pub fn browse(config: &ResolvedConfig, quiet: bool) -> Result Result Result theme_state.filtered_indices.len(), BrowseTab::Waybar => waybar_state.filtered_indices.len(), BrowseTab::Walker => walker_state.filtered_indices.len(), + BrowseTab::Bar => bar_state.filtered_indices.len(), + BrowseTab::Plugins => plugin_state.filtered_indices.len(), BrowseTab::Hyprlock => hyprlock_state.filtered_indices.len(), BrowseTab::Unlock => unlock_state.filtered_indices.len(), BrowseTab::Starship => starship_state.filtered_indices.len(), @@ -1186,6 +1349,8 @@ pub fn browse(config: &ResolvedConfig, quiet: bool) -> Result Result theme_state.filtered_indices.len(), BrowseTab::Waybar => waybar_state.filtered_indices.len(), BrowseTab::Walker => walker_state.filtered_indices.len(), + BrowseTab::Bar => bar_state.filtered_indices.len(), + BrowseTab::Plugins => plugin_state.filtered_indices.len(), BrowseTab::Hyprlock => hyprlock_state.filtered_indices.len(), BrowseTab::Unlock => unlock_state.filtered_indices.len(), BrowseTab::Starship => starship_state.filtered_indices.len(), @@ -1226,6 +1393,8 @@ pub fn browse(config: &ResolvedConfig, quiet: bool) -> Result Result theme_state.filtered_indices.len(), BrowseTab::Waybar => waybar_state.filtered_indices.len(), BrowseTab::Walker => walker_state.filtered_indices.len(), + BrowseTab::Bar => bar_state.filtered_indices.len(), + BrowseTab::Plugins => plugin_state.filtered_indices.len(), BrowseTab::Hyprlock => hyprlock_state.filtered_indices.len(), BrowseTab::Unlock => unlock_state.filtered_indices.len(), BrowseTab::Starship => starship_state.filtered_indices.len(), @@ -1262,6 +1433,8 @@ pub fn browse(config: &ResolvedConfig, quiet: bool) -> Result Result> { + let mut items = Vec::new(); + items.push(OptionItem::with_kind( + "No Bar change".to_string(), + "none".to_string(), + "none", + None, + )); + + for option in bar::list_bar_options()? { + let label = if option.id == "omarchy.bar" { + format!("{} (Default)", option.name) + } else { + option.name.clone() + }; + items.push(OptionItem::with_kind(label, option.id, "named", None)); + } + + Ok(items) +} + +/// Every discovered shell plugin (Omarchy 4.0+ only), with live enabled +/// state. `kind` carries "enabled"/"disabled" so the Plugins tab can toggle +/// on Enter without a round-trip lookup. +fn build_plugin_items() -> Result> { + let mut items = Vec::new(); + for plugin in plugins::list_plugins()? { + let name = if plugin.name.is_empty() { + plugin.id.clone() + } else { + plugin.name.clone() + }; + let marker = if plugin.enabled { "●" } else { "○" }; + let label = format!("{marker} {name} ({})", plugin.kinds.join(",")); + let kind = if plugin.enabled { "enabled" } else { "disabled" }; + items.push(OptionItem::with_kind(label, plugin.id, kind, None)); + } + Ok(items) +} + fn build_waybar_items(config: &ResolvedConfig, theme_path: &Path) -> Result> { waybar::ensure_omarchy_default_theme_link(config, true)?; @@ -1685,6 +1901,35 @@ fn build_waybar_code_preview( } } +fn build_bar_code_preview(item: &LabeledItem) -> Text<'static> { + match item.kind.as_str() { + "none" => Text::from("No Bar change."), + _ => Text::from(vec![ + Line::from(format!("Bar: {}", item.label)), + Line::from(format!("Plugin id: {}", item.value)), + Line::from(""), + Line::from("Applied immediately via `omarchy bar use`,"), + Line::from("independent of the selected theme."), + ]), + } +} + +fn build_plugin_code_preview(item: &LabeledItem) -> Text<'static> { + let state = if item.kind == "enabled" { + "Enabled" + } else { + "Disabled" + }; + Text::from(vec![ + Line::from(item.label.clone()), + Line::from(format!("id: {}", item.value)), + Line::from(format!("State: {state}")), + Line::from(""), + Line::from("Enter: toggle enable/disable (applied immediately,"), + Line::from("independent of the selected theme)."), + ]) +} + fn build_starship_code_preview( config: &ResolvedConfig, theme_path: &Path, @@ -2326,22 +2571,33 @@ fn render_preset_picker( } } +#[allow(clippy::too_many_arguments)] fn render_review( frame: &mut Frame, area: Rect, selected_theme: &str, - waybar_label: String, - walker_label: String, + waybar_label: Option, + walker_label: Option, + bar_label: Option, hyprlock_label: String, unlock_label: String, starship_label: String, ) { - let lines = vec![ + let mut lines = vec![ Line::from("=== Review Selections ==="), Line::from(""), Line::from(format!("Theme: {}", title_case_theme(selected_theme))), - Line::from(format!("Waybar: {}", waybar_label)), - Line::from(format!("Walker: {}", walker_label)), + ]; + if let Some(waybar_label) = waybar_label { + lines.push(Line::from(format!("Waybar: {}", waybar_label))); + } + if let Some(walker_label) = walker_label { + lines.push(Line::from(format!("Walker: {}", walker_label))); + } + if let Some(bar_label) = bar_label { + lines.push(Line::from(format!("Bar: {}", bar_label))); + } + lines.extend([ Line::from(format!("Hyprlock: {}", hyprlock_label)), Line::from(format!("Unlock: {}", unlock_label)), Line::from(format!("Starship: {}", starship_label)), @@ -2349,20 +2605,22 @@ fn render_review( Line::from("Apply: Ctrl+Enter"), Line::from("Cancel: Esc"), Line::from("Switch tabs: Tab / Shift+Tab (or click tab bar)"), - ]; + ]); let review = Paragraph::new(Text::from(lines)) .block(Block::default().title("Review").borders(Borders::ALL)) .wrap(Wrap { trim: false }); frame.render_widget(review, area); } +#[allow(clippy::too_many_arguments)] fn render_status_bar( frame: &mut Frame, area: Rect, tab: BrowseTab, theme: &str, - waybar: String, - walker: String, + waybar: Option, + walker: Option, + bar: Option, hyprlock: String, unlock: String, starship: String, @@ -2372,16 +2630,7 @@ fn render_status_bar( ) { let mut spans = Vec::new(); let mut segments: Vec<(String, Color, Color)> = Vec::new(); - let tab_label = match tab { - BrowseTab::Theme => "Theme", - BrowseTab::Waybar => "Waybar", - BrowseTab::Walker => "Walker", - BrowseTab::Hyprlock => "Hyprlock", - BrowseTab::Unlock => "Unlock", - BrowseTab::Starship => "Starship", - BrowseTab::Presets => "Presets", - BrowseTab::Review => "Review", - }; + let tab_label = tab_label(tab); segments.push((tab_label.to_string(), Color::Black, Color::Yellow)); segments.push(( @@ -2389,8 +2638,15 @@ fn render_status_bar( Color::Black, Color::Cyan, )); - segments.push((format!("Waybar: {waybar}"), Color::Black, Color::Green)); - segments.push((format!("Walker: {walker}"), Color::Black, Color::Blue)); + if let Some(waybar) = waybar { + segments.push((format!("Waybar: {waybar}"), Color::Black, Color::Green)); + } + if let Some(walker) = walker { + segments.push((format!("Walker: {walker}"), Color::Black, Color::Blue)); + } + if let Some(bar) = bar { + segments.push((format!("Bar: {bar}"), Color::Black, Color::Green)); + } segments.push(( format!("Hyprlock: {hyprlock}"), Color::Black, @@ -2488,6 +2744,7 @@ fn render_tab_bar( frame: &mut Frame, area: Rect, titles: &[&str], + order: &[BrowseTab], active: BrowseTab, ranges: &mut Vec<(u16, u16, usize)>, ) { @@ -2498,7 +2755,7 @@ fn render_tab_bar( let mut spans = Vec::new(); let mut cursor = inner.x; - let active_index = tab_index(active); + let active_index = tab_index(order, active); for (idx, title) in titles.iter().enumerate() { let label = format!(" {} ", title); @@ -2592,28 +2849,34 @@ fn clear_picker_preview(backend: &PreviewBackend, state: &mut PickerState) { state.force_clear = true; } +#[allow(clippy::too_many_arguments)] fn clear_active_preview( backend: &PreviewBackend, tab: BrowseTab, theme: &mut PickerState, waybar: &mut PickerState, walker: &mut PickerState, + bar: &mut PickerState, + plugin: &mut PickerState, hyprlock: &mut PickerState, unlock: &mut PickerState, starship: &mut PickerState, presets: &mut PickerState, ) { if let Some(state) = active_picker_mut( - tab, theme, waybar, walker, hyprlock, unlock, starship, presets, + tab, theme, waybar, walker, bar, plugin, hyprlock, unlock, starship, presets, ) { clear_picker_preview(backend, state); } } +#[allow(clippy::too_many_arguments)] fn mark_force_clear( theme: &mut PickerState, waybar: &mut PickerState, walker: &mut PickerState, + bar: &mut PickerState, + plugin: &mut PickerState, hyprlock: &mut PickerState, unlock: &mut PickerState, starship: &mut PickerState, @@ -2622,17 +2885,22 @@ fn mark_force_clear( theme.force_clear = true; waybar.force_clear = true; walker.force_clear = true; + bar.force_clear = true; + plugin.force_clear = true; hyprlock.force_clear = true; unlock.force_clear = true; starship.force_clear = true; presets.force_clear = true; } +#[allow(clippy::too_many_arguments)] fn active_picker_mut<'a>( tab: BrowseTab, theme: &'a mut PickerState, waybar: &'a mut PickerState, walker: &'a mut PickerState, + bar: &'a mut PickerState, + plugin: &'a mut PickerState, hyprlock: &'a mut PickerState, unlock: &'a mut PickerState, starship: &'a mut PickerState, @@ -2642,6 +2910,8 @@ fn active_picker_mut<'a>( BrowseTab::Theme => Some(theme), BrowseTab::Waybar => Some(waybar), BrowseTab::Walker => Some(walker), + BrowseTab::Bar => Some(bar), + BrowseTab::Plugins => Some(plugin), BrowseTab::Hyprlock => Some(hyprlock), BrowseTab::Unlock => Some(unlock), BrowseTab::Starship => Some(starship), @@ -2650,11 +2920,14 @@ fn active_picker_mut<'a>( } } +#[allow(clippy::too_many_arguments)] fn rebuild_active_filtered( tab: BrowseTab, theme: &mut PickerState, waybar: &mut PickerState, walker: &mut PickerState, + bar: &mut PickerState, + plugin: &mut PickerState, hyprlock: &mut PickerState, unlock: &mut PickerState, starship: &mut PickerState, @@ -2662,6 +2935,8 @@ fn rebuild_active_filtered( theme_items: &[OptionItem], waybar_items: &[LabeledItem], walker_items: &[LabeledItem], + bar_items: &[LabeledItem], + plugin_items: &[LabeledItem], hyprlock_items: &[LabeledItem], unlock_items: &[LabeledItem], starship_items: &[LabeledItem], @@ -2671,6 +2946,8 @@ fn rebuild_active_filtered( BrowseTab::Theme => rebuild_filtered(theme, theme_items), BrowseTab::Waybar => rebuild_filtered(waybar, waybar_items), BrowseTab::Walker => rebuild_filtered(walker, walker_items), + BrowseTab::Bar => rebuild_filtered(bar, bar_items), + BrowseTab::Plugins => rebuild_filtered(plugin, plugin_items), BrowseTab::Hyprlock => rebuild_filtered(hyprlock, hyprlock_items), BrowseTab::Unlock => rebuild_filtered(unlock, unlock_items), BrowseTab::Starship => rebuild_filtered(starship, starship_items), @@ -2679,38 +2956,87 @@ fn rebuild_active_filtered( } } -fn tab_index(tab: BrowseTab) -> usize { - match tab { - BrowseTab::Theme => 0, - BrowseTab::Waybar => 1, - BrowseTab::Walker => 2, - BrowseTab::Hyprlock => 3, - BrowseTab::Unlock => 4, - BrowseTab::Starship => 5, - BrowseTab::Review => 6, - BrowseTab::Presets => 7, +/// Tabs shown, in cycling/display order. Omarchy 4.0+ ("Quattro") replaces +/// Waybar/Walker with a single Quickshell bar and its plugin system, so those +/// two tabs are swapped for Bar + Plugins; everything else is unaffected. +fn build_tab_order(bar_enabled: bool) -> Vec { + if bar_enabled { + vec![ + BrowseTab::Theme, + BrowseTab::Bar, + BrowseTab::Plugins, + BrowseTab::Hyprlock, + BrowseTab::Unlock, + BrowseTab::Starship, + BrowseTab::Review, + BrowseTab::Presets, + ] + } else { + vec![ + BrowseTab::Theme, + BrowseTab::Waybar, + BrowseTab::Walker, + BrowseTab::Hyprlock, + BrowseTab::Unlock, + BrowseTab::Starship, + BrowseTab::Review, + BrowseTab::Presets, + ] } } -fn tab_from_index(index: usize) -> BrowseTab { - match index { - 0 => BrowseTab::Theme, - 1 => BrowseTab::Waybar, - 2 => BrowseTab::Walker, - 3 => BrowseTab::Hyprlock, - 4 => BrowseTab::Unlock, - 5 => BrowseTab::Starship, - 6 => BrowseTab::Review, - _ => BrowseTab::Presets, +fn tab_label(tab: BrowseTab) -> &'static str { + match tab { + BrowseTab::Theme => "Theme", + BrowseTab::Waybar => "Waybar", + BrowseTab::Walker => "Walker", + BrowseTab::Bar => "Bar", + BrowseTab::Plugins => "Plugins", + BrowseTab::Hyprlock => "Hyprlock", + BrowseTab::Unlock => "Unlock", + BrowseTab::Starship => "Starship", + BrowseTab::Presets => "Presets", + BrowseTab::Review => "Review", } } -fn next_tab(tab: BrowseTab) -> BrowseTab { - tab_from_index((tab_index(tab) + 1) % 8) +fn tab_index(order: &[BrowseTab], tab: BrowseTab) -> usize { + order.iter().position(|&t| t == tab).unwrap_or(0) } -fn previous_tab(tab: BrowseTab) -> BrowseTab { - tab_from_index((tab_index(tab) + 7) % 8) +fn tab_from_index(order: &[BrowseTab], index: usize) -> BrowseTab { + order[index % order.len()] +} + +fn next_tab(order: &[BrowseTab], tab: BrowseTab) -> BrowseTab { + tab_from_index(order, tab_index(order, tab) + 1) +} + +fn previous_tab(order: &[BrowseTab], tab: BrowseTab) -> BrowseTab { + tab_from_index(order, tab_index(order, tab) + order.len() - 1) +} + +/// Picks between the Waybar/Walker labels (3.x) and a single Bar label (4.0+) +/// for the review/status-bar rows, since only one pair is ever meaningful. +#[allow(clippy::too_many_arguments)] +fn bar_or_waybar_labels( + bar_enabled: bool, + bar_items: &[LabeledItem], + bar_state: &PickerState, + waybar_items: &[LabeledItem], + waybar_state: &PickerState, + walker_items: &[LabeledItem], + walker_state: &PickerState, +) -> (Option, Option, Option) { + if bar_enabled { + (None, None, Some(current_bar_label(bar_items, bar_state))) + } else { + ( + Some(current_waybar_label(waybar_items, waybar_state)), + Some(current_walker_label(walker_items, walker_state)), + None, + ) + } } fn tab_index_from_click(ranges: &[(u16, u16, usize)], column: u16) -> Option { @@ -2978,6 +3304,29 @@ fn current_waybar_label(items: &[LabeledItem], state: &PickerState) -> String { } } +fn current_bar_label(items: &[LabeledItem], state: &PickerState) -> String { + let index = match selected_item_index(state, items.len()) { + Some(index) => index, + None => return "No options".to_string(), + }; + let item = &items[index]; + match item.kind.as_str() { + "none" => "No change".to_string(), + _ => item.label.clone(), + } +} + +fn current_bar_selection(items: &[LabeledItem], state: &PickerState) -> BarSelection { + let index = match selected_item_index(state, items.len()) { + Some(index) => index, + None => return BarSelection::NoChange, + }; + match items[index].kind.as_str() { + "none" => BarSelection::NoChange, + _ => BarSelection::Named(items[index].value.clone()), + } +} + fn current_starship_label(items: &[LabeledItem], state: &PickerState) -> String { let index = match selected_item_index(state, items.len()) { Some(index) => index, diff --git a/rust/tests/support/mod.rs b/rust/tests/support/mod.rs index 1f119a9..0affae8 100644 --- a/rust/tests/support/mod.rs +++ b/rust/tests/support/mod.rs @@ -24,6 +24,13 @@ pub fn setup_env() -> TestEnv { write_stub_ok(&bin.join("notify-send")); write_stub_ok(&bin.join("awww")); write_stub_ok(&bin.join("awww-daemon")); + // Shadow the host's real `omarchy` unified CLI (present on Omarchy + // machines via /usr/bin) so it can't silently intercept subcommands + // meant to exercise the legacy `omarchy--` fallback. Exit + // 127 mimics "no unified command", same as a host with no `omarchy` at + // all. Tests that want to exercise the unified path (e.g. + // `unlock_set_prefers_unified_omarchy_cli`) overwrite this stub. + write_script(&bin.join("omarchy"), "#!/usr/bin/env bash\nexit 127\n"); TestEnv { temp, home, bin } }