i3wm: 无关生产力,且远不止窗口管理

2023-12-30 六 00:06 2024-06-22 六 17:10

修改历史

  • [2024-06-12 三 16:40] main-mode 里直接用 8,9 设置常用窗口布局比例
  • [2024-01-13 六 10:59] 抽取出 code01s1.xmodmap 核心修改,workspace 命名细节问题放在 details 块中
  • [2024-01-05 五 17:49] 添加对 i3bar, i3status 的简单解释

许多推荐 i3wm 的文章或者视频都会以提高生产力或工作效率为理由,个人觉得这句话很难成立,但也难以反驳。一方面,肯定有人通过其定制性提高了某些场景的使用效率,但没有了封装的约束,也容易陷入过度优化,总是被配置问题吸引反而成为效率的阻碍。另一方面,窗口用什么方式切换和生产力没有直接关系,在机器还没有完全取代人的时代,效率主要还是在于人脑的专注程度,何况使用这类窗口管理工具的大部分还是编程人员,编程的效率体现在解决问题的思路上,没有分解清楚问题或者缺少某些知识,窗口切换地再流畅也不会有助于工作的推进,这同样也适用于对其他任何工具表层形式的配置上;但辩护的人可以说,世界上哪里有那么多思考性和创造性的工作,编程里有一大部分时间就是在拷贝、粘贴、修改、运行、调试找 bug 、查资料(问 GPT)、拷贝…的循环中, 这就很需要 workflow 的流畅性了。

至于 i3wm 中的 wm – "窗口管理" 也不是大部分人所想象的那样,比如在 Mac 或者 Windows 桌面里也有工作区 (workspace) 的概念,可以把不同软件分组放在不同工作区,用鼠标拖动窗口到屏幕边缘会自动进行平铺等。 我最初也以为安装好 i3wm 后相当于在原有桌面基础上加一个插件使得窗口可以自动平铺,其他功能大体上保持不变。 然而事实并非如此,虽然 i3wm 确实是窗口管理器,但问题在于它取代的是 gnome-desktop 这类完整的桌面环境, 后者是一个生态系统,包含了许多功能比较紧密联系的模块(的入口),例如声音和亮度调节、通知系统、软件启动器等等。 这些模块在 GNOME 桌面系统中通常是需要协调一致才能保证基本流畅的用户体验, 比如调节音量的同时显示出通知并且更新状态栏里音量数值的高低、启动软件的环境变量继承、处理快捷按键的冲突和统一、关机锁屏等,因此很难把 gnome-desktop 里窗口管理部分替换成 i3wm, 其余功能则保持不变,以至于安装完 i3wm 后很多 Fn 功能键可能都失效了,i3 给你打开了一扇窗,同时关上了很多门,而这些门得自己去打开。

i3wm_screen.webp
Figure 1: 个人 i3wm 桌面截图,左侧 emacs, 悬浮小窗口是基于 emacsclient 的窗口切换工具,右上 chrome, 右下 urxvt 终端,当前聚焦在悬浮窗口上。

配置 i3wm 以及可能所有交互类软件,都不要求很强的技术性(除非你又是这个工具的开发者),它们更多地是一个等待问题或需求出现并搜集信息的过程。刚开始时可能还不清楚自己需要什么,也不知道如何修改。即便使用别人包装好的 startkit (比如本文配置中 UI 部分主要基于 addy-dclxvi/i3-starterpack), 使用过程中仍然会逐渐遇到各种小问题,知道一些名词,用这些词去搜索、试错,这需要花一点时间,但基本取决于获得信息的能力。从 chatgpt 一类的 AI 对话软件就可以侧面体现,目前 chatgpt 在写复杂逻辑的代码上效果还是不太好,但对于回答那些模糊的配置需求问题,简直是良师益友,因为它更多是以高效地方式帮你搜索别人早就处理过的问题,而不是解决新问题。因此很多以前只是从别人那里复制过来而不理解为什么这么写的配置(也许当时以为自己理解),在最近一年里我都陆续清楚了很多。所以从 chatgpt 出现以后才开始 DIY 配置的人应该能大大减少折腾的时间,不过定制欲望可能也会水涨船高‍。

本文是个人 i3wm 配置的整理发布,我可能更多是被其平铺式极简风格(当然包括一些抓人眼球的桌面截图)以及可以方便地定制按键所吸引。 然而如以上图片所展示的一样,本文的配置结果中不会有那些曾经吸引过我的 gaps、圆角和毛玻璃效果等,我原本以为自己会有这种需求,但发现那并不是很重要,或者说刚开始喜欢的东西,不一定能长期相伴。比如图中 emacs 的主题虽然不是很抓眼,但却对我的视网膜相当友好(每个人对颜色的感知度不太一样),长期使用只觉得平淡如水。 因此本文更多是在介绍如何用 i3wm 、xdotool 、xcape 等工具来构建一套符合个人偏好的交互系统,其中 i3wm 提供了核心的窗口排列、切换等功能的 Api,经过长期打磨后,该系统的核心需求是:

  • 每个窗口的有效空间尽量大,几乎只有应用中的内容本身

    gaps 或者圆角都会牺牲一点空间,因此大大减小了吸引力。

  • 方便按键绑定:个人有一些常用的交互性质的 bash 脚本,i3 可以把按键绑定到脚本执行上。
  • 状态栏里只留下最常关注的几列系统信息,以文本或 unicode emoji 形式展示,不需要动态效果。

    因此不需要第三方 polybar 等软件,直接用 i3wm 自带的 i3bar 和 i3status 即可

  • 几个工作区对应不同使用场景,用快捷键切换工作区:

    大部分时候我都是左右两个窗口,比如 emacs/chrome 、 vscode/chrome 、 文件管理器/(office 类软件或多媒体) 等,这用工作区来管理非常方便。

  • 能够提供封装地较好的对工作区/窗口进行操作的 bash 接口(如 i3-msg)

    这实际是对上一条习惯的脚本化,比如可以在项目里写一个 bash 脚本,执行该脚本后就进入到特定 workspace 里并启动不同软件,如 vscode 打开源码文件,chrome 打开项目相关资料的 url 。

    在 emacs org-babel bash 中执行

    我更倾向于把这些命令放在项目的 org 笔记里,通过 org babel 直接执行。

    比如执行如下 org bash 代码块后,会先切换到名为 y 的 workspace ,然后通过 i3 分别启动 typora 和 emacsclient 来预览和编辑 README.md 文件,注意此处用 i3-msg 发送 exec 进行启动,因为这样 typora 和 emacsclient 的父进程会交接给 i3wm 从而不会阻塞 emacs,如果直接执行 typora file 或 emacsclient file 则会因为等待进程而阻塞当前 emacs ,表现为 emacsclient 无法启动。

    i3-msg "workspace y"
    i3-msg "exec typora ~/codes/projects/README.md"
    i3-msg "exec emacsclient ~/codes/projects/README.md -c"
    

这些功能也可以在其他窗口管理器或 GNOME 等预设的桌面中通过脚本和软件来实现,但由于个人最初接触并习惯了 i3wm 的整体交互方式,因此这完全是先入为主的结果。

不同窗口管理器之间的权衡

在配置 i3wm 遇到一些不太容易实现的需求,比如后文提到动态修改 workspace 名时,我就会想是否要换其他的窗口管理器,比如 qtile 是 python 写的,似乎通用的编程语言作为配置语言会更加灵活强大,而我又熟悉 python,但每次找到它们的文档看了一会后还是放弃。一方面,那些不太容易实现的需求虽然偶尔让人心痒,但冷静来看,它们都不在核心需求中,没必要花 99% 精力去实现 1% 的需求(除非拥有了漫长的假期)。另一方面,这些 wm 提供的核心功能都是一样的,我也只是把它们当作一个库来用,而 i3wm 的文档和配置算是各 wm 中相对友好的,如果用 python 或者 haskell 等全功能的编程语言作为配置,除了重新配置的投入,还需要额外的语言环境管理,比如 conda/venv 等,因此我始终还是只使用 i3wm。

另外,本配置的环境是在 ubuntu22.04, 使用 X11, i3 version 4.20.1, 且不接外置屏幕。

i3 的启动和环境变量

i3 是在用户登陆后直接启动的,为了了解它继承的 PATH 环境变量的内容是什么,可以在 i3 配置中临时添加如下命令:

#+name: get-i3wm-env-path
exec_always --no-startup-id echo "$PATH" > /tmp/i3-path

查看其内容:

cat /tmp/i3-path
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin

清楚 i3 的 PATH 变量很关键,因为当 i3 配置中用 exec 设置软件的开机启动、软件的快捷键绑定启动,或者用 dmenu (默认是 mod+d) 启动其他 app 时,由于 dmenu 是 i3 的子进程, 而被启动的 app 又是 dmenu 或 i3 的子进程,因此 app 继承到的也只是以上的 PATH 路径。

