Loading
CamelliaV の BLOG
0%
INITIALIZING
DOC_ID // 3eaca1ONLINE

[临时AI稿][2026.9.30]Karousel 双屏折腾复盘:窗口归属、85 像素与没有生效的热重载

UPDATED: 2026-10-1
READ 12 MIN/COUNT 4444
CamelliaV の BLOG
💡
本文由astra撰写
我原本只想让外接显示器继续用 Karousel 滚动平铺,笔记本屏幕放一个 Kitty 终端,各干各的。结果接上副屏后,窗口开始串屏:主屏切窗口,笔记本上的终端也跟着跑;终端底部显示不全,点一下又变了;好不容易拖回去,下一次聚焦又被卷进主屏。
这次折腾从 2026 年 9 月 29 日持续到次日。前一轮尝试没有解决,接手后也并非一次成功:源码修复、运行时缓存和验证工具的问题交织在一起。最后落地的是一份本地补丁,实现“外接屏 Karousel+笔记本独立浮动”。本文记录整个判断如何被推翻、修正,以及最后究竟验证到了哪里。

先把“分屏”拆成三个问题

Karousel 是 KWin 上的滚动平铺扩展。窗口按列排成一条横向网格,切换焦点时,让目标列滚到可见区域。单屏上顺手的模型,接到双屏后却有一个直观的问题:屏幕右边并不一定是没人看见的屏外,也可能正好是另一块显示器。
这次实际存在三个不同层次的问题:显示器是否处于扩展布局;两块屏幕能否分别切换虚拟桌面;Karousel 是否还把两块屏上的窗口当成同一套网格管理。修好前一层,不代表后一层也解决了。
当时最终保留的显示布局如下,尺寸均为桌面逻辑坐标:
输出
物理分辨率与缩放
逻辑尺寸
位置与用途
HDMI-A-1
2560×1440,125%
2048×1152
左侧,(0, 0),Karousel 主屏
eDP-1
2560×1600,150%
约 1707×1067
右侧,(2048, 0),笔记本副屏
前一轮先尝试增加一个不参与平铺的虚拟桌面。这个思路只能回答“哪些桌面的窗口参与平铺”,不能回答“哪个显示器上的窗口归 Karousel 管”。后来删除额外桌面,启用当时 KWin 提供的 PerOutputVirtualDesktops,两屏切桌面的耦合才有了对应的处理方式。
但虚拟桌面独立之后,Karousel 的窗口归属和溢出问题仍然存在。系统提供了分屏桌面能力,并不会自动替一个原本按单网格设计的扩展补齐多屏逻辑。

为什么看起来像缩放问题,却不能只改高度

最初怀疑的是两块屏幕比例不同:主屏 16:9,笔记本 16:10,还用了不同的缩放。实时读取窗口几何后,确实拿到了一个明确错误:笔记本上的 Kitty 位于 x=2048,宽高却是 2048×1152,也就是主屏的逻辑尺寸。
笔记本只有约 1067 个逻辑像素高,却被要求显示一个 1152 高的窗口,多出来的约 85 像素就成了“底部显示不全”。这说明高度来源有问题,但还不足以说明根因是缩放。
前一轮曾反复尝试按窗口中心、按窗口报告的 output、按排版后的落点来选择高度;也尝试过把网格固定在主屏,再单独修补副屏列高。这些改法可能让某一个瞬间看起来正常,却没有改变一个事实:笔记本窗口仍在主屏网格里。
只要它还是网格成员,聚焦滚动、列重排、拖动结束、脚本重新初始化,都有机会再次写入它的位置和尺寸。更麻烦的是,窗口坐标本身就是排版的结果:拿排版刚改过的坐标,反过来判断窗口属于哪块屏,很容易形成循环。
因此,这次真正要维护的约束变成了:副屏浮动窗口的几何,不应被主屏网格的更新改写。

前面的补丁为什么一直失效

回看过程,可以把走过的弯路分成几类。
尝试
为什么不够
增加一个不平铺的虚拟桌面
过滤的是桌面,不是显示器,无法建立窗口的屏幕归属边界。
用脚本把笔记本拉回固定桌面
当时与全局桌面切换耦合,连主屏切换也被拉回。
只改列高或固定网格锚点
尺寸来源和窗口成员关系没有一起处理,聚焦、拖动后仍会回写。
窗口中心越界就跳过 arrange
几何会被其他回调改写;跳过一次排版不等于退出整套管理。
给列加 parked 标记
仍是列级临时状态,没有统一窗口创建、重新平铺和重载时的准入条件。
收紧滚动范围
不能让一个比屏幕更宽的完整网格凭空消失,逻辑溢出仍然需要实际的放置策略。
接手后,在退出平铺的路径里又找到一个很具体的陷阱:移除窗口时,原有恢复逻辑会调用 ensureVisible,而它拿到的是网格主屏的区域。随后进入浮动状态,还可能触发限制高度的逻辑。
于是,即便“拖到另一块屏就退出平铺”的判断正确,退出动作本身也可能先把窗口搬回主屏,再改变大小。这个发现解释了为什么只补拖动结束的分支依然不可靠。
另一个必须承认的问题是验证方式。前面很多次反馈停在“语法检查通过”“脚本已加载”“没看到报错”,然后就宣称修好。用户真正关心的却是:在主屏多切几次窗口,副屏的终端到底会不会动。两者之间缺了一整层行为证据。

