TL;DR:一个 GNOME Shell 42 扩展,把一堆 AppIndicator 托盘图标收进顶栏里一个可展开的「抽屉」,腾出空间、不再顶掉时钟。难点不在 UI,而在两件事:GNOME 根本没给你一个「托盘容器」可搬,得直接把图标的 actor 在面板和抽屉之间重新认父;以及对付那些每次启动都换一个随机 ID 的流氓应用,否则同一个软件会在设置里冒出一长串重复项。
起因:时钟被顶没了
GNOME 顶栏用久了,装的带托盘图标的应用一多,AppIndicator 图标就排成一长条,往中间的时钟挤。我有强迫症,看不得这个。想要的效果很简单:平时把不常点的图标收起来,顶栏只留一个小按钮,需要时点开抽屉再操作。 找了一圈没有称手的,于是写了 Tray Collapse。
核心难点:GNOME 不给你「托盘容器」
直觉上你会以为有个 trayBox 之类的容器,把它整个挪走就行。并没有。AppIndicator 的每个图标都是直接挂在 Main.panel.statusArea 下、最终落在面板右侧 _rightBox 里的独立 actor。所以折叠的本质是:把单个图标的 actor 从面板上摘下来,重新挂到我自己的抽屉容器里——也就是 GTK/Clutter 里所谓的 reparenting。
先找出所有 AppIndicator 图标(它们的 key 有统一前缀):
const statusArea = Main.panel.statusArea;
for (const key in statusArea) {
if (!key.startsWith(APPINDICATOR_PREFIX))
continue;
const indicator = statusArea[key];
// …决定这个 indicator 该进抽屉还是留顶栏…
}
搬移就是「从旧父节点摘下、加到新父节点」。进抽屉和回顶栏是一对镜像操作:
_moveIn(container) {
const parent = container.get_parent();
if (parent) parent.remove_child(container); // 先从顶栏摘下
this._drawerBox.add_child(container); // 挂进抽屉
}
_moveOut(container) {
this._detach(container);
Main.panel._rightBox.insert_child_at_index(container, 0); // 放回顶栏最左
}
关键点:搬的是同一个 actor,不是复制一份。所以图标的菜单、点击行为、动态更新全都原封不动——抽屉里的图标仍是个功能完整的面板按钮,而不是一张截图占位。这也是我坚持用 reparenting 而不是「自己画一排假图标再转发点击」的原因:后者要模拟整套菜单交互,几乎不可能做对。
抽屉的开关:一个胶囊按钮
顶栏只留一个胶囊按钮,点一下切换抽屉的可见性,顺便换个图标(收起/展开两个符号):
_toggle() {
this._expanded = !this._expanded;
this._drawerBox.visible = this._expanded;
this._icon.gicon = this._expanded
? this._iconExpanded : this._iconCollapsed;
}
抽屉不展开时 visible = false,不占顶栏横向空间——这正是「腾地方」的核心。
最大的坑:每次启动换 ID 的流氓图标
这是让我返工最多的地方。理想情况下每个应用有个稳定 ID,我记住「这个该收、那个该留」就行。但现实是,有些 AppIndicator 应用上报的 ID 形如 legacy:appname:1873452,后面那串数字每次启动都变。后果很难绷:你昨天把某个图标设成「收进抽屉」,今天重启它换了 ID,扩展不认识了,于是它又跑回顶栏;而设置页里则堆着一排「同一个应用、不同随机尾号」的幽灵条目。
解法是给 ID 做归一化——把随机尾号削掉,只认前面稳定的部分:
function canonicalId(id) {
const m = /^legacy:(.+):\d+$/.exec(String(id));
return m ? `legacy:${m[1]}` : String(id);
}
光归一化还不够,还得清理历史遗留的脏条目,但又不能误伤——有些应用确实会同时开好几个合法图标(互为 "peer",比如一个应用的多个实例)。所以清理时跳过仍然活着的、以及互为 peer 的,只删真正过期的,并把旧 ID 在用户的「收进抽屉」名单里改写成新 ID:
for (const [oldId, old] of [...map]) {
if (liveIds.has(oldId) || entry.peers.has(oldId))
continue; // 活着的、或互为 peer 的,留
map.delete(oldId); // 只删过期的幽灵
this._renameInList('collapsed-ids', oldId, it.id); // 顺手迁移用户设置
}
这段逻辑看着不起眼,却是「重启后设置依然生效、设置页不堆垃圾」的关键。我前后改了好几版才把误删 peer、漏迁移设置这些边角填平。
设置怎么跨进程:known-indicators 这座桥
GNOME 扩展分两个进程:扩展本体跑在 gnome-shell 里,设置界面(prefs)是另起的进程。设置页要展示「你都见过哪些图标、各自标题是什么」,但它够不到 gnome-shell 里的实时状态。我的桥是 gsettings——扩展把见过的图标序列化成 JSON 存进一个 key,prefs 读它来渲染列表:
const arr = [...map].map(([id, v]) =>
({ id, title: v.title, peers: [...v.peers] }));
this._settings.set_string('known-indicators', JSON.stringify(arr));
整条数据流:
gnome-shell 进程 prefs 进程(独立)
┌──────────────────────┐ ┌─────────────────────┐
│ 扫描 statusArea │ │ 读 known-indicators │
│ 归一化/去重 indicators │ gsettings │ 渲染「逐图标」开关 │
│ 写 known-indicators ──┼──(JSON 字符串)─▶│ 用户勾选 → 写 │
│ 读 collapsed/pinned ◀─┼────────────────┤ collapsed/pinned │
└──────────────────────┘ └─────────────────────┘
可调的配置键
全部行为通过 6 个 gsettings 键控制:
| key | 类型 | 默认 | 作用 |
|---|---|---|---|
default-collapse |
bool | false |
新出现的图标是否默认收进抽屉 |
pinned-ids |
string[] | [] |
强制常驻顶栏的图标 ID |
collapsed-ids |
string[] | [] |
强制收进抽屉的图标 ID |
known-indicators |
string(JSON) | '[]' |
扩展维护的「已知图标」记录,供设置页渲染 |
drawer-spacing |
int (0–40) | 12 |
抽屉里图标的横向间距(px) |
panel-hpadding |
int (0–24) | 12 |
顶栏托盘图标的水平内边距(px),只影响 AppIndicator 图标 |
「逐图标控制」就是设置页把 known-indicators 列出来,你勾哪个、它就把对应 ID 写进 pinned-ids 或 collapsed-ids。
安装
# 源码(开发)
./install.sh
# 或从 Releases 下扩展包 / Ubuntu deb 装系统级
gnome-extensions install <zip>
要求:GNOME Shell 42、X11 会话、AppIndicator 扩展(Ubuntu 22.04 自带)。卸载会把所有图标原样搬回顶栏,不留残留。界面截图见仓库 README。
小结
- reparenting 同一个 actor 是整个扩展能成立的根基——折的是真图标,菜单交互零损耗。
canonicalId归一化随机 ID 是从「能用」到「重启后依然好用」的分水岭,也是我花时间最多的地方。- gsettings 当跨进程桥 是 GNOME 扩展 shell↔prefs 通信的惯用解法,known-indicators 这个 JSON key 就是这么来的。
如果你的 GNOME 顶栏也被托盘图标挤满了,欢迎试试、提 issue。我另外两个 X11 桌面小工具——GNOME 顶栏宠物 Notchi 和 Linux 全局语音输入 Voice Input——同样是折腾桌面环境的产物,感兴趣可以顺手看看。