比如用 demu 启动 emacs, 要在 org 中预览 latex 公式,而 latex 安装路径可能是 /usr/local/texlive/2022/bin/x86_64-linux/, 不在以上 PATH 中, emacs 就会因为找不到 latex 可执行文件而无法预览。

再比如,如果使用 ibus-rime 输入法,一般要在环境中添加以下变量:

export GTK_IM_MODULE=ibus
export QT_IM_MODULE=ibus
export XMODIFIERS=@im=ibus

如果这些变量都只是设置在 ~/.bashrc 里,那么从 i3 里启动的 wps 就用不了 ibus 输入法,因为 ~/.bashrc 是在终端启动时才默认执行。这些环境变量的设置应该在 i3 启动前被执行, ~/.profile 文件就是比较合适的,它在用户登录的时候马上就会被执行,比 i3 要更早。

因此一种做法是把这些变量设置都放在 bashrc 里,然后在 ~/.profile 里去执行 bashrc(这条语句在 ubuntu 的 .profile 里装机后就默认存在):

if [ -f "$HOME/.bashrc" ]; then
	. "$HOME/.bashrc"
fi

也可以把 i3 需要用到的变量单独放到 ~/.profile 里赋值,还可以对环境变量要求苛刻的软件(如 emacs )创建一个单独的脚本,包装一下:

export PATH=/usr/local/texlive/2023/bin/x86_64-linux:$PATH.
emacs

然后在 i3 里给该脚本的执行绑定一个按键,后文会有详细的例子。当然对于 emacs 也可以在 init.el 通过其自身的方式来设置环境变量。

按键设置

用 xmodmap 和 xcape 修改按键映射

i3wm 的一大特点是可以使用 bindsym 命令来绑定按键功能,大部分操作还是键盘驱动,而我又是 vim/evil 用户,为了减轻手指压力,个人使用 xmodmap 和 xcape 对键盘上部分按键映射进行修改。

假设当前没有进行任何按键修改,那么先用以下命令导出当前键盘的配置文件

xmodmap -pke > ~/metaesc/lib/code01.xmodmap

为了能够在需要的时候切换回到原始的按键布局,应该拷贝一份后再修改,比如称为 code01s1.xmodmap

cp ~/metaesc/lib/code01.xmodmap ~/metaesc/lib/code01s1.xmodmap

接着开始修改其中的按键,这基本是查找目标按键-复制-查找原按键-注释-粘贴-修改的过程,比如要把 Caps_Lock 修改成 Control_L 按键(左 ctrl),那么先搜索到 Control_L, 把那一行复制到 Caps_Lock 下,然后注释掉 Caps_Lock, 并将 Contrl_L 的 keycode 改成 Caps_Lock 的,如下

! keycode  66 = Caps_Lock NoSymbol Caps_Lock
keycode  66 = Control_L NoSymbol Control_L

除此之外,用 xcape 来对部分控制键(shift,super,ctrl)添加短按时触发按键信号的能力,使得一个按键可以有两种用途。这些功能封装在名为 code01s1.sh 脚本中:

#!/usr/bin/env bash
xmodmap ~/metaesc/lib/code01s1.xmodmap
sleep 0.2
killall xcape
sleep 0.2
xcape -e 'Alt_L=Escape;Control_L=Control_R|c;Shift_L=space;Super_L=Cancel;Super_R=Redo;' -t 240
  • Super_L=Cancel 表示短按 Super_L 相当于敲击 Cancel 键
  • Super_R=Redo 表示短按 Super_R 就等于 Redo

    Redo 和 Cancel 一般不在标准键盘上,但它们都有对应的 keycode, 也就是有相应的按键信号,因此可以拿来做其他用处,后文中会将这两个按键绑定到特定功能脚本上。

  • Control_L=Control_R|c 表示短按 Control_L 则相当于按 Control-C 组合键,注意等号右侧要用 Control_R 而不是 Control_C ,否则会陷入无限递归?

code01s1.xmodmap 核心修改如下:

! 先清除涉及到修改的控制按键,比如 control, alt, super, capslock
clear mod1
clear mod4
clear mod3
clear control
clear lock
clear shift

! 将空格备份到一个没有使用过的 keycode 上,因为后续会用 shift 覆盖空格按键
! 但之后又要用 xcape  shift 上重新恢复空格,因此需要备份
! keycode   8 =
keycode   8 = space NoSymbol space

! 把左 shift 改成右 super/win 按键,在 xcape 上追加 Redo
! keycode  50 = Shift_L NoSymbol Shift_L
keycode  50 = Super_L NoSymbol Super_L

! 将空格改为 Shift_L, xcape 上再对 Shift_L 追加空格
! keycode  65 = space NoSymbol space
keycode  65 = Shift_L NoSymbol Shift_L

!  cpaslock 改成 Control_L, 并在 xcape 上追加 Control_R  C 的组合
! keycode  66 = Caps_Lock NoSymbol Caps_Lock
keycode  66 = Control_L NoSymbol Control_L
! 由于没有备份 Caps_Lock, 因此目前 Capslock 没有对应任何按键信号(键盘上也没有对应按键)
! 如果需要的话,可以用类似备份空格的方式,找一个空的 keycode 绑定,或者覆盖键盘上不常用的按键

! 将右 ALT 改为 Super_R, 并在 xcape 上追加 Redo 按键,用于切换窗口
!keycode 108 = Alt_R Meta_R Alt_R Meta_R
keycode  108 = Super_R NoSymbol Super_R

! 对控制按键重置
add control = Control_L Control_R
add mod1 = Alt_L Alt_R
add mod3 = Hyper_R
add mod4 = Super_L Super_R
add shift = Shift_R Hyper_L
add lock = Caps_Lock

注意以上 keycode 对每个机器或键盘可能是不一样的,因此要依照自己的 xmodmap 修改。

完整的 xmodmap 文件参见 metaesc/lib/code01s1.xmodmap at main · metaescape/metaesc

xmodmap 的教程参考: xmodmap - ArchWiki

对该按键方案解释:

  • 左 ALT 短按是 ESC, 长按不变

    个人很喜欢这个修改,因为它不会影响 ALT 原始的功能,但可以用左手大拇指按 ESC, 这对于 vim/evil 用户非常友好,不需要伸长小指或无名指去按左上角的 ESC。由于 ALT 也称为 Meta, 因此干脆把自己的配置称为 metaesc: ubuntu 个人配置, github 帐号也叫做 metaescape, 这个词直译过来甚至有点禅意 – 元逸,解释成"元循环逃逸"?当然,这也像个法号。

  • 空格键长按是 Shift, 短按保持不变

    这可以减轻小拇指按 shift 的压力,使用任意一只手的大拇指长按空格来输入大写字母。 它是我第二喜欢的改键方案,因此部分网站里的用户名就叫做 spaceshift, 这个词直译过来也很有趣:空间转移/斗转星移。

  • capslock 长按改成 ctrl, 短按则改成 Ctrl-c.

    该策略的第一部分是比较经典的,被许多人推荐,这样左手小拇指按 ctrl 会变得很舒服。

    短按改成 ctrl-c 的原因是,一方面经常在 shell 里执行命令,要强制结束某些命令或程序,ctrl-c 是很常用的;另外 emacs org 里执行代码块也是按两次 ctrl-c , 而我经常在 emacs org 里写一些代码 demo, 因此直接小指按两次 capslock 即可 ( org 有个命令就叫做 org-ctrl-c-ctrl-c, 有很多功能,见 The Very Busy C-c C-c Key (The Org Manual))。结合以上两个改键策略,使得我的小拇指基本只会按 ctrl 和 tab (以及常规的 a,q,z 字母),这些都是在比较舒适的范围内。

  • 左 shift 改为 Super_L (Win_L), 短按则触发 Cancel 按键(之后用于切换输入法)
  • 右 alt 改为 Super_R (Win_R), 并且短按则触发 Redo 按键(之后用于快速切换窗口)

完整改键如下图:

keyboards-20220903232619-7657e.png

如果有需要,可以用 code01default.sh 恢复到默认按键布局:

#!/usr/bin/env bash
xmodmap ~/metaesc/lib/code01.xmodmap 
killall xcape

因此,目前有了 code01s1.shcode01default.sh 两个脚本,执行之后分别切换到 metaesc 布局和默认布局, 如果需要尝试更多新的按键设置,再新建一个类似脚本比如 code01s2.sh 以及对应 code01s2.xmodmap 文件即可。 后文将这两个脚本的执行通过 i3wm 的按键绑定功能设置快捷键,就可以随时切换不同键盘布局了。

i3 前缀键 (修饰键) 设置

i3 的操作一般都需要配合一个前缀按键,默认是 Alt (Mod1),这会和很多其他软件冲突,我使用的是 Win 键(Mod4),在 linux 中也称 super 键。用 set 将 Mod4 命名为 $super, 同理, Mod1 命名为 $alt 变量

