ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用PySide6构建窗口跟随悬浮窗:以Codex为实例

用PySide6构建窗口跟随悬浮窗:以Codex为实例 如果你也经常用 OpenAI Codex CLI 在终端里写代码应该遇到过这种场景编辑器、浏览器、训练脚本的日志窗口叠在一起Codex 窗口被挤到角落每次要切过去看任务状态都得在全屏窗口里一个一个找焦点。为了减少这种来回切换的视觉成本我做了一个很小的桌面工具一个始终置顶、半透明、会跟着 Codex 窗口走的悬浮窗。这个工具本身不复杂核心只有两件事找到目标窗口的位置把我的小窗口移动到它旁边。真正值得沉淀的是背后的桌面窗口遍历、进程匹配、DPI 坐标换算、无边框置顶窗设计这些很零散但很有用的知识点。下面我会从原理拆到代码再给出一套可直接运行的 PySide6 示例帮助你掌握“窗口跟随”这类桌面自动化能力的完整套路。1. 这个“跟随窗口”究竟在解决什么问题1.1 从实际场景说起Codex CLI 这类 AI 编程工具通常运行在终端窗口里它本身不会提供一个像 IDE 那样的侧边栏也不会把任务进度主动钉在屏幕上。当你在几个大窗口之间来回切换时想随时看到 Codex 的当前状态并不方便。一个比较自然的做法是给 Codex 窗口“挂”一个浮动信息栏。这个信息栏可以显示目标窗口的标题、当前位置或者你想放进去的任何提示内容。它不需要用户手动拖动因为只要检测到 Codex 窗口移动了它就自动跟过去。这个场景还可以继续扩大不光是跟 Codex你可以让它跟随任意进程的窗口。只要知道进程名或窗口标题就能挂一个小面板在旁边。常见的应用包括给长时间运行的训练脚本做状态浮窗给视频录制工具做一个小的“正在录制”提示条给远程终端窗口做快捷指令栏给测试脚本做实时日志悬浮面板。本质上这是一种“基于位置关系的桌面 UI 组合”思路两个本来没有任何关系的窗口通过其中一个的位置去联动另一个的坐标。1.2 技术思路桌面窗口跟随的实现套路实现窗口跟随最简单也最可靠的方案是轮询。流程大致如下定时例如每 300ms查找目标进程的主窗口句柄调用GetWindowRect获取目标窗口在屏幕上的坐标计算悬浮窗的新坐标比如目标窗口右上角偏移 12 像素把悬浮窗移动到新坐标。这套思路不依赖任何窗口管理器也不需要在 Windows 底层实现事件钩子只要能拿到窗口句柄就能工作。在 Windows 平台上核心 API 都来自user32.dll例如EnumWindows、GetWindowThreadProcessId、GetWindowRect、MoveWindow等。1.3 为什么不选全局窗口钩子第一次做这类工具时我优先考虑的是 Windows 的SetWinEventHook因为它可以监听窗口移动事件事件来了再跟随比轮询更实时。但实践下来它有明显的缺点全局钩子需要写原生代码Python 里虽然可以用 ctypes 绕过但回调函数的生命周期管理复杂。钩子回调频率很高如果目标窗口在快速拖动悬浮窗也会频繁 move容易造成闪烁。权限和杀毒软件容易拦截此类全局钩子导致工具在别人电脑上不可用。所以对“跟随窗口”这种需求定时轮询完全够用。300ms 的轮询频率人眼几乎感知不到延迟CPU 占用也很低。2. 环境准备与项目结构2.1 运行环境本文示例以 Windows 10/11 Python 3.10 为例。需要用到一个轻量级 GUI 框架 PySide6以及一个用于读取进程信息的 psutil 库。版本要求不需要特别严格PySide6 大版本稳定即可。如果你本机已经装过 PySide6请留意 QT 插件版本是否匹配。程序核心逻辑不依赖某个非常新的 PySide6 API所以兼容性比较好。2.2 安装依赖建议先建一个虚拟目录避免污染全局 Python 环境。mkdir codex-follower cd codex-follower python -m venv venv venv\Scripts\activate然后安装依赖pip install PySide6 psutil安装完成后可以用下面命令验证 PySide6 是否能正常加载from PySide6.QtWidgets import QApplication print(QApplication.instance() is not None)2.3 项目目录本示例采用三个文件分离职责codex-follower/ ├── venv/ ├── src/ │ ├── window_tracker.py # Windows 窗口查找与坐标读取 │ ├── floating_window.py # 悬浮窗 UI 与跟随逻辑 │ └── main.py # 程序入口window_tracker.py只负责底层窗口 APIfloating_window.py负责界面main.py负责组装。这样后续要扩展为“跟随任意窗口”或者把界面换成 Tkinter成本都很低。3. 核心技术点拆解3.1 如何找到 Codex 窗口句柄一个进程可能创建多个窗口窗口还有主窗口、子窗口、隐藏窗口之分。要找到“用户能看到的主窗口”比较可靠的方法是这样用psutil.process_iter()遍历进程找到名称匹配的进程并记录它的 PID调用EnumWindows枚举所有顶层窗口对每个窗口调用GetWindowThreadProcessId判断它是否属于目标 PID过滤掉不可见窗口以及没有标题的窗口如果多个窗口都匹配优先选择标题包含 “Codex” 的窗口。这里有一个容易被坑的点GetWindowThreadProcessId的第二个参数是一个 DWORD 指针用来接收 PID。用 ctypes 调用时需要先创建wintypes.DWORD再用ctypes.byref传进去。另外不要只看进程名。如果 Codex 是在某个终端软件里运行的GUI 窗口可能是终端模拟器的窗口进程名也不一定是codex.exe。所以更通用的方式是允许用户配置“进程名”或“窗口标题关键词”。3.2 如何读取窗口位置拿到窗口句柄后读取位置使用GetWindowRectrect.left rect.top rect.right rect.bottom这四个值分别表示窗口左上角和右下角的屏幕坐标。窗口宽度可以用right - left计算高度用bottom - top计算。需要注意GetWindowRect返回的是屏幕坐标不是相对父窗口的坐标。对于顶层窗口来说这个坐标可以直接用于 Qt 的move(x, y)。3.3 坐标换算与 DPI 缩放这是最容易出问题的地方。在 Windows 上如果系统开启了高 DPI 缩放150%、200% 等不同进程可能运行在不同 DPI 感知模式下。如果只在一个地方读取坐标可能出现悬浮窗位置偏了半个屏幕的情况。解决办法是让 Python 进程声明 DPI 感知。常见方式有两种SetProcessDpiAwareness(2)表示 Per-Monitor DPI AwareWindows 8.1 推荐SetProcessDPIAware()表示 System DPI Aware老 API。在 PySide6 程序中建议在创建QApplication之前就设置 DPI 感知这样 Qt 后续拿到的屏幕坐标才会和GetWindowRect保持同一套坐标系。3.4 悬浮窗的形态控制做一个“会跟着 Codex 走的小窗口”形态上通常是无系统边框置顶显示半透明或圆角背景不抢焦点甚至可以鼠标穿透。PySide6 中通过WindowFlags组合实现Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.ToolTool标志很关键它让窗口不在任务栏显示也不抢焦点。WindowTransparentForInput可以实现鼠标点击穿透悬浮窗下面的窗口仍然可以正常交互。不过鼠标穿透是一把双刃剑。如果点击穿透用户就无法点击悬浮窗上的退出按钮。实际工具一般会提供一个快捷键来切换穿透模式。4. 完整实战案例做一个会跟着 Codex 走的悬浮窗下面进入完整代码阶段。示例项目命名为codex-follower实现效果如下找到codex.exe进程的主窗口悬浮窗出现在目标窗口右上角偏移 12 像素每 300ms 同步一次位置悬浮窗显示目标窗口标题、当前位置和跟随状态默认开启鼠标穿透按F2切换鼠标穿透模式按Esc退出程序。4.1 编写窗口工具类先创建src/window_tracker.py封装 Windows 窗口查找和坐标读取。# 文件路径src/window_tracker.py import ctypes from ctypes import wintypes import psutil user32 ctypes.windll.user32 WNDENUMPROC ctypes.WINFUNCTYPE( wintypes.BOOL, wintypes.HWND, wintypes.LPARAM, ) class RECT(ctypes.Structure): _fields_ [ (left, ctypes.c_long), (top, ctypes.c_long), (right, ctypes.c_long), (bottom, ctypes.c_long), ] def _find_pid_by_process_name(process_name: str): target_name process_name.lower() for proc in psutil.process_iter([pid, name]): try: if proc.info[name] and proc.info[name].lower() target_name: return proc.info[pid] except (psutil.NoSuchProcess, psutil.AccessDenied): continue return None def find_window_by_process_name(process_name: str, keyword: str ): 根据进程名和窗口标题关键词查找顶层窗口。 返回 (hwnd, title)找不到时返回 (None, None)。 pid _find_pid_by_process_name(process_name) if pid is None: return None, None matches [] def enum_handler(hwnd, lparam): if not user32.IsWindowVisible(hwnd): return True window_pid wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(window_pid)) if window_pid.value ! pid: return True length user32.GetWindowTextLengthW(hwnd) if length 0: return True buf ctypes.create_unicode_buffer(length 1) user32.GetWindowTextW(hwnd, buf, length 1) title buf.value if keyword and keyword.lower() not in title.lower(): return True matches.append((hwnd, title)) return True enum_proc WNDENUMPROC(enum_handler) user32.EnumWindows(enum_proc, 0) if not matches: return None, None # 优先返回标题包含关键词的窗口 for hwnd, title in matches: if keyword and keyword.lower() in title.lower(): return hwnd, title return matches[0][0], matches[0][1] def get_window_rect(hwnd): 返回窗口的 (x, y, width, height)失败返回 None。 if not hwnd: return None rect RECT() ok user32.GetWindowRect(hwnd, ctypes.byref(rect)) if not ok: return None x rect.left y rect.top width rect.right - rect.left height rect.bottom - rect.top return x, y, width, height def is_window_minimized(hwnd): 判断窗口是否最小化。 return bool(user32.IsIconic(hwnd))这里的关键点有两个。一是枚举回调里把enum_proc赋值给变量避免回调函数对象被垃圾回收。二是通过GetWindowTextLengthW过滤空标题窗口因为很多后台窗口没有标题但可见。4.2 编写悬浮窗接下来创建src/floating_window.py这是整个项目的 UI 主体。# 文件路径src/floating_window.py from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QShortcut, QKeySequence from PySide6.QtWidgets import ( QLabel, QPushButton, QVBoxLayout, QWidget, ) from window_tracker import ( find_window_by_process_name, get_window_rect, is_window_minimized, ) class FloatingWindow(QWidget): def __init__( self, process_name: str codex.exe, keyword: str codex, offset_x: int 12, offset_y: int 12, passthrough: bool True, ): super().__init__() self.process_name process_name self.keyword keyword self.offset_x offset_x self.offset_y offset_y self.passthrough passthrough self.target_hwnd None self.setFixedSize(280, 96) self._apply_window_flags() self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) layout QVBoxLayout(self) self.title_label QLabel(等待 Codex 窗口...) self.position_label QLabel(位置: --) self.status_label QLabel(状态: 已启用鼠标穿透) layout.addWidget(self.title_label) layout.addWidget(self.position_label) layout.addWidget(self.status_label) self.toggle_btn QPushButton(F2: 切换鼠标穿透 / Esc: 退出) layout.addWidget(self.toggle_btn) QShortcut(QKeySequence(F2), self, self.toggle_passthrough) QShortcut(QKeySequence(Esc), self, self.close) self.timer QTimer(self) self.timer.timeout.connect(self.sync_position) self.timer.start(300) self.sync_position() def _apply_window_flags(self): flags ( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool ) if self.passthrough: flags | Qt.WindowType.WindowTransparentForInput self.setWindowFlags(flags) def toggle_passthrough(self): 切换鼠标穿透模式。 self.passthrough not self.passthrough self._apply_window_flags() self.show() self.status_label.setText( 状态: 已启用鼠标穿透 if self.passthrough else 状态: 已禁用鼠标穿透 ) def sync_position(self): 轮询目标窗口位置并移动悬浮窗。 hwnd, title find_window_by_process_name(self.process_name, self.keyword) if hwnd is None: self.target_hwnd None self.hide() return self.target_hwnd hwnd self.title_label.setText(f目标: {title[:24] if title else No Title}) # 最小化时不跟随隐藏自身 if is_window_minimized(hwnd): self.hide() return rect get_window_rect(hwnd) if rect is None: self.hide() return x, y, width, height rect new_x x width self.offset_x new_y y self.offset_y self.move(new_x, new_y) self.position_label.setText( f位置: ({x}, {y}) 尺寸: {width}x{height} ) self.show()这段代码的核心逻辑在sync_position()中。每 300ms 执行一次查找目标窗口如果找不到就隐藏悬浮窗如果目标窗口最小化也隐藏读取窗口矩形并计算出新坐标调用move()移动悬浮窗。键盘快捷键用QShortcut实现即使鼠标穿透键盘事件仍然可以被窗口接收。F2用于切换鼠标穿透Esc用于退出程序。4.3 编写主程序最后创建src/main.py负责 DPI 初始化和启动 Qt 事件循环。# 文件路径src/main.py import ctypes import sys from PySide6.QtWidgets import QApplication from floating_window import FloatingWindow def enable_dpi_awareness(): 开启高 DPI 感知保证坐标读取和窗口显示一致。 try: ctypes.windll.shcore.SetProcessDpiAwareness(2) except AttributeError: ctypes.windll.user32.SetProcessDPIAware() except Exception: # 部分环境下 SetProcessDpiAwareness 可能被拒绝忽略即可 pass def main(): enable_dpi_awareness() app QApplication(sys.argv) win FloatingWindow( process_namecodex.exe, keywordcodex, offset_x12, offset_y12, passthroughTrue, ) win.show() sys.exit(app.exec()) if __name__ __main__: main()这里把SetProcessDpiAwareness(2)放在QApplication创建之前是因为 Qt 会读取当前进程的 DPI 感知状态如果晚了可能出现坐标系统不一致。4.4 运行与验证在src目录下运行python main.py正常情况你会看到一个小悬浮窗出现在 Codex 窗口的右侧。手动拖动 Codex 窗口悬浮窗会在很短时间跟着移动。如果当前没有 Codex 进程悬浮窗会直接隐藏不会在桌面上残留。这是一种比较安全的行为避免程序启动后出现一个“无处可跟”的空白窗口。4.5 扩展跟随任意窗口如果你并不想跟 Codex而是想跟 Chrome、VS Code、记事本等任意窗口只需要修改主程序的参数即可win FloatingWindow( process_nameCode.exe, keywordVisual Studio Code, offset_x12, offset_y12, passthroughTrue, )由于查找逻辑按“进程名 窗口标题关键词”过滤支持范围很广。需要注意进程名可能不叫Code.exe以你的版本实际进程名为准。5. 常见问题与排查思路在实际运行时很多问题不一定出在 Python 逻辑上而更多是 Windows 窗口机制本身的特性。下面把高频问题整理成一张表再做具体说明。问题现象常见原因解决思路悬浮窗不出现找不到目标进程或窗口标题不匹配用任务管理器确认进程名放宽 keyword 关键词悬浮窗位置偏了半个屏幕DPI 缩放导致坐标不一致在 QApplication 创建前设置 DPI 感知跟随有延迟轮询间隔太长将 QTimer 间隔降到 200ms但避免低于 100ms拖动目标窗口时悬浮窗闪烁窗口频繁 show/hide 或 move 太频繁优化隐藏逻辑只在需要时更新坐标鼠标点击穿透后无法关闭窗口仍然拦截不到鼠标事件用快捷键 Esc 关闭或用托盘菜单目标窗口最小化后悬浮窗不消失没有检查最小化状态调用 IsIconic 判断并隐藏悬浮窗找错窗口跟随到另一个同名进程多个同进程实例标题过滤不严谨增加 PID 指定或更精确的窗口标题匹配5.1 悬浮窗不出现这是最常见的问题。原因通常是codex.exe并不是运行 Codex CLI 的进程。如果你是在 Windows Terminal、VS Code 集成终端里运行 Codex那么真正的窗口进程是WindowsTerminal.exe或Code.exe而不是codex.exe。排查方法在命令行执行tasklist | findstr codex看进程是否真实存在用 Spy 或任务管理器查看具体进程名把keyword改成窗口标题里稳定出现的单词。如果进程名确实不是codex.exe但窗口标题里有 “codex”也可以只按标题过滤。你可以把_find_pid_by_process_name改成返回None然后枚举窗口时直接过滤标题就能实现“无进程名限制只看标题”的跟随模式。5.2 位置偏移与 DPI如果你在 100% 缩放的电脑上开发在 150% 缩放的电脑上运行就会踩到 DPI 问题。核心原因就是 Qt 和GetWindowRect使用了不同坐标体系。解决办法已经写在enable_dpi_awareness()里。如果仍然偏移检查 Windows 显示设置里的“更改文本、应用等项目的大小”是否是 100% 以上并确认SetProcessDpiAwareness(2)有没有被异常吞掉。5.3 点击穿透与快捷键冲突开启WindowTransparentForInput后悬浮窗本身接收不到鼠标点击但键盘快捷键仍能生效。如果你的 Esc 或 F2 被其他软件占用快捷键可能失效。此时可以改用全局热键库或托盘图标但不建议为了一个小工具引入过重依赖。5.4 多显示器负坐标场景如果目标窗口从主屏幕拖到了左侧扩展屏窗口坐标可能是负数比如(-1920, 100)。GetWindowRect和 Qt 的move()都能处理负坐标不会出问题。但如果你的悬浮窗宽度计算不当可能把窗口移出屏幕边界。建议在代码里增加一个屏幕边界钳制逻辑。from PySide6.QtWidgets import QApplication def clamp_to_screen(self, x, y): screen QApplication.primaryScreen().availableGeometry() if x screen.left(): x screen.left() if y screen.top(): y screen.top() return x, y这里只是示例思路实际多显示器环境还要考虑不同屏幕的 DPI 单独设置。6. 工程化与最佳实践6.1 性能与轮询节奏窗口跟随工具对实时性要求并不高。300ms 间隔已经非常流畅且 CPU 占用几乎可以忽略。如果你要跟的是一个快速移动的窗口比如录屏软件可以降到 150ms再低就容易出现拖动时悬浮窗“抖动”的效果。不建议用while True sleep代替 QTimer。原因有两个Qt 事件循环需要时间处理 move、show 等界面操作阻塞循环会导致界面卡顿QTimer 能自然与 Qt 的绘制周期对齐减少闪烁。如果以后要同时跟随多个窗口可以用一个 QTimer 遍历多个进程而不是为每个窗口创建独立线程。6.2 处理目标窗口关闭与最小化从健壮性角度看必须处理以下情况目标窗口不存在目标窗口存在但最小化目标窗口被关闭后重新打开多个目标窗口同时存在。示例代码里用hide()而不是close()处理目标窗口隐藏这样目标窗口再次出现时悬浮窗还能继续复用。不要每次销毁重建 QWidget那会增加不必要的复杂度。6.3 日志记录桌面小工具最容易出现“本地能跑、别人电脑上不能跑”的问题。建议使用 Python 标准库logging记录关键路径import logging logging.basicConfig(levellogging.INFO, format%(asctime)s %(levelname)s %(message)s) logging.info(target window not found, process%s, self.process_name)这样在用户反馈“悬浮窗没出现”时可以直接看日志判断是进程名问题还是窗口标题过滤问题。6.4 授权与安全边界这类工具本质上是“读取其他进程窗口信息”并“移动自己的窗口”属于常规桌面自动化不涉及注入、Hook、密码读取等敏感行为。但作为工程实践仍要遵守几个边界只读取窗口标题和矩形坐标不读取目标进程内部数据不做键盘记录、鼠标模拟、后台监听不在未授权环境中部署发布给别人使用时建议标明工具用途和运行权限要求。如果要读取的窗口属于管理员权限进程普通权限的 Python 进程可能拿不到句柄需要以管理员身份运行。这里不推荐让工具主动请求管理员权限应该在文档中说明。6.5 打包分发如果要给同事用可以用PyInstaller打包成独立 exepip install pyinstaller pyinstaller -F -w src/main.py --name codex-follower其中-w表示不显示控制台窗口。打包后注意测试 DPI 感知是否仍然生效因为某些打包方式会改变进程的 DPI 设置。7. 总结与后续优化方向这个小工具解决的核心问题是让一个悬浮窗始终跟随另一个窗口移动。背后涉及的桌面 API 和 UI 细节不算难但组合起来却非常实用。通过本文你已经掌握用 psutil 查找目标进程 PID用 EnumWindows 遍历顶层窗口并匹配 PID用 GetWindowRect 读取窗口坐标用 PySide6 创建无边框、置顶、可点击穿透的悬浮窗用 QTimer 做低频轮询和位置同步用 DPI 感知解决多缩放环境下的坐标偏差。后续如果想继续完善可以往这几个方向扩展给悬浮窗增加可配置文本例如显示当前环境变量、最近一条命令执行状态支持配置文件把进程名、关键词、偏移量、是否穿透放到一个config.json中增加托盘图标让悬浮窗在无目标窗口时也能从托盘恢复增加多目标跟随能力比如同时跟随 Codex 窗口和测试日志窗口做跨平台适配macOS 上可以使用 Quartz 窗口 APILinux 上则可以借助 X11 的_NET_CLIENT_LIST。如果你在终端环境里经常需要盯着多个窗口的状态这个跟随悬浮窗的思路可以给你省下不少切换焦点的时间。动手改一改参数让它变成你自己的生产力小工具会比直接下载一个现成的软件更有意思。欢迎在评论区分享你的扩展玩法。
返回列表