最终改的是管理边界

这次没有继续把一套网格硬拆成两套,而是落实最初的使用需求:外接屏负责滚动平铺,笔记本负责独立浮动。
新增的本地配置是:
tiledOutput 是本次补丁新增的配置,不是上游原版现成支持的开关。只给原版加这一行,不会获得本文描述的行为。 默认空字符串保留原有模式;指定输出不存在时,当前实现回退到活动输出。

1. 在所有进入平铺的入口检查归属

窗口首次被发现、主动切回平铺、窗口规则要求重新平铺,这些入口都检查它是否属于受管理的输出。笔记本窗口不会因为某条规则允许 Kitty 平铺,就被重新收进主屏网格。
这样,“副屏浮动”成为贯穿窗口生命周期的约束,而不是某次排版时碰巧跳过的一行判断。

2. 跨屏移动时退出平铺,并保留几何

拖动过程中避免排版和用户抢位置;拖动结束或收到外部跨屏几何变化时,窗口如果已到其他输出,就从平铺状态转为浮动状态。
这里增加了保留几何的处理:这条退出路径跳过原来的回主屏可见性修正,也不执行浮动缩高。之后再聚焦这个窗口,主屏网格里已经没有它,便不会把它当成某一列滚回来。
拖回主屏之后,可以按 Meta+Space 重新加入平铺。当前实现没有把“拖回来自动重新平铺”作为目标。

3. 分开处理逻辑滚动和实际窗口坐标

排除副屏窗口,只解决了副屏被卷走的问题。主屏自身的列仍可能滚出右边界,进入右侧笔记本。
这次保留网格的逻辑坐标和滚动计算。在受限输出模式下,排版发现一列横向越过主屏区域,就把它的实际位置放到全部显示器布局的最左侧之外。示意计算如下:
目标列重新进入主屏范围时,再按正常坐标放置。因此,主屏窗口仍能通过 Karousel 聚焦和滚动找回来,副屏也不再承担“主屏右侧溢出区”的角色。
这里有明确代价:这是整列停放,不是合成器级裁剪。 原本可能只露出一部分的边缘列,现在会整列移到屏外。它解决的是本次左右排列布局里的串屏问题,不能等同于原生支持多显示器的滚动合成器。

4. 使用主屏自己的当前虚拟桌面

既然系统启用了按输出切换虚拟桌面,排版也应查询主屏自己的当前桌面。支持 currentDesktopForScreen 的环境中,受限输出模式使用这个接口,避免把另一个输出的桌面状态当成主屏的排版目标。

最难查的一段:改好的代码并没有按预期运行

源码和回归测试通过之后,实机却又出现旧版行为:临时窗口和笔记本终端继续被排列到副屏甚至更远的位置。这一次没有继续补坐标,而是转头验证加载链路。
最终碰到了几个叠在一起的问题。
首先是 QML 缓存。 直接替换已安装的 main.js,卸载再加载脚本,并不保证当前 KWin 进程使用的就是磁盘上的新版。换一个 JavaScript 文件名还不够,QML 入口也可能仍是缓存的旧组件。
其次是目录内容也可能被缓存。 在原目录新增 QML 文件后,KWin 日志给出了很迷惑的错误:
文件明明存在、名字也没有大小写错误。结合后续实验,问题与当前进程保存的 QML 目录条目缓存一致:把完整的 QML 和 JavaScript 放进一个全新的运行目录后,组件成功加载,并主动回报了实际读到的配置,包括 tiledOutput=HDMI-A-1。
第三个坑是改元数据入口并不能控制自动发现。 排查 KWin 的 queryScriptsToLoad() 后发现,所查实现按插件类型拼接固定路径:
所以,仅修改 X-Plasma-MainScript,再调用 start,仍可能回到标准路径上的缓存入口。最终部署采取了两条并行的路径:当前会话显式加载新运行目录中的 QML;磁盘上的标准入口和脚本也同步更新,供下一次 KWin 启动使用。
探测工具本身也踩了坑。所查 KWin 实现按当前脚本列表长度分配 D-Bus 脚本 ID,频繁卸载后可能与存留脚本的 ID 冲突。本次探测器增加了临时占位条目,避开遇到的冲突;最终加载还通过启动回报和窗口行为交叉确认,而不是只相信一个返回的整数 ID。
这些细节不适合概括成“所有 KWin 版本都这样”。但在这次会话里,它们说明了一件事:文件、注册状态、组件实例和实际执行的代码,是四件需要分别确认的事。