#+name: set-modifier
set $super Mod4
set $alt Mod1

Mod4 和 Mod1 实际也只是一个名称,只是默认分别对应 super/win 和 alt 键,可以在 xmodmap 里修改(但没有必要):

clear mod1
clear mod4
clear mod3
add mod1 = Alt_L Alt_R
add mod3 = Hyper_R
add mod4 = Super_L Super_R

切换键盘布局的子菜单(binding mode)

接着设置 i3 启动后就执行 code01s1.sh 脚本进行改键

然后创建一个 i3 的 binding mode, 把它称为 $keymap-switch-mode, 用于切换按键布局。 i3wm 的 binding mode 类似于一般软件里的子菜单功能,通过一个按键(或鼠标点击)进入子菜单,然后在其中又可以通过快捷键(鼠标点击)执行某个操作。

比如以下设置表明,进入 $keymap-switch-mode 子菜单后,会在状态栏里显示一段文本提示: "which keymap to change? s1(1), 回到默认(0)" ,接着如果继续按 1 则设置成以上提到的按键方案,按 0 则回到默认布局:

#+name: set-keymap
exec_always --no-startup-id exec ~/metaesc/lib/code01s1.sh

set $keymap-switch-mode "which keymap to change? s1(1), 回到默认(0)"
mode $keymap-switch-mode {
    bindsym 0 exec ~/metaesc/lib/code01default.sh, mode default
    bindsym 1 exec ~/metaesc/lib/code01s1.sh, mode default
    bindsym Return mode default
    bindsym n mode default
    bindsym Escape mode default
}

可以添加以下绑定来进入 keymap-switch-mode 子菜单, 但由于我很少切换按键布局,因此没必要浪费一个全局的 super-k 按键。在后文中会继续添加一个称为 main-mode 的 binding-mode,以下命令实际不是直接在 i3config 里全局设置

bindsym $super+k mode $keymap-switch-mode

而是得加入到 $main-mode 的 scope 里绑定,示例为:

set $main-mode "欢迎进入 main 菜单"
mode $main-mode {
    bindsym $super+k mode $keymap-switch-mode
    # ...
}

bindsym $super+semicolon mode "$main_mode"

这样先通过快捷键 super+; 进入 main-mode 再按 super-k 才进入 $keymap-switch-mode 子菜单,后文会详细介绍。

emacs hydra 的类比

如果使用 emacs, i3wm 的 binding-mode 也类似于 emacs 中的 hydra, 可以将操作都放在一个 mode 里,同时在 mode 里能继续嵌套 mode,这使得按键分配呈树状,极大增加按键空间,同时也使得配置结构更为整齐,还可以加入提示界面。

但我后文都用子菜单来表示 binding-mode, 更直观

i3wm 的 reload,restart,quit

如果你刚安装好 i3wm, 然后修改了 ~/.config/i3/config, 要重新加载配置的话,可以用以下命令:

i3-msg "reload"

根据官方 i3: i3 User’s Guide 的说明,如果出现奇怪的问题,你也可以重启 i3wm

i3-msg "restart"

reload 更快,大部分情况用它就够了,就像修改了 ~/.bashrc 只需要重新执行 source ~/.bashrc, 但也可以用重启终端的方式来重新加载 ~/.bashrc. 个人遇到的需要 restart 的情况一般是右上角状态栏不显示某些图标(比如网络)或者某些窗口不显示的情况。

如果不想每次执行命令行,可以把重新加载相关的命令绑定到快捷键上

#+name: i3-reload-restart-quit
# reload the configuration file
bindsym r reload, mode "default"
# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)
bindsym Shift+r restart, mode "default"
bindsym $super+r restart, mode "default"

bindsym $super+q exec "i3-nagbar -t warning -m 'Exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'"

但我把以上代码放到后文介绍的 main-mode 菜单中,执行 super-; 后按 r 是重新加载。

workspace 和窗口常用操作

以下许多窗口相关的操作实现都会基于一些基础的窗口处理函数,例如获得当前 focus 的窗口的名称和id 、获取当前 workspace 编号、切换到目标窗口等函,我在 xwish 一文中介绍过这些基本函数,完整代码见 metaesc/lib/utils.sh at main · metaescape/metaesc

同一个 workspace 里窗口切换

大部分情况下,在一个 workspace 中我只开 2 个窗口,三个的情况都很少(上文图片右下角终端只是用来展示), 因此没必要用 super+方向键或者 vim 风格的 super+hjkl 来上下左右切换窗口,仅需一个按键循环切换即可,由于 i3 没有提供现成的方法,因此这里用 bash + wmctl 实现, 见 cycle windows in workspace

注意以下先调用 ~/metaesc/lib/restore_fullscreen.sh 检查是否全屏,如果是则取消全屏,然后再进行切换:

#+name: focus-next-window
bindsym Redo exec ~/metaesc/lib/restore_fullscreen.sh && exec ~/metaesc/lib/focus_next_win.sh

此外,这里绑定了改键一节提到的 Redo 按键,它实际对应的是原右 ALT 按键。

i3wm 有个 focus_follows_mouse 参数,默认情况就是 yes, 也就是光标放在哪个窗口,就聚焦到那个窗口,偶尔开超过 3 个窗口的情况用触控板或者鼠标切换就很方便了。

以下是 super+方向键切换窗口的设置,作为备用。

#+name: switch-window-in-workspace
bindsym $super+Left focus left
bindsym $super+Down focus down
bindsym $super+Up focus up
bindsym $super+Right focus right

workspace 名称设置

#+name: set-workspace-name
set $ws1 "1⚓"
set $ws2 "2⚕"
set $ws3 "3☯"
set $ws4 "4⚑"
set $ws5 "5"
set $ws6 "6"
set $ws7 "7office"
set $ws8 "8"
set $ws9 "9"
set $ws10 "zero"
set $wst "t"
set $wsy "y"
set $wsu "u"

由于上一节中避免用 super+hjkl 切换窗口,因此省出来了几个键盘中心的按键,如果需要的话,可以绑定到额外的 workspace 切换上:

set $wsh "h"
set $wsj "j"
set $wsk "k"

i3 的工作区(workspace)命名机制的奇怪设计

要注意的是,i3 的工作区(workspace)命名机制设计的很奇怪,一般来说,作为一个操作对象,每个 workspace 应该对应一个唯一的数字 id, 该 id 可以绑定到一个按键上,同时对应一个对外显示的工作区名称。但 i3 中,提供给用户的 workspace 核心标识是 name (其内部维护了地址类型的 id, 但用户不会去用这个 id 识别对应的 workspace,因为地址都是比较长的 16 进制串),而其数字编号是从 name 中用正则匹配出来的:

假设 i3config 里有以下两条语句:

set $ws1 "1:1⚓"
bindsym $super+1 workspace $ws1

$ws1 只是变量名,它在 i3 读取配置文件后会被展开成字符串,展开后变量名是不会被 i3 进程维护的,因此之后用命令 i3-msg "workspace $ws1" 是无效的。所以以上设置完全等价于

bindsym $super+1 workspace "1:1⚓"

这表明在按 super+1 按键时,相当于执行 i3msg 'workspace "1:1⚓"' 命令,它会切换到 name 等于 1:1⚓ 的工作区,一旦切换到该工作区,i3 就会对 name 进行解析,发现其前缀为 1, 因此会给它赋予一个值为 1 的 num 属性(默认都是 -1),可通过 workspace number 1 切换过去。

而如果改成以下形式:

bindsym $super+1 workspace number "1:1⚓"

那么按 super+1 后,i3 先正则匹配出前缀里包含的数字 1, 然后从当前已经打开的工作区中找到第一个 name 属性中前缀数字是 1 的工作区(这种工作区的 num 就是 1)。如果没有这样的工作区,那么会创建一个新的 name 等于 "1:1⚓" 的工作区,同时根据前缀数字将工作区的 num 属性设置为 1 。

这种机制导致,如果你在 i3config 中给 workspace 设置了 "1:1⚓" 这样带有 emoji 的名称,那么当想用脚本来临时创建一个同名的工作区并且在其中启动某些程序,那么必须去 i3config 里拷贝出这个完整的名称,写成 i3-msg "workspace 1:1⚓" 或者 i3-msg "workspace number 1:1⚓", 只有这样,用 super+1 才能正确切换到该空间。

如果写成 i3-msg "workspace number 1", 那么只有 1:1⚓ 空间已经存在时才会切换过去,否则就创建一个 name 和 num 都是 1 的空间,而:

  • 如果 super+1 绑定的是 workspace "1:1⚓", 那么按 super+1 就只会切换到 "1:1⚓" 空间,而不是当前新建的 name 为 1 的空间
  • 如果 super+1 绑定的是 workspace number "1:1⚓" ,那么按 super+1 会跳转到名称为 1 的空间,而这个空间名称里是不带 emoji 的。

