diff --git a/.github/ISSUE_TEMPLATE/01_bug_report.yml b/.github/ISSUE_TEMPLATE/01_bug_report.yml
new file mode 100644
index 0000000..87d427f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/01_bug_report.yml
@@ -0,0 +1,27 @@
+name: Bug Report
+description: Report a plugin issue
+labels: [bug]
+body:
+ - type: input
+ id: plugin
+ attributes:
+ label: Plugin name
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: xfetch version
+ validations:
+ required: true
+ - type: dropdown
+ id: os
+ attributes:
+ label: OS
+ options: [Linux, macOS, Windows]
+ - type: textarea
+ id: description
+ attributes:
+ label: What happened?
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/02_feature_request.yml b/.github/ISSUE_TEMPLATE/02_feature_request.yml
new file mode 100644
index 0000000..7d25c2d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/02_feature_request.yml
@@ -0,0 +1,16 @@
+name: Feature Request
+description: Suggest an improvement for an existing plugin
+labels: [enhancement]
+body:
+ - type: input
+ id: plugin
+ attributes:
+ label: Plugin name
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: What would you like to add or change?
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/03_new_plugin.yml b/.github/ISSUE_TEMPLATE/03_new_plugin.yml
new file mode 100644
index 0000000..52ea191
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/03_new_plugin.yml
@@ -0,0 +1,15 @@
+name: New Plugin Suggestion
+description: Propose a new official plugin
+labels: [new-plugin]
+body:
+ - type: textarea
+ id: description
+ attributes:
+ label: What should the plugin do?
+ validations:
+ required: true
+ - type: textarea
+ id: source
+ attributes:
+ label: Data source or API
+ description: Where would the data come from?
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..0086358
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: true
diff --git a/Cargo.lock b/Cargo.lock
index ee5f979..58923e1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,18 +2,77 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libredox"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -32,6 +91,17 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
[[package]]
name = "serde"
version = "1.0.229"
@@ -86,12 +156,53 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "thiserror"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "xfetch-plugin-animate-logo"
version = "0.1.0"
@@ -110,6 +221,15 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "xfetch-plugin-display-resolution"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "xfetch-plugin-api",
+]
+
[[package]]
name = "xfetch-plugin-docker"
version = "0.1.0"
@@ -128,6 +248,61 @@ dependencies = [
"xfetch-plugin-api",
]
+[[package]]
+name = "xfetch-plugin-music-player"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "xfetch-plugin-api",
+]
+
+[[package]]
+name = "xfetch-plugin-theme-detection"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "xfetch-plugin-api",
+]
+
+[[package]]
+name = "xfetch-plugin-theme-manager"
+version = "0.1.0"
+dependencies = [
+ "dirs",
+ "serde",
+ "serde_json",
+ "xfetch-plugin-api",
+]
+
+[[package]]
+name = "xfetch-plugin-timezone"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "xfetch-plugin-api",
+]
+
+[[package]]
+name = "xfetch-plugin-user-info"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "xfetch-plugin-api",
+]
+
+[[package]]
+name = "xfetch-plugin-weather"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "xfetch-plugin-api",
+]
+
[[package]]
name = "zmij"
version = "1.0.23"
diff --git a/Cargo.toml b/Cargo.toml
index 7ddefdb..3cdcaec 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,8 +1,15 @@
[workspace]
members = [
"plugins/animate-logo",
+ "plugins/display-resolution",
"plugins/docker",
"plugins/github-stats",
+ "plugins/music-player",
+ "plugins/theme-detection",
+ "plugins/theme-manager",
+ "plugins/timezone",
+ "plugins/user-info",
+ "plugins/weather",
]
resolver = "2"
diff --git a/plugins/display-resolution/Cargo.toml b/plugins/display-resolution/Cargo.toml
new file mode 100644
index 0000000..9f0ecea
--- /dev/null
+++ b/plugins/display-resolution/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "xfetch-plugin-display-resolution"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+xfetch-plugin-api = { workspace = true }
diff --git a/plugins/display-resolution/README.md b/plugins/display-resolution/README.md
new file mode 100644
index 0000000..63b7ae5
--- /dev/null
+++ b/plugins/display-resolution/README.md
@@ -0,0 +1,102 @@
+
+
Display Resolution Plugin
+
Displays monitor resolution and refresh rate in xfetch.
+
+
+
+
+
+
+
+ | Kind |
+ info_provider |
+
+
+ | Binary |
+ xfetch-plugin-display-resolution |
+
+
+ | Dependencies |
+ xrandr (X11), wlr-randr (Wayland), xdpyinfo (X11 fallback) |
+
+
+
+
+
+
+Build
+
+cargo build --release --manifest-path plugins/display-resolution/Cargo.toml
+
+Install
+
+xfetch plugin install display-resolution
+
+Configuration
+
+{
+ "info_plugins": [
+ {
+ "plugin": "display-resolution"
+ }
+ ],
+ "modules": [
+ "os",
+ "kernel",
+ "plugin:display-resolution",
+ "shell",
+ "cpu",
+ "memory"
+ ]
+}
+
+The display-resolution plugin does not require any arguments.
+
+Output
+
+
+
+
+ | Platform |
+ Example Output |
+
+
+
+
+ | Single monitor (X11) |
+ DP-1: 1920x1080 @ 144.00* (primary) |
+
+
+ | Multiple monitors |
+ eDP-1: 1920x1080 @ 60.00* (primary) HDMI-1: 3840x2160 @ 30.00 |
+
+
+ | macOS |
+ Display: 2560 x 1600 |
+
+
+ | Not detected |
+ Display: unknown |
+
+
+
+
+How It Works
+
+
+ - xfetch sends a JSON request with
kind: "info_provider".
+ - On Linux, the plugin tries detection in this order:
xrandr → wlr-randr → xdpyinfo.
+ - On macOS, the plugin uses
system_profiler SPDisplaysDataType.
+ - On Windows, the plugin uses a PowerShell script with
GetDeviceCaps.
+ - The plugin returns a JSON response with the formatted lines.
+ - xfetch displays them under the
plugin:display-resolution module key.
+
+
+Notes
+
+
+ - On Linux, requires at least one display utility (
xrandr, wlr-randr, or xdpyinfo).
+ - On Windows, requires PowerShell.
+ - On macOS, requires
system_profiler (included by default).
+ - Multiple monitors are listed with indentation.
+
diff --git a/plugins/display-resolution/src/main.rs b/plugins/display-resolution/src/main.rs
new file mode 100644
index 0000000..d7420de
--- /dev/null
+++ b/plugins/display-resolution/src/main.rs
@@ -0,0 +1,202 @@
+use std::process::Command;
+use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines};
+
+#[derive(Debug, Default, serde::Deserialize)]
+struct PluginArgs {}
+
+fn main() {
+ let _args = match read_info_plugin_args_or_default::() {
+ Ok(value) => value,
+ Err(err) => {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+ };
+
+ let lines = get_display_info();
+
+ if let Err(err) = write_info_lines(lines) {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+}
+
+fn get_display_info() -> Vec {
+ if cfg!(target_os = "linux") {
+ get_x11_display_info()
+ .or_else(|| get_wayland_display_info())
+ .or_else(|| get_xrandr_display_info())
+ .unwrap_or_else(|| vec![" Display: unknown".to_string()])
+ } else if cfg!(target_os = "macos") {
+ get_macos_display_info()
+ .unwrap_or_else(|| vec![" Display: unknown".to_string()])
+ } else if cfg!(target_os = "windows") {
+ get_windows_display_info()
+ .unwrap_or_else(|| vec![" Display: unknown".to_string()])
+ } else {
+ vec![" Display: unsupported platform".to_string()]
+ }
+}
+
+fn get_xrandr_display_info() -> Option> {
+ let output = Command::new("xrandr")
+ .args(["--current"])
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let mut result = Vec::new();
+ let mut display_count = 0;
+
+ for line in stdout.lines() {
+ let line = line.trim();
+ if line.contains(" connected ") && line.contains('x') {
+ display_count += 1;
+ if let Some(res) = extract_resolution_xrandr(line) {
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ let name = parts[0];
+ let primary = if line.contains("primary") { " (primary)" } else { "" };
+ if display_count == 1 {
+ result.push(format!(" {}: {}{}", name, res, primary));
+ } else {
+ result.push(format!(" {}: {}{}", name, res, primary));
+ }
+ }
+ }
+ }
+
+ if result.is_empty() { None } else { Some(result) }
+}
+
+fn extract_resolution_xrandr(line: &str) -> Option {
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ for (i, part) in parts.iter().enumerate() {
+ if part.contains('x') && part.chars().all(|c| c.is_ascii_digit() || c == 'x') {
+ if let Some(ref_rate) = parts.get(i + 1) {
+ if ref_rate.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '*') {
+ let rate = ref_rate.trim_end_matches('*').trim_end_matches('+');
+ return Some(format!("{} @ {} Hz", part, rate));
+ }
+ }
+ return Some(part.to_string());
+ }
+ }
+ None
+}
+
+fn get_x11_display_info() -> Option> {
+ let output = Command::new("xdpyinfo")
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let mut result = Vec::new();
+
+ for line in stdout.lines() {
+ let line = line.trim();
+ if line.starts_with("dimensions:") {
+ let res = line.trim_start_matches("dimensions:").trim();
+ result.push(format!(" Display: {}", res));
+ }
+ }
+
+ if result.is_empty() { None } else { Some(result) }
+}
+
+fn get_wayland_display_info() -> Option> {
+ let output = Command::new("wlr-randr")
+ .output()
+ .ok()
+ .filter(|o| o.status.success())?;
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let mut result = Vec::new();
+ let mut current_name = String::new();
+
+ for line in stdout.lines() {
+ let line = line.trim();
+ if line.ends_with(':') && !line.starts_with(' ') {
+ current_name = line.trim_end_matches(':').to_string();
+ }
+ if let Some(resolution) = line.strip_suffix(" px") {
+ if resolution.contains('x') && resolution.chars().all(|c| c.is_ascii_digit() || c == 'x') {
+ let name = current_name.clone();
+ if result.is_empty() {
+ result.push(format!(" {}: {} px", name, resolution));
+ } else {
+ result.push(format!(" {}: {} px", name, resolution));
+ }
+ }
+ }
+ }
+
+ if result.is_empty() { None } else { Some(result) }
+}
+
+fn get_macos_display_info() -> Option> {
+ let output = Command::new("system_profiler")
+ .args(["SPDisplaysDataType"])
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let mut result = Vec::new();
+
+ for line in stdout.lines() {
+ let line = line.trim();
+ if let Some(res) = line.strip_prefix("Resolution: ") {
+ result.push(format!(" Display: {}", res));
+ }
+ }
+
+ if result.is_empty() { None } else { Some(result) }
+}
+
+fn get_windows_display_info() -> Option> {
+ let ps_script = r#"
+Add-Type @"
+using System;
+using System.Runtime.InteropServices;
+public class Screen {
+ [DllImport("user32.dll")]
+ public static extern IntPtr GetDC(IntPtr hwnd);
+ [DllImport("gdi32.dll")]
+ public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
+ public const int HORZRES = 8;
+ public const int VERTRES = 10;
+}
+"@
+$hdc = [Screen]::GetDC([IntPtr]::Zero)
+$w = [Screen]::GetDeviceCaps($hdc, [Screen]::HORZRES)
+$h = [Screen]::GetDeviceCaps($hdc, [Screen]::VERTRES)
+Write-Output "${w}x${h}"
+"#;
+
+ let output = Command::new("powershell")
+ .args(["-NoProfile", "-Command", ps_script])
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let res = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ if res.is_empty() || !res.contains('x') {
+ return None;
+ }
+
+ Some(vec![format!(" Display: {}", res)])
+}
diff --git a/plugins/music-player/Cargo.toml b/plugins/music-player/Cargo.toml
new file mode 100644
index 0000000..d79062e
--- /dev/null
+++ b/plugins/music-player/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "xfetch-plugin-music-player"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+xfetch-plugin-api = { workspace = true }
diff --git a/plugins/music-player/README.md b/plugins/music-player/README.md
new file mode 100644
index 0000000..f4dbb15
--- /dev/null
+++ b/plugins/music-player/README.md
@@ -0,0 +1,100 @@
+
+
Music Player Plugin
+
Displays currently playing track from MPD and/or Spotify in xfetch.
+
+
+
+
+
+
+
+ | Kind |
+ info_provider |
+
+
+ | Binary |
+ xfetch-plugin-music-player |
+
+
+ | Dependencies |
+ mpc (MPD), playerctl (Spotify) |
+
+
+
+
+
+
+Build
+
+cargo build --release --manifest-path plugins/music-player/Cargo.toml
+
+Install
+
+xfetch plugin install music-player
+
+Configuration
+
+{
+ "info_plugins": [
+ {
+ "plugin": "music-player"
+ }
+ ],
+ "modules": [
+ "os",
+ "kernel",
+ "plugin:music-player",
+ "shell",
+ "cpu",
+ "memory"
+ ]
+}
+
+The music-player plugin does not require any arguments. It detects MPD via mpc status and Spotify via playerctl.
+
+Output
+
+
+
+
+ | State |
+ Example Output |
+
+
+
+
+ | MPD playing |
+ MPD: Song Title - Artist ▶ playing |
+
+
+ | Spotify playing |
+ Spotify: Artist - Song Title |
+
+
+ | Both active |
+ Music Players: MPD: Song Title - Artist Spotify: Artist - Song |
+
+
+ | No player detected |
+ Music: no active player |
+
+
+
+
+How It Works
+
+
+ - xfetch sends a JSON request with
kind: "info_provider".
+ - The plugin checks MPD via
mpc status and Spotify via playerctl -p spotify metadata.
+ - If no player is active, it reports it gracefully.
+ - The plugin returns a JSON response with the formatted lines.
+ - xfetch displays them under the
plugin:music-player module key.
+
+
+Notes
+
+
+ - MPD detection requires
mpc CLI in PATH and a running MPD daemon.
+ - Spotify detection requires
playerctl and a running Spotify client.
+ - Both players are checked independently; if both are active, all info is shown.
+
diff --git a/plugins/music-player/src/main.rs b/plugins/music-player/src/main.rs
new file mode 100644
index 0000000..0e02122
--- /dev/null
+++ b/plugins/music-player/src/main.rs
@@ -0,0 +1,112 @@
+use std::process::Command;
+use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines};
+
+#[derive(Debug, Default, serde::Deserialize)]
+struct PluginArgs {}
+
+fn main() {
+ let _args = match read_info_plugin_args_or_default::() {
+ Ok(value) => value,
+ Err(err) => {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+ };
+
+ let lines = get_music_info();
+
+ if let Err(err) = write_info_lines(lines) {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+}
+
+fn get_music_info() -> Vec {
+ let mut result = Vec::new();
+
+ let mpd = get_mpd_status();
+ let spotify = get_spotify_status();
+
+ match (mpd, spotify) {
+ (Some(m), Some(s)) => {
+ result.push(" Music Players:".to_string());
+ result.extend(m.iter().map(|l| format!(" {}", l)));
+ result.extend(s.iter().map(|l| format!(" {}", l)));
+ }
+ (Some(m), None) => result.extend(m),
+ (None, Some(s)) => result.extend(s),
+ (None, None) => result.push(" Music: no active player".to_string()),
+ }
+
+ result
+}
+
+fn get_mpd_status() -> Option> {
+ let output = Command::new("mpc").args(["status"]).output().ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let lines: Vec<&str> = stdout.lines().collect();
+
+ if lines.is_empty() {
+ return None;
+ }
+
+ let mut result = Vec::new();
+
+ let song = lines.first()?.trim().to_string();
+ if !song.is_empty() {
+ result.push(format!(" MPD: {}", song));
+ } else {
+ result.push(" MPD: stopped".to_string());
+ }
+
+ if lines.len() > 1 {
+ let status_line = lines[1].trim();
+ if let Some(state) = status_line.split_whitespace().next() {
+ let state_icon = match state {
+ "[playing]" => "▶",
+ "[paused]" => "⏸",
+ _ => "⏹",
+ };
+ result.push(format!(" {} {}", state_icon, status_line));
+ }
+ }
+
+ Some(result)
+}
+
+fn get_spotify_status() -> Option> {
+ let output = Command::new("playerctl")
+ .args(["-p", "spotify", "metadata", "--format", "{{artist}} - {{title}}"])
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ let paused = Command::new("playerctl")
+ .args(["-p", "spotify", "status"])
+ .output()
+ .ok()?;
+ if paused.status.success() {
+ let status = String::from_utf8_lossy(&paused.stdout).trim().to_string();
+ if status == "Paused" {
+ return Some(vec![
+ " Spotify: paused".to_string(),
+ ]);
+ }
+ }
+ return None;
+ }
+
+ let meta = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ if meta.is_empty() {
+ return None;
+ }
+
+ Some(vec![
+ format!(" Spotify: {}", meta),
+ ])
+}
diff --git a/plugins/theme-detection/Cargo.toml b/plugins/theme-detection/Cargo.toml
new file mode 100644
index 0000000..39744ee
--- /dev/null
+++ b/plugins/theme-detection/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "xfetch-plugin-theme-detection"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+xfetch-plugin-api = { workspace = true }
diff --git a/plugins/theme-detection/README.md b/plugins/theme-detection/README.md
new file mode 100644
index 0000000..0d19c6a
--- /dev/null
+++ b/plugins/theme-detection/README.md
@@ -0,0 +1,97 @@
+
+
Theme Detection Plugin
+
Detects and displays current GTK and KDE Plasma theme settings in xfetch.
+
+
+
+
+
+
+
+ | Kind |
+ info_provider |
+
+
+ | Binary |
+ xfetch-plugin-theme-detection |
+
+
+ | Dependencies |
+ gsettings (GTK), plasmarc/kdeglobals (KDE) |
+
+
+
+
+
+
+Build
+
+cargo build --release --manifest-path plugins/theme-detection/Cargo.toml
+
+Install
+
+xfetch plugin install theme-detection
+
+Configuration
+
+{
+ "info_plugins": [
+ {
+ "plugin": "theme-detection"
+ }
+ ],
+ "modules": [
+ "os",
+ "kernel",
+ "plugin:theme-detection",
+ "shell",
+ "cpu",
+ "memory"
+ ]
+}
+
+The theme-detection plugin does not require any arguments.
+
+Output
+
+
+
+
+ | Environment |
+ Example Output |
+
+
+
+
+ | GNOME / GTK |
+ GTK Theme: Adwaita-dark (dark) Icons: Adwaita Cursor: Adwaita Font: Cantarell 11 |
+
+
+ | KDE Plasma |
+ GTK: Breeze (light) Plasma: breeze-dark Colors: BreezeDark Icons: breeze-dark Cursor: breeze_cursors |
+
+
+ | No theme detected |
+ Theme: not detected |
+
+
+
+
+How It Works
+
+
+ - xfetch sends a JSON request with
kind: "info_provider".
+ - The plugin reads GTK settings via
gsettings get org.gnome.desktop.interface.
+ - KDE Plasma themes are read from
~/.config/plasmarc and ~/.config/kdeglobals.
+ - The plugin returns a JSON response with the formatted lines.
+ - xfetch displays them under the
plugin:theme-detection module key.
+
+
+Notes
+
+
+ - GTK detection works on any desktop using
gsettings (GNOME, Budgie, Cinnamon, etc.).
+ - KDE detection reads from standard Plasma config files.
+ - If both GTK and KDE are detected, both are shown (GTK listed first).
+ - Uses
prefer-dark/prefer-light color scheme from GNOME for dark/light detection.
+
diff --git a/plugins/theme-detection/src/main.rs b/plugins/theme-detection/src/main.rs
new file mode 100644
index 0000000..9e8f9a8
--- /dev/null
+++ b/plugins/theme-detection/src/main.rs
@@ -0,0 +1,167 @@
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines};
+
+#[derive(Debug, Default, serde::Deserialize)]
+struct PluginArgs {}
+
+fn main() {
+ let _args = match read_info_plugin_args_or_default::() {
+ Ok(value) => value,
+ Err(err) => {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+ };
+
+ let lines = get_theme_info();
+
+ if let Err(err) = write_info_lines(lines) {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+}
+
+fn get_theme_info() -> Vec {
+ let mut result = Vec::new();
+
+ let gtk_theme = get_gsetting("org.gnome.desktop.interface", "gtk-theme");
+ let icon_theme = get_gsetting("org.gnome.desktop.interface", "icon-theme");
+ let cursor_theme = get_gsetting("org.gnome.desktop.interface", "cursor-theme");
+ let font_name = get_gsetting("org.gnome.desktop.interface", "font-name");
+ let color_scheme = get_gsetting("org.gnome.desktop.interface", "color-scheme");
+
+ let kde_theme = get_kde_theme();
+ let kde_color = get_kde_color_scheme();
+
+ match (>k_theme, &kde_theme) {
+ (Some(_), None) | (None, None) => {
+ if let Some(ref theme) = gtk_theme {
+ let variant = color_scheme.as_deref().unwrap_or("default");
+ let icon = if variant.contains("dark") { "" } else { "" };
+ result.push(format!("{} GTK Theme: {} ({})", icon, theme, variant_display(variant)));
+ } else {
+ result.push(" Theme: not detected".to_string());
+ return result;
+ }
+ }
+ (None, Some(_)) | (Some(_), Some(_)) => {
+ if let Some(ref theme) = gtk_theme {
+ let variant = color_scheme.as_deref().unwrap_or("default");
+ let icon = if variant.contains("dark") { "" } else { "" };
+ result.push(format!("{} GTK: {} ({})", icon, theme, variant_display(variant)));
+ }
+ if let Some(ref theme) = kde_theme {
+ result.push(format!(" Plasma: {}", theme));
+ if let Some(ref color) = kde_color {
+ result.push(format!(" Colors: {}", color));
+ }
+ }
+ }
+ }
+
+ if let Some(ref icons) = icon_theme {
+ result.push(format!(" Icons: {}", icons));
+ }
+
+ if let Some(ref cursor) = cursor_theme {
+ result.push(format!(" Cursor: {}", cursor));
+ }
+
+ if let Some(ref font) = font_name {
+ result.push(format!(" Font: {}", font));
+ }
+
+ result
+}
+
+fn get_gsetting(schema: &str, key: &str) -> Option {
+ let output = Command::new("gsettings")
+ .args(["get", schema, key])
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ if value.is_empty() || value == "''" || value == "\"\"" {
+ return None;
+ }
+
+ Some(
+ value
+ .trim_matches('\'')
+ .trim_matches('"')
+ .to_string(),
+ )
+}
+
+fn get_kde_theme() -> Option {
+ let config_paths = vec![
+ format!(
+ "{}/.config/plasmarc",
+ std::env::var("HOME").unwrap_or_default()
+ ),
+ format!(
+ "{}/.config/kdeglobals",
+ std::env::var("HOME").unwrap_or_default()
+ ),
+ ];
+
+ for path in config_paths {
+ if Path::new(&path).exists() {
+ if let Ok(content) = fs::read_to_string(&path) {
+ for line in content.lines() {
+ if line.starts_with("theme=") {
+ return Some(line[6..].to_string());
+ }
+ }
+ }
+ }
+ }
+
+ None
+}
+
+fn get_kde_color_scheme() -> Option {
+ let config_path = format!(
+ "{}/.config/kdeglobals",
+ std::env::var("HOME").unwrap_or_default()
+ );
+
+ if !Path::new(&config_path).exists() {
+ return None;
+ }
+
+ let content = fs::read_to_string(&config_path).ok()?;
+ let mut in_general = false;
+
+ for line in content.lines() {
+ if line.trim() == "[General]" {
+ in_general = true;
+ continue;
+ }
+ if in_general {
+ if line.starts_with('[') {
+ break;
+ }
+ if line.starts_with("ColorScheme=") {
+ return Some(line[12..].to_string());
+ }
+ }
+ }
+
+ None
+}
+
+fn variant_display(variant: &str) -> &str {
+ match variant {
+ "prefer-dark" => "dark",
+ "prefer-light" => "light",
+ "default" => "default",
+ _ => variant,
+ }
+}
diff --git a/plugins/theme-manager/Cargo.toml b/plugins/theme-manager/Cargo.toml
new file mode 100644
index 0000000..b4a5979
--- /dev/null
+++ b/plugins/theme-manager/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "xfetch-plugin-theme-manager"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+dirs = "6"
+xfetch-plugin-api = { workspace = true }
diff --git a/plugins/theme-manager/README.md b/plugins/theme-manager/README.md
new file mode 100644
index 0000000..641f056
--- /dev/null
+++ b/plugins/theme-manager/README.md
@@ -0,0 +1,186 @@
+
+
Theme Manager Plugin
+
Browse, search, install, and inspect xfetch themes from the remote registry.
+
+
+
+
+
+
+
+ | Kind |
+ info_provider |
+
+
+ | Binary |
+ xfetch-plugin-theme-manager |
+
+
+ | Dependencies |
+ curl CLI |
+
+
+ | Registry |
+ github.com/xfetch-cli/configs (themes/index.json) |
+
+
+
+
+
+
+Build
+
+cargo build --release --manifest-path plugins/theme-manager/Cargo.toml
+
+Install
+
+xfetch plugin install theme-manager
+
+Actions
+
+list
+
+Display all available themes from the registry:
+
+{
+ "info_plugins": [
+ { "plugin": "theme-manager" }
+ ],
+ "modules": ["plugin:theme-manager"]
+}
+
+This is the default action when none is specified.
+
+search
+
+Search themes by name, description, author, or tags:
+
+{
+ "info_plugins": [
+ {
+ "plugin": "theme-manager",
+ "args": {
+ "action": "search",
+ "query": "dark"
+ }
+ }
+ ],
+ "modules": ["plugin:theme-manager"]
+}
+
+info
+
+Show detailed information about a specific theme:
+
+{
+ "info_plugins": [
+ {
+ "plugin": "theme-manager",
+ "args": {
+ "action": "info",
+ "name": "dracula"
+ }
+ }
+ ],
+ "modules": ["plugin:theme-manager"]
+}
+
+install
+
+Download and install a theme from the registry:
+
+{
+ "info_plugins": [
+ {
+ "plugin": "theme-manager",
+ "args": {
+ "action": "install",
+ "name": "dracula"
+ }
+ }
+ ],
+ "modules": ["plugin:theme-manager"]
+}
+
+The theme file is saved to ~/.config/xfetch/themes/<name>.jsonc.
+
+Args
+
+
+
+
+ | Field |
+ Type |
+ Required |
+ Description |
+
+
+
+
+ action |
+ string |
+ No |
+ One of list (default), search, info, install |
+
+
+ name |
+ string |
+ For info and install |
+ Theme name (e.g., dracula, nord) |
+
+
+ query |
+ string |
+ For search |
+ Search term matching name, description, author, or tags |
+
+
+ registry |
+ string |
+ No |
+ Custom registry URL (defaults to xfetch-cli/configs) |
+
+
+
+
+Output
+
+list example
+
+Theme Manager -- 6 themes available
+
+ dracula xscriptor section (#dark #dracula #popular)
+ Dark magenta, red, and cyan palette inspired by the Dracula color scheme.
+
+ nord xscriptor section (#light #nord #arctic #minimal)
+ Cool blue and arctic cyan palette based on the Nord color scheme.
+
+install example
+
+Theme 'dracula' installed successfully.
+ Path: /home/user/.config/xfetch/themes/dracula.jsonc
+ Author: xscriptor
+ Layout: section
+
+Activate with: xfetch theme set dracula
+Or add to config.jsonc: "theme": "dracula"
+
+How It Works
+
+
+ - xfetch sends a JSON request with
kind: "info_provider" and the configured args.
+ - The plugin fetches the theme registry from the remote URL via
curl.
+ - Depending on the
action, it filters, displays, or downloads theme files.
+ - For
install, the theme JSONC file is saved to ~/.config/xfetch/themes/.
+ - The plugin returns a JSON response with the formatted lines.
+ - xfetch displays them under the
plugin:theme-manager module key.
+
+
+Notes
+
+
+ - Requires
curl to be installed and available in PATH.
+ - Network connectivity is required for all actions.
+ - The registry URL can be overridden with the
registry arg for self-hosted registries.
+ - After installing a theme, activate it with
xfetch theme set <name> or by adding "theme": "<name>" to config.jsonc.
+
diff --git a/plugins/theme-manager/src/main.rs b/plugins/theme-manager/src/main.rs
new file mode 100644
index 0000000..4e010a1
--- /dev/null
+++ b/plugins/theme-manager/src/main.rs
@@ -0,0 +1,287 @@
+use serde::Deserialize;
+use std::path::PathBuf;
+use std::process::Command;
+use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines};
+
+const DEFAULT_REGISTRY: &str =
+ "https://raw.githubusercontent.com/xfetch-cli/configs/main/themes/index.json";
+
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+struct PluginArgs {
+ action: String,
+ name: Option,
+ query: Option,
+ registry: Option,
+}
+
+impl Default for PluginArgs {
+ fn default() -> Self {
+ Self {
+ action: "list".to_string(),
+ name: None,
+ query: None,
+ registry: None,
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct ThemeIndex {
+ #[allow(dead_code)]
+ registry: String,
+ themes: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+struct ThemeEntry {
+ name: String,
+ author: String,
+ version: String,
+ description: String,
+ layout: Option,
+ palette_style: Option,
+ tags: Option>,
+ source: String,
+}
+
+fn main() {
+ let args = match read_info_plugin_args_or_default::() {
+ Ok(v) => v,
+ Err(err) => {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+ };
+
+ let lines = match handle_action(&args) {
+ Ok(lines) => lines,
+ Err(err) => vec![format!("Theme Manager: {}", err)],
+ };
+
+ if let Err(err) = write_info_lines(lines) {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+}
+
+fn handle_action(args: &PluginArgs) -> Result, String> {
+ match args.action.as_str() {
+ "list" => list_themes(args),
+ "search" => search_themes(args),
+ "info" => theme_info(args),
+ "install" => install_theme(args),
+ _ => Err(format!("Unknown action '{}'. Use list, search, info, or install.", args.action)),
+ }
+}
+
+fn fetch_index(registry_url: &str) -> Result {
+ let data = if registry_url.starts_with('/') || registry_url.starts_with('~') {
+ let path = if registry_url.starts_with('~') {
+ let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
+ registry_url.replacen('~', &home, 1)
+ } else {
+ registry_url.to_string()
+ };
+ std::fs::read_to_string(&path)
+ .map_err(|e| format!("Failed to read local registry '{}': {}", path, e))?
+ } else {
+ let output = Command::new("curl")
+ .args(["-s", "--max-time", "15", registry_url])
+ .output()
+ .map_err(|e| format!("Failed to run curl: {}", e))?;
+
+ if !output.status.success() {
+ let code = output.status.code().unwrap_or(-1);
+ return Err(format!(
+ "Failed to fetch theme registry (HTTP {}). Check your internet connection.",
+ code
+ ));
+ }
+
+ String::from_utf8_lossy(&output.stdout).to_string()
+ };
+
+ serde_json::from_str(&data)
+ .map_err(|e| format!("Failed to parse theme registry: {}", e))
+}
+
+fn theme_dir() -> PathBuf {
+ let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
+ config_dir.join("xfetch").join("themes")
+}
+
+fn list_themes(args: &PluginArgs) -> Result, String> {
+ let registry_url = args.registry.as_deref().unwrap_or(DEFAULT_REGISTRY);
+ let index = fetch_index(registry_url)?;
+
+ if index.themes.is_empty() {
+ return Ok(vec!["Theme Manager: no themes available in registry".to_string()]);
+ }
+
+ let mut result = Vec::new();
+ result.push(format!("Theme Manager -- {} themes available", index.themes.len()));
+ result.push(String::new());
+
+ for theme in &index.themes {
+ let layout = theme.layout.as_deref().unwrap_or("default");
+ let tags = theme
+ .tags
+ .as_ref()
+ .map(|t| format!("#{}", t.join(" #")))
+ .unwrap_or_default();
+ result.push(format!(" {} {} {} ({})", theme.name, theme.author, layout, tags));
+ result.push(format!(" {}", theme.description));
+ result.push(String::new());
+ }
+
+ Ok(result)
+}
+
+fn search_themes(args: &PluginArgs) -> Result, String> {
+ let registry_url = args.registry.as_deref().unwrap_or(DEFAULT_REGISTRY);
+ let query = args.query.as_deref().unwrap_or("").to_lowercase();
+ let index = fetch_index(registry_url)?;
+
+ if query.is_empty() {
+ return list_themes(args);
+ }
+
+ let matches: Vec<&ThemeEntry> = index
+ .themes
+ .iter()
+ .filter(|t| {
+ t.name.to_lowercase().contains(&query)
+ || t.description.to_lowercase().contains(&query)
+ || t.author.to_lowercase().contains(&query)
+ || t.tags
+ .as_ref()
+ .map(|tags| tags.iter().any(|tag| tag.to_lowercase().contains(&query)))
+ .unwrap_or(false)
+ })
+ .collect();
+
+ if matches.is_empty() {
+ return Ok(vec![format!("Theme Manager: no themes matching '{}'", query)]);
+ }
+
+ let mut result = Vec::new();
+ result.push(format!(
+ "Theme Manager -- {} themes matching '{}'",
+ matches.len(),
+ query
+ ));
+ result.push(String::new());
+
+ for theme in matches {
+ let layout = theme.layout.as_deref().unwrap_or("default");
+ let tags = theme
+ .tags
+ .as_ref()
+ .map(|t| format!("#{}", t.join(" #")))
+ .unwrap_or_default();
+ result.push(format!(" {} {} {} ({})", theme.name, theme.author, layout, tags));
+ result.push(format!(" {}", theme.description));
+ result.push(String::new());
+ }
+
+ Ok(result)
+}
+
+fn theme_info(args: &PluginArgs) -> Result, String> {
+ let name = args
+ .name
+ .as_deref()
+ .ok_or_else(|| "Theme name required for info action".to_string())?;
+ let registry_url = args.registry.as_deref().unwrap_or(DEFAULT_REGISTRY);
+ let index = fetch_index(registry_url)?;
+
+ let theme = index
+ .themes
+ .iter()
+ .find(|t| t.name == name)
+ .ok_or_else(|| format!("Theme '{}' not found in registry", name))?;
+
+ let tags = theme
+ .tags
+ .as_ref()
+ .map(|t| t.join(", "))
+ .unwrap_or_else(|| "none".to_string());
+
+ let installed_path = theme_dir().join(format!("{}.jsonc", name));
+ let installed = if installed_path.exists() { "yes" } else { "no" };
+
+ Ok(vec![
+ format!("Theme: {}", theme.name),
+ format!(" Author: {}", theme.author),
+ format!(" Version: {}", theme.version),
+ format!(" Layout: {}", theme.layout.as_deref().unwrap_or("default")),
+ format!(" Palette: {}", theme.palette_style.as_deref().unwrap_or("default")),
+ format!(" Tags: {}", tags),
+ format!(" Installed: {}", installed),
+ String::new(),
+ format!(" {}", theme.description),
+ String::new(),
+ format!(" Source: {}", theme.source),
+ ])
+}
+
+fn install_theme(args: &PluginArgs) -> Result, String> {
+ let name = args
+ .name
+ .as_deref()
+ .ok_or_else(|| "Theme name required for install action".to_string())?;
+
+ let registry_url = args.registry.as_deref().unwrap_or(DEFAULT_REGISTRY);
+ let index = fetch_index(registry_url)?;
+
+ let theme = index
+ .themes
+ .iter()
+ .find(|t| t.name == name)
+ .ok_or_else(|| format!("Theme '{}' not found in registry", name))?;
+
+ let dest_dir = theme_dir();
+ std::fs::create_dir_all(&dest_dir)
+ .map_err(|e| format!("Failed to create themes directory: {}", e))?;
+
+ let dest_path = dest_dir.join(format!("{}.jsonc", name));
+
+ let source = &theme.source;
+ if source.starts_with('/') || source.starts_with('~') {
+ let src_path = if source.starts_with('~') {
+ let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
+ source.replacen('~', &home, 1)
+ } else {
+ source.to_string()
+ };
+ std::fs::copy(&src_path, &dest_path)
+ .map_err(|e| format!("Failed to copy theme file: {}", e))?;
+ } else {
+ let output = Command::new("curl")
+ .args(["-s", "--max-time", "30", "-o", &dest_path.to_string_lossy(), source])
+ .output()
+ .map_err(|e| format!("Failed to run curl: {}", e))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("Failed to download theme '{}': {}", name, stderr));
+ }
+ }
+
+ if !dest_path.is_file() || dest_path.metadata().map(|m| m.len()).unwrap_or(0) == 0 {
+ return Err(format!("Downloaded theme '{}' is empty or missing", name));
+ }
+
+ let layout = theme.layout.as_deref().unwrap_or("default");
+
+ Ok(vec![
+ format!("Theme '{}' installed successfully.", name),
+ format!(" Path: {}", dest_path.display()),
+ format!(" Author: {}", theme.author),
+ format!(" Layout: {}", layout),
+ String::new(),
+ format!("Activate with: xfetch theme set {}", name),
+ format!("Or add to config.jsonc: \"theme\": \"{}\"", name),
+ ])
+}
diff --git a/plugins/timezone/Cargo.toml b/plugins/timezone/Cargo.toml
new file mode 100644
index 0000000..1c0a3a2
--- /dev/null
+++ b/plugins/timezone/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "xfetch-plugin-timezone"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+xfetch-plugin-api = { workspace = true }
diff --git a/plugins/timezone/README.md b/plugins/timezone/README.md
new file mode 100644
index 0000000..42d7e24
--- /dev/null
+++ b/plugins/timezone/README.md
@@ -0,0 +1,105 @@
+
+
Timezone Plugin
+
Displays current time, date, and timezone information in xfetch.
+
+
+
+
+
+
+
+ | Kind |
+ info_provider |
+
+
+ | Binary |
+ xfetch-plugin-timezone |
+
+
+ | Dependencies |
+ date, timedatectl (optional) |
+
+
+
+
+
+
+Build
+
+cargo build --release --manifest-path plugins/timezone/Cargo.toml
+
+Install
+
+xfetch plugin install timezone
+
+Configuration
+
+{
+ "info_plugins": [
+ {
+ "plugin": "timezone"
+ }
+ ],
+ "modules": [
+ "os",
+ "kernel",
+ "plugin:timezone",
+ "shell",
+ "cpu",
+ "memory"
+ ]
+}
+
+Args
+
+
+
+
+ | Field |
+ Type |
+ Required |
+ Description |
+
+
+
+
+ format |
+ string |
+ No |
+ date format string. Default: %Z %z (timezone name + UTC offset). |
+
+
+
+
+Output
+
+
+
+
+ | Example Output |
+
+
+
+
+ Wednesday, 23 July 2026 14:30 America/New York (EST -05:00) |
+
+
+
+
+How It Works
+
+
+ - xfetch sends a JSON request with
kind: "info_provider".
+ - The plugin detects the timezone from
/etc/timezone, /etc/localtime, or timedatectl.
+ - The plugin runs
date to get the current time and UTC offset.
+ - The plugin returns a JSON response with the formatted lines.
+ - xfetch displays them under the
plugin:timezone module key.
+
+
+Notes
+
+
+ - Timezone detection order:
/etc/timezone → /etc/localtime symlink → timedatectl.
+ - The
format arg is passed directly to date — use standard date format specifiers.
+ - Works on Linux, macOS, and most Unix-like systems.
+
diff --git a/plugins/timezone/src/main.rs b/plugins/timezone/src/main.rs
new file mode 100644
index 0000000..f85366a
--- /dev/null
+++ b/plugins/timezone/src/main.rs
@@ -0,0 +1,129 @@
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines};
+
+#[derive(Debug, Default, serde::Deserialize)]
+struct PluginArgs {
+ format: Option,
+}
+
+fn main() {
+ let args = match read_info_plugin_args_or_default::() {
+ Ok(value) => value,
+ Err(err) => {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+ };
+
+ let fmt = args.format.as_deref().unwrap_or("%Z %z");
+ let lines = get_tz_info(fmt);
+
+ if let Err(err) = write_info_lines(lines) {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+}
+
+fn get_tz_info(format: &str) -> Vec {
+ let tz = detect_timezone();
+ let tz_name = tz.as_deref().unwrap_or("unknown");
+
+ let now_output = Command::new("date")
+ .args([&format!("+{}", format)])
+ .output();
+
+ let date_info = match now_output {
+ Ok(o) if o.status.success() => {
+ String::from_utf8_lossy(&o.stdout).trim().to_string()
+ }
+ _ => String::new(),
+ };
+
+ let utc_offset = Command::new("date")
+ .args(["+%:z"])
+ .output();
+
+ let offset = match utc_offset {
+ Ok(o) if o.status.success() => {
+ String::from_utf8_lossy(&o.stdout).trim().to_string()
+ }
+ _ => String::new(),
+ };
+
+ let local_time = Command::new("date")
+ .args(["+%A, %d %B %Y %H:%M"])
+ .output();
+
+ let datetime = match local_time {
+ Ok(o) if o.status.success() => {
+ String::from_utf8_lossy(&o.stdout).trim().to_string()
+ }
+ _ => String::new(),
+ };
+
+ let mut result = Vec::new();
+
+ if !datetime.is_empty() {
+ result.push(format!(" {}", datetime));
+ }
+
+ if !tz_name.is_empty() && tz_name != "unknown" {
+ let display = tz_name.replace('_', " ");
+ if !date_info.is_empty() {
+ result.push(format!(" {} ({})", display, date_info));
+ } else if !offset.is_empty() {
+ result.push(format!(" {} ({})", display, offset));
+ } else {
+ result.push(format!(" {}", display));
+ }
+ } else if !offset.is_empty() {
+ result.push(format!(" UTC offset: {}", offset));
+ }
+
+ if result.is_empty() {
+ result.push(" Timezone: unknown".to_string());
+ }
+
+ result
+}
+
+fn detect_timezone() -> Option {
+ let tz_file = Path::new("/etc/timezone");
+ if tz_file.exists() {
+ if let Ok(content) = fs::read_to_string(tz_file) {
+ let line = content.trim().to_string();
+ if !line.is_empty() {
+ return Some(line);
+ }
+ }
+ }
+
+ let localtime = Path::new("/etc/localtime");
+ if localtime.exists() {
+ if let Ok(path) = fs::read_link(localtime) {
+ let path_str = path.to_string_lossy();
+ if let Some(idx) = path_str.find("zoneinfo/") {
+ let tz = path_str[idx + 9..].to_string();
+ if !tz.is_empty() {
+ return Some(tz);
+ }
+ }
+ }
+ }
+
+ let output = Command::new("timedatectl")
+ .args(["show", "--property=Timezone", "--value"])
+ .output()
+ .ok()?;
+
+ if output.status.success() {
+ let tz = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ if !tz.is_empty() {
+ return Some(tz);
+ }
+ }
+
+ None
+}
diff --git a/plugins/user-info/Cargo.toml b/plugins/user-info/Cargo.toml
new file mode 100644
index 0000000..cb787b1
--- /dev/null
+++ b/plugins/user-info/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "xfetch-plugin-user-info"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+xfetch-plugin-api = { workspace = true }
diff --git a/plugins/user-info/README.md b/plugins/user-info/README.md
new file mode 100644
index 0000000..3df37e5
--- /dev/null
+++ b/plugins/user-info/README.md
@@ -0,0 +1,103 @@
+
+
User Info Plugin
+
Displays user account information (name, UID, GID, home, shell, groups) in xfetch.
+
+
+
+
+
+
+
+ | Kind |
+ info_provider |
+
+
+ | Binary |
+ xfetch-plugin-user-info |
+
+
+ | Dependencies |
+ id, getent, groups |
+
+
+
+
+
+
+Build
+
+cargo build --release --manifest-path plugins/user-info/Cargo.toml
+
+Install
+
+xfetch plugin install user-info
+
+Configuration
+
+{
+ "info_plugins": [
+ {
+ "plugin": "user-info"
+ }
+ ],
+ "modules": [
+ "os",
+ "kernel",
+ "plugin:user-info",
+ "shell",
+ "cpu",
+ "memory"
+ ]
+}
+
+Args
+
+
+
+
+ | Field |
+ Type |
+ Required |
+ Description |
+
+
+
+
+ show_groups |
+ bool |
+ No |
+ Show user group memberships (max 10). Default: false. |
+
+
+
+
+Output
+
+
+
+
+ | Example Output |
+
+
+
+
+ John (john) uid: 1000 gid: 1000 /home/john zsh |
+
+
+ | With groups enabled |
+ John (john) uid: 1000 gid: 1000 /home/john zsh groups: wheel, users, docker |
+
+
+
+
+How It Works
+
+
+ - xfetch sends a JSON request with
kind: "info_provider".
+ - The plugin reads
USER/LOGNAME env vars or runs whoami.
+ - UID/GID are collected via
id -u / id -g.
+ - GECOS (full name) is looked up via
getent passwd.
+ - Groups are listed via
groups (if enabled).
+ - The plugin returns a JSON response with the formatted lines.
+ - xfetch displays them under the
plugin:user-info module key.
+
diff --git a/plugins/user-info/src/main.rs b/plugins/user-info/src/main.rs
new file mode 100644
index 0000000..67203cc
--- /dev/null
+++ b/plugins/user-info/src/main.rs
@@ -0,0 +1,150 @@
+use std::env;
+use std::process::Command;
+use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines};
+
+#[derive(Debug, Default, serde::Deserialize)]
+struct PluginArgs {
+ show_groups: Option,
+}
+
+fn main() {
+ let args = match read_info_plugin_args_or_default::() {
+ Ok(value) => value,
+ Err(err) => {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+ };
+
+ let lines = get_user_info(args.show_groups.unwrap_or(false));
+
+ if let Err(err) = write_info_lines(lines) {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+}
+
+fn get_user_info(show_groups: bool) -> Vec {
+ let mut result = Vec::new();
+
+ let username = whoami();
+ let uid = get_uid();
+ let gid = get_gid();
+ let home = get_home();
+ let shell = get_shell();
+ let gecos = get_gecos(&username);
+
+ let display_name = gecos.as_deref().unwrap_or(&username);
+ result.push(format!(" {} ({})", display_name, username));
+
+ result.push(format!(" uid: {} gid: {}", uid, gid));
+
+ if let Some(h) = home {
+ result.push(format!(" {}", h));
+ }
+
+ if let Some(s) = shell {
+ let sname = s.rsplit('/').next().unwrap_or(&s);
+ result.push(format!(" {}", sname));
+ }
+
+ if show_groups {
+ let groups = get_groups();
+ if !groups.is_empty() {
+ let group_str = groups.join(", ");
+ result.push(format!(" groups: {}", group_str));
+ }
+ }
+
+ result
+}
+
+fn whoami() -> String {
+ env::var("USER")
+ .or_else(|_| env::var("LOGNAME"))
+ .unwrap_or_else(|_| {
+ Command::new("whoami")
+ .output()
+ .ok()
+ .filter(|o| o.status.success())
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
+ .unwrap_or_else(|| "unknown".to_string())
+ })
+}
+
+fn get_uid() -> String {
+ Command::new("id")
+ .args(["-u"])
+ .output()
+ .ok()
+ .filter(|o| o.status.success())
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
+ .unwrap_or_else(|| "?".to_string())
+}
+
+fn get_gid() -> String {
+ Command::new("id")
+ .args(["-g"])
+ .output()
+ .ok()
+ .filter(|o| o.status.success())
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
+ .unwrap_or_else(|| "?".to_string())
+}
+
+fn get_home() -> Option {
+ env::var("HOME").ok()
+}
+
+fn get_shell() -> Option {
+ env::var("SHELL").ok()
+}
+
+fn get_gecos(username: &str) -> Option {
+ let output = Command::new("getent")
+ .args(["passwd", username])
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let entry = String::from_utf8_lossy(&output.stdout);
+ let fields: Vec<&str> = entry.trim().split(':').collect();
+ if fields.len() >= 5 {
+ let gecos = fields[4].trim().to_string();
+ if !gecos.is_empty() && gecos != "," {
+ let name = gecos.split(',').next().unwrap_or("").trim().to_string();
+ if !name.is_empty() {
+ return Some(name);
+ }
+ }
+ }
+
+ None
+}
+
+fn get_groups() -> Vec {
+ let output = match Command::new("groups").output() {
+ Ok(o) if o.status.success() => o,
+ _ => return Vec::new(),
+ };
+
+ let line = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ let parts: Vec<&str> = line.split(':').collect();
+
+ let groups_str = if parts.len() >= 2 {
+ parts[1].trim()
+ } else {
+ parts[0].trim()
+ };
+
+ let current_user = whoami();
+ groups_str
+ .split_whitespace()
+ .map(|s| s.to_string())
+ .filter(|g| g != ¤t_user)
+ .take(10)
+ .collect()
+}
diff --git a/plugins/weather/Cargo.toml b/plugins/weather/Cargo.toml
new file mode 100644
index 0000000..a43e4b6
--- /dev/null
+++ b/plugins/weather/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "xfetch-plugin-weather"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+xfetch-plugin-api = { workspace = true }
diff --git a/plugins/weather/README.md b/plugins/weather/README.md
new file mode 100644
index 0000000..d6f4394
--- /dev/null
+++ b/plugins/weather/README.md
@@ -0,0 +1,125 @@
+
+
Weather Plugin
+
Displays current weather conditions in xfetch.
+
+
+
+
+
+
+
+ | Kind |
+ info_provider |
+
+
+ | Binary |
+ xfetch-plugin-weather |
+
+
+ | Dependencies |
+ curl CLI |
+
+
+ | API |
+ wttr.in (free, no key required) |
+
+
+
+
+
+
+Build
+
+cargo build --release --manifest-path plugins/weather/Cargo.toml
+
+Install
+
+xfetch plugin install weather
+
+Configuration
+
+{
+ "info_plugins": [
+ {
+ "plugin": "weather",
+ "args": {
+ "location": "London"
+ }
+ }
+ ],
+ "modules": [
+ "os",
+ "kernel",
+ "plugin:weather",
+ "shell",
+ "cpu",
+ "memory"
+ ]
+}
+
+Args
+
+
+
+
+ | Field |
+ Type |
+ Required |
+ Description |
+
+
+
+
+ location |
+ string |
+ No |
+ City name, coordinates, or airport code. Auto-detects if omitted. |
+
+
+ format |
+ string |
+ No |
+ Custom wttr.in format string. Default: %c+%t+%w+%h+%p |
+
+
+
+
+Output
+
+
+
+
+ | State |
+ Example Output |
+
+
+
+
+ | Weather fetched |
+ ☀ +15°C Clear Humidity: 60% Wind: ↑15 km/h Precipitation: 0% |
+
+
+ | Network error |
+ Weather: could not fetch |
+
+
+
+
+How It Works
+
+
+ - xfetch sends a JSON request with
kind: "info_provider" and the configured location.
+ - The plugin calls
curl to fetch weather from wttr.in.
+ - The plugin parses the CSV-like response (condition, temperature, wind, humidity, precipitation).
+ - The plugin returns a JSON response with the formatted lines.
+ - xfetch displays them under the
plugin:weather module key.
+
+
+Notes
+
+
+ - Requires
curl to be installed and available in PATH.
+ - Uses the free wttr.in API — no API key required.
+ - Network connectivity is required.
+ - If no location is specified, wttr.in auto-detects location by IP.
+
diff --git a/plugins/weather/src/main.rs b/plugins/weather/src/main.rs
new file mode 100644
index 0000000..ba9607c
--- /dev/null
+++ b/plugins/weather/src/main.rs
@@ -0,0 +1,88 @@
+use std::process::Command;
+use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines};
+
+#[derive(Debug, Default, serde::Deserialize)]
+struct PluginArgs {
+ location: Option,
+ format: Option,
+}
+
+fn main() {
+ let args = match read_info_plugin_args_or_default::() {
+ Ok(value) => value,
+ Err(err) => {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+ };
+
+ let lines = get_weather(args.location.as_deref(), args.format.as_deref());
+
+ if let Err(err) = write_info_lines(lines) {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+}
+
+fn get_weather(location: Option<&str>, format: Option<&str>) -> Vec {
+ let loc = location.unwrap_or("");
+ let fmt = format.unwrap_or("%C|%t|%w|%h|%p");
+
+ let url = if loc.is_empty() {
+ format!("https://wttr.in/?format={}", fmt)
+ } else {
+ format!("https://wttr.in/{}?format={}", loc, fmt)
+ };
+
+ let output = match Command::new("curl")
+ .args(["-s", "--max-time", "10", &url])
+ .output()
+ {
+ Ok(o) if o.status.success() => o,
+ _ => return vec![" Weather: could not fetch".to_string()],
+ };
+
+ let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ if raw.is_empty() {
+ return vec![" Weather: no data".to_string()];
+ }
+
+ let parts: Vec<&str> = raw.split('|').map(|s| s.trim()).collect();
+ let mut result = Vec::new();
+
+ if parts.len() >= 4 {
+ let condition = parts[0].trim();
+ let temp = parts[1].trim();
+ let wind = parts[2].trim();
+ let humidity = parts[3].trim();
+
+ if condition.is_empty() && temp.is_empty() && wind.is_empty() {
+ result.push(format!(" Weather: {}", raw));
+ return result;
+ }
+
+ let icon = match condition {
+ c if c.contains("Clear") || c.contains("Sunny") => "\u{e30d}",
+ c if c.contains("Cloud") || c.contains("Overcast") => "\u{e302}",
+ c if c.contains("Rain") || c.contains("Drizzle") => "\u{e315}",
+ c if c.contains("Snow") || c.contains("Ice") => "\u{e318}",
+ c if c.contains("Thunder") || c.contains("Storm") => "\u{e31a}",
+ c if c.contains("Fog") || c.contains("Mist") || c.contains("Haze") => "\u{e376}",
+ c if c.contains("Partly") => "\u{e312}",
+ _ => "\u{e30d}",
+ };
+
+ result.push(format!("{} {} {}", icon, temp, condition));
+
+ let precip = parts.get(4).map(|s| s.trim()).unwrap_or("");
+ result.push(format!(" \u{e36e} Humidity: {}", humidity));
+ result.push(format!(" \u{e374} Wind: {}", wind));
+ if !precip.is_empty() {
+ result.push(format!(" Precipitation: {}", precip));
+ }
+ } else {
+ result.push(format!(" Weather: {}", raw));
+ }
+
+ result
+}