折腾 Niri WM:手搓一个完美的多显示器下拉终端 (Drop-down Terminal)
从 X11 或 Sway 迁移到 Niri 这个无限卷轴式 (Scrollable-tiling) 的 Wayland WM 后,我最不习惯的就是失去了原来用惯的下拉终端(类似 tdrop 或 Guake)。
Niri 确实原生支持了 scratchpad 机制,你可以把窗口扔进去隐藏,再用快捷键呼出。但它属于通用级别的窗口隐藏,没法做到"按下一个特定的快捷键,专属的终端窗口就从屏幕上方固定弹出,再按一次就消失",而且在多显示器环境下,跨屏呼出经常会遇到焦点错乱的问题。
为了解决这个问题,我决定利用 Niri 强大的 IPC 接口(niri msg --json)搭配 jq,自己手写一个极其丝滑的下拉终端管理脚本。
方案思路
- 专属标识 :让终端(以 Alacritty 为例)以特定的
app-id启动(例如alacritty-drop)。 - 窗口规则 :在 Niri 配置里拦截这个
app-id,让它默认就是浮动状态(Floating)。 - 状态机控制 :
- 如果终端不存在:启动它,并自动调整为占满屏幕宽度、高度 75%。
- 如果终端在其他工作区或显示器:把它瞬间"抓"到当前显示器和当前工作区,并夺取焦点。
- 如果终端已经在当前工作区,但没聚焦:把它推到最前并聚焦。
- 如果终端已经在当前工作区且正在聚焦:认为用户用完了,把它扔回
scratchpad隐藏。
1. Niri 窗口规则与快捷键配置
首先,我们需要在 ~/.config/niri/config.kdl 中加入窗口规则,让这个终端一出生就是浮动的:
kdl
window-rule {
match app-id="alacritty-drop"
open-floating true
}
然后绑定你的召唤快捷键(比如 Alt+1),让它去执行我们接下来要写的脚本:
kdl
binds {
Alt+1 { spawn "bash" "-c" "~/.config/waybar/tdrop-niri.sh"; }
}
2. 核心脚本解析
这是最核心的部分。新建脚本 ~/.config/waybar/tdrop-niri.sh 并赋予执行权限。
这里面踩过最大的坑是多显示器的工作区索引冲突 。在 Niri 里,每个显示器的 Workspace 索引(idx)是独立的。比如左边屏幕有个 idx=1(实际上是 Scratchpad),右边屏幕也有个正常的 idx=1。如果只用 idx 去判断终端到底在不在当前屏幕,很容易导致脚本原地抽风,以为左边屏幕隐藏的终端已经在右边屏幕显示了。
正确的做法是:获取全局唯一的 ID 进行对比,并在跨屏召唤时,先强行转移 Monitor,再转移 Workspace。
完整的脚本如下:
bash
#!/usr/bin/env bash
CLASS="alacritty-drop"
# 1. 获取当前正在交互的屏幕和工作区信息
ACTIVE_WORKSPACE=$(niri msg --json workspaces | jq -r '.[] | select(.is_focused==true)')
ACTIVE_WORKSPACE_ID=$(echo "$ACTIVE_WORKSPACE" | jq -r '.id')
ACTIVE_WORKSPACE_IDX=$(echo "$ACTIVE_WORKSPACE" | jq -r '.idx')
ACTIVE_OUTPUT=$(echo "$ACTIVE_WORKSPACE" | jq -r '.output')
# 2. 查找我们的下拉终端窗口是否存在
WINDOW=$(niri msg --json windows | jq -r "first(.[] | select(.app_id==\"$CLASS\"))")
if [[ "$WINDOW" == "null" || -z "$WINDOW" ]]; then
# 情况 A: 终端压根没运行,拉起它
alacritty --class "$CLASS" &
# 稍微等一下让窗口真正建出来,然后自动设置宽高 (宽100%,高75%)
sleep 0.5
WINDOW_ID=$(niri msg --json windows | jq -r "first(.[] | select(.app_id==\"$CLASS\") | .id)")
if [[ "$WINDOW_ID" != "null" && -n "$WINDOW_ID" ]]; then
niri msg action set-window-width --id "$WINDOW_ID" 100%
niri msg action set-window-height --id "$WINDOW_ID" 75%
fi
else
# 终端在后台运行中,解析它的当前状态
WINDOW_ID=$(echo "$WINDOW" | jq -r ".id")
WIN_WORKSPACE_ID=$(echo "$WINDOW" | jq -r ".workspace_id")
IS_FOCUSED=$(echo "$WINDOW" | jq -r ".is_focused")
if [[ "$WIN_WORKSPACE_ID" == "null" || -z "$WIN_WORKSPACE_ID" ]]; then
WIN_WORKSPACE_ID="none"
fi
# 情况 B: 终端在其他工作区,或者在隐藏的 Scratchpad 里
if [[ "$ACTIVE_WORKSPACE_ID" != "$WIN_WORKSPACE_ID" ]]; then
# 核心坑点:跨屏幕移动必须先 move-window-to-monitor 否则焦点和位置会乱
niri msg action move-window-to-monitor --id "$WINDOW_ID" "$ACTIVE_OUTPUT"
niri msg action move-window-to-workspace --window-id "$WINDOW_ID" "$ACTIVE_WORKSPACE_IDX"
niri msg action focus-window --id "$WINDOW_ID"
# 重新确保尺寸是下拉终端的尺寸
niri msg action set-window-width --id "$WINDOW_ID" 100%
niri msg action set-window-height --id "$WINDOW_ID" 75%
# 情况 C: 终端就在眼前,但焦点在其他窗口上
elif [[ "$IS_FOCUSED" == "false" ]]; then
niri msg action focus-window --id "$WINDOW_ID"
# 情况 D: 终端就在眼前,且当前正在使用。按快捷键就是为了收起它
else
# 把它丢进 scratchpad 隐藏起来,并且不要改变当前其他窗口的焦点
niri msg action move-window-to-workspace --window-id "$WINDOW_ID" "scratchpad" --focus false
fi
fi
代码细节拆解
niri msg --json+jq:Wayland 下想要 hack 窗口行为,最爽的就是这种提供完善 JSON IPC 的 WM。我们通过一两行 jq 就能拿到精确到每个窗口的内部 ID、所处的工作区以及当前的 Focus 状态。- 多显示器匹配逻辑 :你会注意到,移动工作区时,用的是相对索引
$ACTIVE_WORKSPACE_IDX(配合上一步的 monitor 转移),但判断在不在同一工作区时,用的是绝对主键$ACTIVE_WORKSPACE_ID!=$WIN_WORKSPACE_ID。这是彻底解决多显示器召唤失败的关键。 - 隐藏逻辑 :Niri 并没有
hide这种命令。它的本质是存在一个名为scratchpad的特殊隐藏工作区。所以把下拉终端收起,等价于move-window-to-workspace ... "scratchpad",加上--focus false可以保证你收起终端后,键盘输入焦点能平滑回到你刚刚在看的代码编辑器或浏览器上。
总结
在 Niri 里折腾这种小功能,一开始可能会觉得不如 X11 下用 xdotool 那样简单粗暴,但熟悉了它结构化的 JSON IPC 后,你会发现这种控制既精准又优雅,再也不用靠玄学的 sleep 和瞎猜窗口层级来写判断了。
希望这个脚本能帮到同样在使用 Niri 的朋友们,打造一个完美的下拉终端。