要实现简单的以 num 为核心标识,并且让每个空间对应一个可以随时修改甚至动态变化的展示名称,还需要手动编写 workspace 的处理脚本,也就是 workspace_command (v4.23 以后),非常复杂,这是目前为止我觉得 i3 里最反直觉的设计。

为此,我直接避免在脚本里切换到带有复杂 unicode(比如以上的 ws1 到 ws4) 的工作区,而用 $wst, $wsy, $wsu 作为脚本里切换的对象,比如 i3-msg "workspace t"; code ~/codes/; typora ~/codes/README.md

workspace 切换和窗口移动到目标 workspace

通过 super 和数字切换窗口,通过 super+shift+数字将当前窗口移动到目标窗口

#+name: focus-workspace-with-super-number
bindsym $super+1 workspace $ws1
bindsym $super+2 workspace $ws2
bindsym $super+3 workspace $ws3
bindsym $super+4 workspace $ws4
bindsym $super+5 workspace $ws5
bindsym $super+6 workspace $ws6
bindsym $super+7 workspace $ws7
bindsym $super+8 workspace $ws8
bindsym $super+9 workspace $ws9
bindsym $super+0 workspace $ws10
bindsym $super+t workspace $wst
bindsym $super+y workspace $wsy
bindsym $super+u workspace $wsu

bindsym $super+Shift+1 move container to workspace $ws1
bindsym $super+Shift+2 move container to workspace $ws2
bindsym $super+Shift+3 move container to workspace $ws3
bindsym $super+Shift+4 move container to workspace $ws4
bindsym $super+Shift+5 move container to workspace $ws5
bindsym $super+Shift+6 move container to workspace $ws6
bindsym $super+Shift+7 move container to workspace $ws7
bindsym $super+Shift+8 move container to workspace $ws8
bindsym $super+Shift+9 move container to workspace $ws9
bindsym $super+Shift+0 move container to workspace $ws10
bindsym $super+Shift+t move container to workspace $wst
bindsym $super+Shift+y move container to workspace $wsy
bindsym $super+Shift+u move container to workspace $wsu

当前 workspace 与上一个 workspace 的切换,如果上一个 workspace 在另一个屏幕,这个操作也可以切换到其他屏幕。

#+name: switch-to-last-workspace
# last activate workspace
workspace_auto_back_and_forth yes
bindsym $alt+Tab workspace back_and_forth 

全屏

这个比较常用,因此是直接用全局 super+m 切换

#+name: toggle-fullscreen
bindsym $super+m fullscreen toggle

改变窗口大小

以下放在 main-mode 中绑定,用 super+; 进入 main-mode 后(持续)按 hjkl 就可以比较平滑地改变窗口长宽,另外设置数字 8 和 9 快速调整当前应用比例,8 是均匀大小,9 是左边宽右边窄

#+name: resize-window-with-hjkl
bindsym h resize shrink width 5 px
bindsym l resize grow width 5 px
bindsym j resize grow height 5 px
bindsym k resize shrink height 5 px  
bindsym 8 resize set 50 ppt 50 ppt
bindsym 9 resize set 62 ppt 38 ppt

交换窗口

以下需要放在 main-mode 中,比如用 super+; 进入 main-mode ,然后按 H 就可以把当前窗口和左侧窗口交换

#+name: swap-window-with-shift-hjkl
bindsym Shift+h move left
bindsym Shift+j move down
bindsym Shift+k move up
bindsym Shift+l move right

关闭窗口的子菜单

以下创建一个用于提示是否关闭窗口的子菜单,由于比较常用,直接指定一个快捷按键进入该菜单

#+name: close-window-mode
set $close "Close the window? (y/n)"
mode $close {
    bindsym y kill, mode default
    bindsym Return kill, mode default
    bindsym q kill, mode default
    bindsym n mode default
    bindsym Escape mode default
}
bindsym $super+q mode $close

最终效果是,按 super+q 后,状态栏显示 "Close the window? (y/n)", 按 y/return/q 都是确认关闭窗口,n 和 Esc 则取消。

修改工作区布局的子菜单

改变工作区布局的一个例子是左右分屏变成上下分屏,或者把分屏改成 tab 层叠样式。

这部分往往需要连续操作,例如要先 focus 到 workspace 的 root 结点,然后将 split 模式改为上下而不是左右,接着打开一个 terminal 等,因此放在一个子菜单中处理,另外切换布局也比较少用的,类似按键切换菜单, $layout_mode 也放在 main-mode 里激活。

#+name: window-layout-mode
set $splith-note zenity --notification --window-icon="info" --text=" | split" --timeout=1
set $splitv-note zenity --notification --window-icon="info" --text="-- split" --timeout=1

set $layout_mode "container layout stack[s] tabbed[t] other split[o] parent[p] child[c]"
mode $layout_mode {
  # change container layout (stacked, tabbed, toggle split)
  bindsym s layout stacking
  bindsym t layout tabbed
  # bindsym o layout toggle split
  bindsym o layout toggle

  bindsym p focus parent
  bindsym c focus child

  # 用 \ 切换左右布局
  bindsym backslash split h, exec $splith-note
  # 用 - 切换为上下布局
  bindsym minus split v, exec $splitv-note
  bindsym Escape mode default
  bindsym Return mode "default"
}

以上用到了 zenity 命令来在修改布局后发送提示信息,关于 zenity 和通知系统相关设置,后文会有介绍。

鼠标与 float 窗口

floating_modifier 设置为 super 后,按住 super 键后,可以用鼠标拖动悬浮窗口。

此外用 Menu 按键对窗口进行悬浮或平铺的切换(实际很少用),并且限制 float 窗口的最大尺寸,因为有些应用默认 尺寸 float 之后尺寸太大(比如 chrome),看上去仍然像平铺一样。

#+name: float-window-setting
floating_modifier $super
# toggle tiling / floating
bindsym Menu floating toggle
for_window [title="^EmacsAnywhere$"] floating enable
floating_maximum_size 1200 x 900

title 需要通过在终端执行 xprop 然后点击对应的 window, 在显示信息中查找 _NET_WM_NAME 变量对应的名称,如果没有该变量,则匹配 WM_NAME 变量里的窗口名称

通知系统

通知系统机制比我直观上所认为的复杂的多,尽管最初想要的只是发一个消息到屏幕上而已,但网络搜索到的相关信息却非常杂乱, 似乎有几十种解决方案,但又没有一个能简单地说清楚怎么把简单一句话发到指定 x,y 坐标或 center/left 之类的屏幕位置上(也许现在更多资料了)。

原因在于一个完整的消息系统实际涉及多个子模块,包括发送消息的前端,接收和处理消息的服务,显示消息的窗口等等。 而之所以要这么多模块,是因为广义上的 "消息" 或 "通知" 远比一行 "字符串" 要复杂,比如内容上要支持文本、图片、声音不同格式,如果不同格式消息展示的形式要统一,那么来源于不同程序的消息就要发给同一个服务,让它来统筹这些消息,然后将消息按优先级等属性排好序,对不同优先级给予不同的样式,最后才是调出一个窗口来展示这些样式。 再例如,收到邮件弹出通知框后,点击它可能要展开成个更大一点的邮件列表窗口,再点击列表里条目则出现一个预览图,继续双击则打开邮件客户端阅读完整内容,因此消息还涉及一套交互逻辑。

以上是从通用角度来看的,但既然要自己定制化,那么可以换一个视角,从最简单的开始想:

例如可以对屏幕亮度变化进行编码,闪一次表示任务完成,闪两次说明后台进程异常退出了, 那么这时你只要写一个 bash 脚本去调用屏幕亮度管理程序即可(不过也得查清楚自己机器的屏幕是通过哪个接口控制的), 同样还可以编码声音,如果你喜欢莫尔斯码,那么只要找一个能够发出滴和嗒的声音的程序,同自己约定了一套编码体系,就可以用两个声音来发送任何消息了。可以发挥任何想象力去实践,毕竟只要有差异,就可以编码了。 我以前尝试过把消息写在图片里,然后通过更换壁纸的方式把消息展示到桌面上、把消息放在 i3 的 i3bar 里像弹幕一样滚动、 用当前窗口的透明度的各种渐变方式来编码等等,但这些方式几乎在实现后就被立即否定了,最终我才知道自己只需要一个颜色样式和当前桌面主题一致的能够展示少量 unicode 字符串的通知的功能。

通知中的 hello world: osd_cat

osd_cat 这是命令行里 cat 或者 echo 的延伸,全称是 on screen display concatenate (files), echo 是把消息打印在终端, cat 是把文本文件内容读取(concatenate)并打印到终端, osd_cat 则读取文本文件内容并打印在屏幕的任何指定角落,它只适合 X11 ,由于功能过于简单,因此不需要一个消息服务进程来调度。

如果只需要发送字符串信息,做到极简,可以考虑 osd_cat。

