Skip to content

Commit fd54b04

Browse files
authored
[1283] 修复启动页按下 Shift+方向键崩溃问题,改用 QShortcut 注册启动页标签快捷键 (#4522)
1 parent 7121062 commit fd54b04

5 files changed

Lines changed: 368 additions & 19 deletions

File tree

TeXmacs/tests/python/1283.py

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""
4+
| Tester | Platform | Status |
5+
| ----------------------- | ----------- | ------ |
6+
| Darcy Shen <da@liii.pro>| Linux (X11) | Passed |
7+
8+
Automated end-to-end UI test for issue 1283:
9+
1. Pressing Shift + arrow keys (Left, Right, Up, Down) on the Startup page
10+
(tmfs://startup-tab) must not crash Mogan STEM.
11+
2. Tab shortcuts on the startup page (Ctrl+T to create a new tab, Ctrl+1/Ctrl+2
12+
to switch between tabs) must work as expected.
13+
3. Tab shortcuts inside document tabs must continue to work.
14+
"""
15+
16+
import os
17+
import sys
18+
import time
19+
import subprocess
20+
21+
# Ensure UTF-8 and unbuffered stdout
22+
if hasattr(sys.stdout, "reconfigure"):
23+
sys.stdout.reconfigure(line_buffering=True, encoding="utf-8")
24+
if hasattr(sys.stderr, "reconfigure"):
25+
sys.stderr.reconfigure(line_buffering=True, encoding="utf-8")
26+
27+
# DPI awareness on Windows
28+
if sys.platform == "win32":
29+
try:
30+
import ctypes
31+
ctypes.windll.shcore.SetProcessDpiAwareness(2)
32+
except Exception:
33+
try:
34+
import ctypes
35+
ctypes.windll.user32.SetProcessDPIAware()
36+
except Exception:
37+
pass
38+
39+
# Fallback: import pynput from ~/git/pynput/lib if not installed system-wide
40+
try:
41+
import pynput
42+
except ImportError:
43+
pynput_path = os.path.expanduser("~/git/pynput/lib")
44+
if os.path.exists(pynput_path):
45+
sys.path.insert(0, pynput_path)
46+
import pynput
47+
48+
from pynput.keyboard import Key, Controller as KeyboardController
49+
from pynput.mouse import Button, Controller as MouseController
50+
51+
IS_WINDOWS = sys.platform == "win32"
52+
IS_DARWIN = sys.platform == "darwin"
53+
54+
55+
def find_repo_root():
56+
cur = os.path.abspath(os.path.dirname(__file__))
57+
while cur != "/" and cur != os.path.dirname(cur):
58+
if os.path.exists(os.path.join(cur, "TeXmacs")) and os.path.exists(os.path.join(cur, "src")):
59+
return cur
60+
cur = os.path.dirname(cur)
61+
return os.path.abspath(".")
62+
63+
64+
def find_mogan_binary(repo_root):
65+
candidates = [
66+
os.path.join(repo_root, "build/linux/x86_64/releasedbg/moganstem"),
67+
os.path.join(repo_root, "build/linux/x86_64/release/moganstem"),
68+
os.path.join(repo_root, "build/linux/x86_64/debug/moganstem"),
69+
os.path.join(repo_root, "build/packages/stem/data/bin/MoganSTEM.exe"),
70+
os.path.join(repo_root, "build/windows/x64/releasedbg/MoganSTEM.exe"),
71+
os.path.join(repo_root, "build/windows/x64/release/MoganSTEM.exe"),
72+
os.path.join(repo_root, "build/macosx/arm64/releasedbg/MoganSTEM.app/Contents/MacOS/MoganSTEM"),
73+
os.path.join(repo_root, "build/macosx/arm64/release/MoganSTEM.app/Contents/MacOS/MoganSTEM"),
74+
os.path.join(repo_root, "build/macosx/x86_64/releasedbg/MoganSTEM.app/Contents/MacOS/MoganSTEM"),
75+
os.path.join(repo_root, "build/macosx/x86_64/release/MoganSTEM.app/Contents/MacOS/MoganSTEM"),
76+
]
77+
for c in candidates:
78+
if os.path.exists(c):
79+
return c
80+
raise FileNotFoundError("Mogan binary not found. Please build stem first (xmake b stem).")
81+
82+
83+
def focus_mogan_window():
84+
"""Ensure Mogan window is raised and focused across platforms."""
85+
if IS_DARWIN:
86+
try:
87+
import AppKit
88+
for app in AppKit.NSWorkspace.sharedWorkspace().runningApplications():
89+
if "Mogan" in (app.localizedName() or ""):
90+
app.activateWithOptions_(AppKit.NSApplicationActivateIgnoringOtherApps)
91+
return
92+
except Exception:
93+
pass
94+
subprocess.run(
95+
["osascript", "-e", 'tell application "System Events" to set frontmost of first process whose name contains "Mogan" to true'],
96+
capture_output=True,
97+
)
98+
return
99+
100+
if IS_WINDOWS:
101+
try:
102+
import ctypes
103+
from ctypes import wintypes
104+
user32 = ctypes.windll.user32
105+
106+
def callback(hwnd, _lparam):
107+
if user32.IsWindowVisible(hwnd):
108+
n = user32.GetWindowTextLengthW(hwnd)
109+
buf = ctypes.create_unicode_buffer(n + 1)
110+
user32.GetWindowTextW(hwnd, buf, n + 1)
111+
if "STEM" in buf.value or "Mogan" in buf.value:
112+
user32.ShowWindow(hwnd, 9) # SW_RESTORE
113+
user32.SetForegroundWindow(hwnd)
114+
return False
115+
return True
116+
117+
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
118+
user32.EnumWindows(EnumWindowsProc(callback), 0)
119+
except Exception:
120+
pass
121+
return
122+
123+
# Linux (X11)
124+
try:
125+
import Xlib.display
126+
import Xlib.X
127+
import Xlib.protocol.event
128+
129+
d = Xlib.display.Display()
130+
root = d.screen().root
131+
132+
def find_mogan(win):
133+
try:
134+
cls = win.get_wm_class()
135+
if cls and "mogan" in cls[0].lower():
136+
return win
137+
name = win.get_wm_name()
138+
if name and ("Mogan" in name or "STEM" in name):
139+
return win
140+
for child in win.query_tree().children:
141+
res = find_mogan(child)
142+
if res:
143+
return res
144+
except Exception:
145+
pass
146+
return None
147+
148+
w = find_mogan(root)
149+
if w:
150+
net_active = d.intern_atom("_NET_ACTIVE_WINDOW")
151+
cm = Xlib.protocol.event.ClientMessage(
152+
window=w,
153+
client_type=net_active,
154+
data=(32, [2, Xlib.X.CurrentTime, 0, 0, 0]),
155+
)
156+
root.send_event(
157+
cm,
158+
event_mask=Xlib.X.SubstructureRedirectMask | Xlib.X.SubstructureNotifyMask,
159+
)
160+
w.set_input_focus(Xlib.X.RevertToParent, Xlib.X.CurrentTime)
161+
w.configure(stack_mode=Xlib.X.Above)
162+
d.sync()
163+
except Exception:
164+
pass
165+
166+
167+
def run_test():
168+
repo_root = find_repo_root()
169+
bin_path = find_mogan_binary(repo_root)
170+
print(f"[1283] Using binary: {bin_path}")
171+
172+
env = os.environ.copy()
173+
env["TEXMACS_PATH"] = os.path.join(repo_root, "TeXmacs")
174+
175+
print("[1283] Launching Mogan STEM...")
176+
proc = subprocess.Popen([bin_path, "-d"], env=env, cwd=repo_root)
177+
modifier = Key.cmd if IS_DARWIN else Key.ctrl
178+
179+
try:
180+
time.sleep(3.0)
181+
ret = proc.poll()
182+
if ret is not None:
183+
print(f"[1283] ERROR: Mogan exited prematurely with code {ret}")
184+
return 1
185+
186+
print("[1283] Focusing Mogan window...")
187+
focus_mogan_window()
188+
time.sleep(0.5)
189+
190+
# Click inside the startup page content area to guarantee keyboard focus
191+
mouse = MouseController()
192+
mouse.position = (1000, 1000)
193+
time.sleep(0.3)
194+
mouse.click(Button.left)
195+
time.sleep(0.5)
196+
197+
kb = KeyboardController()
198+
199+
# Step 1: Verify Shift+Arrow keys on the startup page do NOT crash
200+
test_arrows = [
201+
(Key.left, "Shift+Left"),
202+
(Key.right, "Shift+Right"),
203+
(Key.up, "Shift+Up"),
204+
(Key.down, "Shift+Down"),
205+
]
206+
207+
for key, name in test_arrows:
208+
print(f"[1283] Pressing {name} on startup page...")
209+
with kb.pressed(Key.shift):
210+
kb.press(key)
211+
kb.release(key)
212+
213+
time.sleep(0.5)
214+
ret = proc.poll()
215+
if ret is not None:
216+
print(f"[1283] CRASH REPRODUCED: Mogan crashed on {name} with exit code {ret}!")
217+
return 1
218+
print(f"[1283] {name} passed (no crash).")
219+
220+
# Step 2: Verify Ctrl+T creates a new tab from the startup page
221+
print("[1283] Pressing Ctrl+T on startup page to create a new tab...")
222+
with kb.pressed(modifier):
223+
kb.press("t")
224+
kb.release("t")
225+
time.sleep(1.5)
226+
227+
ret = proc.poll()
228+
if ret is not None:
229+
print(f"[1283] CRASH: Mogan crashed after Ctrl+T with exit code {ret}!")
230+
return 1
231+
232+
# Step 3: Switch back to startup tab (Ctrl+1) and to second tab (Ctrl+2)
233+
print("[1283] Pressing Ctrl+1 to switch to first tab (startup page)...")
234+
with kb.pressed(modifier):
235+
kb.press("1")
236+
kb.release("1")
237+
time.sleep(1.0)
238+
239+
ret = proc.poll()
240+
if ret is not None:
241+
print(f"[1283] CRASH: Mogan crashed after Ctrl+1 with exit code {ret}!")
242+
return 1
243+
244+
print("[1283] Pressing Ctrl+2 to switch to second tab...")
245+
with kb.pressed(modifier):
246+
kb.press("2")
247+
kb.release("2")
248+
time.sleep(1.0)
249+
250+
ret = proc.poll()
251+
if ret is not None:
252+
print(f"[1283] CRASH: Mogan crashed after Ctrl+2 with exit code {ret}!")
253+
return 1
254+
255+
# Step 4: Press Ctrl+T inside the second tab to create a third tab
256+
print("[1283] Pressing Ctrl+T in second tab to create third tab...")
257+
with kb.pressed(modifier):
258+
kb.press("t")
259+
kb.release("t")
260+
time.sleep(1.5)
261+
262+
ret = proc.poll()
263+
if ret is not None:
264+
print(f"[1283] CRASH: Mogan crashed after Ctrl+T in tab with exit code {ret}!")
265+
return 1
266+
267+
# Step 5: Switch among tabs using Ctrl+1 and Ctrl+3
268+
print("[1283] Pressing Ctrl+1 then Ctrl+3...")
269+
with kb.pressed(modifier):
270+
kb.press("1")
271+
kb.release("1")
272+
time.sleep(0.8)
273+
274+
with kb.pressed(modifier):
275+
kb.press("3")
276+
kb.release("3")
277+
time.sleep(0.8)
278+
279+
ret = proc.poll()
280+
if ret is not None:
281+
print(f"[1283] CRASH: Mogan crashed during tab switching with exit code {ret}!")
282+
return 1
283+
284+
print("[1283] TEST PASSED: All Shift+Arrow keys and tab shortcuts work properly.")
285+
return 0
286+
287+
finally:
288+
if proc.poll() is None:
289+
print("[1283] Terminating Mogan...")
290+
proc.terminate()
291+
try:
292+
proc.wait(timeout=3)
293+
except subprocess.TimeoutExpired:
294+
proc.kill()
295+
296+
297+
if __name__ == "__main__":
298+
sys.exit(run_test())

devel/1283.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# [1283] 修复启动页按下 Shift+方向键崩溃问题
2+
3+
## 1 相关文档
4+
- [216_14.md](216_14.md) - 启动页通用键盘桥接(历史变更)
5+
6+
## 2 任务相关的代码文件
7+
- `src/Plugins/Qt/QTMStartupTabWidget.hpp` — 移除 keyPressEvent / keyReleaseEvent 声明,增加 setup_shortcuts
8+
- `src/Plugins/Qt/QTMStartupTabWidget.cpp` — 移除通用 key-press 转发,改用 Qt 原生 QShortcut 注册标签页快捷键(Ctrl+T / Ctrl+1~9)
9+
- `src/Typeset/Boxes/Basic/boxes.cpp``find_innermost_scroll` 增加防御性判空 `if (is_nil (b)) return path ();`
10+
- `TeXmacs/tests/python/1283.py` — 基于 pynput 的端到端自动化测试脚本
11+
12+
## 3 如何测试
13+
14+
### 3.1 端到端自动化测试
15+
```bash
16+
xmake b stem
17+
python3 TeXmacs/tests/python/1283.py
18+
```
19+
20+
测试流程:
21+
1. 启动 Mogan STEM,聚焦到启动页;
22+
2. 依次按下 `Shift+Left``Shift+Right``Shift+Up``Shift+Down`,验证进程未崩溃;
23+
3. 在启动页按下 `Ctrl+T` 新建标签页,验证标签页创建成功;
24+
4.`Ctrl+1` 切回启动页,按 `Ctrl+2` 切到新建标签页;
25+
5. 在新建标签页中再次按 `Ctrl+T` 新建第 3 个标签页,按 `Ctrl+1``Ctrl+3` 验证标签页切换正常。
26+
27+
### 3.2 手动测试
28+
1. 启动 Mogan STEM,保持在启动页;
29+
2.`Shift + 左/右/上/下` 箭头键,确认程序不崩溃;
30+
3.`Ctrl + T`,确认新建标签页打开;
31+
4.`Ctrl + 1`,切回启动页;
32+
5.`Ctrl + 2`,切回文档页。
33+
34+
## 4 What
35+
启动页(`tmfs://startup-tab`)处于焦点状态时,按下 `Shift + 任意方向键`,程序直接崩溃(`SIGSEGV`,信号 11)。
36+
37+
## 5 Why
38+
1. 历史改动(`216_14`)为了让 `Ctrl+T``Ctrl+1..6` 在启动页生效,在 `QTMStartupTabWidget` 中实现了 `keyPressEvent` / `keyReleaseEvent`,将所有键盘事件通过 `eval_scheme("(key-press ...)")` 转发到 Scheme 侧文本编辑器。
39+
2. Scheme 侧 `generic-kbd.scm` 中将 `Shift+方向键``S-left`, `S-right`, `S-up`, `S-down`)绑定为选区移动操作 `(kbd-select kbd-*)`,最终调用当前编辑器的 `go_left()` / `go_right()` / `cursor_move_sub()`
40+
3. 启动页由纯 Qt 控件承载,没有文档排版盒,其 `get_box()` 为空(`is_nil(b)`)。`cursor_move_sub` 中调用 `find_innermost_scroll(get_box(), ...)`,未对 `b` 进行判空就直接解引用 `b->find_box_path(...)`,引发空指针解引用崩溃(`SIGSEGV`)。
41+
4. 启动页是纯 Qt 控件界面,不应该复用 Scheme 文本编辑器的 `key-press` 机制。
42+
43+
## 6 How
44+
1. **移除启动页对 Scheme `key-press` 的无差别转发**
45+
`QTMStartupTabWidget` 中移除 `keyPressEvent``keyReleaseEvent` 覆盖,让 Qt 原生子控件自然处理键盘事件(列表选择、按钮切换等),不再误触发文档编辑器的光标与选区移动。
46+
2. **使用 Qt 原生 `QShortcut` 注册启动页快捷键**
47+
`QTMStartupTabWidget::setup_shortcuts()` 中通过 `QShortcut` 直接注册启动页所需的标签页快捷键:
48+
- `QKeySequence::AddTab``Ctrl+T` / `Cmd+T`):触发 `(new-document)`
49+
- `Qt::CTRL | (Qt::Key_1 + i)``Ctrl+1` ~ `Ctrl+9`):触发 `(switch-to-view-index i)`
50+
由于 `QShortcut` 附着于 `QTMStartupTabWidget`,在切换到文档视图导致启动页隐藏时,这些快捷键由 Qt 机制自动休眠,文档视图原有的 Scheme 快捷键机制正常接管。
51+
3. **防御性编程**
52+
`src/Typeset/Boxes/Basic/boxes.cpp``find_innermost_scroll` 函数开头增加 `if (is_nil (b)) return path ();` 判空保护,防止任何空盒调用导致崩溃。

0 commit comments

Comments
 (0)