适用环境Linux X11 桌面KWin X11Qt/PyQt5HiDPI 缩放如 1.5x目标实现桌面歌词挂件的“点击穿透”——平时鼠标完全穿透悬停时浮现控制条并可交互。验证环境KWin X11 HiDPI 1.5x 真机实测通过。核心结论Qt 的WA_TransparentForMouseEvents不等于 X11 穿透必须用XShapeCombineMask修改 X 输入区域并注意 HiDPI 物理像素换算。目录需求与背景为什么点击穿透这么难X11 输入区域原理四个典型失败坑最终方案三层机制核心代码可直接复用验证方法经验教训常见问题 FAQ总结1. 需求与背景桌面歌词挂件是一个透明置顶小窗只显示歌词文字。交互需求平时鼠标从字幕上滑过、点击字幕下方的窗口完全不受阻挡穿透。鼠标悬停在字幕上 1.2 秒浮现迷你控制条暂停 / ±0.5s / 关闭此时按钮可点、可拖动。移开鼠标控制条消失恢复穿透。主流桌面歌词网易云、QQ 音乐就是这种交互。在 X11 下实现“点击穿透”并不简单。Qt 提供的属性只能解决 Qt 内部事件分发无法改变 X 服务器层面的输入区域。本文记录完整踩坑过程与最终方案。2. 为什么点击穿透这么难X11 输入区域原理X11 中一个窗口是否接收鼠标事件由 X 服务器的input shape输入区域决定。Qt 的WA_TransparentForMouseEvents只是让 Qt 内部不再向该窗口分发鼠标事件但 X 层仍然认为该窗口占据那块屏幕鼠标点击会被它拦截下层窗口收不到。因此真正的穿透必须修改 X 服务器的 input shape。3. 四个典型失败坑坑 1WA_TransparentForMouseEvents根本不是穿透pythonself.setAttribute(Qt.WA_TransparentForMouseEvents, True)Qt 文档说明它只是让 Qt 内部不再分发鼠标事件给这个窗口。但 X 服务器的 input shape 没有变——在 X 层面这个窗口依然“占用”那块屏幕鼠标点上去事件发给它下面的窗口收不到。结论Qt 属性 ≠ X 层穿透。X11 下必须改 X 服务器的输入区域。坑 2XShapeCombineRectangles传空矩形会直接报错想通过传空矩形清空输入区域pythonXShapeCombineRectangles(dpy, win, ShapeInput, 0, 0, None, 0, ShapeSet)X 协议直接返回BadValue实测X Error: BadValue, opcode 129 (SHAPE)。更麻烦的是ctypes 不检查 X 协议错误调用看起来“成功”了实际 shape 从未设置——穿透一直没生效但没有任何报错。正确做法用 pixmap 掩码XShapeCombineMask。1×1 全 0 的 1bpp pixmap 就能把输入区域清空。这是 Chromium/Electron 的标准做法。坑 3shape 必须在窗口真正创建后设置在__init__里直接设置错误pythoninput_pass(self) # 内部调用了 self.winId()winId()会强制提前创建 X 窗口。但WA_TranslucentBackground需要 ARGB visualQt 在show()时才真正重建 X 窗口导致winId改变shape 丢在旧窗口上。修法在showEvent里设置此时 ARGB visual 就绪、winId 稳定pythondef showEvent(self, e): super().showEvent(e) if not getattr(self, _shape_done, False): self._shape_done True input_pass(self)坑 4HiDPI 下 X 窗口是物理像素pixmap 尺寸必须 ×缩放屏幕 1.5x 缩放时Qt 报告的窗口宽是逻辑 690pxX 层的窗口实际是物理 1035px。input pixmap 用逻辑尺寸690×115→ 输入区域只覆盖左上 690×115 物理像素 → 窗口底部控制条按钮区永远穿透、点不到。排查时的诡异现象点窗口中心被拦截、点底部按钮却穿透——就是这个原因。pythondef input_block(widget): r widget.devicePixelRatioF() or 1.0 _input_pixmap_mask(widget, 1, int(widget.width() * r), int(widget.height() * r))4. 最终方案三层机制三个要素各司其职层作用实现X 输入区域input shape决定“鼠标点在哪”XShapeCombineMask 1bpp pixmap窗口边界bounding决定“窗口长什么样”恒整窗矩形绝不用 setMask 裁剪悬停检测穿透窗口收不到鼠标事件QTimer 轮询QCursor.pos()是否在窗口几何内为什么不能用 setMaskQWidget.setMask()会把 bounding shape 裁成 mask 形状比如文字形状。后果mask 形状之外的区域X 层认为“那里没有窗口” → 控制条位于窗口底部整个落在窗口形状外 → 永远点不到。要“平时视觉上只剩文字”透明背景ARGB 平时 input shape 清空就够了——透明区域看不见input 又空不挡点击视觉和交互上都是“只有文字”。悬停检测因为窗口平时完全穿透收不到 enter/leave 鼠标事件所以用轮询pythondef _hover_check(self): inside self.geometry().contains(QCursor.pos()) now time.monotonic() if inside: if self._hover_t0 is None: self._hover_t0 now elif now - self._hover_t0 1.2 and not self._ui_shown: self._show_ui() else: self._hover_t0 None if self._ui_shown: self._hide_ui()5. 核心代码可直接复用pythonimport ctypes _X11 ctypes.CDLL(libX11.so.6) _X11.XOpenDisplay.restype ctypes.c_void_p _X11.XOpenDisplay.argtypes [ctypes.c_char_p] _X11.XFlush.argtypes [ctypes.c_void_p] _X11.XCreateGC.restype ctypes.c_ulong _X11.XCreateGC.argtypes [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_void_p] _X11.XSetForeground.argtypes [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong] _X11.XFillRectangle.argtypes [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_int, ctypes.c_int, ctypes.c_uint, ctypes.c_uint] _X11.XFreeGC.argtypes [ctypes.c_void_p, ctypes.c_ulong] _X11.XFreePixmap.argtypes [ctypes.c_void_p, ctypes.c_ulong] _X11.XCreatePixmap.restype ctypes.c_ulong _X11.XCreatePixmap.argtypes [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint] _dpy _X11.XOpenDisplay(None) _XExt ctypes.CDLL(libXext.so.6) # XShape 扩展在 libXext _XExt.XShapeCombineMask.argtypes [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_ulong, ctypes.c_int] _SHAPE_INPUT 2 # ShapeInput _SHAPE_SET 0 # ShapeSet def _input_pixmap_mask(widget, bits, w, h): bits0 → 全穿透bits1 → 该区域可点 wid int(widget.winId()) pm _X11.XCreatePixmap(_dpy, wid, w, h, 1) # 1bpp gc _X11.XCreateGC(_dpy, pm, 0, None) _X11.XSetForeground(_dpy, gc, bits) _X11.XFillRectangle(_dpy, pm, gc, 0, 0, w, h) _XExt.XShapeCombineMask(_dpy, wid, _SHAPE_INPUT, 0, 0, pm, _SHAPE_SET) _X11.XFlush(_dpy) _X11.XFreeGC(_dpy, gc) _X11.XFreePixmap(_dpy, pm) def input_pass(widget): 穿透输入区域 1x1 全 0 → 清空 _input_pixmap_mask(widget, 0, 1, 1) def input_block(widget): 可点输入区域 整窗HiDPI 必须 ×devicePixelRatio r widget.devicePixelRatioF() or 1.0 _input_pixmap_mask(widget, 1, max(1, int(widget.width() * r)), max(1, int(widget.height() * r)))调用时机状态调用窗口 show 后首次input_pass(self)悬停浮现控制条input_block(self) 窗口可交互移开鼠标input_pass(self)窗口 resize 后若在“可点”态重调input_block配套窗口属性pythonself.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool) self.setAttribute(Qt.WA_TranslucentBackground) # ARGB 真透明 self.setAttribute(Qt.WA_TransparentForMouseEvents, True) # Qt 层也忽略6. 验证方法前四轮失败全是“推理正确、实测失败”。最终用真机点击验证bash# 1. 垫底窗口全屏 tk 按钮点击就计数写 /tmp/bg_hits.json # 2. Qt 挂件窗口叠在上面 # 3. xdotool 实际点击看计数变化text平时点字幕: 垫底计数 1 穿透生效 ✅ 悬停后点按钮: 垫底计数不变 被挂件拦截按钮可点✅ 移开再点: 垫底计数 1 恢复穿透 ✅注意HiDPI 下 xdotool 用物理坐标、Qt 用逻辑坐标×1.5 换算测试时很容易点在“看起来对、实际错”的位置——先打印窗口几何再算点击点。7. 经验教训ctypes 不抛 X 协议错误——调用“成功”≠ 生效。用xwininfo -shape/XShapeGetRectangles或真机点击验证。穿透的关键在 X 输入区域不在 Qt 属性。真机验证优先交互类问题尤其窗口系统层用垫底窗口 xdotool 实测别靠代码推理。HiDPI 是隐藏大坑X 层全是物理像素Qt API 全是逻辑像素换算漏一处就出现“部分区域失效”的怪象。setMask 会改变窗口命中区域——透明窗口想“看起来只有文字”靠透明背景 input shape别靠裁剪窗口。8. 常见问题 FAQQ1为什么WA_TransparentForMouseEvents不起作用它只影响 Qt 内部事件分发不改变 X 服务器的 input shape。X 层仍然拦截鼠标事件。Q2XShapeCombineRectangles传空矩形为什么失败X 协议返回BadValue。正确做法是用XShapeCombineMask 1×1 全 0 的 1bpp pixmap 清空输入区域。Q3为什么要在showEvent里设置 shapewinId()会提前创建 X 窗口但WA_TranslucentBackground需要 ARGB visualQt 在show()时会重建 X 窗口导致 winId 改变shape 丢失。showEvent时 winId 已稳定。Q4HiDPI 下为什么底部按钮点不到X 窗口使用物理像素Qt 使用逻辑像素。input pixmap 如果只用逻辑尺寸只覆盖左上部分物理区域底部区域仍然穿透。Q5能不能用setMask让窗口只显示文字形状不能。setMask会改变 bounding shape导致控制条区域落在窗口形状外永远点不到。应该用透明背景 input shape 清空。Q6悬停检测为什么用轮询因为窗口平时完全穿透收不到 enter/leave 事件只能用 QTimer 轮询QCursor.pos()。9. 总结X11 下实现点击穿透的核心是修改 X 服务器的 input shape而不是依赖 Qt 属性。关键点用XShapeCombineMask 1bpp pixmap 设置输入区域。在showEvent中设置确保 winId 稳定。HiDPI 下 pixmap 尺寸乘以devicePixelRatioF()。不要用setMask裁剪窗口形状。悬停检测用轮询。用垫底窗口 xdotool 做真机验证。本文基于 KWin X11 HiDPI 1.5x 真机实测整理。不同桌面环境、Qt 版本和 X11 实现下细节可能不同请结合实际调整。