# 注意安装包的名称叫做 xosd-bin, 安装后会有 osd_cat 这个二进制可执行程序
sudo apt install xosd-bin
# 将 hello world 发送到屏幕,默认是在屏幕左上角,红色,字体很小,持续大概 5s 钟
echo "hello world" | osd_cat 

可以指定基本的样式:

# -p 指定 position, 但实际是垂直方向的位置,只有 top, middle, bottom 三个选项, -o 是纵向 offset
# -A 指定 Alignment, 但实际是水平方向的位置,只有 left, center, right 三个选项
# -c 是颜色, -s 是阴影大小(即复制相同的一份文字),内容里可以用 \n 分隔行(需要 echo -e)
# -f 是字体,通过 xlsfonts 查看, -d 是 delay
echo -e "code rsync\ndone" | osd_cat -p top -o 50 -A center -d 2 -c "#6272A4" -f "7x14bold"

notify-send 以及 dunst 服务

osd_cat 可以满足文本的发送,但如果有更多的要求,例如和系统主题保持一致的 ui 、图标等,就需要更多。

notify-send 是 ubuntu 预装的一个命令,它的基本用法和 osd_cat 类似,比如 notify-send "message" 就是把 字符串发出去,但它发送的目标位置不是屏幕,而是先发给消息服务进程,然后由消息服务进程根据预定的样式去联系窗口管理服务(如 X11)把消息框绘制显示出来。

  • 如果使用默认 gnome 桌面,notify-send 先把消息发给 gnome 的消息服务进程。
  • 使用 i3wm 后,gnome 的消息服务进程一般不会启动, 需要其他的消息服务来转接这个信息,以下使用 dunst, 其配置文件是 ~/.config/dunst/dunstrc

配置中包含有不同的 section, 以 key = value 的形式展示,可以对不同类型和等级的信息设置样式。

[global]
# 全局的配置
[urgency_low]
# 低等级消息发送,通过 notify-send -u low "message" 来发送

[urgency_normal]
...
# 这些不同的 section 可以覆盖 global, 因此发送不同样式的消息

[urgency_critical]
...

完整配置为:

[global]
monitor = 0
follow = mouse
# auto adjust
# geometry = "0x0-20+10" # upper right corner
# geometry = "0x0+920+0" # upper middle
geometry = "0x0-20-40" # bottom right
indicate_hidden = yes
frame_color = "#aaaaaa"
shrink = no
separator_height = 0
padding = 8
horizontal_padding = 24
frame_width = 2
sort = no
idle_threshold = 120
font = Noto Sans 8
line_height = 4
markup = full
format = "<b>%s</b>\n%b"

# left right
alignment = center

show_age_threshold = 60
word_wrap = yes
ignore_newline = no

# stack together notificaiton with the same content
stack_duplicates = true
hide_duplicate_count = yes

show_indicators = no

icon_position = left
icon_path = /usr/share/icons/ubuntu-mono-dark/status/16/

sticky_history = yes
history_length = 20
browser = /usr/bin/firefox -new-tab
always_run_script = true
title = Dunst
class = Dunst

corner_radius = 5

[shortcuts]
close = ctrl+space
close_all = ctrl+shift+space
history = ctrl+grave
context = ctrl+shift+period

[urgency_low]
background = "#2f343f"
foreground = "#d8dee8"
timeout = 2

[urgency_normal]
background = "#2f343f"
foreground = "#d8dee8"
timeout = 4

[urgency_critical]
background = "#2f343f"
foreground = "#d8dee8"
frame_color = "#bf616a"
timeout = 2

这基本拷贝自 addy-dclxvi/i3-starterpack: A simple guide (and example of configuration) to install i3 & its and essentials packages, then make them look eye candy. (之后的 i3bar 也是用的该库提供的配置)

修改 ~/.config/dunst/dunstrc 后要重新加载配置的话,需要执行 killall dunst, 然后再用 zentiy 或者 notify-send 发送一个消息即可重新激活 dunst 。

dunst 的不足点

  • 只有三个 urgency 等级,也不能自定义新的等级或消息类别
  • 通知窗口的位置(geometry)以及核心 UI 是全局设置的,不能针对不同类型消息设定不同的位置。
  • 如果对某些类型的消息要用不同 UI 的话,需要准备不同的 dunstrc 配置, 发送消息前重启 dunstrc 服务,然后调用 notify-send, 这很麻烦。

这是在使用中偶然想到的需求,但实际发现自己并不在意消息的各种样式(只要和系统颜色统一,不是太突兀),比如我从来没有区分不同 urgency 等级,因此核心关注点还是消息的内容,所以这些不足也不是问题。

zenity: 支持用户输入框

zenity 是一个独立的工具,它可以不依赖消息服务直接和绘图相关的组件沟通从而把消息框画出来,而这个框还可以交互,例如弹出输入框接收密码,这适用于用按键绑定那些需要 sudo 才能执行的命令。从该角度看,它有点类似 osd_cat 的交互版本。

但 zentiy 也可以和消息服务 dunstrc 沟通,比如 zenity --notification --text="WER" 命令和 notify-send 效果是一样的,它会先把消息发给消息服务,然后由消息服务和绘图组件沟通把消息显示

zenity 在后文亮度调节中使用到。如果本地没有,可通过 sudo apt install zenity 安装

Fn 键调节系统设置

这部分需要根据机器的硬件配置去查找设置方案,本人使用的是机械革命 code01 2020 款笔记本,采用的是 amd 集成显卡,因此后文设置亮度的地方会看到包含 amdgpu_bl0 的文件。

声音调节

以下是安装 i3 默认配置里音量调节的代码,其中 XF86AudioRaiseVolume 对应的是 Fn+F10 (按键上显示了音量加符号) 不需要修改按 Fn+ 对应图片的 F 键就可以调节声音。

# Use pactl to adjust volume in PulseAudio.
set $refresh_i3status killall -SIGUSR1 i3status
bindsym XF86AudioRaiseVolume exec --no-startup-id pactl set-sink-volume @DEFAULT_SINK@ +10% && $refresh_i3status
bindsym XF86AudioLowerVolume exec --no-startup-id pactl set-sink-volume @DEFAULT_SINK@ -10% && $refresh_i3status
bindsym XF86AudioMute exec --no-startup-id pactl set-sink-mute @DEFAULT_SINK@ toggle && $refresh_i3status
bindsym XF86AudioMicMute exec --no-startup-id pactl set-source-mute @DEFAULT_SOURCE@ toggle && $refresh_i3status

以上绑定了四个按键, raise, lower, mute, micMute, 其中有两个 mute 按键,但我的键盘上只有一个 mute 键(应该大部分键盘也只有一个 Mute 按钮),对应 fn+f8, xmodmap 里的 keycode 如下,确实存在两个关于 Mute 的按键信号,也许是插上麦克风或耳机后按 Fn-F8 会发送 keycode 198, 拔掉之后则发送 keycode 121, 总之它是能用的,和 chatgpt 也没讨论出具体结果,不再深究。

keycode 121 = XF86AudioMute NoSymbol XF86AudioMute
keycode 198 = XF86AudioMicMute NoSymbol XF86AudioMicMute

基于以上代码做一点定制,使得在执行后可以通过 notify-send 实时显示出音量变化(我没有把音量显示在顶层的 i3bar 上,因此希望动态调节时显示音量)

#+name: fn-audio-control
set $audio_note ~/metaesc/lib/show_audio.sh
bindsym XF86AudioRaiseVolume exec --no-startup-id pactl set-sink-volume @DEFAULT_SINK@ +10% && $audio_note
bindsym XF86AudioLowerVolume exec --no-startup-id pactl set-sink-volume @DEFAULT_SINK@ -10% && $audio_note
bindsym XF86AudioMute exec --no-startup-id pactl set-sink-mute @DEFAULT_SINK@ toggle && $audio_note
bindsym XF86AudioMicMute exec --no-startup-id pactl set-source-mute @DEFAULT_SOURCE@ toggle && $audio_note

show_audio.sh 脚本如下:

#!/usr/bin/env bash
audio=$(pactl list sinks | grep -e '^[[:space:]]Volume:' | tr -sc '0-9%' '\n' | grep % -m 1 2>/dev/null)
mute=$(pactl list sinks | grep 'Mute: yes' 2>/dev/null)
if [ -n "$mute" ];then
    notify-send -t 500 "🎶 Mute"
else
    notify-send -t 500 "🎶 $audio"
fi

亮度调节

亮度控制和电脑硬件有关,需要专门查询,个人用的机械革命 code01 是从 /sys/class/backlight/amdgpu_bl0/brightness 读取亮度值,也是通过修改这个文件里的值来改变亮度。

#+name: fn-bright-control
bindsym XF86MonBrightnessUp exec bash ~/metaesc/lib/brightness_code01.sh 20
bindsym XF86MonBrightnessDown exec bash ~/metaesc/lib/brightness_code01.sh -20