最后怎样验证,而不是再说一次“应该好了”

为了读取真实状态,这次用了短生命周期的 KWin 探测脚本,通过 D-Bus 把输出布局和窗口几何回传给本地接收器。Wayland 下几何更新可能异步完成,因此不能在写入坐标的同一个瞬间就把旧值当成最终结果;动作之后要重新取样。
源码层面新增了三组多屏回归场景,覆盖副屏窗口创建、规则重新平铺、聚焦、重载、跨屏退出和平铺列溢出。完整测试运行输出了 65 组测试,TypeScript 编译和 ESLint 也通过了。
实机验证没有只盯着一张静止画面:
验证动作
观察结果
主屏临时窗口连续 6 次切换、滚动
目标窗口回到主屏可见区域,测试窗口没有侵入笔记本区域。
同时记录副屏 Kitty
主屏切换期间,它的位置与尺寸保持不变。
将一个测试窗口移到笔记本,再交替聚焦 4 次
仍留在副屏,保持设定高度,没有被拉回或缩小。
完成最终部署,再次重载并切换 Zen/Kitty 焦点 4 次
笔记本 Kitty 保持在 (2048, 0),约 1707.33×1067.33。
清理临时测试窗口
保留原有应用,移除本次创建的测试窗口。
最后尺寸里的小数来自分数缩放下的几何舍入,与原先多出约 85 像素不是同一回事。
也要区分测试范围:模拟测试覆盖了交互拖动回调;实机自动化明确验证的是跨屏几何移动和之后的反复聚焦。没有做长期桌面使用测试,也没有穷举显示器热插拔、所有排列方式和所有应用的最小尺寸约束。

重启以后还在吗

修复不是只留在当前内存里。标准 contents/ui/main.qml 与 contents/code/main.js 已更新,并核对过与实测运行目录中的文件一致;启用状态、指定输出以及分屏虚拟桌面的配置也已经写入磁盘。
因此,按正常启动链路,重启后应当继续生效,不需要手动重新执行修复脚本。不过,写下本文时验证到的是文件持久化和脚本重载,没有实际重启整机验证。这条边界不能用一句“永久修好了”带过。
另外,这是一份本地补丁。升级或重装 Karousel 可能覆盖它;显示器接口名称变化后也需要检查配置。源码、修改前备份、部署脚本和验证记录都单独保留,后续维护时应从源码重新构建,而不是继续手改编译后的 JavaScript。

这次真正留下的教训

这次修复不是一条漂亮的直线。前半段围着高度、锚点和窗口中心反复加条件;后半段即使写出了正确方向的代码,也一度因为缓存和加载入口又跑回旧行为。AI 编程助手的多次“已经修好”超出了当时掌握的证据,用户的反复反馈才把验证拉回真实使用场景。
最终有效的转变,是先写清不应被破坏的约束:主屏切换时,副屏窗口的几何必须保持不变。再沿着窗口进入、移动、退出和重新加载的完整路径维护这个约束,最后用动作前后的数据检查它。
现在得到的能力边界很清楚:外接屏继续滚动平铺,笔记本独立浮动;主屏的越界列停放到屏外。这已经满足本次需求,但还不是每屏一套独立网格,更不是对 Karousel 多屏支持的完整重写。
窗口没有再串屏,只是这次工作的结果。更值得记下的是:下一次看到“语法通过、插件已加载、没有报错”,还要继续追问——正在执行的是哪份代码,用户关心的那条行为约束又在哪里被验证了?

参考

  • Karousel 上游项目:理解滚动平铺模型与项目本身的能力边界。本文涉及本地修改,不能直接当作上游使用说明。
  • KWin scripting.cpp:本次查阅的加载、自动发现和脚本 ID 分配实现入口;链接跟随上游变化,不是本地安装版本的不可变快照。
  • 本文的尺寸、状态和测试次数来自本次本地探测与回归记录,记录时间为 2026-09-29 至 2026-09-30。正文由执行修复的 AI 助手根据会话和验证结果整理。
 
NAVIGATION // Related Articles
Loading...
© 2024-2026 CamelliaV