尝试用可滾动平铺窗口管理器 niri 替换基于 i3 的 swaywm 以及一个简单的窗口切换脚本
trying scrollable tiling window manager niri, replacing swaywm
zping
2770 字
2025-06-22 11:49 +0000
可滾动平铺的窗口管理器
这几天有点时间,借着升级系统的机会,看了一下 swaywm 的替代方案。
早先选择 swaywm 主要是因为之前 i3 用了很长时间,切换时可以顺手一些。当时确实也还花了点时间对 swaywm 进行了一些配置。但生命在于折腾,在使用了一两个月的 swaywm 以后,感觉 swaywm 设计上虽然非常简洁,但是毕竟用久了,还是有点审美疲劳。还有一点是我对于 i3/swaywm 当前的工作区(workspace)模型在支持多显示器的条件下总是显得有一点诡异。而且实话是自己日常也会用到苹果笔记本。整体观感上的落差哪怕是对我这种人来说也是存在的。我个人对于苹果的生态并不看好。尽管我未来可能仍会继续使用苹果本,但是杞人忧天一下似乎也不是坏事。总体来说,为追求一些更“现代”的开源桌面方案浪费些时间是可以接受的事情。
随手搜了一下,发现现在比较流行的窗口管理器概念叫可滾动平铺(scrollable tiling/STWM)。这个概念最早的实践也许是 PaperWM ,当然,它自己的项目介绍里它也平铺可滚动(Tiled Scrollable)来描述这个功能。历史问题对我来说也不重要,这个项目实际上是 GNOME shell 的扩展。而我需要依赖更少的实现,同时我当然希望这个项目就在 Nix 的默认仓库中。最后我找到了 niri 这个项目。
以上这些基本上是废话。使用 niri 替换 sway 并不太难。大部分可以直接实现。从 sway 切换到 niri 需要注意的地方大致有以下几点:
-
niri 本身没有实现桌面的状态条(status bar)。而我原来用的就是它文档中默认搭配的 waybar ,反过来, waybar 本身也支持 niri ,切换时只需要把配置中的 sway 相关的部分替换成 niri 的即可。
-
greetd 的配置同样也是要替换掉 sway ,这里用的是 niri 有启动脚本 niri-session ,这个脚本基本上是通过命令
systemctl start --wait --user niri来启动。 -
swayidle 的配置也改动。当然, niri 本身也是直接使用 swayidle ,这里原来通过
swaymsg来执行的操作,则应替换成niri msg action。 -
Xwayland 在 niri 的支持并不像 Sway 那样是默认开启的。所以需要专门的 xwayland-satellite 来实现对 X11 应用的支持。
-
niri 切换窗口的机制和 sway 以及传统的窗口管理器不一样。它似乎没有维护一个窗口排队。它有切换到上一个窗口(focus-window-previous)、上一个工作区(focus-workspace-previous)这两个指令,而没有对应的切换到下一个窗口或工作区的指令。只有显示器有切换到下一个显示器(focus-monitor-next)和上一个显示器这种对称的指令(focus-monitor-previous)。这个引出下面一系列的事情,等会再说。
-
应用启动部分, niri 默认配置里用的是 fuzzel ,其实我之前用的是 wofi 。这没有什么说的,如果用 wofi 就更改 niri 的配置;如果不想改这部分 niri 配置就安装 fuzzel。
-
最后一个就是 niri 自己集成了截屏、录屏和投屏的能力。这部分 sway 是没有集成的。所以会有区别。这里还有个人的看法。也许是因为我自己用 wayshot 加上 slurp 写了一个截屏脚本。我并不是很喜欢它做的截屏功能。而且作者本人对待这个功能的态度也不是很让人喜欢。我也不明白为什么一个窗口管理器需要有录屏和投屏相关的功能,如果作者不想管这些事情,他完全没有必要把这个功能在窗口管理器中实现。而且这样导致窗口管理器需要 xdg-desktop-portal-gnome ,这些作为一个非常小众的东西会引起不必要的麻烦。
至于 niri 本身的使用,它引入的列的概念,确实可以更方便的安排窗口之间的排列关系。这对于大屏幕来说更有价值。
窗口切换脚本
除了为了更新的概念而尝试窗口管理器,还有一点是需要改进一下自己的工作流。总结了一下自己使用 i3/swaywm 的体感。我在 i3 下用得最多的可能就是来回切工作区(workspace)。我的设定是有几个比较重要的窗口打开在固定的工作区,工作区本身有序号,并且有快捷键映射,想切换的时候可以直接凭借肌肉记忆切换。但这样做还是有一点负担。毕竟我需要找到在切换前需要分析一下自己要切换的工作区是哪一个。但是一旦形成肌肉记忆,有的时候会不自觉的切错,如果完全不思考,总是切到自己用的最多的一个。所以,我一直希望不依赖工作区的概念。如果切换当前的窗口,则应当有一个交互的过程。也就是跳到一个对话窗口,选择自己需要切换的目标。
这里谈一下,喜欢“可滾动”这一特性的人一般来说,他的空间想象会很好。但我其实并没有太强的空间想象力,在打开五六个窗口以后,我不会记得它们之间的关系。正像我上面说的,我最后都是把打开的窗口固定下来,然后依靠肌肉记忆。所以像 niri 这个窗口管理器对我来说只是更有现代感而已。让我了解了最新桌面实现的流行趋势。我真正的需求是更容易从一堆窗口里更快地找到自己需要的窗口,工作区还有无限滚动的概念并不太适合我。类似 dmenu,以及我之前使用过的 wofi/rofi 都有这个功能,但我一直觉得有点问题。直到这一次,切换到 niri 下的时候,因为 niri 奇怪的切换窗口的逻辑,我仔细想了一下我的这个需求。比较合适的展现打开的窗口列表的方式是对窗口进行一个排序。我很自然地想到根据切换窗口的历史进行排序。这个其实并不难,我只要维持一个队列,在每次切换时,把上一次的窗口提到队列最前面就好了。当然,如果是以前我可能因为这个实现会消耗太多精力就算了。但是因为有大语言模型。我在一个搜到的简单原型之上,直接让它生成了一个 bash 脚本,自己修了点一些就用上了。这个东西其实和窗口管理器虽然有联系,但窗口管理器本身的概念更多的是对屏幕空间的管理,一般不会直接涉及这一点。
我这里分享一下我的实现。当然这个是高度客制化的东西,也肯定存在实现上的问题,比如说当窗口标题中包括复杂的双引号单引号时,这个脚本有可能会出错。但是这里只是提供一个思路。这个类似于之前视窗系统的体验,但是视窗并不能直接通过对话框检索打开的窗口。而果厂 MacOS 下,其实并不是切换窗口,而是切换应用,窗口预览也不是全局。还需要专门的第三方应用实现。
#!/usr/bin/env bash
# orig ref: https://github.com/armerpunkt/niri-fuzzel-switcher/
set -xeu
SWBIN=/run/current-system/sw/bin
exec > >(${SWBIN}/logger -t windows-switcher) 2>&1
WINDOW_IDS_FN=/tmp/switch-window-ids
window_ids=()
window_titles=()
focused_wid=$(niri msg --json windows | jq -r '.[] | select(.is_focused == true) | .id')
while read i; do
declare -a line_array="($i)"
window_ids+=(${line_array[0]})
if [[ ${line_array[1]} == "org.qutebrowser.qutebrowser" ]];then
window_titles+=("${line_array[2]} \0icon\x1fqutebrowser")
elif [[ ${line_array[1]} == "mpv" ]];then
window_titles+=("${line_array[2]} \0icon\x1fmpv")
elif [[ ${line_array[1]} == "emacs" ]];then
window_titles+=("${line_array[2]} - emacs \0icon\x1f${line_array[1]}")
elif [[ ${line_array[1]} == "com.mitchellh.ghostty" ]];then
window_titles+=("${line_array[2]} - ghostty \0icon\x1f${line_array[1]}")
else
window_titles+=("${line_array[2]} \0icon\x1f${line_array[1]}")
fi
done <<<$(niri msg --json windows | jq -r '.[] | select(.is_focused == false) | [ .id, .app_id, .title] | "\""+join ("\" \"")+"\""')
sort_windows_by_file() {
local file=${WINDOW_IDS_FN}
# 检查文件是否存在
[[ ! -f "$file" ]] && return 0
# 读取文件中的 window_id
local file_ids=()
while IFS= read -r line; do
file_ids+=("$line")
done < "$file"
# 创建临时数组存储排序结果
local sorted_ids=()
local sorted_titles=()
# 步骤1:处理文件中存在的 ID
declare -A id_present
for id in "${window_ids[@]}"; do
id_present["$id"]=1
done
# 按照文件顺序添加存在的 ID
for file_id in "${file_ids[@]}"; do
if [[ -n "${id_present[$file_id]:-}" ]]; then
# 查找所有匹配的索引
for idx in "${!window_ids[@]}"; do
if [[ "${window_ids[$idx]}" == "$file_id" ]]; then
sorted_ids+=("$file_id")
sorted_titles+=("${window_titles[$idx]}")
# 标记已处理
unset "window_ids[$idx]" "window_titles[$idx]"
fi
done
fi
done
# 步骤2:添加剩余未处理的元素(保持原始顺序)
for idx in "${!window_ids[@]}"; do
sorted_ids+=("${window_ids[$idx]}")
sorted_titles+=("${window_titles[$idx]}")
done
# 更新原始数组
window_ids=("${sorted_ids[@]}")
window_titles=("${sorted_titles[@]}")
}
write_window_ids_to_file() {
if [ $# -ne 1 ]; then
echo "Usage: write_window_ids_to_file <output_file>" >&2
return 1
fi
# 遍历数组查找 selected_window_id
for ((i = 0; i < ${#window_ids[@]}; i++)); do
if [[ "${window_ids[i]}" == "${selected_wid}" ]]; then
unset 'arr_ref[i]' # 删除原位置的元素
fi
if [[ "${window_ids[i]}" == "${focused_wid}" ]]; then
unset 'arr_ref[i]' # 删除原位置的元素
fi
done
window_ids=("${focused_wid}" "${selected_wid}" "${window_ids[@]}") # 在开头插入并保留其他元素顺序
local output_file="$1"
local element
if ! declare -p window_ids &>/dev/null; then
echo "Error: window_ids array not defined" >&2
return 1
fi
# 清空目标文件
: > "$output_file"
# 逐元素处理特殊字符,只允许正整数
for element in "${window_ids[@]}"; do
if [[ "$element" =~ ^[1-9][0-9]*$ ]]; then
printf "%s\n" "$element" >> "$output_file"
fi
done
}
sort_windows_by_file
result=$(printf "%b\n" "${window_titles[@]}" | fuzzel --counter --dmenu --index)
selected_wid=${window_ids[result]}
write_window_ids_to_file ${WINDOW_IDS_FN}
if [ "x$result" != "x" ] && [ $result != -1 ]; then
niri msg action focus-window --id ${selected_wid}
fi