XF86MonBrightnessUp, XF86MonBrightnessDown 在 code01 上分别是 Fn+F11 和 Fn+F12, 它们的 xmodmap 对应:

keycode 232 = XF86MonBrightnessDown NoSymbol XF86MonBrightnessDown
keycode 233 = XF86MonBrightnessUp NoSymbol XF86MonBrightnessUp
#!/usr/bin/env bash
# Description: Adjusts the screen brightness on systems with AMD GPUs by modifying system files.
# It takes a single argument specifying the amount to change the brightness (positive or negative).
# If no argument is provided, the default change is set to 0.
#
# Usage: ./set_brightness.sh <change_in_brightness>
var1=$1
[ -z "$var1" ] && var1="0"

brightness_file=/sys/class/backlight/amdgpu_bl0/brightness
mod=$(stat -c "%a" $brightness_file)
if [ $mod -ne 646 ]; then
    PASSWD="$(zenity --password --title=Authentication)"
    echo -e $PASSWD | sudo -S chmod 646 $brightness_file
fi
var1=$(($var1 + $(cat $brightness_file)))
[ $var -gt 255 ] && var1=255 #max brightness is 255
echo $var1 > $brightness_file
notify-send -t 500 "🌟 $var1"
# # 亮度配置:2 ends here

这个脚本一大部分是为了解决 /sys/class/backlight/amdgpu_bl0/brightness 的权限问题,该文件默认权限是 644 ,写入内容是需要 sudo 权限的,因此先检查权限是否为 644, 如果是就改为 646, 因此每次重启电脑后第一次调节亮度的时候需要调用 zenity 来询问并获取 sudo 密码,之后就不再需要了。

sleep and lock

默认情况下 fn+f1 是 sleep 功能:

keycode 150 = XF86Sleep NoSymbol XF86Sleep

没有进行任何配置,按 Fn+F1, code01 就可以能够执行休眠,接着按任意按键唤醒,因此无需额外配置,但这个也是硬件相关,有些机器或系统可能就需要进一步设置。

此外,对于关机、logout 等功能,在后文中有用 dmenu 脚本形式来选择。

开机启动软件和服务

指定 app 到 workspace, 指定 workspace 到屏幕

通过 xprop 找到信息:

  • _NET_WM_NAME 对应的是 title
  • WM_CLASS 的第二个元素对应的是 class

例如 WM_CLASS(STRING) = "irssi", "URxvt", 那么 class 是 URxvt

#+name: assign-app-to-workspace
assign [class="Wpsoffice"] $ws7
assign [title="EasyConnect"] $ws10
assign [class="EasyConnect"] $ws10
assign [class="Zotero"] $ws10 

(备用)两个显示器的话,可以指定 workspace 到特定的显示器 (在只有一个显示器时,不影响功能)

#+name: assign-workspace-to-monitor
workspace $ws1 output primary
workspace $ws2 output primary
workspace $ws3 output primary
# workspace $ws4 output VGA-1-1

不同显示器之间切换(主要适用于左右两个屏幕)

#+name: switch-to-last-monitor
bindsym $super+Tab focus output right

网络相关软件启动

nm-applet 是 NetworkManager (ubuntu 自带的)的一个图形界面前端,启动后默认会在右上角状态栏显示一个可点击的图标,用于选择 WIFI 名称等。

用 for_window 的方式指定特定 workspace 的 layout.

#+name: network-manager
# NetworkManager is the most popular way to manage wireless networks on Linux,
# and nm-applet is a desktop environment-independent system tray GUI for it.
exec --no-startup-id nm-applet

for_window [workspace=$ws10] layout tabbed
exec --no-startup-id ~/.local/Clash/cfw
exec --no-startup-id /usr/share/sangfor/EasyConnect/EasyConnect

emacs 和 chrome 的启动

#+name: emacs-chrome-initialize
exec --no-startup-id sleep 1; i3-msg "workspace $ws1"; urxvt --title "main-tmux"
exec --no-startup-id sleep 2; i3-msg "workspace $ws2"; ~/metaesc/lib/open_emacs 'default' 'emacs-jkl'
exec --no-startup-id sleep 2; google-chrome --new-window \
                                            https://chat.openai.com/ \
                                            https://gemini.google.com/ \
                                            https://www.chatpdf.com/ \
                                            https://filehelper.weixin.qq.com/ \
                                            https://weread.qq.com/web/ \

open_emacs 是对 emacs 启动命令的包装,用于手动添加 emacs 需要的环境变量、设置 emacs 窗口的名称(i3 可以通过这个名称来识别需要特殊对待的 emacs 窗口,尽管当前没什么作用)、还可以启动不同配置目录下的 emacs (早期尝试不同包的时候有用到,稳定之后就基本只用一个配置了)

#!/usr/bin/env bash
# 打开不同 配置 的 emacs 方式, 但基本只用默认的

export PATH=$HOME/miniconda3/envs/usr/bin:$PATH
version=${version:-29.2}
em=~/codes/emacs-${version}/src/emacs
emc=~/codes/emacs-${version}/lib-src/emacsclient
if [ "$1" == 'default' ]; then
    if [ ! -z $2 ]; then
        $em --name "$2"
    else
        $em
    fi
	#systemctl restart --user emacsanywhere.service
elif [ "$1" == 'client' ]; then
    $emc -c
elif [ "$1" == 'test' ]; then
	$em --init-directory ~/.wemacs_test/
else 
	$em
fi

电量和时间提醒

#+name: clock-notify
exec_always --no-startup-id ~/metaesc/lib/clock_notify.sh

这是一个自己写的脚本,基本是一个 for 循环,通过 notify-send, 当电量低于 50% 时每 30s 发通知,同时每 10 分钟报时(主要是全屏的时候也能弹出时间提醒)。

ps aux | grep clock_notify.sh | grep -v "grep\|vim\|$$" | awk '{ print $2 }' | xargs kill 2>/dev/null

