Skip to content

Commit 34a177e

Browse files
committed
fix: 🐛 修复启动主窗口出现{死窗口、完全透明}问题
1 parent 7b3b26a commit 34a177e

10 files changed

Lines changed: 331 additions & 158 deletions

File tree

docs/auto_hide.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
- 热区位于屏幕左上,宽 `screenWidth / 4`、高 6~24px,仅在按住 Ctrl 且鼠标位于热区时才从托盘唤醒,避免全屏或移动时误触。
1212
- 唤醒时短暂设置窗口 TopMost,防止被全屏应用遮挡,随后恢复原状态。
1313
- 托盘菜单唤起走同一流程,确保第一下就能拿到窗口句柄。
14+
- 热区 watcher 在应用启动时即创建定时器,但会通过 `shouldWatch()` 动态判定是否需要工作;避免因为启动瞬间不满足条件而“永远不启动”。
15+
- 热区触发改为“边沿触发”(进入热区且按住 Ctrl 的第一次触发),避免按住 Ctrl 停留在热区时重复唤起导致时序竞态。
1416

1517
### 磁吸与吸附条件
1618
- 磁吸区更宽:`screenWidth / 6` × `screenHeight / 3`,越界(x < 0 或 y < 0)也视为需要靠边。
@@ -35,3 +37,7 @@
3537
- **原因**:设置页面属于沉浸式配置场景,用户可能需要频繁切换窗口(如选择背景图片、查阅资料),或者进行长时间的微调操作。
3638
- **实现**:在 `onWindowBlur` 和自动隐藏定时器中检查当前 `selectedIndex`,如果是设置页(Index 2)则直接返回。
3739
- **效果**:即使窗口失去焦点(如点击了外部或切换了应用),只要 DeskTidy 停留在设置页,窗口就会保持显示,直到用户主动关闭或切换页面。
40+
41+
## 热区响应性(2026-02-05)
42+
- 之前热区检测使用秒级轮询(1.2s~1.5s),会让“Ctrl+鼠标进热区”看起来需要晃两下才触发(本质是错过了 tick)。
43+
- 现在热区 watcher 使用 `80ms` 轮询 + “边沿触发”,进入热区且按住 Ctrl 的第一次 tick 立刻触发,停留不会重复触发。

docs/behavior/search_input.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# 搜索输入(Type-to-Search)行为说明
2+
3+
## 目标
4+
在应用主页或其他 Tab 页面中,用户直接键盘输入时,应自动聚焦到搜索框并把输入内容写入。
5+
6+
## 现状实现
7+
- 在主页根部增加全局 `Focus`,监听 `onKeyEvent`(仅在没有文本输入焦点时生效)。
8+
- 当捕获到可打印字符:
9+
1. 自动切换到 **应用 Tab**
10+
2. 聚焦搜索框
11+
3. 将字符写入搜索框并更新搜索结果
12+
13+
## 关键代码
14+
- `lib/screens/desk_tidy_home/state.dart`:根部 `Focus` 与全局焦点节点
15+
- `lib/screens/desk_tidy_home/logic_search.dart``_handleGlobalKeyEvent()` 输入转发逻辑
16+
17+
## 注意事项
18+
- 如果当前已有文本输入控件聚焦,则不会劫持按键,避免干扰设置页输入。
19+

docs/hotkey.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ await windowManager.show();
197197
198198
补充:唤起后会自动聚焦搜索框;当搜索框已有内容时,会尝试全选,便于直接覆盖输入。
199199
另外:热键唤起时会优先切到“应用”Tab 再显示窗口,避免窗口出现后再发生 Tab 跳转带来的卡顿/不流畅观感。
200+
如遇到透明空白窗口,唤起流程会强制恢复窗口不透明度(防止被异常中断卡在 0.0)。
201+
如果出现“窗口可点击但完全透明/无UI”的情况,窗口聚焦时会强制恢复面板可见性,避免卡在隐藏态。
202+
补充:唤起前会等待一帧完成 UI 构建,并且会取消“延迟隐藏”的过期任务,避免出现“刚显示又被隐藏/白屏”的竞态问题。
203+
补充:当“延迟隐藏”被新唤起打断时,会自动恢复面板可见性,防止窗口保持透明但仍可点击的异常状态。
204+
补充:为规避极低概率的 Release 渲染不同步,`show()` 后会额外延迟追加两次 1px 尺寸“震动”(160ms/420ms)来强制触发 `WM_SIZE`
200205

201206
## 窗口布局配置
202207

docs/rendering_glitch_debug.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ await windowManager.setSize(currentSize);
5353

