Author SHA1 Message Date
moons-14 913a86e9c2 fix(labwc): close only the active Ghostty tab with Mod+Q 2026-08-06 19:12:54 +09:00
moons-14 5ebcbb4abf labwc 2026-08-06 17:22:00 +09:00
moons-14 f8fd8a3d99 wallpaper engine 2026-08-06 17:13:02 +09:00
moons-14 16742d2fd7 window overlay 2026-08-05 06:21:26 +09:00
moons-14 15da7affaa window-overview 2026-08-05 05:53:23 +09:00
moons-14 548647fec7 window switch 2026-08-05 05:27:40 +09:00
moons-14 bcbd08c225 wallpaper vicinae 2026-08-05 05:04:22 +09:00
moons-14 a0ae83d24e feat 2026-08-05 04:37:32 +09:00
22 changed files with 1192 additions and 138 deletions
+1
View File
@@ -104,6 +104,7 @@
"interface.cli"
"interface.labwc"
"interface.niri"
"interface.wallpaperengine"
"platform.intel-nvidia-desktop"
"security.secrets"
"security.secure-boot"
+22 -4
View File
@@ -20,6 +20,20 @@ let
"@command" = command;
};
closeFocused = {
"@key" = "W-q";
"@onRelease" = "yes";
action = {
"@name" = "If";
query.identifier = "com.mitchellh.ghostty";
"then".action = {
"@name" = "Execute";
"@command" = "${lib.getExe' pkgs.wtype "wtype"} -M ctrl -M shift -P w -p w -m shift -m ctrl";
};
"else".action."@name" = "Close";
};
};
snapToEdge =
key: direction:
keybind key {
@@ -99,11 +113,11 @@ in
numlock = "on";
keybind = [
# labwc window management
(action "W-q" "Close")
closeFocused
(action "W-f" "ToggleMaximize")
(action "W-c" "Iconify")
(action "W-Tab" "NextWindow")
(action "W-S-Tab" "PreviousWindow")
(execute "W-Tab" "window-overview")
(execute "W-S-Tab" "window-overview --reverse")
(action "W-Up" "Lower")
(action "W-Down" "Raise")
(action "W-Left" "NextWindow")
@@ -125,7 +139,7 @@ in
(execute "W-l" "loginctl lock-session")
(execute "W-v" "vicinae vicinae://launch/clipboard/history?toggle=true")
(execute "W-j" "nani-translate-primary")
(execute "W-S-j" "normcap-translate")
(execute "W-S-j" "nani-translate-ocr")
(execute "W-C-j" "${lib.getExe' pkgs.xdg-utils "xdg-open"} naniapp://translate")
(execute "W-space" "ghostty +toggle-quick-terminal")
(execute "W-p" "wdisplays")
@@ -222,6 +236,10 @@ in
];
};
# Labwc's Close action closes Ghostty's whole GTK window. Route Mod+Q to
# Ghostty's current-tab action instead while keeping normal window close elsewhere.
programs.ghostty.settings.keybind = [ "ctrl+shift+w=close_tab" ];
xdg.configFile."labwc/shutdown".text = lib.mkAfter ''
${systemctl} --user stop graphical-session.target
'';
@@ -1,28 +1,103 @@
From: Codex <[email protected]>
Subject: [PATCH] output: remove destroyed output from ext-workspace group
Subject: [PATCH] output: detach destroyed outputs from protocol handles
The DRM backend destroys and recreates outputs when a session is paused and
resumed. Remove the output from the ext-workspace group before wlroots
finishes it, otherwise the group's output_bind listener remains attached and
wlr_output_finish() aborts.
resumed. Remove the output from the ext-workspace group and the foreign
toplevel handles before wlroots finishes it. Both protocols attach bind
listeners to the output; leaving either listener behind makes
wlr_output_finish() abort.
---
src/output.c | 3 +++
1 file changed, 3 insertions(+)
src/foreign-toplevel/foreign.c | 14 ++++++++++++++
src/output.c | 6 ++++++
include/foreign-toplevel/foreign.h | 2 ++
3 files changed, 22 insertions(+)
diff --git a/src/output.c b/src/output.c
index 2eab8ec..f19c3ff 100644
index 2eab8ec..6f6e7ef 100644
--- a/src/output.c
+++ b/src/output.c
@@ -277,6 +277,9 @@ handle_output_destroy(struct wl_listener *listener, void *data)
@@ -27,8 +27,9 @@
#include "common/macros.h"
#include "common/mem.h"
#include "common/scene-helpers.h"
#include "common/string-helpers.h"
#include "config/rcxml.h"
+#include "foreign-toplevel/foreign.h"
#include "labwc.h"
#include "layers.h"
#include "node.h"
@@ -276,7 +277,15 @@ handle_output_destroy(struct wl_listener *listener, void *data)
struct output *output = wl_container_of(listener, output, destroy);
struct seat *seat = &server.seat;
regions_evacuate_output(output);
regions_destroy(seat, &output->regions);
+ wlr_ext_workspace_group_handle_v1_output_leave(
+ server.workspaces.ext_group, output->wlr_output);
+
+ struct view *view;
+ wl_list_for_each(view, &server.views, link) {
+ foreign_toplevel_remove_output(view->foreign_toplevel, output->wlr_output);
+ }
+
if (seat->overlay.active.output == output) {
overlay_finish(seat);
}
@@ -297,7 +305,6 @@ handle_output_destroy(struct wl_listener *listener, void *data)
output->workspace_osd = NULL;
}
- struct view *view;
wl_list_for_each(view, &server.views, link) {
if (view->output == output) {
view_on_output_destroy(view);
diff --git a/include/foreign-toplevel/foreign.h b/include/foreign-toplevel/foreign.h
index 69a340a..d8ec9b4 100644
--- a/include/foreign-toplevel/foreign.h
+++ b/include/foreign-toplevel/foreign.h
@@ -3,11 +3,14 @@
#define LABWC_FOREIGN_TOPLEVEL_H
struct view;
+struct wlr_output;
struct foreign_toplevel;
struct foreign_toplevel *foreign_toplevel_create(struct view *view);
void foreign_toplevel_set_parent(struct foreign_toplevel *toplevel,
struct foreign_toplevel *parent);
+void foreign_toplevel_remove_output(struct foreign_toplevel *toplevel,
+ struct wlr_output *output);
void foreign_toplevel_destroy(struct foreign_toplevel *toplevel);
#endif /* LABWC_FOREIGN_TOPLEVEL_H */
diff --git a/src/foreign-toplevel/foreign.c b/src/foreign-toplevel/foreign.c
index 7f1ec8d..0f628a3 100644
--- a/src/foreign-toplevel/foreign.c
+++ b/src/foreign-toplevel/foreign.c
@@ -1,4 +1,5 @@
// SPDX-License-Identifier: GPL-2.0-only
#include "foreign-toplevel/foreign.h"
+#include <wlr/types/wlr_foreign_toplevel_management_v1.h>
#include <assert.h>
#include "common/mem.h"
@@ -34,6 +34,18 @@ foreign_toplevel_set_parent(struct foreign_toplevel *toplevel, struct foreign_to
parent ? &parent->wlr_toplevel : NULL);
}
+void
+foreign_toplevel_remove_output(struct foreign_toplevel *toplevel,
+ struct wlr_output *output)
+{
+ if (!toplevel || !toplevel->wlr_toplevel.handle) {
+ return;
+ }
+
+ wlr_foreign_toplevel_handle_v1_output_leave(
+ toplevel->wlr_toplevel.handle, output);
+}
+
void
foreign_toplevel_destroy(struct foreign_toplevel *toplevel)
{
--
2.51.0
@@ -61,6 +61,33 @@ let
'';
};
mkVicinaeScript =
{
name,
title,
description,
command,
mode ? "compact",
message ? null,
}:
{
name = "vicinae/scripts/wallpaper-engine/${name}";
value = {
executable = true;
text = ''
#!${lib.getExe pkgs.bash}
# @vicinae.schemaVersion 1
# @vicinae.title ${title}
# @vicinae.description ${description}
# @vicinae.mode ${mode}
# @vicinae.icon 🖼️
${lib.getExe controller} ${lib.escapeShellArg command}
${lib.optionalString (message != null) "printf '%s\\n' ${lib.escapeShellArg message}"}
'';
};
};
launcher = pkgs.writeShellApplication {
name = "linux-wallpaperengine-launcher";
runtimeInputs = [
@@ -115,6 +142,50 @@ assert lib.assertMsg (builtins.elem scaling [
pkgs.linux-wallpaperengine
];
xdg.dataFile = builtins.listToAttrs [
(mkVicinaeScript {
name = "reload";
title = "Reload Wallpaper Engine";
description = "Restart the active Wallpaper Engine background";
command = "restart";
message = "Wallpaper Engine reloaded";
})
(mkVicinaeScript {
name = "next";
title = "Next Wallpaper Engine Wallpaper";
description = "Switch to the next configured Wallpaper Engine background";
command = "next";
message = "Switched to the next Wallpaper Engine wallpaper";
})
(mkVicinaeScript {
name = "previous";
title = "Previous Wallpaper Engine Wallpaper";
description = "Switch to the previous configured Wallpaper Engine background";
command = "previous";
message = "Switched to the previous Wallpaper Engine wallpaper";
})
(mkVicinaeScript {
name = "list";
title = "List Wallpaper Engine Wallpapers";
description = "Show the configured Wallpaper Engine backgrounds";
command = "list";
mode = "fullOutput";
})
(mkVicinaeScript {
name = "current";
title = "Current Wallpaper Engine Wallpaper";
description = "Show the active Wallpaper Engine background";
command = "current";
})
(mkVicinaeScript {
name = "stop";
title = "Stop Wallpaper Engine";
description = "Stop Wallpaper Engine for this session";
command = "stop";
message = "Wallpaper Engine stopped";
})
];
systemd.user.services = lib.optionalAttrs hasWallpapers {
linux-wallpaperengine = {
Unit = {
+74 -1
View File
@@ -1,5 +1,78 @@
_: {
{ pkgs, ... }:
let
tessdataBest = pkgs.runCommand "tessdata-best-jpn-eng-chi-sim" { } ''
mkdir -p "$out"
ln -s ${
pkgs.fetchurl {
url = "https://github.com/tesseract-ocr/tessdata_best/raw/main/jpn.traineddata";
hash = "sha256-Nr35rII/WRHmJMMNBVPokLirx8MaZbPvFNqUNljEC3k=";
}
} "$out/jpn.traineddata"
ln -s ${
pkgs.fetchurl {
url = "https://github.com/tesseract-ocr/tessdata_best/raw/main/eng.traineddata";
hash = "sha256-goCu0Hgv4nJXpo6hD+fvMkyg+Nhb0v0UXRwrVgvLZro=";
}
} "$out/eng.traineddata"
ln -s ${
pkgs.fetchurl {
url = "https://github.com/tesseract-ocr/tessdata_best/raw/main/chi_sim.traineddata";
hash = "sha256-T+8tEwbI6HYW1NPkxsZ/r11EvjNCKQz48vD246p+c1s=";
}
} "$out/chi_sim.traineddata"
'';
tesseract = pkgs.tesseract5.override {
tessdata = tessdataBest;
};
naniTranslatePrimary = pkgs.writeShellApplication {
name = "nani-translate-primary";
runtimeInputs = with pkgs; [
jq
wl-clipboard
xdg-utils
];
text = ''
selected_text="$(wl-paste --primary --no-newline)" || exit 0
[ -n "$selected_text" ] || exit 0
encoded_text="$(printf '%s' "$selected_text" | jq -sRr @uri)"
exec xdg-open "naniapp://translate?source=$encoded_text"
'';
};
naniTranslateOcr = pkgs.writeShellApplication {
name = "nani-translate-ocr";
runtimeInputs = with pkgs; [
grim
jq
slurp
tesseract
xdg-utils
];
text = ''
geometry="$(slurp)" || exit 0
captured_text="$(
grim -g "$geometry" - \
| tesseract stdin stdout -l jpn+eng+chi_sim --oem 1 --psm 6
)"
[ -n "$captured_text" ] || exit 0
encoded_text="$(printf '%s' "$captured_text" | jq -sRr @uri)"
exec xdg-open "naniapp://translate?source=$encoded_text"
'';
};
in
{
programs.naniTranslateLinux.enable = true;
xdg.mimeApps.defaultApplications."x-scheme-handler/naniapp" = "nani.desktop";
home.packages = [
naniTranslatePrimary
naniTranslateOcr
];
}
+19 -17
View File
@@ -1,24 +1,8 @@
{ lib, pkgs, ... }:
let
keybindings = import ./keybindings.nix;
naniTranslatePrimary = pkgs.writeShellApplication {
name = "nani-translate-primary";
runtimeInputs = with pkgs; [
jq
wl-clipboard
xdg-utils
];
text = ''
selected_text="$(wl-paste --primary --no-newline)" || exit 0
[ -n "$selected_text" ] || exit 0
encoded_text="$(printf '%s' "$selected_text" | jq -sRr @uri)"
exec xdg-open "naniapp://translate?source=$encoded_text"
'';
};
in
{
home.packages = [ naniTranslatePrimary ];
programs.niri.settings = {
binds = keybindings // {
@@ -57,6 +41,24 @@ in
hotkey-overlay.title = "Move Window to Monitor Down";
};
"Mod+Tab" = {
repeat = false;
action.spawn = [
"window-overview"
"--hold"
];
hotkey-overlay.title = "Window Overview: Next";
};
"Mod+Shift+Tab" = {
repeat = false;
action.spawn = [
"window-overview"
"--hold"
"--reverse"
];
hotkey-overlay.title = "Window Overview: Previous";
};
"Print".action.spawn = [
"screenshot"
"region"
@@ -119,7 +121,7 @@ in
};
"Mod+Shift+J" = {
repeat = false;
action.spawn = [ "normcap-translate" ];
action.spawn = [ "nani-translate-ocr" ];
hotkey-overlay.title = "OCR and Translate with Nani";
};
"Mod+Ctrl+J" = {
+1
View File
@@ -18,6 +18,7 @@ let
(oldAttrs: {
patches = (oldAttrs.patches or [ ]) ++ [
./patches/window-switcher-labwc.patch
./patches/taskbar-overview-hook.patch
];
});
+4
View File
@@ -2,6 +2,10 @@
{
description = "Noctalia Wayland desktop shell";
includes = [
"applications.window-overview"
];
imports = {
nixos = [ inputs.noctalia.nixosModules.default ];
home = [ inputs.noctalia.homeModules.default ];
@@ -0,0 +1,43 @@
diff --git a/src/shell/bar/widgets/taskbar_widget.cpp b/src/shell/bar/widgets/taskbar_widget.cpp
--- a/src/shell/bar/widgets/taskbar_widget.cpp
+++ b/src/shell/bar/widgets/taskbar_widget.cpp
@@ -5,6 +5,7 @@
#include "compositors/workspace_backend.h"
#include "config/config_service.h"
#include "core/deferred_call.h"
+#include "core/process/process.h"
#include "i18n/i18n.h"
#include "render/core/color.h"
#include "render/core/renderer.h"
@@ -36,6 +37,18 @@
namespace {
+ [[nodiscard]] bool launchTaskbarOverview(const std::string& appId) {
+ constexpr const char* command = "noctalia-taskbar-overview";
+ if (appId.empty() || !process::commandExists(command)) {
+ return false;
+ }
+ return process::runAsync({
+ command,
+ "--app-id",
+ appId.c_str(),
+ });
+ }
+
// Integer centering; optional odd spare pixel on the end side (right/bottom).
[[nodiscard]] float centeredOffset(float extent, float content, float inset = 0.0F, bool oddSpareOnEnd = true) {
const float inner = std::max(0.0F, extent - inset * 2.0F);
@@ -373,6 +386,12 @@ void TaskbarWidget::activateOrLaunchPinned(const TaskModel& task) {
return;
}
+ const std::string overviewAppId =
+ !task.appId.empty() ? task.appId : (!task.desktopEntryId.empty() ? task.desktopEntryId : task.idLower);
+ if (launchTaskbarOverview(overviewAppId)) {
+ return;
+ }
+
const std::string cycleKey = !task.desktopEntryId.empty() ? task.desktopEntryId : task.idLower;
std::size_t& cursor = m_groupedAppCycleCursor[cycleKey];
if (cursor >= windows.size()) {
@@ -24,56 +24,3 @@ diff --git a/src/shell/switcher/window_switcher.cpp b/src/shell/switcher/window_
if (key.empty()) {
continue;
}
diff --git a/src/shell/bar/widgets/taskbar_widget.cpp b/src/shell/bar/widgets/taskbar_widget.cpp
--- a/src/shell/bar/widgets/taskbar_widget.cpp
+++ b/src/shell/bar/widgets/taskbar_widget.cpp
@@ -5,6 +5,7 @@
#include "compositors/workspace_backend.h"
#include "config/config_service.h"
#include "core/deferred_call.h"
+#include "core/process/process.h"
#include "i18n/i18n.h"
#include "render/core/color.h"
#include "render/core/renderer.h"
@@ -36,6 +37,18 @@
namespace {
+ [[nodiscard]] bool launchTaskbarOverview(const std::string& appId) {
+ constexpr const char* command = "noctalia-taskbar-overview";
+ if (appId.empty() || !process::commandExists(command)) {
+ return false;
+ }
+ return process::runAsync({
+ command,
+ "--app-id",
+ appId.c_str(),
+ });
+ }
+
// Integer centering; optional odd spare pixel on the end side (right/bottom).
[[nodiscard]] float centeredOffset(float extent, float content, float inset = 0.0F, bool oddSpareOnEnd = true) {
const float inner = std::max(0.0F, extent - inset * 2.0F);
@@ -373,6 +386,12 @@ void TaskbarWidget::activateOrLaunchPinned(const TaskModel& task) {
return;
}
+ const std::string overviewAppId =
+ !task.appId.empty() ? task.appId : (!task.desktopEntryId.empty() ? task.desktopEntryId : task.idLower);
+ if (launchTaskbarOverview(overviewAppId)) {
+ return;
+ }
+
const std::string cycleKey = !task.desktopEntryId.empty() ? task.desktopEntryId : task.idLower;
std::size_t& cursor = m_groupedAppCycleCursor[cycleKey];
if (cursor >= windows.size()) {
@@ -792,6 +811,9 @@ void TaskbarWidget::create() {
}
if (data.button == BTN_LEFT) {
if (!cycleCandidates.empty()) {
+ if (cycleCandidates.size() > 1 && launchTaskbarOverview(current->appId)) {
+ return;
+ }
std::size_t& cursor = m_groupedAppCycleCursor[cycleKey];
if (cursor >= cycleCandidates.size()) {
cursor = 0;
@@ -1,44 +0,0 @@
{ pkgs, ... }:
let
normcap = pkgs.normcap.override {
tesseract4 = pkgs.tesseract4.override {
enableLanguages = [
"chi_sim"
"jpn"
"eng"
];
};
};
normcapTranslate = pkgs.writeShellApplication {
name = "normcap-translate";
runtimeInputs = [
normcap
pkgs.jq
pkgs.xdg-utils
];
text = ''
captured_text="$(
normcap \
--cli-mode \
--screenshot-handler grim \
--show-introduction False \
--update False \
--detect-codes False \
--notification False \
-l jpn eng chi_sim
)" || exit 0
[ -n "$captured_text" ] || exit 0
encoded_text="$(printf '%s' "$captured_text" | jq -sRr @uri)"
exec xdg-open "naniapp://translate?source=$encoded_text"
'';
};
in
{
home.packages = [
normcap
normcapTranslate
];
}
-7
View File
@@ -1,7 +0,0 @@
{
description = "NormCap OCR screen capture";
includes = [
"applications.nani"
];
}
@@ -0,0 +1,7 @@
{ pkgs, ... }:
let
windowOverview = pkgs.callPackage ../package.nix { };
in
{
home.packages = [ windowOverview ];
}
@@ -0,0 +1,7 @@
{
description = "Fullscreen Wayland window overview with captured previews";
includes = [
"systems.wayland"
];
}
@@ -0,0 +1,77 @@
{
lib,
fetchCrate,
libgbm,
libglvnd,
libxkbcommon,
jq,
makeWrapper,
pkg-config,
rustPlatform,
wayland,
wl-clipboard,
}:
rustPlatform.buildRustPackage rec {
pname = "window-overview";
version = "1.5.0";
src = fetchCrate {
pname = "wlr-chooser";
inherit version;
hash = "sha256-Jb59m1z+2G5istcbC0sg9jRVcC/bQh/OY0ny9OdTsdw=";
};
patches = [
./patches/window-overview.patch
./patches/niri-backend.patch
];
cargoHash = "sha256-KBLgtS8ULrmsOf6BN5SlkVQyXB12utvr/KonnKnCTCM=";
nativeBuildInputs = [
makeWrapper
pkg-config
];
buildInputs = [
libgbm
libglvnd
libxkbcommon
wayland
];
cargoBuildFlags = [
"--bin"
"wlr-switcher"
];
postInstall = ''
mv "$out/bin/wlr-switcher" "$out/bin/.window-overview-wrapped"
makeWrapper "$out/bin/.window-overview-wrapped" "$out/bin/window-overview" \
--add-flags "--layout grid" \
--prefix PATH : "${
lib.makeBinPath [
jq
wl-clipboard
]
}" \
--prefix LD_LIBRARY_PATH : "${
lib.makeLibraryPath [
libgbm
libglvnd
]
}:/run/opengl-driver/lib"
ln -s window-overview "$out/bin/noctalia-taskbar-overview"
'';
meta = {
description = "Fullscreen Wayland window overview with captured previews";
homepage = "https://github.com/sjourdois/wlr-utils";
license = with lib.licenses; [
asl20
mit
];
mainProgram = "window-overview";
platforms = lib.platforms.linux;
};
}
@@ -0,0 +1,431 @@
--- a/src/chooser_cli.rs
+++ b/src/chooser_cli.rs
@@ -81,6 +81,7 @@
initial_cycle: None,
snapshot: false,
cycle_socket: None,
+ niri_backend: false,
};
match run_overlay(opts, t0) {
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -65,7 +65,14 @@
// must connect, enumerate, and open sessions before any thumbnail appears.
let (tx, rx) = mpsc::channel();
let snapshot = opts.snapshot;
- std::thread::spawn(move || ui::capture_thread(tx, snapshot));
+ let niri_backend = opts.niri_backend;
+ std::thread::spawn(move || {
+ if niri_backend {
+ ui::niri_capture_thread(tx);
+ } else {
+ ui::capture_thread(tx, snapshot);
+ }
+ });
shell::tlog(t0, "capture-thread spawned");
let out: ui::Outcome = Arc::new(Mutex::new(None));
--- a/src/switcher_cli.rs
+++ b/src/switcher_cli.rs
@@ -88,6 +88,7 @@
let t0 = Instant::now();
let cli = Cli::parse();
i18n::init();
+ let niri_backend = std::env::var_os("NIRI_SOCKET").is_some();
if cli.doctor {
if let Err(e) = wlr_capture::doctor::report("wlr-switcher", env!("CARGO_PKG_VERSION")) {
@@ -129,32 +130,50 @@
initial_cycle: Some(!cli.reverse),
snapshot: true,
cycle_socket: Some(cycle_socket),
+ niri_backend,
};
// Pre-flight: wlr-switcher switches *windows*, which need the foreign-toplevel
// capture source (wlroots >= 0.20 / Sway >= 1.12). On older compositors connect()
// now succeeds for screen-only capture, but there are no windows to offer — so say
// so clearly and exit, instead of showing an empty dimmed overlay (issue #1).
- match wl::Client::connect() {
- Ok(client) if !client.can_capture_windows() => {
- eprintln!("{}", tr!("capture-no-window"));
- std::process::exit(2);
- }
- Ok(_) => {}
- Err(e) => {
- eprintln!("{}", tr!("error", error = format!("{e:#}")));
- std::process::exit(2);
+ if !niri_backend {
+ match wl::Client::connect() {
+ Ok(client) if !client.can_capture_windows() => {
+ eprintln!("{}", tr!("capture-no-window"));
+ std::process::exit(2);
+ }
+ Ok(_) => {}
+ Err(e) => {
+ eprintln!("{}", tr!("error", error = format!("{e:#}")));
+ std::process::exit(2);
+ }
}
}
match run_overlay(opts, t0) {
Ok(Some(sel)) => {
// Focus the picked window (outputs aren't focusable, so ignore them).
- if sel.is_window
- && let Err(e) = wl::activate_window(&sel.app_id, &sel.title, sel.dup_index)
- {
- eprintln!("{}", tr!("error", error = format!("{e:#}")));
- std::process::exit(2);
+ if sel.is_window {
+ let result: anyhow::Result<()> = if niri_backend {
+ let output = std::process::Command::new("niri")
+ .args(["msg", "action", "focus-window", "--id", &sel.identifier])
+ .output();
+ match output {
+ Ok(output) if output.status.success() => Ok(()),
+ Ok(output) => Err(anyhow::anyhow!(
+ "{}",
+ String::from_utf8_lossy(&output.stderr).trim()
+ )),
+ Err(e) => Err(e.into()),
+ }
+ } else {
+ wl::activate_window(&sel.app_id, &sel.title, sel.dup_index).map_err(Into::into)
+ };
+ if let Err(e) = result {
+ eprintln!("{}", tr!("error", error = format!("{e:#}")));
+ std::process::exit(2);
+ }
}
}
Ok(None) => std::process::exit(1), // cancelled
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -6,7 +6,9 @@
use crate::tr;
use std::collections::{HashMap, HashSet};
+use std::io::Write;
use std::path::PathBuf;
+use std::process::{Command, Stdio};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -366,6 +368,288 @@
}
}
+#[derive(Clone)]
+struct NiriWindow {
+ id: String,
+ app_id: String,
+ title: String,
+}
+
+enum ClipboardSnapshot {
+ Empty,
+ Content { mime: String, data: Vec<u8> },
+ Unavailable,
+}
+
+fn save_clipboard() -> ClipboardSnapshot {
+ let Ok(types) = Command::new("wl-paste").arg("--list-types").output() else {
+ return ClipboardSnapshot::Unavailable;
+ };
+ if !types.status.success() {
+ return ClipboardSnapshot::Empty;
+ }
+ let types = String::from_utf8_lossy(&types.stdout);
+ let offered: Vec<&str> = types.lines().filter(|line| !line.is_empty()).collect();
+ let Some(mime) = offered
+ .iter()
+ .copied()
+ .find(|mime| *mime == "text/plain;charset=utf-8")
+ .or_else(|| offered.first().copied())
+ else {
+ return ClipboardSnapshot::Empty;
+ };
+ let Ok(data) = Command::new("wl-paste")
+ .args(["--type", mime])
+ .output()
+ else {
+ return ClipboardSnapshot::Unavailable;
+ };
+ if !data.status.success() {
+ return ClipboardSnapshot::Unavailable;
+ }
+ ClipboardSnapshot::Content {
+ mime: mime.to_owned(),
+ data: data.stdout,
+ }
+}
+
+fn restore_clipboard(snapshot: ClipboardSnapshot) {
+ match snapshot {
+ ClipboardSnapshot::Empty => {
+ let _ = Command::new("wl-copy").arg("--clear").status();
+ }
+ ClipboardSnapshot::Content { mime, data } => {
+ let Ok(mut copy) = Command::new("wl-copy")
+ .args(["--type", &mime])
+ .stdin(Stdio::piped())
+ .spawn()
+ else {
+ return;
+ };
+ if let Some(mut stdin) = copy.stdin.take() {
+ let _ = stdin.write_all(&data);
+ }
+ let _ = copy.wait();
+ }
+ ClipboardSnapshot::Unavailable => {}
+ }
+}
+
+/// Decode one field emitted by jq's `@tsv` formatter. It escapes the only
+/// characters that would otherwise make the line-oriented transport ambiguous.
+fn unescape_tsv(value: &str) -> String {
+ let mut decoded = String::with_capacity(value.len());
+ let mut chars = value.chars();
+ while let Some(ch) = chars.next() {
+ if ch != '\\' {
+ decoded.push(ch);
+ continue;
+ }
+ match chars.next() {
+ Some('t') => decoded.push('\t'),
+ Some('r') => decoded.push('\r'),
+ Some('n') => decoded.push('\n'),
+ Some('\\') => decoded.push('\\'),
+ Some(other) => {
+ decoded.push('\\');
+ decoded.push(other);
+ }
+ None => decoded.push('\\'),
+ }
+ }
+ decoded
+}
+
+/// Ask niri for its toplevel list. niri does not implement the
+/// ext-image-copy-capture window protocol, so its IPC is the source of both the
+/// stable window id and the metadata used by this backend.
+fn niri_windows() -> Result<Vec<NiriWindow>, String> {
+ let response = Command::new("niri")
+ .args(["msg", "-j", "windows"])
+ .output()
+ .map_err(|e| format!("could not run niri msg: {e}"))?;
+ if !response.status.success() {
+ return Err(String::from_utf8_lossy(&response.stderr).trim().to_owned());
+ }
+
+ let mut jq = Command::new("jq")
+ .args([
+ "-r",
+ ".[] | [(.id | tostring), (.app_id // \"\"), (.title // \"\")] | @tsv",
+ ])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|e| format!("could not run jq: {e}"))?;
+ jq.stdin
+ .take()
+ .ok_or_else(|| String::from("jq stdin was unavailable"))?
+ .write_all(&response.stdout)
+ .map_err(|e| format!("could not pass niri window list to jq: {e}"))?;
+ let output = jq
+ .wait_with_output()
+ .map_err(|e| format!("could not read jq output: {e}"))?;
+ if !output.status.success() {
+ return Err(String::from_utf8_lossy(&output.stderr).trim().to_owned());
+ }
+
+ let text = String::from_utf8(output.stdout)
+ .map_err(|e| format!("niri window list was not UTF-8: {e}"))?;
+ let mut windows = Vec::new();
+ for line in text.lines() {
+ let mut fields = line.splitn(3, '\t');
+ let Some(id) = fields.next() else { continue };
+ let Some(app_id) = fields.next() else { continue };
+ let Some(title) = fields.next() else { continue };
+ windows.push(NiriWindow {
+ id: id.to_owned(),
+ app_id: unescape_tsv(app_id),
+ title: unescape_tsv(title),
+ });
+ }
+ windows.sort_by(|a, b| {
+ a.app_id
+ .to_lowercase()
+ .cmp(&b.app_id.to_lowercase())
+ .then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase()))
+ });
+ Ok(windows)
+}
+
+fn niri_window_source(w: &NiriWindow, dup_index: usize) -> Source {
+ let is_system = w.app_id.is_empty();
+ let (title, subtitle) = if is_system {
+ (w.title.clone(), String::new())
+ } else {
+ (w.app_id.clone(), w.title.clone())
+ };
+ Source {
+ key: w.id.clone(),
+ token: format!("Window: {}", w.id),
+ filter: format!("{} {}", w.app_id, w.title).to_lowercase(),
+ title,
+ subtitle,
+ is_window: true,
+ is_system,
+ app_id: w.app_id.clone(),
+ win_title: w.title.clone(),
+ dup_index,
+ }
+}
+
+/// Capture a static overview through niri's IPC. This is deliberately separate
+/// from the generic Wayland backend: niri can render any toplevel by id (even an
+/// occluded one), but does not advertise ext-image-copy-capture.
+pub fn niri_capture_thread(tx: Sender<Msg>) {
+ let windows = match niri_windows() {
+ Ok(windows) => windows,
+ Err(e) => {
+ eprintln!("niri overview backend: {e}");
+ return;
+ }
+ };
+
+ let mut dup: HashMap<(String, String), usize> = HashMap::new();
+ let mut current = Vec::with_capacity(windows.len());
+ for window in windows {
+ let e = dup
+ .entry((window.app_id.clone(), window.title.clone()))
+ .or_insert(0);
+ let source = niri_window_source(&window, *e);
+ *e += 1;
+ current.push((source, window));
+ }
+ if tx
+ .send(Msg::Sources(
+ current.iter().map(|(source, _)| source.clone()).collect(),
+ ))
+ .is_err()
+ {
+ return;
+ }
+
+ let capture_dir = wlr_capture::paths::runtime_dir().join(format!(
+ "window-overview-niri-{}",
+ std::process::id()
+ ));
+ if let Err(e) = std::fs::create_dir_all(&capture_dir) {
+ eprintln!("niri overview backend: could not create capture directory: {e}");
+ return;
+ }
+ // niri's screenshot action also sets the clipboard. Preserve the existing
+ // selection so opening the overview does not unexpectedly replace it.
+ let clipboard = save_clipboard();
+
+ for (source, window) in current {
+ if let Some(path) = icons::resolve(&window.app_id)
+ && let Some((w, h, rgba)) = icons::load(&path, 128)
+ && tx
+ .send(Msg::Icon {
+ key: source.key.clone(),
+ w: w as usize,
+ h: h as usize,
+ rgba,
+ })
+ .is_err()
+ {
+ break;
+ }
+
+ let path = capture_dir.join(format!("{}.png", window.id));
+ let command = Command::new("niri")
+ .args(["msg", "action", "screenshot-window", "--id"])
+ .arg(&window.id)
+ .args(["--write-to-disk", "true", "--show-pointer", "false", "--path"])
+ .arg(&path)
+ .output();
+ let Ok(output) = command else {
+ continue;
+ };
+ if !output.status.success() {
+ eprintln!(
+ "niri overview backend: could not capture window {}: {}",
+ window.id,
+ String::from_utf8_lossy(&output.stderr).trim()
+ );
+ continue;
+ }
+
+ // niri encodes PNGs off the compositor thread, after acknowledging the
+ // IPC action. Wait briefly for that completion without delaying the UI.
+ let mut bytes = None;
+ for _ in 0..200 {
+ match std::fs::read(&path) {
+ Ok(data) => {
+ bytes = Some(data);
+ break;
+ }
+ Err(_) => std::thread::sleep(Duration::from_millis(10)),
+ }
+ }
+ let Some(bytes) = bytes else { continue };
+ let Ok(image) = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png) else {
+ continue;
+ };
+ let image = image.into_rgba8();
+ let (w, h, rgba) = thumbnail_rgba(image.width(), image.height(), image.into_raw());
+ if tx
+ .send(Msg::Thumb {
+ key: source.key,
+ w,
+ h,
+ rgba,
+ })
+ .is_err()
+ {
+ break;
+ }
+ let _ = std::fs::remove_file(path);
+ }
+ let _ = std::fs::remove_dir(capture_dir);
+ restore_clipboard(clipboard);
+}
+
/// Cheap content fingerprint of a frame (subsampled FNV-1a), to tell whether a
/// capture actually changed between rounds — used by the headless bench.
fn quick_hash(rgba: &[u8]) -> u64 {
@@ -515,11 +799,14 @@
/// Downscale a capture to a thumbnail (max side `THUMB_MAX`), never upscaling.
fn thumbnail(img: wl::CapturedImage) -> (usize, usize, Vec<u8>) {
- let (w, h) = (img.width, img.height);
+ thumbnail_rgba(img.width, img.height, img.rgba)
+}
+
+fn thumbnail_rgba(w: u32, h: u32, rgba: Vec<u8>) -> (usize, usize, Vec<u8>) {
let scale = (THUMB_MAX as f32 / w as f32)
.min(THUMB_MAX as f32 / h as f32)
.min(1.0);
- let src = match image::RgbaImage::from_raw(w, h, img.rgba) {
+ let src = match image::RgbaImage::from_raw(w, h, rgba) {
Some(s) => s,
None => return (0, 0, Vec::new()),
};
@@ -557,6 +844,9 @@
pub snapshot: bool,
/// Receives next/previous commands from repeated compositor keybind launches.
pub cycle_socket: Option<std::os::unix::net::UnixDatagram>,
+ /// Use niri's IPC for window enumeration and snapshots instead of the
+ /// ext-image-copy-capture protocol that niri does not implement.
+ pub niri_backend: bool,
}
pub struct App {
@@ -0,0 +1,336 @@
--- a/src/chooser_cli.rs
+++ b/src/chooser_cli.rs
@@ -79,0 +80,4 @@
+ app_id_filter: None,
+ initial_cycle: None,
+ snapshot: false,
+ cycle_socket: None,
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -33,3 +33,3 @@
-/// Returns the held lock file (keep it alive), or `None` if another instance owns
-/// it — sway processes its own keybinding even over our exclusive keyboard grab,
-/// so re-pressing the bind would otherwise stack overlays.
+/// Returns the held lock and command socket for the first instance. Later
+/// invocations forward their requested cycle direction through the socket and
+/// return `None`, which also supports compositors that keep handling the binding.
@@ -36 +36,3 @@
-pub fn acquire_switch_lock() -> Option<std::fs::File> {
+pub fn acquire_switch_lock(
+ forward: bool,
+) -> Option<(std::fs::File, std::os::unix::net::UnixDatagram)> {
@@ -38,0 +41 @@
+ let socket_path = dir.join("wlr-switcher.sock");
@@ -45,2 +49,10 @@
- flock(&f, FlockOperation::NonBlockingLockExclusive).ok()?;
- Some(f)
+ if flock(&f, FlockOperation::NonBlockingLockExclusive).is_err() {
+ let socket = std::os::unix::net::UnixDatagram::unbound().ok()?;
+ let command = if forward { b"next" } else { b"prev" };
+ let _ = socket.send_to(command, socket_path);
+ return None;
+ }
+ let _ = std::fs::remove_file(&socket_path);
+ let socket = std::os::unix::net::UnixDatagram::bind(socket_path).ok()?;
+ socket.set_nonblocking(true).ok()?;
+ Some((f, socket))
@@ -56 +68,2 @@
- std::thread::spawn(move || ui::capture_thread(tx));
+ let snapshot = opts.snapshot;
+ std::thread::spawn(move || ui::capture_thread(tx, snapshot));
--- a/src/shell.rs
+++ b/src/shell.rs
@@ -617,0 +618,3 @@
+ Keysym::plus | Keysym::KP_Add => Key::Plus,
+ Keysym::equal => Key::Equals,
+ Keysym::minus | Keysym::KP_Subtract => Key::Minus,
--- a/src/switcher_cli.rs
+++ b/src/switcher_cli.rs
@@ -75,0 +76,6 @@
+ /// Show only windows whose Wayland app-id exactly matches this value.
+ #[arg(long)]
+ app_id: Option<String>,
+ /// Select the previous window when the overview first opens.
+ #[arg(long)]
+ reverse: bool,
@@ -96,2 +102,2 @@
- let _lock = match acquire_switch_lock() {
- Some(lock) => lock,
+ let (_lock, cycle_socket) = match acquire_switch_lock(!cli.reverse) {
+ Some(instance) => instance,
@@ -121,0 +128,4 @@
+ app_id_filter: cli.app_id,
+ initial_cycle: Some(!cli.reverse),
+ snapshot: true,
+ cycle_socket: Some(cycle_socket),
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -8,0 +9 @@
+use std::path::PathBuf;
@@ -41,0 +43,32 @@
+const DEFAULT_OVERVIEW_SCALE: f32 = 0.85;
+const MIN_OVERVIEW_SCALE: f32 = 0.55;
+const MAX_OVERVIEW_SCALE: f32 = 1.0;
+
+fn overview_scale_path() -> Option<PathBuf> {
+ if let Some(path) = std::env::var_os("XDG_STATE_HOME") {
+ return Some(PathBuf::from(path).join("window-overview/scale"));
+ }
+ std::env::var_os("HOME")
+ .map(PathBuf::from)
+ .map(|path| path.join(".local/state/window-overview/scale"))
+}
+
+fn load_overview_scale() -> f32 {
+ overview_scale_path()
+ .and_then(|path| std::fs::read_to_string(path).ok())
+ .and_then(|value| value.trim().parse::<f32>().ok())
+ .unwrap_or(DEFAULT_OVERVIEW_SCALE)
+ .clamp(MIN_OVERVIEW_SCALE, MAX_OVERVIEW_SCALE)
+}
+
+fn save_overview_scale(scale: f32) {
+ let Some(path) = overview_scale_path() else {
+ return;
+ };
+ let Some(parent) = path.parent() else {
+ return;
+ };
+ if std::fs::create_dir_all(parent).is_ok() {
+ let _ = std::fs::write(path, format!("{scale:.2}\n"));
+ }
+}
@@ -177 +210 @@
-pub fn capture_thread(tx: Sender<Msg>) {
+pub fn capture_thread(tx: Sender<Msg>, snapshot: bool) {
@@ -189,0 +223 @@
+ let mut captured: HashSet<String> = HashSet::new();
@@ -236,0 +271 @@
+ captured.retain(|key| present.contains(key.as_str()));
@@ -255 +290 @@
- if sessions.contains_key(&s.key) {
+ if sessions.contains_key(&s.key) || (snapshot && captured.contains(&s.key)) {
@@ -295 +330,3 @@
- let Some(key) = by_id.get(&id) else { continue };
+ let Some(key) = by_id.get(&id).cloned() else {
+ continue;
+ };
@@ -313,0 +351,6 @@
+ if snapshot {
+ captured.insert(key.clone());
+ sessions.remove(&key);
+ by_id.remove(&id);
+ client.close_session(&id);
+ }
@@ -508,0 +552,8 @@
+ /// Exact Wayland app-id filter, used by taskbar launches.
+ pub app_id_filter: Option<String>,
+ /// Initial cycle direction. `None` leaves the first item selected.
+ pub initial_cycle: Option<bool>,
+ /// Capture each window once instead of continuously refreshing previews.
+ pub snapshot: bool,
+ /// Receives next/previous commands from repeated compositor keybind launches.
+ pub cycle_socket: Option<std::os::unix::net::UnixDatagram>,
@@ -533,0 +585,5 @@
+ app_id_filter: Option<String>,
+ initial_forward: bool,
+ overview_scale: f32,
+ cycle_socket: Option<std::os::unix::net::UnixDatagram>,
+ queued_cycles: isize,
@@ -567 +623 @@
- pending_initial_select: false,
+ pending_initial_select: opts.initial_cycle.is_some(),
@@ -571,0 +628,5 @@
+ app_id_filter: opts.app_id_filter,
+ initial_forward: opts.initial_cycle.unwrap_or(true),
+ overview_scale: load_overview_scale(),
+ cycle_socket: opts.cycle_socket,
+ queued_cycles: 0,
@@ -604 +665,4 @@
- return; // nothing to cycle yet; keep the pending initial jump
+ self.queued_cycles = self
+ .queued_cycles
+ .saturating_add(if forward { 1 } else { -1 });
+ return;
@@ -639,0 +704,12 @@
+ loop {
+ let command = self.cycle_socket.as_ref().and_then(|socket| {
+ let mut buf = [0_u8; 8];
+ socket.recv(&mut buf).ok().map(|len| (buf, len))
+ });
+ match command {
+ Some((buf, len)) if &buf[..len] == b"next" => self.cycle(true),
+ Some((buf, len)) if &buf[..len] == b"prev" => self.cycle(false),
+ Some(_) => {}
+ None => break,
+ }
+ }
@@ -698,0 +775,5 @@
+ .filter(|s| {
+ self.app_id_filter
+ .as_ref()
+ .is_none_or(|app_id| s.app_id.eq_ignore_ascii_case(app_id))
+ })
@@ -713,2 +793,0 @@
- // Exposé covers the whole screen: dim almost to opaque so the real windows
- // behind are hidden (a client can't move them; this hides them instead).
@@ -716 +795 @@
- c[3] = c[3].max(0.96);
+ c[3] = 0.5;
@@ -734 +813,5 @@
- self.selected = if n > 1 { 1 } else { 0 };
+ self.selected = if n > 1 {
+ if self.initial_forward { 1 } else { n - 1 }
+ } else {
+ 0
+ };
@@ -737,0 +821,8 @@
+ if self.queued_cycles != 0 {
+ let n = self.visible().len();
+ if n > 0 {
+ self.selected = (self.selected as isize + self.queued_cycles)
+ .rem_euclid(n as isize) as usize;
+ self.queued_cycles = 0;
+ }
+ }
@@ -745 +836 @@
- let (esc, next, prev, enter) = ctx.input(|i| {
+ let (esc, next, prev, enter, zoom_in, zoom_out, wheel_zoom) = ctx.input(|i| {
@@ -746,0 +838,8 @@
+ let wheel_zoom = i
+ .events
+ .iter()
+ .filter_map(|event| match event {
+ egui::Event::MouseWheel { delta, .. } => Some(delta.y),
+ _ => None,
+ })
+ .sum::<f32>();
@@ -755,0 +855,3 @@
+ i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
+ i.key_pressed(egui::Key::Minus),
+ wheel_zoom,
@@ -771,0 +874,12 @@
+ if self.view == View::Grid && (zoom_in || zoom_out || wheel_zoom != 0.0) {
+ let delta = if zoom_in {
+ 0.05
+ } else if zoom_out {
+ -0.05
+ } else {
+ wheel_zoom.signum() * 0.05
+ };
+ self.overview_scale =
+ (self.overview_scale + delta).clamp(MIN_OVERVIEW_SCALE, MAX_OVERVIEW_SCALE);
+ save_overview_scale(self.overview_scale);
+ }
@@ -1020 +1134,5 @@
- let area = ctx.content_rect().shrink(24.0);
+ let full_area = ctx.content_rect().shrink(24.0);
+ let area = egui::Rect::from_center_size(
+ full_area.center(),
+ full_area.size() * self.overview_scale,
+ );
@@ -1048,0 +1167 @@
+ let selected = *i == self.selected;
@@ -1062,9 +1181,3 @@
- rect.size() * (0.86 + 0.14 * ease),
- );
- self.paint_expose_tile(
- ui,
- s,
- scaled,
- *i == self.selected,
- resp.hovered(),
- ease,
+ rect.size()
+ * (0.86 + 0.14 * ease)
+ * if selected { 1.04 } else { 1.0 },
@@ -1071,0 +1185 @@
+ self.paint_expose_tile(ui, s, scaled, selected, resp.hovered(), ease);
@@ -1077,0 +1192,8 @@
+ ui.painter().text(
+ egui::pos2(full_area.right(), full_area.top()),
+ egui::Align2::RIGHT_TOP,
+ format!("− {:.0}% +", self.overview_scale * 100.0),
+ egui::FontId::proportional(14.0),
+ self.theme.text_dim,
+ );
+
@@ -1107 +1229,14 @@
- let white = egui::Color32::WHITE.gamma_multiply(a);
+ let accent = if s.is_window {
+ t.window_accent
+ } else {
+ t.screen_accent
+ };
+ let content_alpha = if selected || hovered { 1.0 } else { 0.55 };
+ let white = egui::Color32::WHITE.gamma_multiply(a * content_alpha);
+ if selected {
+ p.rect_filled(
+ rect.expand(8.0),
+ radius + 8.0,
+ fade(accent).gamma_multiply(0.35),
+ );
+ }
@@ -1127,0 +1263,7 @@
+ if !selected && !hovered {
+ p.rect_filled(
+ rect,
+ radius,
+ egui::Color32::from_black_alpha(72).gamma_multiply(a),
+ );
+ }
@@ -1136 +1278,5 @@
- egui::Color32::from_black_alpha(160).gamma_multiply(a),
+ if selected {
+ fade(accent).gamma_multiply(0.9)
+ } else {
+ egui::Color32::from_black_alpha(160).gamma_multiply(a)
+ },
@@ -1175,9 +1321,22 @@
- let accent = if s.is_window {
- t.window_accent
- } else {
- t.screen_accent
- };
- let (sw, col) = if selected {
- (3.0, accent)
- } else if hovered {
- (2.0, accent)
+ if selected {
+ p.rect_stroke(
+ rect.expand(4.0),
+ radius + 4.0,
+ egui::Stroke::new(3.0, fade(egui::Color32::WHITE)),
+ egui::StrokeKind::Inside,
+ );
+ p.rect_stroke(
+ rect,
+ radius,
+ egui::Stroke::new(6.0, fade(accent)),
+ egui::StrokeKind::Inside,
+ );
+ let marker = egui::pos2(rect.right() - 18.0, rect.top() + 18.0);
+ p.circle_filled(marker, 13.0, fade(accent));
+ p.text(
+ marker,
+ egui::Align2::CENTER_CENTER,
+ "✓",
+ egui::FontId::proportional(18.0),
+ fade(egui::Color32::WHITE),
+ );
@@ -1185,8 +1344,7 @@
- (1.0, t.thumb)
- };
- p.rect_stroke(
- rect,
- radius,
- egui::Stroke::new(sw, fade(col)),
- egui::StrokeKind::Inside,
- );
+ p.rect_stroke(
+ rect,
+ radius,
+ egui::Stroke::new(if hovered { 2.0 } else { 1.0 }, fade(accent)),
+ egui::StrokeKind::Inside,
+ );
+ }
+5
View File
@@ -30,6 +30,7 @@ required on every supported host.
| `interface.linux-desktop` | NixOS with Home Manager |
| `interface.labwc` | NixOS with Home Manager |
| `interface.niri` | NixOS with Home Manager |
| `interface.wallpaperengine` | NixOS with Home Manager |
| `platform.nixos` | NixOS |
| `platform.desktop` | Physical NixOS desktop |
| `platform.intel-nvidia-desktop` | Intel/NVIDIA physical NixOS desktop |
@@ -58,6 +59,10 @@ selects labwc. A daily-use macOS development machine can combine
`interface.macos`, `workload.development`, and `workload.personal`. Hardware
support does not implicitly select an interface or workload.
`interface.wallpaperengine` is an opt-in Linux desktop appearance profile. It
enables Wallpaper Engine and disables Noctalia's wallpaper surface, so hosts
without this profile retain Noctalia's configured wallpaper.
`workload.personal` provides Pear Desktop on both NixOS and macOS. Home Manager
enables performance improvements, synced lyrics, tracker blocking, the album
color theme, and custom output-device selection while preserving user-owned
@@ -4,8 +4,6 @@
pkgs.playerctl
];
programs.noctalia.settings.wallpaper.enabled = false;
xdg.userDirs = {
enable = true;
createDirectories = true;
@@ -11,9 +11,8 @@
"applications.ghostty"
"applications.gtk"
"applications.loupe"
"applications.linux-wallpaperengine"
"applications.nautilus"
"applications.normcap"
"applications.nani"
"applications.papers"
"applications.qalculate-gtk"
"applications.resources"
@@ -0,0 +1,3 @@
{
programs.noctalia.settings.wallpaper.enabled = false;
}
@@ -0,0 +1,7 @@
{
description = "Wallpaper Engine backgrounds for Linux desktop sessions";
includes = [
"applications.linux-wallpaperengine"
];
}