while true; do
    time=$(date +"%R")
    minute=$(date +"%M")
    power=$(upower -i $(upower -e | grep BAT) | grep percentage | tr -s " " | cut -f 3 -d" ")
    power_value=$(echo $power | cut -d "%" -f1)
    if [ $power_value -lt 50 ]; then
        notify-send -t 2000 "电量低: $power"
    fi

    if [ $((10#$minute % 10)) == 0 ]; then
        notify-send --icon=user-idle-panel -t 5000 "⌚$time"
    fi
    sleep 30
done

显示器和壁纸

透明、壁纸与状态栏

sudo apt install compton #透明效果
sudo apt install feh #壁纸
#+name: set-monitor-and-wallpaper
exec ~/metaesc/lib/monitor.sh
exec_always --no-startup-id compton --conf ~/.config/compton.conf -b
exec_always --no-startup-id ~/metaesc/lib/wallpaper.sh

monitor.sh 脚本是通过 arandr 软件生成的:

sudo apt install arandr

安装好后,执行 arandr, 用鼠标拖放设置好显示器后,点击其中一个类似下载符号的按钮,保存自动生成的 bash 脚本为 mointor.sh,如果有外接显示器则更需要这种方式来设置。

wallpaper.sh 也是一个 while 循环写的服务,每 10 分钟调用一次 feh –bg-scale xxx.png –bg-fill 切换桌面背景

#!/usr/bin/env bash

wallpapers=(/data/resource/pictures/wallpaper-dark/*)
count=$(ls -1q  /data/resource/pictures/wallpaper-dark | wc -l)
ps aux | grep wallpaper.sh | grep -v "grep\|vim\|$$" | awk '{ print $2 }' | xargs kill -9
i=$(($RANDOM % $count))
while true
do
	feh --bg-scale ${wallpapers[$i]} --bg-fill
	i=$(( (i + 1) % $count))
    sleep 600
done

输入法启动与切换

#+name: ibus-rime
exec --no-startup-id ibus-daemon --xim -d -r
bindsym Cancel --release exec exec ~/metaesc/lib/switch_input_method.sh

switch_input_method.sh 主要用来统一 emacs 里输入法切换和系统输入法切换:

  • 如果当前窗口是 emacs,发送 alt+plus 按键,这个按键在 emacs 里绑定成切换 emacs-rime 输入法
  • 否则直接调用 ibus 命令关闭或打开中文输入法

Cancel 是短按左 Super 键所发出的,而这已经被改到了左 Shift 的位置,因此不管在 emacs 内外,都是通过左 shift 切换输入法。

focus_win=$(xprop -id $(xdotool getactivewindow) | grep WM_CLASS | cut -d'"' -f4)
 if [[ $focus_win =~ 'Emacs' ]]; then
	 # emacsclient --eval 切换不到
	 # xdotool key --clearmodifiers alt+plus #switch in emacs
	 xdotool key --clearmodifiers ctrl+backslash #switch in emacs
 else
	current_engine=$(ibus engine)
	# Check if the current engine is rime
	if [ "$current_engine" == "rime" ]; then
		# Set the IBus engine to xkb:us::eng
		ibus engine xkb:us::eng
	else
		# Set the IBus engine to rime
		ibus engine rime
	fi
fi

如果是基于 fcitx 的输入法(比如搜狗、googlepinyin),启动方式为:

exec --no-startup-id fcitx &

UI 和 i3bar 相关设置

字体和 border 样式

i3 配置中默认的字体配置为:

# Font for window titles. Will also be used by the bar unless a different font
# is used in the bar {} block below.

# This font is widely installed, provides lots of unicode glyphs, right-to-left
# text rendering and scalability on retina/hidpi displays (thanks to pango).
font pango:DejaVu Sans Mono 8

pango 是一个处理字体的库,通过 fc-list 查看列表,比如部分结果如下

~/.fonts/noto/NotoSansMono-CondensedExtraBold.ttf: Noto Sans Mono,Noto Sans Mono Cond ExtBd:style=Condensed ExtraBold,Regular
~/.fonts/noto/NotoSansNewTaiLue-Regular.ttf: Noto Sans New Tai Lue:style=Regular
~/.fonts/noto/NotoSerifKhmer-SemiCondensedThin.ttf: Noto Serif Khmer,Noto Serif Khmer SemCond Thin:style=SemiCondensed Thin,Regular

找出其中 style 前面的那部分名字和 sytle 拼接,比如 Noto Sans Mono,Noto Sans Mono Cond ExtBd:style=Condensed ExtraBold,Regular 可以写出两种字体

Noto Sans Mono Cond ExtBd Condensed ExtraBold
Noto Sans Mono Cond ExtBd Regular #  Noto Sans Mono Cond ExtBd 
#+name: set-font-border
font pango:Noto Sans CJK Mono SC 9

for_window [class=".*"] border pixel 1

主题颜色是从 addy-dclxvi/i3-starterpack 里拷贝,但把 focused 窗口的边框颜色(child_border)修改成了接近 dracula 主题里的紫蓝色 #6272A4 (第一行最后一个)。

以下各种颜色中,视觉上显著的基本只有 client.focused 的 child_border (第一行最后一个), 它是真正的窗口边框颜色。 而 border 值其实是窗口标题栏(titlebar)的边框颜色,但 i3 默认隐藏 titlebar 。

#+name: addy-theme
# class                 border  backgr. text    indicator child_border
client.focused          #2f343f #2f343f #d8dee8 #bf616a   #6272A4
client.focused_inactive #2f343f #2f343f #d8dee8 #2f343f   #2f343f
client.unfocused        #2f343f #2f343f #d8dee8 #2f343f   #2f343f
client.urgent           #2f343f #2f343f #d8dee8 #2f343f   #2f343f
client.placeholder      #2f343f #2f343f #d8dee8 #2f343f   #2f343f
client.background       #2f343f

i3bar and i3status(addy)

仍然是基于 addy-dclxvi/i3-starterpack 中提供的 bar 和 i3status 设置(dunst ,border, bar, i3status 都是都是有关视觉效果的,需要比较统一的主题颜色,除非想自己设计主题,否则最好基于其他人提供的一套配置做小的修改):

#+name: addy-bar
bar {
    id bar-0
    # output primary
    # mode hide
    hidden_state hide
    modifier none
    # strip_workspace_numbers yes
    # binding_mode_indicator yes
    # tray_output primary
    position top
    colors {
        background #2f343f
        statusline #2f343f
        separator #4b5262

        # colour of            border  backgr.  text
        focused_workspace      #2f343f #bf616a #d8dee8
        active_workspace       #2f343f #2f343f #d8dee8
        inactive_workspace     #2f343f #2f343f #d8dee8
        urgent_workspace       #2f343f #ebcb8b #2f343f
    }
    # i3bar_command i3bar --transparency
    status_command i3status
}

这里大致解释一下 bar 和 i3status 是什么,以及他们之间的关系(可能有偏差)。

bar 就是一般桌面里的状态栏,默认情况下 i3wm 是放在屏幕下方,position top 可以把它调整到上方。

当 i3 启动并解析以上配置时,发现这里有名为 bar 的区域,于是去找其中的 i3bar_command 选项,发现是 i3bar --transparency (如果没有,那么默认执行 i3bar), 那么执行该命令启动 i3bar 进程(它是安装 i3wm 时附带的)。因此 i3bar 是独立的,如果不想用它,可以执行其他绘制状态栏的命令,但我并没有尝试过其他 bar 。

i3bar 启动时候会把以上 bar 配置里的颜色、位置等信息都读取并根据这些样式绘制出一个状态栏(i3bar 和 i3wm 共享一个 config, 但 i3bar 只关注其中 bar 区域的配置),同时它也会去找 bar 中 status_command 选项对应的命令,以上是 i3status, 于是启动 i3status 进程(也是 i3wm 自带的),然后把该进程打印出的标准输出都显示在状态栏的右侧。

也就是说,如果追求精简,完全可以自己写一个 bash 脚本来显示自己想看到的信息,比如:

while true; do
	time=$(date +"%R")
	day=$(date +"%b%d")
	echo "⌚$time$day "
	sleep 2
done

如果它叫做 simple_bar.sh, 那可以用 status_command /path/to/simple_bar.sh 来执行,它只把时间显示出来,并且每 2 秒更新一次。

但如果要显示电量,cpu 占用率等等,就需要自己去找读取这些信息的命令,并且对于颜色等还要专门编写特殊的控制字符去注明,会变得很麻烦。 i3status 就是把这种底层原始的过程式获取信息方式用声明式语法封装了。完整的 i3status 配置放在 ~/.config/i3status/config, 如下

general {
        output_format = "i3bar"
        colors = false
        markup = pango
        interval = 5
        color_good = '#2f343f'
		color_degraded = '#ebcb8b'
		color_bad = '#ba5e57'
}

order += "tztime local"
# order += "memory"
order += "load"
order += "disk /home"
order += "cpu_temperature 0"
order += "battery 0"

#order += "disk /"
#order += "ethernet enp1s0"
#order += "wireless wlp2s0"
# order += "volume master"


load {
        format = "<span background='#f59335'> %5min Load </span>"
}

cpu_temperature 0 {
        format = "<span background='#bf616a'> 🌡 %degrees °C </span>"
        path = "/sys/class/thermal/thermal_zone0/temp"
}

disk "/" {
        format = "<span background='#fec7cd'>  %free Free </span>"
}

disk "/home" {
        format = "<span background='#a1d569'> %free 🐏 </span>"
}

ethernet enp1s0 {
        format_up = "<span background='#88c0d0'> %ip </span>"
        format_down = "<span background='#88c0d0'> Disconnected </span>"
}

wireless wlp2s0 {
        format_up = "<span background='#b48ead'>  %essid </span>"
        format_down = "<span background='#b48ead'>  Disconnected </span>"
}

volume master {
        format = "<span background='#ebcb8b'>  %volume </span>"
        format_muted = "<span background='#ebcb8b'>  Muted </span>"
        device = "default"
        mixer = "Master"
        mixer_idx = 0
}

memory {
        format = "<span background='#88c0d0'> %free 💾 </span>"
		#format = "<span background='#88c0d0'> 💾%used/%free </span>"
        threshold_degraded = "10%"
        format_degraded = "MEMORY: %free"
}

battery 0 {
	last_full_capacity = true
	integer_battery_capacity = true
        format = "<span background='#a3be8c'>  %status %percentage </span>"
        format_down = "No Battery"
        status_chr = "⚡"
        status_bat = "🔋"
        status_unk = "? UNK"
        status_full = "☻ FULL"
        path = "/sys/class/power_supply/BAT%d/uevent"
        low_threshold = 10
}

tztime local {
		format = "<span background='#81a1c1'> %time </span>"
		format_time = "%b%d 🧘🏻‍♂️ %a %H:%M"
}

这里各类特定信息用 block 形式封装,比如以下是 cpu 温度信息,意思是,从 path 文件去读取这个信息,然后以 format 形式展示出来。

cpu_temperature 0 {
        format = "<span background='#bf616a'> 🌡 %degrees °C </span>"
        path = "/sys/class/thermal/thermal_zone0/temp"
}

一些常见的信息则不需要指定获取路径或者命令,比如以下硬盘剩余空间, i3status 默认有一套读取常见信息的机制,因此只需要修改样式。

disk "/" {
        format = "<span background='#fec7cd'>  %free Free </span>"
}

最后这些信息通过 order 变量拼接起来,可以根据自己的偏好进行增删,比如我把以下网络和音量的显示都注释了,因为 nm-applet 就会显示网络图标,而音量调节时用 notify-send 显示通知了,如果要查看音量,用 Fn+音量调节一次就可以显示,因此不太需要长期地占用一个格子。

#order += "ethernet enp1s0"
#order += "wireless wlp2s0"
# order += "volume master"

总的来说,i3bar 就是一个特殊的长条形的消息窗口,通过 status_command 里的命令(如 i3status) 来接受消息,并持续显示在其右侧。bar 的左侧是 workspace 的名称展示,前文说到过,workspace 的 display name 机制设计的很奇怪,作者也意识到这个问题,于是在 v4.23 版本后在 bar 里又引入了 workspace_command 选项,可以用类似 i3status 的方式写一套规则动态地修改 workspace 的展示名称,但初步看来还是比较复杂,没有深究。

透明调节

#+name: transset-up-down
bindsym $super+equal exec /usr/bin/transset --dec 0.1 -a 
bindsym $super+minus exec /usr/bin/transset --inc 0.1 -a 

由于不是很常用,放在 main-mode 子菜单下。

常用命令的按键绑定

终端启动

默认一般是通过 bindsym $super+Return exec i3-sensible-terminal 来启动 terminal, 但这无法控制启动的 layout , 个人使用方式如下(rxvt-unicode 作为终端)

#+name: open-split-termial
bindsym $super+backslash split h; exec urxvt
bindsym $super+minus split v; exec urxvt

这样按 super+\ 就是在右边新建终端 super+- 是在下方,这与之前提到的修改布局 mode 里按键是统一的。

在子菜单 main-mode 中快速启动

以下快速启动 app 的按键绑定设置放在 main-mode 中

#+name: quick-start-apps
bindsym Shift+e exec ~/metaesc/lib/open_emacs 'default', mode "default"
bindsym e exec ~/metaesc/lib/open_emacs "client", mode "default"
bindsym $super+p exec ~/metaesc/lib/dmkill, mode "default"
bindsym $super+o exec ~/metaesc/lib/dmlogout, mode "default"
bindsym $super+g exec google-chrome, mode "default"
bindsym $super+f exec nautilus, mode "default"
# start dmenu with dacula theme
bindsym $super+b exec "dmenu_run -nf '#F8F8F2' -nb '#282A36' -sb '#6272A4' -sf '#F8F8F2' -fn 'monospace-10' -p 'dmenu%'", mode "default"
# for desktop
bindsym $super+d exec "i3-dmenu-desktop", mode "default"

dmkill 和 dmlogout 两个脚本是从 https://www.gitlab.com/dwt1/dmscripts 获取的,分别是通过 dmenu 下拉菜单来选择并 kill 进程以及执行关机,重启等命令。

dmenu_run 会找出 PATH 环境变量中所有可执行文件(binary),i3-dmenu-desktop 则找出 /usr/share/applications/~/.local/share/applications/ 目录下的 .desktop 文件,具体可通过 man 命令查看详情。

main-mode 菜单

终于到了 main-mode 子菜单的设置, 对于那些不是非常频繁但也常用的按键和子菜单激活,都放在 main-mode 中,这样可以节省很多 super+ 按键

#+name: i3-main-mode
set $main_mode pkill[s-p] logout[s-o] emacs[e] swap/resize[[S]-hjkl,8,9] keymaps[s-k]
mode "$main_mode" {

    <<switch-window-in-workspace>>

    <<i3-reload-restart-quit>>

    <<resize-window-with-hjkl>>

    <<swap-window-with-shift-hjkl>>

    bindsym $super+l mode $layout_mode
    bindsym $super+k mode $keymap-switch-mode

    # 绑定 t 直接跳转到 test
    bindsym t workspace $ws1 exec tmux select-window -t 0:9, mode "default"

    <<quick-start-apps>>

    <<transset-up-down>>

    bindsym Escape mode "default"
    bindsym Return mode "default"
    bindsym q mode "default"

}

bindsym $super+semicolon mode "$main_mode"

也就是,按 super+; 后会进入一个选择菜单,继续按 E 启动 Emacs, 按 super-l 进入 layout 调整模式, super-k 进入键盘布局设置模式等等

<<switch-window-in-workspace>> 是 org babel noweb 的引用形式,在最终配置中,这一行会被之前定义的 #+name 为 switch-window-in-workspace 里的代码块中的内容替换掉,可以用 Ctrl-f 在网页中搜索名称进行跳转查看。

截屏

#+name: screen-capture-like-mac
bindsym $alt+Control+4 exec gnome-screenshot -ai

super-c/v: 拷贝粘贴的统一

#+name: copy-paste-with-super-cv
bindsym $super+c exec ~/metaesc/lib/paste.sh c
bindsym $super+v exec ~/metaesc/lib/paste.sh v

paste.sh 是统一在常用软件上的复制粘贴,包括 vscode 终端里默认的 Ctrl-Shift-C/V , urxvt 里粘贴的 ctrl+alt+v 都用 super-c/v 来统一,(前文说到,左 shift 改成了 super, 因此按起来也不费劲)。

#!/usr/bin/env bash
var1=$1
focus_win=$(xprop -id $(xdotool getactivewindow) | grep WM_CLASS | cut -d'"' -f2)
if [[ "$var1" == 'c' ]]; then
	timestamp=$(date +"%Y/%m/%d-%R")
	if [[ $focus_win =~ emacs|gnome-terminal|urxvt ]]; then
        # gnome-terminal|urxvt need tmux
		sleep 0.2
		xdotool key --clearmodifiers y
		sleep 0.1
		content=$(xclip -selection c -o)
		from=$(xprop -id $(xdotool getactivewindow) | egrep "^WM_NAME" | cut -d'"' -f2)
	elif [[ $focus_win =~ chrome ]]; then
		sleep 0.2
		xdotool key --clearmodifiers ctrl+c
		notify-send 'copied from chrome' -t 300
		# sleep 0.1; content=$(xclip -selection c -o)
		# xdotool key Y; sleep 0.1
		# from=$(xclip -selection c -o)
	elif [[ $focus_win =~ code ]]; then
		sleep 0.2
	    xdotool key --clearmodifiers ctrl+C
		notify-send 'copied from code' -t 300
    fi
    # dump to a note file
	# echo "${content} \"$timestamp : ${from}\"" >> ~/braindump/lib/vocab.org
	# could use xprop to get WM_NAME for source refile
elif [[ "$var1" == 'v' ]]; then
	sleep 0.1
	if [[ $focus_win =~ code|gnome-terminal ]]; then
	    xdotool key --clearmodifiers ctrl+V
	elif [[ $focus_win =~ 'urxvt' ]]; then
	    xdotool key --clearmodifiers ctrl+alt+v
    else
	    xdotool key --clearmodifiers ctrl+v
    fi
fi

F2/F3/F10: 管理窗口和笔记的 emacs 接口

对于剩余的一些使用较少也更复杂的关于窗口的操作,用 i3-emacs-xwish.sh 脚本统一实现:

#+name: emacs-popups
bindsym F2 --release exec ~/metaesc/lib/i3-emacs-publish.sh
bindsym F3 --release exec ~/metaesc/lib/i3-emacs-xwish.sh
bindsym F10 --release exec ~/metaesc/lib/i3-emacs-quicknote.sh

i3-emacs-quicknote.sh 的功能是,如果当前在 emacs 中那么发送 super-F10, 这个按键在 emacs 中绑定的功能是切换到显示 agenda 和 日志文件的 buffer, 并且光标直接定位到日志文件最后一行等待输入, 如果不在 emacs 中则弹出一个 emacsclient 小窗口并定位到日志 buffer 的最后一行并等待输入, 用于快速记录。

这两脚本都需要 ivy 等 emacs 包支持,这是另外一个故事了,不在此展开,完整脚本链接:

主配置

最后,把以上所有代码块汇总:

# i3 config file (v4)
<<set-font-border>>
<<set-modifier>>
<<switch-window-in-workspace>>
<<emacs-popups>>
<<focus-next-window>>
<<toggle-fullscreen>>
<<set-workspace-name>>
<<focus-workspace-with-super-number>>
<<switch-to-last-workspace>>
<<close-window-mode>>
<<window-layout-mode>>
<<open-split-termial>>
<<ibus-rime>>

<<assign-app-to-workspace>>
<<assign-workspace-to-monitor>>
<<switch-to-last-monitor>>
<<network-manager>>
<<set-monitor-and-wallpaper>>
<<clock-notify>>
<<set-keymap>>
<<emacs-chrome-initialize>>

<<fn-audio-control>>
<<fn-bright-control>>

<<i3-main-mode>>
<<addy-bar>>
<<addy-theme>>
<<vertical-monitor-bar>>

<<float-window-setting>>

<<screen-capture-like-mac>>
<<copy-paste-with-super-cv>>

再次说明:

<<float-window-setting>> 是 org babel noweb 的引用形式,在最终配置中,这一行会被之前定义的 #+name 为 float-window-setting 里的代码块中的内容替换掉,可以用 Ctrl-f 在网页中搜索名称进行跳转查看。

radioLinkPopups

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