如果你也经常用 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只负责底层窗口 API,floating_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 如何读取窗口位置
拿到窗口句柄后,读取位置使用GetWindowRect:
rect.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 Aware,Windows 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_name="codex.exe", keyword="codex", offset_x=12, offset_y=12, passthrough=True, ) 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_name="Code.exe", keyword="Visual Studio Code", offset_x=12, offset_y=12, passthrough=True, )由于查找逻辑按“进程名 + 窗口标题关键词”过滤,支持范围很广。需要注意,进程名可能不叫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(level=logging.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打包成独立 exe:
pip 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 窗口 API,Linux 上则可以借助 X11 的
_NET_CLIENT_LIST。
如果你在终端环境里经常需要盯着多个窗口的状态,这个跟随悬浮窗的思路可以给你省下不少切换焦点的时间。动手改一改参数,让它变成你自己的生产力小工具,会比直接下载一个现成的软件更有意思。欢迎在评论区分享你的扩展玩法。