5454
**原理**:通过 1 像素的尺寸往返跳变,强制产生 `WM_SIZE` 消息,从而触发 C++ 端的 `SyncChildContentSize()`
5555

56+
### 4. 额外兜底:延迟二次“震动”(2026-02-05)
57+
在极少数情况下(尤其是“刚隐藏/刚启动应用 -> 立刻热键唤起”这类时序),一次性震动可能发生在窗口尚未完全进入可呈现状态的时间点,导致仍然出现“白/透明/死界面(可点击但不刷新)”。
58+
59+
兜底策略:在 `show()` 后除了立刻震动外,再延迟追加 160ms / 420ms 两次震动,并通过 token 校验避免在隐藏流程中误触发。
60+
5661
## 涉及文件
5762

5863
| 文件 | 变更内容 |

lib/screens/desk_tidy_home/logic_bootstrap.dart

Lines changed: 96 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -33,30 +33,104 @@ extension _DeskTidyHomeBootstrap on _DeskTidyHomePageState {
3333
service.stopPolling();
3434
}
3535

36+
void _pokeUi() {
37+
if (!mounted) return;
38+
_setState(() {});
39+
SchedulerBinding.instance.scheduleFrame();
40+
}
41+
42+
Future<void> _awaitUiFrame({
43+
Duration timeout = const Duration(milliseconds: 120),
44+
}) async {
45+
final binding = WidgetsBinding.instance;
46+
final frameFuture = binding.endOfFrame;
47+
binding.scheduleFrame();
48+
try {
49+
await frameFuture.timeout(timeout);
50+
} catch (_) {}
51+
}
52+
53+
Future<void> _prepareUiForShow({bool forceAppTab = false}) async {
54+
_visibilityToken++;
55+
if (mounted) {
56+
_setState(() {
57+
_panelVisible = true;
58+
if (forceAppTab) _selectedIndex = 0;
59+
});
60+
}
61+
await _awaitUiFrame();
62+
}
63+
64+
Future<void> _nudgeWindowSizeForRedraw({required int token}) async {
65+
if (!mounted) return;
66+
if (_trayMode || !_panelVisible) return;
67+
if (_visibilityToken != token) return;
68+
try {
69+
final currentSize = await windowManager.getSize();
70+
await windowManager.setSize(
71+
Size(currentSize.width + 1, currentSize.height),
72+
);
73+
await windowManager.setSize(currentSize);
74+
} catch (_) {}
75+
_pokeUi();
76+
}
77+
78+
void _scheduleRedrawNudges() {
79+
final token = _visibilityToken;
80+
unawaited(_nudgeWindowSizeForRedraw(token: token));
81+
unawaited(
82+
Future.delayed(
83+
const Duration(milliseconds: 160),
84+
() => _nudgeWindowSizeForRedraw(token: token),
85+
),
86+
);
87+
unawaited(
88+
Future.delayed(
89+
const Duration(milliseconds: 420),
90+
() => _nudgeWindowSizeForRedraw(token: token),
91+
),
92+
);
93+
}
94+
95+
Future<void> _ensureWindowOpaque() async {
96+
try {
97+
await windowManager.setOpacity(1.0);
98+
} catch (_) {}
99+
unawaited(
100+
Future.delayed(const Duration(milliseconds: 120), () async {
101+
try {
102+
await windowManager.setOpacity(1.0);
103+
} catch (_) {}
104+
}),
105+
);
106+
}
107+
108+
void _ensurePanelVisible() {
109+
if (!mounted) return;
110+
if (_trayMode) return;
111+
if (!_panelVisible) {
112+
_setState(() => _panelVisible = true);
113+
}
114+
}
115+
36116
Future<void> _bringWindowToFrontFromHotkey() async {
37117
_windowHandle = findMainFlutterWindowHandle() ?? _windowHandle;
38118
_trayMode = false;
39119
_lastActivationMode = _ActivationMode.hotkey;
40120
_ignoreBlurUntil = DateTime.now().add(const Duration(milliseconds: 600));
41-
42-
if (mounted) {
43-
// Switch to App tab before showing the window to avoid a visible
44-
// "tab jump" (e.g. from Settings -> Apps) after the window is already up.
45-
if (!_panelVisible || _selectedIndex != 0) {
46-
_setState(() {
47-
_panelVisible = true;
48-
_selectedIndex = 0;
49-
});
50-
}
51-
}
121+
await _prepareUiForShow(forceAppTab: true);
52122

53123
await windowManager.setAlwaysOnTop(true);
54124
await windowManager.setSkipTaskbar(true);
55125
await windowManager.restore();
56126
await windowManager.show();
127+
_scheduleRedrawNudges();
57128

58129
_dockManager.onPresentFromHotkey();
59130
_updateHotkeyPolling();
131+
await _ensureWindowOpaque();
132+
_ensurePanelVisible();
133+
_pokeUi();
60134

61135
forceSetForegroundWindow(_windowHandle);
62136
await windowManager.focus();
@@ -97,14 +171,7 @@ extension _DeskTidyHomeBootstrap on _DeskTidyHomePageState {
97171
_ignoreBlurUntil = DateTime.now().add(const Duration(milliseconds: 600));
98172

99173
// 先准备内容,避免白屏闪烁
100-
if (mounted) {
101-
// Prepare content and switch to App tab before showing the window.
102-
// This makes hotkey wake-up feel immediate and avoids a delayed tab swap.
103-
_setState(() {
104-
_panelVisible = true;
105-
_selectedIndex = 0;
106-
});
107-
}
174+
await _prepareUiForShow(forceAppTab: true);
108175

109176
// 加载快捷键专属窗口布局并应用
110177
final layout = await AppPreferences.loadHotkeyWindowLayout();
@@ -117,24 +184,15 @@ extension _DeskTidyHomeBootstrap on _DeskTidyHomePageState {
117184
Offset(bounds.x.toDouble(), bounds.y.toDouble()),
118185
);
119186

120-
// [Anti-Flash] 先设置透明度为0,防止白屏闪烁
121-
await windowManager.setOpacity(0.0);
122-
123187
await windowManager.setAlwaysOnTop(true);
124188
await windowManager.setSkipTaskbar(true);
125189
await windowManager.restore(); // 先恢复窗口状态
126190
await windowManager.show(); // 再显示窗口
127191

128-
// [Fix] Force a tiny resize to trigger WM_SIZE and sync child HWND in Release mode
129-
final currentSize = await windowManager.getSize();
130-
await windowManager.setSize(
131-
Size(currentSize.width + 1, currentSize.height),
132-
);
133-
await windowManager.setSize(currentSize);
192+
_scheduleRedrawNudges();
134193

135-
// 等待一帧渲染
136-
await Future.delayed(const Duration(milliseconds: 50));
137-
await windowManager.setOpacity(1.0);
194+
await _ensureWindowOpaque();
195+
_ensurePanelVisible();
138196

139197
_dockManager.onPresentFromHotkey();
140198
_updateHotkeyPolling();
@@ -144,6 +202,7 @@ extension _DeskTidyHomeBootstrap on _DeskTidyHomePageState {
144202
await windowManager.focus(); // 也调用 Flutter 的 focus 作为补充
145203
await _syncDesktopIconVisibility();
146204
// _startDesktopIconSync removed (handled by service)
205+
_pokeUi();
147206

148207
unawaited(
149208
Future.delayed(const Duration(milliseconds: 800), () {
@@ -162,6 +221,9 @@ extension _DeskTidyHomeBootstrap on _DeskTidyHomePageState {
162221
});
163222
});
164223
} finally {
224+
unawaited(_ensureWindowOpaque());
225+
_ensurePanelVisible();
226+
_pokeUi();
165227
_hotkeyPresentInFlight = false;
166228
if (_hotkeyRefocusRequested) {
167229
_hotkeyRefocusRequested = false;
@@ -233,11 +295,14 @@ extension _DeskTidyHomeBootstrap on _DeskTidyHomePageState {
233295
await windowManager.setSkipTaskbar(false);
234296
await windowManager.show();
235297
await windowManager.restore();
298+
_scheduleRedrawNudges();
236299
await windowManager.focus();
237300
await _syncDesktopIconVisibility();
238301
if (mounted) _setState(() => _panelVisible = true);
239302
// _startDesktopIconSync removed
240303
_onMainWindowPresented();
304+
unawaited(_ensureWindowOpaque());
305+
_pokeUi();
241306
}
242307
_updateHotkeyPolling();
243308
},

lib/screens/desk_tidy_home/logic_runtime.dart

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ extension _DeskTidyHomeRuntime on _DeskTidyHomePageState {
99
_lastActivationMode = _ActivationMode.hotCorner;
1010

1111
// 先准备内容,避免白屏闪烁
12-
if (mounted) _setState(() => _panelVisible = true);
12+
await _prepareUiForShow();
1313

1414
// 加载热区专属窗口布局并应用
1515
final layout = await AppPreferences.loadHotCornerWindowLayout();
@@ -26,18 +26,13 @@ extension _DeskTidyHomeRuntime on _DeskTidyHomePageState {
2626
await windowManager.setSkipTaskbar(true);
2727
await windowManager.restore(); // 先恢复窗口状态
2828
await windowManager.show(); // 再显示窗口
29-
30-
// [Fix] Force a tiny resize to trigger WM_SIZE and sync child HWND in Release mode
31-
final currentSize = await windowManager.getSize();
32-
await windowManager.setSize(
33-
Size(currentSize.width + 1, currentSize.height),
34-
);
35-
await windowManager.setSize(currentSize);
29+
_scheduleRedrawNudges();
3630

3731
_dockManager.onPresentFromHotCorner();
3832
await windowManager.focus();
3933
await _syncDesktopIconVisibility();
4034
_onMainWindowPresented();
35+
_pokeUi();
4136
// Drop always-on-top after we are visible.
4237
unawaited(
4338
Future.delayed(const Duration(milliseconds: 800), () {
@@ -54,16 +49,18 @@ extension _DeskTidyHomeRuntime on _DeskTidyHomePageState {
5449
_lastActivationMode = _ActivationMode.tray;
5550

5651
// 先准备内容,避免白屏闪烁
57-
if (mounted) _setState(() => _panelVisible = true);
52+
await _prepareUiForShow();
5853

5954
await windowManager.setAlwaysOnTop(true);
6055
await windowManager.setSkipTaskbar(true);
6156
await windowManager.restore(); // 先恢复窗口状态
6257
await windowManager.show(); // 再显示窗口
58+
_scheduleRedrawNudges();
6359
_dockManager.onPresentFromTray();
6460
await windowManager.focus();
6561
await _syncDesktopIconVisibility();
6662
_onMainWindowPresented();
63+
_pokeUi();
6764
unawaited(
6865
Future.delayed(const Duration(milliseconds: 800), () {
6966
windowManager.setAlwaysOnTop(false);
@@ -75,10 +72,19 @@ extension _DeskTidyHomeRuntime on _DeskTidyHomePageState {
7572
_dockManager.onDismissToTray();
7673
_trayMode = true;
7774
_updateHotkeyPolling();
75+
final hideToken = ++_visibilityToken;
7876
if (mounted) _setState(() => _panelVisible = false);
7977
_setupAutoRefresh();
8078
await windowManager.setSkipTaskbar(true);
8179
await Future<void>.delayed(_DeskTidyHomePageState._hotAnimDuration);
80+
if (!mounted) return;
81+
if (_visibilityToken != hideToken || !_trayMode) {
82+
if (mounted && !_panelVisible) {
83+
_setState(() => _panelVisible = true);
84+
_pokeUi();
85+
}
86+
return;
87+
}
8288
await windowManager.hide();
8389
}
8490

lib/screens/desk_tidy_home/logic_search.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,47 @@ extension _DeskTidyHomeSearchLogic on _DeskTidyHomePageState {
150150
return KeyEventResult.handled;
151151
}
152152

153+
bool _isTextInputFocused() {
154+
final primary = FocusManager.instance.primaryFocus;
155+
if (primary == null) return false;
156+
return primary.context?.widget is EditableText;
157+
}
158+
159+
KeyEventResult _handleGlobalKeyEvent(FocusNode node, KeyEvent event) {
160+
if (event is! KeyDownEvent) return KeyEventResult.ignored;
161+
162+
if (HardwareKeyboard.instance.isControlPressed ||
163+
HardwareKeyboard.instance.isAltPressed ||
164+
HardwareKeyboard.instance.isMetaPressed) {
165+
return KeyEventResult.ignored;
166+
}
167+
168+
if (_isTextInputFocused() || _appSearchFocus.hasFocus) {
169+
return KeyEventResult.ignored;
170+
}
171+
172+
final ch = event.character;
173+
if (ch == null || ch.isEmpty) return KeyEventResult.ignored;
174+
if (ch == '\n' || ch == '\r' || ch == '\t') {
175+
return KeyEventResult.ignored;
176+
}
177+
178+
if (_selectedIndex != 0) {
179+
_setState(() => _selectedIndex = 0);
180+
}
181+
182+
final current = _appSearchController.text;
183+
final next = '$current$ch';
184+
_appSearchController.text = next;
185+
_appSearchController.selection = TextSelection.collapsed(
186+
offset: next.length,
187+
);
188+
_updateSearchQuery(next);
189+
_focusSearchField(selectAllIfHasText: false);
190+
191+
return KeyEventResult.handled;
192+
}
193+
153194
// ...
154195

155196
// 提取 LayoutMetrics 类或方法来统一计算

0 commit comments

Comments
 (0)