用 FZF 作为窗口切换工具

2022-07-20 三 23:14 2023-12-07 四 20:04

修改历史

  • 2022-7 看了 使用 Emacs 作为万能粘合剂 的讨论后,突然有了表达欲,基本完成文中叙述性的内容。
  • 2022-6 用 org 搭建的 blog 逐渐成型,开始想把过程重新梳理一遍,以免自己也完全忘记,但一直进展缓慢。
  • 2021-6 编写了核心功能,readme 写了简单介绍,提交到 github 存档。

为什么需要它?

本文介绍我在使用 ubuntu+i3wm 过程中对窗口操作的一些常用函数,并且基于这些函数实现的基于 fzf 的窗口切换工具,其中许多代码片段来自 stackoverflow 问答。

首先简单地梳理一些概念,作为一种窗口管理器, i3wm 管理的对象就是显示在屏幕上的各类窗口,例如打开的谷歌浏览器, 视频播放器,终端,他们有各自的功能,但在视觉上,都是以一个个的窗口形式展现。绘制和定义这些窗口的并不是 i3wm, 而是 X Window System, 它抽象出了 window, workspace(工作区) 这些概念(或数据结构), 使得我们可以说出“拖动 chrome 到右边”,"把视频全屏播放" 之类的话。

i3wm 对窗口的展示做了一些规范,例如

  • 最好把窗口放在几个不同的工作区
  • 每个工作区可以有不同的窗口和布局,
  • 同一个工作区里的窗口之间尽量不要重叠
  • 每个窗口都对齐紧贴着(not i3-gap)

此类规范/风格/偏见称为平铺式窗口管理。这完全是一种使用习惯,就和如何摆放家具、整理书桌是一样的。

大部分对窗口的操作, i3wm 都提供了现成的命令,这些应该都是对 X 提供的接口的封装,例如关闭窗口,全屏, 将当前窗口移动到另外一个工作区等。但使用久了发现自己大部分情况都在一个 workspace 下, 并且总是 emacs 在左,浏览器或其他的以阅读为主的应用在右,而大部分操作又都集中在 emacs 中。 因此我希望有一种方法能快速地把右边的窗口进行替换,使得可以在 emacs+pdf, emacs+chrome, emacs+文件管理, emacs+X 之间切换,同时不破坏窗口的比例,i3 有一个交换的命令如下:

i3-msg "[id=$current_win_id] swap container with id $id"

这要求获得当前窗口以及目标窗口的 id ,我需要一种直观便捷的方式来选择目标窗口,获得窗口的 id 后自动执行以上命令。 本以为这个功能会有现成实现,但在 github 搜索一番后并没有找到(大概率还是我漏掉了)。

也许像专用于窗口启动和切换的 rofi 工具里会包含此类功能,但我启动 app 都用精简的 dmenu, 切换不可见窗口用 win-数字直接到对应 workspace (因为对各 workspace 做了功能约定,例如 9 号 space 只开 pycharm, 0 号只开 zotero), 为了一个交换窗口的功能而安装一个全功能的包有点杀鸡用牛刀的感觉,并且想着装了 rofi 大概率也得折腾一遍,因此还是自己编写只有自己需要的相关功能,这才有了以下的脚本。

最终想要的效果是,通过一个按键弹出悬浮的窗口选择器,输入关键词进行搜索,按 enter 或者逗号切换到目标窗口,按 / 则将目标窗口与当前窗口交换,如下图:

此外还实现了切换到当前窗口里下一个窗口的功能,详见后文。

基本操作函数

以下是获取 workspace, window 这些对象的名称或者 id 信息的函数,基本就是 xdotool, wmctrl, xprop, grep 命令的标准输入输出之间的 pipe 流,每个函数内容都很简短,名称也直接,在此不做单独解释。

如果有兴趣想自己实现一些功能,建议用 man xdotool 大致阅读一遍命令的说明,然后将以下内容保存到一个 bash 脚本里,例如我保存在 ~/myconf/xwish/utils.sh 中, 如有修改则在终端执行 source ~/myconf/xwish/utils.sh 后再执行对应的函数进行测试。

另外 wmcrtrl 也出现较多,建议同样用 man 查看文档。 比如 wmctrl -l 命令是显示所有窗口,以下工作区 1 (对应编号 0)里开了一个 Terminal, 工作区 2 有 emacs 和 chrome

wmctrl -l
/usr/bin/wmctrl
0x01600006  0 code Terminal
0x010000ca  1 code emacs@code
0x01200005  1 code emacs - Capture output from a shell command with babel in org-mode - Stack Overflow - Google Chrome
#+name: window-basic
get_active_win_name() {
    echo $(xdotool getwindowfocus getwindowname)
}

get_active_win_id() {
    id_dec=$(xdotool getactivewindow)
    # hex
    echo $(printf 0x%08x $id_dec)
}

get_active_workspace_id() {
    # echo $(wmctrl -d | grep '\*' | cut -d ' ' -f1)
    echo $(xdotool get_desktop)
}

get_ids_in_current_workspace() {
    local workspace=$(get_active_workspace_id)
    local ids_in_workspace=$(wmctrl -l | grep "^0x.\{8\}\s\+$workspace " | awk '{print $1}')
    echo $ids_in_workspace
}

_get_next_nonblank_in_string() {
    local ids="$1"
    local idx="$2"
    IFS=' ' read -r -a arr <<< "$ids"
    local len=${#arr[@]}
    for i in "${!arr[@]}"; do
        if [[ "${arr[$i]}" = "${idx}" ]]; then
            j=$(((i + 1) % len))
            echo "${arr[$j]}";
            break
        fi
    done
}

get_next_win_in_workspace() {
    local ids=$(get_ids_in_current_workspace)
    local current_id=$(get_active_win_id)
    echo $(_get_next_nonblank_in_string "$ids" "$current_id")
}

focus_to_win_by_id() {
    wmctrl -ia $1
}

focus_to_win_by_name() {
    wmctrl -a $1
}

focus_next_win_in_workspace() {
    local next=$(get_next_win_in_workspace)
    focus_to_win_by_id "$next"
}

is_active_window_full_screen() {
    local id=$(get_active_win_id)
    xprop -id "$id" | grep "_NET_WM_STATE(ATOM) .* _NET_WM_STATE_FULLSCREEN"
}

restore fullscreen

有了以上的基本函数,接着就是浮出水面的时候了,首先实现一个简单的恢复全屏的命令,将代码保存成 restore_fullscreen.sh 文件,绑定一个快捷键,就拥有了说出 "将全屏取消" 的能力。

. ~/myconf/xwish/utils.sh
get_active_win_id > /tmp/current_win_id
is_active_window_full_screen
if [ $? == 0 ]; then
    wmctrl -r ':ACTIVE:' -b toggle,fullscreen
fi

注:以上保存当前窗口 id 到临时文件是为了方便后文的窗口交换的实现。

全屏命令参考自: Full screen window command from Linux terminal? - Super User

popup fzf window swaper

这部分是需求实现的核心了,逻辑如下:

  • 使用了 utils.sh 里的 get_active_win_id 获得当前窗口的 id, 并保存。
  • 由于 i3wm 无法在全屏时显示 float window (全屏相当于是优先级最高的浮动窗口), 因此在真正弹出选择窗前,先检查当前窗口是否全屏,如果是,则退出全屏。
  • 以上两个操作都写在 restore_fullscreen.sh 中
  • 启动一个名为 "select_window" 的 terminal, 为了让窗口浮动,需要在 i3 配置里加上:
for_window [title="^select_window$"] floating enable
  • 在 terminal 里执行以下脚本
# use fzf to choose window, press / to swap current focused window to selected window
# press enter or , to go to selected window
win=$(wmctrl -l | tr -s ' ' | grep -v "select_window\|EmacsAnywhere" |\
        fzf --height 50%  --header ', to swich; / to swap' --expect=/,,)
current_win_id=$(cat /tmp/current_win_id)

[[ -z ${win} ]] && exit;
echo "$win" | grep '^/' > /dev/null && { #swap
    id=$(echo $win | tail -1 | cut -d ' ' -f2) #second
    i3-msg "[id=$current_win_id] swap container with id $id"
    # i3-msg "restart" #fix window tree update delay
    exit;
} 
echo "$win" | grep '^,' > /dev/null && { #switch
    id=$(echo $win | tail -1 | cut -d ' ' -f2) #second
    i3-msg [id=$id] focus
}

# use enter to select 
id=$(echo $win | tail -1 | cut -d ' ' -f1) #first
i3-msg [id=$id] focus

第一句话是弹出一个 fzf 选择菜单,剩下的都是根据 fzf 按键和选择结果去匹配是按了 / 还是逗号或 enter, 分别执行 swap 和 focus.

  • 将整个过程串起来, i3 里的配置如下:
bindsym $super+comma exec ~/myconf/xwish/restore_fullscreen.sh && gnome-terminal --geometry=80x15 --title='select_window' -- ~/myconf/xwish/fzf_window.sh

这样, 按 win+, 键就可以 "选择-切换/交换" 窗口了

注意需要在启动 terminal 前去获得当前窗口 id, 否则 terminal 起来后,当前窗口就是 terminal 了, 这是为什么要在 restore_fullscreen.sh 里把 id 保存到文件的原因,当然可能会有更好的方法, 但这是我能想到的最直观的实现了。

cycle windows in workspace

utils.sh 中还有一个 focus_next_win_in_workspace 函数,可以用来循环切换窗口,对于经常只开两个窗口 (不论是上下还是左右 split, 或者如 tab, stack layout)的情形,只需要绑定一个按键就可以在窗口间来回跳转。

基本原理就是,先获取当前 focus 的 window, 然后从 wmctrl -l 结果列表中找出当前 workspace 里的所有 window, 按默认顺序找到下一个窗口后 focus。

. ~/myconf/xwish/utils.sh
if [ "$1" == "fade" ]; then # make unfocused window transparent
    transset --dec 0.1 -a 
fi

focus_next_win_in_workspace
if [ "$1" == 'highlight' ]; then # focus then highlight(blink)
    transset --dec 0.2 -a  # transparent
    sleep 0.3
    transset --inc 1 -a # solid
fi

if [ "$1" == "fade" ]; then
    transset --inc 1 -a 
fi

这里在执行切换前后还使用 transset 来修改窗口的透明度,使得非活跃窗口是透明的, 但尝试一段时间后个人并不是很习惯,因此加上了参数,如果没有 fade 或 highlight, 默认就只是切窗口。

i3 里可以绑定如下命令,用 f2 切换。

bindsym F2 exec ~/myconf/xwish/restore_fullscreen.sh && exec ~/myconf/xwish/focus_next_win.sh

为什么不需要它?

在使用以上脚本的一段时间里,个人体验还是比较满意的,但随着对 emacs 越来越熟悉,更多的工作流都迁移到了 emacs 内部进行,例如 org + pdf-view 读 pdf, org + nov 读 epub, 再后来还加入了 eaf-browser, 继续吸收了部分 chrome 的流量,大部分时候都是在一个 workspace 下只开 emacs, 左边是 org 或代码, 右边是各类阅读类 buffer 或 shell,窗口管理的任务更多变成了 emacs 内的 tab 和 window layout 的管理。

当前我进行窗口 swap 的次数已经很少了,然而偶尔需要时,还是能体会到其便利。另外,循环切换窗口的命令还可以和 emacs 进行融合,先判断当前 workspace 是否有且只有一个 emacs, 如果是则发送 C-x o 按键进行 emacs 内的 window 切换,统一了 i3 和 emacs 的窗口切换习惯。

. ~/myconf/xwish/utils.sh

if [ $(get_ids_in_current_workspace | wc -w) == "1" ]; then
    if [[ $(get_active_win_name) =~ "emacs" ]]; then
        xdotool key --clearmodifiers ctrl+x o
    fi
else
    focus_next_win_in_workspace
fi

这使得我更加频繁使用它了。

因此我能想到的不需要这些脚本的情况有:

  • 用 emacs popup window 来取代 终端+fzf 选择器,因为可以利用 emacs 内的选择过滤功能, 比如 libpinyin 使得以用拼音来检索中文,还可以从其他应用直接切换到 emacs 中的特定的 tab/window/buffer
  • 完全 live in emacs, 不用切换 workspace 和 X window 了,直接把 F2 改成 other-window,
  • 从 linux 回到 windows 或 macos
  • 不需要窗口管理,脑机接口能准确预测我的意图,配合眼动识别自动在我的注视区呈现我想要的内容, 也许在元宇宙之类的交互形式里。

如对本文有任何疑问,欢迎通过 github issue 邮件 metaescape at foxmail dot com 进行反馈