Browse Source

触摸屏可以正常输入了

张洋 3 weeks ago
parent
commit
84dac70fa2

+ 394 - 11
DuiLib/Control/UIEdit.cpp

@@ -42,6 +42,100 @@ namespace DuiLib
 		return dwTid;
 	}
 
+	// r37 新增:抽"前台窗口属于哪个进程" —— 用来把两种"焦点空了"分开:
+	//   前台还是本进程 ⇒ 只是键盘焦点被抢(可以且应该抢回来);
+	//   前台已经是别的进程 ⇒ 用户主动切走了应用(绝不能抢)。
+	static DWORD DiagWndPid(HWND h)
+	{
+		DWORD dwPid = 0;
+		if (h != NULL && ::IsWindow(h)) ::GetWindowThreadProcessId(h, &dwPid);
+		return dwPid;
+	}
+
+	// 前向声明:DiagFgIsTouchKb 要用它做进程名白名单,实现放下面(见 DiagProcName)。
+	static void DiagProcName(HWND h, LPTSTR szOut, int cch);
+
+	// r38 新增:把"前台窗口是不是触摸键盘"这一件事判死 —— 本轮唯一需要保护的判据。
+	//
+	// Win10 的现代触摸键盘由 TextInputHost.exe 承载,窗口类就是
+	// `Windows.UI.Core.CoreWindow`(真机日志实证:fgClass=Windows.UI.Core.CoreWindow
+	//  fgPid=7012 fgTid=7080);老式 TabTip 的宿主类名是 `IPTip_Main_Window`。两个都收。
+	// 只有命中这两个才认为"前台是被触摸键盘拿走的",其余一律返回 false
+	//   ⇒ 用户主动切到别的应用时**绝不抢**,正常多任务完全不受影响。
+	static bool DiagFgIsTouchKb(HWND h)
+	{
+		if (h == NULL || !::IsWindow(h)) return false;
+		TCHAR szCls[128] = { 0 };
+		::GetClassName(h, szCls, _countof(szCls));
+		if (_tcsicmp(szCls, _T("Windows.UI.Core.CoreWindow")) != 0
+			&& _tcsicmp(szCls, _T("IPTip_Main_Window")) != 0)
+		{
+			return false;   // 类名不对 ⇒ 与触摸键盘无关(例如用户点了桌面/别的应用)
+		}
+
+		// 类名只是必要条件 —— `Windows.UI.Core.CoreWindow` 是**所有 UWP 应用**共用的类名,
+		// 单凭它会把"用户切到一个 UWP 应用"也误判成"键盘夺前台"。所以再用进程名判死。
+		// 进程名取不到("?")时**放行**:取不到是极少数情况,而不抢的代价是用户按键继续
+		// 没有落点(功能直接坏掉),比偶尔打断一次切换严重得多。
+		TCHAR szExe[128] = { 0 };
+		DiagProcName(h, szExe, _countof(szExe));
+		if (szExe[0] == _T('?') || szExe[0] == _T('\0')) return true;
+		return (_tcsicmp(szExe, _T("TextInputHost.exe")) == 0
+			|| _tcsicmp(szExe, _T("TabTip.exe")) == 0
+			|| _tcsicmp(szExe, _T("InputApp.exe")) == 0);
+	}
+
+	// r38 新增:取窗口所属进程的**可执行文件名**(只要文件名,不要全路径)。
+	//
+	// 为什么不直接调 QueryFullProcessImageName:它受 `#if(_WIN32_WINNT >= 0x0600)`
+	//   保护,而本工程(DuiLib_Static)的 WINVER 兜底较低(同上面 WS_EX_NOACTIVATE 那次)
+	//   ⇒ 走 GetProcAddress 动态取,零版本依赖、不新增 lib、不改工程设置。
+	//
+	// 用途(本轮的判据靠它才判得死):
+	//   `Windows.UI.Core.CoreWindow` 这个类名**不是触摸键盘专有** —— 所有 UWP 应用都用它。
+	//   只靠类名,用户切到一个 UWP 应用时我们也会误判成"键盘夺前台"。补上进程名
+	//   (TextInputHost.exe / TabTip.exe / InputApp.exe)才能真正把两种情况分开。
+	//   取不到就返回 "?",**绝不影响任何行为**(只写日志)。
+	static void DiagProcName(HWND h, LPTSTR szOut, int cch)
+	{
+		typedef BOOL (WINAPI *PFN_QFPE)(HANDLE, DWORD, LPWSTR, PDWORD);
+		if (szOut == NULL || cch <= 0) return;
+		szOut[0] = _T('\0');
+		if (h == NULL || !::IsWindow(h)) return;
+		DWORD dwPid = 0;
+		::GetWindowThreadProcessId(h, &dwPid);
+		if (dwPid == 0) return;
+
+		HMODULE hK32 = ::GetModuleHandle(_T("kernel32.dll"));
+		PFN_QFPE pfn = (hK32 != NULL) ? (PFN_QFPE)::GetProcAddress(hK32, "QueryFullProcessImageNameW") : NULL;
+		if (pfn == NULL) { szOut[0] = _T('?'); szOut[1] = _T('\0'); return; }
+
+		// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION(Vista+;字面量以避开 SDK 版本宏)
+		HANDLE hProc = ::OpenProcess(0x1000, FALSE, dwPid);
+		if (hProc == NULL) { szOut[0] = _T('?'); szOut[1] = _T('\0'); return; }
+
+		WCHAR szFull[512] = { 0 };
+		DWORD cchLen = _countof(szFull) - 1;
+		const BOOL bOk = pfn(hProc, 0, szFull, &cchLen);
+		::CloseHandle(hProc);
+		if (!bOk) { szOut[0] = _T('?'); szOut[1] = _T('\0'); return; }
+
+		LPCTSTR pSrc = NULL;
+#ifdef UNICODE
+		pSrc = szFull;
+#else
+		char szAnsi[512] = { 0 };
+		::WideCharToMultiByte(CP_ACP, 0, szFull, -1, szAnsi, _countof(szAnsi), NULL, NULL);
+		pSrc = szAnsi;
+#endif
+		// 只留文件名(手工截取,不依赖 CRT 的 _tcsrchr / 重叠拷贝语义)
+		int nLast = -1;
+		for (int i = 0; pSrc[i] != _T('\0'); ++i) { if (pSrc[i] == _T('\\')) nLast = i; }
+		int i = 0;
+		for (int k = (nLast >= 0 ? nLast + 1 : 0); pSrc[k] != _T('\0') && i < cch - 1; ++i, ++k) szOut[i] = pSrc[k];
+		szOut[i] = _T('\0');
+	}
+
 	// 调 CPaintManagerUI 的"最近一次输入是不是触摸"的判据(见 UIManager.h)。
 	// 只用于日志标注,不参与任何判断。
 	class CEditWnd : public CWindowWnd
@@ -187,6 +281,39 @@ namespace DuiLib
 				(int)(::GetFocus() == m_hWnd ? 1 : 0),
 				(unsigned)LOWORD(dwSelStart), (unsigned)LOWORD(dwSelEnd), m_pOwner->GetText());
 		}
+
+		// ==================================================================
+		// 【r40 取证 · 只写日志,不参与任何判据】把"编辑窗此刻到底能不能编辑"钉死。
+		//
+		// 为什么需要这一行:r39 的 EDIT-KEYMSG 已经证明**按键确实到达了本窗口**
+		//   (focusIsSelf=1、WM_KEYDOWN/WM_CHAR 都有),可编辑框就是不进字。
+		//   剩下的可能只有两类,必须一刀切开:
+		//     ① **本窗口拒绝这个字符** —— 只读 / 被禁用 / 文本长度上限已满;
+		//     ② **本窗口不可见或被摆到了屏幕外** —— 字其实进去了,但用户看不见,
+		//        而 EN_CHANGE 反射回 CEditUI::m_sText 的那条链又没走通
+		//        ⇒ DuiLib 自绘的框当然是空的。
+		//   字段分工:
+		//     ro / en / limit ⇒ 切开 ①;   vis / rect ⇒ 切开 ②;
+		//     len 是基准值(和 EDIT-KEYAFTER 的 len 一比就知道字符有没有生效);
+		//     unicode / oldProc ⇒ 万一 CallWindowProc 的目标过程不对,这里能看出来。
+		// ==================================================================
+		{
+			const LONG lStyleNow = ::GetWindowLong(m_hWnd, GWL_STYLE);
+			RECT rcWndNow = { 0 };
+			::GetWindowRect(m_hWnd, &rcWndNow);
+			HWND hParNow = ::GetParent(m_hWnd);
+			CDuiString sOwnerName = m_pOwner->GetName();
+			DuiLibDiag::Log(_T("EDIT-STATE phase=init owner=%s hwnd=%p ro=%d en=%d vis=%d limit=%u len=%d style=0x%08X rect=[%d,%d,%d,%d] parent=%p/%s unicode=%d oldProc=%p"),
+				sOwnerName.GetData(), (void*)m_hWnd,
+				(int)((lStyleNow & 0x0800L) ? 1 : 0),          // 0x0800 = ES_READONLY
+				(int)(::IsWindowEnabled(m_hWnd) ? 1 : 0),
+				(int)(::IsWindowVisible(m_hWnd) ? 1 : 0),
+				(unsigned)::SendMessage(m_hWnd, 0x00D5, 0, 0),  // 0x00D5 = EM_GETLIMITTEXT(字面量以避开 SDK 版本宏)
+				(int)::GetWindowTextLength(m_hWnd), (unsigned)lStyleNow,
+				(int)rcWndNow.left, (int)rcWndNow.top, (int)rcWndNow.right, (int)rcWndNow.bottom,
+				(void*)hParNow, DiagWndClass(hParNow),
+				(int)(::IsWindowUnicode(m_hWnd) ? 1 : 0), (void*)m_OldWndProc);
+		}
 	}
 
 	RECT CEditWnd::CalPos()
@@ -329,36 +456,265 @@ namespace DuiLib
 			}
 			bHandled = FALSE;
 		}
+		else if( uMsg == WM_SETFOCUS || uMsg == WM_KEYDOWN || uMsg == WM_CHAR ) {
+			// 【r39 取证 · 只写日志,行为**完全不变**】
+			// 本分支放在 VK_RETURN / VK_TAB 两支**之后**,所以那两支的语义一字不动;
+			// 末尾照旧 `bHandled = FALSE` ⇒ 与原来"落到 else"的路径完全一致。
+			//
+			// 目的:把"按键到底有没有进到这个窗口"这一件事一次判死 ——
+			//   · 抢回之后敲键盘,这三条**一条都不出现** ⇒ 按键根本没投进来
+			//     (前台/焦点没恢复对,"事后抢回"这条路走不通);
+			//   · 出现 WM_KEYDOWN 却没有 EN_CHANGE ⇒ 按键到了、被吞(输入法/业务过滤器层);
+			//   · 出现 WM_CHAR 或直接 EDIT-CHANGE ⇒ 落点正常。
+			CDuiString sKeyOwnerName = (m_pOwner != NULL) ? m_pOwner->GetName() : CDuiString(_T("?"));
+			HWND hKeyFocus = ::GetFocus();
+			DuiLibDiag::Log(_T("EDIT-KEYMSG msg=%u owner=%s wParam=%u lParam=%u nFocus=%p focusIsSelf=%d"),
+				(unsigned)uMsg, sKeyOwnerName.GetData(), (unsigned)wParam, (unsigned)lParam,
+				(void*)hKeyFocus, (int)(hKeyFocus == m_hWnd ? 1 : 0));
+			bHandled = FALSE;
+		}
 		else bHandled = FALSE;
 
-		if( !bHandled ) return CWindowWnd::HandleMessage(uMsg, wParam, lParam);
+		if( !bHandled ) 
+		{
+			// 【r40 取证 · 行为零改变】把默认处理的返回值**原样**带出去(与原写法完全等价),
+			// 只在按键类消息之后补一行"事后状态"。
+			//
+			// 这一行是整个问题的裁判:
+			//   · len 变大 / 变小 ⇒ 编辑框**接受了**这个字符。那么"用户看不见字"就只能
+			//     是显示或反射链的问题(m_sText 没被 EN_CHANGE 更新 ⇒ DuiLib 自绘为空);
+			//   · len 不变 ⇒ 编辑框**拒绝**了它(只读/禁用/上限),或者这条消息压根没走到
+			//     真正 EDIT 的窗口过程。两者改法完全不同,所以必须先看这一行。
+			const LRESULT lDefRes = CWindowWnd::HandleMessage(uMsg, wParam, lParam);
+			if( uMsg == WM_CHAR || uMsg == WM_KEYDOWN )
+			{
+				TCHAR szTxtNow[512] = { 0 };
+				::GetWindowText(m_hWnd, szTxtNow, _countof(szTxtNow));
+				const LONG lStyleAfter = ::GetWindowLong(m_hWnd, GWL_STYLE);
+				CDuiString sOwnerAfter = (m_pOwner != NULL) ? m_pOwner->GetName() : CDuiString(_T("?"));
+				DuiLibDiag::Log(_T("EDIT-KEYAFTER msg=%u wParam=%u owner=%s len=%d ro=%d en=%d vis=%d text='%s'"),
+					(unsigned)uMsg, (unsigned)wParam, sOwnerAfter.GetData(),
+					(int)::GetWindowTextLength(m_hWnd),
+					(int)((lStyleAfter & 0x0800L) ? 1 : 0),
+					(int)(::IsWindowEnabled(m_hWnd) ? 1 : 0),
+					(int)(::IsWindowVisible(m_hWnd) ? 1 : 0),
+					szTxtNow);
+			}
+			return lDefRes;
+		}
 		return lRes;
 	}
 
 	LRESULT CEditWnd::OnKillFocus(UINT uMsg, WPARAM wParam, LPARAM lParam, BOOL& bHandled)
 	{
 		LRESULT lRes = ::DefWindowProc(m_hWnd, uMsg, wParam, lParam);
+
+		// ---- r37:把现场一次性记全(变量全部落局部,避免 DiagWndClass 的静态缓冲互相覆盖)----
+		HWND hNewFocus = ::GetFocus();
+		HWND hFg = ::GetForegroundWindow();
+		const DWORD dwSelfTid = ::GetCurrentThreadId();
+		const DWORD dwSelfPid = ::GetCurrentProcessId();
+		const DWORD dwNewTid = DiagWndTid(hNewFocus);
+		const DWORD dwFgPid = DiagWndPid(hFg);
+		const DWORD dwFgTid = DiagWndTid(hFg);
+		const bool bFgOurs = (hFg != NULL && dwFgPid == dwSelfPid);
+		CPaintManagerUI* pManager = (m_pOwner != NULL) ? m_pOwner->GetManager() : NULL;
+		const bool bOwnerKeepsFocus = (pManager != NULL && pManager->GetFocus() == m_pOwner);
 		{
 			CDuiString sOwnerName = (m_pOwner != NULL) ? m_pOwner->GetName() : CDuiString(_T("?"));
-			HWND hNewFocus = ::GetFocus();
-			const DWORD dwSelfTid = ::GetCurrentThreadId();
-			const DWORD dwNewTid = DiagWndTid(hNewFocus);
+			TCHAR szNewCls[128] = { 0 };
+			TCHAR szFgCls[128] = { 0 };
+			if (hNewFocus != NULL && ::IsWindow(hNewFocus)) ::GetClassName(hNewFocus, szNewCls, _countof(szNewCls));
+			if (hFg != NULL && ::IsWindow(hFg)) ::GetClassName(hFg, szFgCls, _countof(szFgCls));
+			// r38:把"前台到底是哪个程序"也记下来 —— 类名只能说"像 UWP",进程名才定案。
+			TCHAR szFgExe[128] = { 0 };
+			DiagProcName(hFg, szFgExe, _countof(szFgExe));
+
 			// ★ 这条就是"输入法正在拼的字会不会消失"的现场。
 			//   失焦本身不致命 —— 致命的是"失焦即销毁"叠加"瞬态失焦"(见 OnClose)。
 			//   判读:
 			//     · newFocus = 00000000 ⇒ 本线程此刻**没有**焦点窗口。
-			//       真实成因只有两种:整个窗口失活(触摸键盘抢前台时的典型形态),
-			//       或者焦点正在搬家途中。此时 sameProc 无意义(下面记成 -1,
-			//       别再把它读成"被别的进程抢走"——NULL 没有进程可言)。
+			//       此时 sameProc 无意义(下面记成 -1,别再把它读成"被别的进程抢走"
+			//       —— NULL 没有进程可言)。真实成因靠下面三个新字段分开。
 			//     · newFocus != NULL 且 sameProc=0 ⇒ 真被**别的进程/线程**抢走;
 			//     · newClass = EditWnd ⇒ 我们自己的另一个输入框接走了焦点(正常切框);
 			//     · newClass = #32770 ⇒ 被本线程的弹框本体拿走(窗口内部搬家)。
-			DuiLibDiag::Log(_T("EDIT-KILLFOCUS hwnd=%p owner=%s newFocus=%p newClass=%s newTid=%u selfTid=%u sameProc=%d -> 投递 WM_CLOSE(去留由 OnClose 复核)"),
-				(void*)m_hWnd, sOwnerName.GetData(), (void*)hNewFocus, DiagWndClass(hNewFocus),
+			//
+			//   ── r37 新增字段:专为把"两种焦点空了"分开(判读已按 r38 真机证据修订)──
+			//     · fg / fgClass / fgPid / fgTid / fgExe —— 此刻**前台窗口**是谁。
+			//       ★ r38 真机实证到的形态是:**前台被触摸键盘夺走**
+			//         (fgClass=Windows.UI.Core.CoreWindow、fgExe 应为 TextInputHost.exe)。
+			//         前台一变,系统必然清空**旧**前台线程的键盘焦点 —— 所以同一条日志里
+			//         newFocus=00000000 且 killParam=00000000,两者是同一件事的两面。
+			//         触摸键盘是按"哪个应用持有输入焦点"投递按键/候选词/退格的;我们此刻
+			//         既不是前台、焦点也被清空 ⇒ 每一次按键全部丢掉。**这就是本体。**
+			//       · fg=00000000 ⇒ 系统当前没有前台窗口(少见,见下面的 EDIT-FGLOST 分支)。
+			//     · fgOurs=1 ⇒ 前台仍是本进程的窗口,只是本线程键盘焦点被清空
+			//         ⇒ 属于"焦点被抢",多发生在窗口内部的焦点搬家途中,会自行恢复。
+			//     · killParam —— WM_KILLFOCUS 的 wParam(系统指定的接手窗口,比 GetFocus() 权威)。
+			DuiLibDiag::Log(_T("EDIT-KILLFOCUS hwnd=%p owner=%s newFocus=%p newClass=%s newTid=%u selfTid=%u sameProc=%d fg=%p fgClass=%s fgTid=%u fgPid=%u fgExe=%s fgOurs=%d ownerKeepsFocus=%d killParam=%p -> 投递 WM_CLOSE(去留由 OnClose 复核)"),
+				(void*)m_hWnd, sOwnerName.GetData(), (void*)hNewFocus, szNewCls,
 				(unsigned)dwNewTid, (unsigned)dwSelfTid,
-				(int)(hNewFocus == NULL ? -1 : ((dwNewTid == dwSelfTid) ? 1 : 0)));
+				(int)(hNewFocus == NULL ? -1 : ((dwNewTid == dwSelfTid) ? 1 : 0)),
+				(void*)hFg, szFgCls, (unsigned)dwFgTid, (unsigned)dwFgPid, szFgExe,
+				(int)(bFgOurs ? 1 : 0), (int)(bOwnerKeepsFocus ? 1 : 0), (void*)(HWND)wParam);
 		}
 		PostMessage(WM_CLOSE);
+
+		// ==================================================================
+		// ★★★【r37 根治 · 把被"莫名清空"的键盘焦点抢回来】(2026-09-24)
+		//
+		// 真机形态(用户原话):「用手指点触摸键盘的回退按钮无法删除输入框的文字,
+		//   但是用鼠标点同一个按钮就可以正常删除」。
+		//
+		// 触摸键盘是**另一个进程**的窗口(TabTip),手指点它时本进程一行代码都不会执行
+		// —— 所以"点键盘没反应"绝不可能是我们窗口内部逻辑写错。差别只可能在一个地方:
+		//   触摸键盘把按键投给「**前台窗口所在线程的焦点窗口**」。
+		//     · 鼠标点它:TabTip 是 NOACTIVATE 窗口,点它不改变激活 ⇒ 本进程仍是前台
+		//       ⇒ 焦点窗口是编辑窗 ⇒ 字符/退格/候选词全部正常落点。
+		//     · 手指点它:触摸键盘自己**把前台拿走了**(r38 真机日志实证:
+		//       `fgClass=Windows.UI.Core.CoreWindow fgPid=7012 fgOurs=0`)⇒
+		//         ① 前台一变,系统必然清空旧前台线程的键盘焦点(见下方日志
+		//            newFocus=00000000,killParam 同样是 00000000);
+		//         ② 触摸键盘是靠"有输入焦点的那个应用"投递按键/候选词/退格的,
+		//            我们这个应用此刻既非前台、焦点也被清空 ⇒ 按键没有落点。
+		//       ⇒ 用户看到的正是"打字母不进、退格删不掉、候选词点了没反应"。
+		//
+		// 注意:OnClose 那处修复(延后判 owner 是否仍是 DuiLib 焦点)只解决了
+		//   "窗口被销毁",**并没有解决"焦点不见了"** —— 窗口活着但没有键盘焦点,
+		//   按键依旧无处可去。这就是用户反馈"还是不行"的那一半。
+		//
+		// ── r38 / r39 两轮真机证据的合并结论(**两种形态,同一个根**)──
+		//
+		//   r37 版日志看到的形态(形态 A):
+		//     EDIT-KILLFOCUS ... newFocus=00000000
+		//          fg=0001020C fgClass=Windows.UI.Core.CoreWindow fgTid=7080 fgPid=7012
+		//          fgOurs=0 ownerKeepsFocus=1 killParam=00000000
+		//     ⇒ 前台被**触摸键盘本人**拿走(Win10 现代触摸键盘 = TextInputHost.exe,
+		//       窗口类正是 Windows.UI.Core.CoreWindow)。
+		//
+		//   r38 版日志看到的形态(形态 B):
+		//     EDIT-KILLFOCUS ... newFocus=00000000 fg=00000000 fgExe= fgOurs=0 ownerKeepsFocus=1
+		//     ⇒ 前台**落空**(系统当前没有任何前台窗口)。
+		//
+		//   两者本质相同:**本进程既不是前台、本线程键盘焦点又被清空** ⇒ 触摸键盘
+		//   投递按键/候选词/退格时找不到落点。所以判据必须把两种都收进来(见下方 bKbTookFg)。
+		//   r38 只收了形态 A,结果正好把真机上实际出现的那次排除在外 —— 这就是
+		//   "还是不行"的直接原因。
+		//
+		//   同时这也解释了 r37 两个抢回钩子为什么一行日志都没打 —— 它们**都是死代码**:
+		//     · UIManager 的 ACT-RECLAIM 挂在 WM_ACTIVATE 上:弹框在那一刻**早已是
+		//       INACTIVE**(最后一次 WND-ACTIVATE 是 531080953 的 wParam=0),
+		//       系统不会为它再补发一次 ⇒ 钩子永不触发;
+		//     · 本文件的 EDIT-REFOCUS 判据要求 fgOurs=1,而真实形态恰恰是 fgOurs=0
+		//       ⇒ 条件恒假。
+		//
+		//   唯一**确实到达**的信号就是这条 WM_KILLFOCUS(r37 日志里它准时出现)。
+		//   所以抢回逻辑必须挂在这里。
+		//
+		// 判据(纯状态、零时间戳,三条缺一不可):
+		//   ① newFocus = NULL      —— 不是"切到我们自己的另一个框"(那会是另一个 EditWnd);
+		//   ② ownerKeepsFocus       —— DuiLib 侧焦点仍在这个 EDIT 上 ⇒ 用户还在这个框里打字
+		//                              (弹框正在销毁/失焦时该值为假 ⇒ 不抢);
+		//   ③ 前台是触摸键盘**或落空** —— 两种都算(形态 A / 形态 B,见上)。
+		//      **用户主动 Alt+Tab、点别的应用或点桌面时,前台是那个应用的窗口
+		//      (既不是 NULL、也不命中键盘类名/进程名)⇒ ③ 为假 ⇒ 绝不抢。**
+		//
+		// 动作顺序**不能反**:先把前台要回来,再复位焦点 ——
+		//   前台变更本身会重置线程的键盘焦点,先 SetFocus 再抢前台等于白设。
+		//   ① SetForegroundWindow(画布窗口):常规路径(前台本就是被键盘临时拿走的);
+		//   ② 若被系统"后台进程不得抢前台"的闸门挡住 ⇒ 挂到当前前台线程上重试一次
+		//      (经典配方,用完**立刻脱开**,不常驻、不放钩子);
+		//   ③ 前台回来后再 ::SetFocus(本编辑窗)。
+		// 全程无定时器、无时间戳、无事后清洗;不改几何、不改命中、不碰业务侧键盘显隐。
+		// ==================================================================
+		if (hNewFocus == NULL && bOwnerKeepsFocus)
+		{
+			CDuiString sOwnerName = (m_pOwner != NULL) ? m_pOwner->GetName() : CDuiString(_T("?"));
+			TCHAR szFgClsR[128] = { 0 };
+			TCHAR szFgExe[128] = { 0 };
+			if (hFg != NULL && ::IsWindow(hFg)) ::GetClassName(hFg, szFgClsR, _countof(szFgClsR));
+			DiagProcName(hFg, szFgExe, _countof(szFgExe));
+			// ★★★ r39 关键修正:把「前台落空(fg = NULL)」也算作触摸键盘夺前台 ★★★
+			//
+			//   r38 这里写的是 `hFg != NULL && ...` —— 恰好把**真机唯一出现过的那种形态**
+			//   排除在外。r38 真机日志里那两行就是铁证:
+			//     EDIT-KILLFOCUS ... newFocus=00000000 fg=00000000 fgExe= fgOurs=0 ownerKeepsFocus=1
+			//     EDIT-FGLOST    ... -> 非触摸键盘夺前台,不动作        ← 于是我们一个字都没做
+			//
+			//   「触摸键盘夺走前台」在真机上有**两种形态**,本质完全相同(本进程既不是
+			//   前台、本线程键盘焦点又被清空 ⇒ 按键注入没有落点),必须一起收:
+			//     · 形态 A(r37 日志实证):
+			//         fg = Windows.UI.Core.CoreWindow / fgExe = TextInputHost.exe
+			//         —— 键盘**本人持有前台**;
+			//     · 形态 B(r38 日志实证):
+			//         fg = 00000000 —— **前台落空**(当前没有任何前台窗口)。
+			//         成因:触摸键盘的窗口是 WS_EX_NOACTIVATE 的,手指触摸它引发的
+			//         激活交接**没有落点**,系统索性把前台置空。
+			//         ⇔ 对照组正是"鼠标点同一个键盘一切正常":鼠标点 NOACTIVATE 窗口
+			//           根本不产生激活交接,前台一直留在我们身上。
+			//
+			//   安全性完全不变:用户主动 Alt+Tab / 点别的应用 / 点桌面时,fg 是
+			//   **那个应用的窗口**(既不是 NULL,也不命中键盘类名)⇒ 判据为假 ⇒ 绝不抢。
+			const bool bFgIsKb = (hFg != NULL && DiagFgIsTouchKb(hFg));
+			const bool bKbTookFg = (hFg == NULL) || (!bFgOurs && bFgIsKb);
+
+			if (!bKbTookFg)
+			{
+				// 焦点确实空了,但不是触摸键盘的账(例如用户切应用/切盘途中)⇒ 一个字都不动。
+				// 留痕是为了万一还要再查:把"谁拿了前台"直接写进日志。
+				DuiLibDiag::Log(_T("EDIT-FGLOST hwnd=%p owner=%s newFocus=NULL fg=%p(%s) fgExe=%s fgOurs=%d -> 前台属他方(既非键盘也非落空),不动作"),
+					(void*)m_hWnd, sOwnerName.GetData(), (void*)hFg, szFgClsR, szFgExe, (int)(bFgOurs ? 1 : 0));
+			}
+			else
+			{
+				HWND hCanvas = (pManager != NULL) ? pManager->GetPaintWindow() : NULL;
+				const bool bCanvasOk = (hCanvas != NULL && ::IsWindow(hCanvas) && ::IsWindowVisible(hCanvas));
+
+				// ① 把前台要回来
+				BOOL bOkFg = FALSE;
+				if (bCanvasOk) bOkFg = ::SetForegroundWindow(hCanvas);
+				HWND hFg1 = ::GetForegroundWindow();
+				const bool bOurFg1 = (hFg1 != NULL && DiagWndPid(hFg1) == dwSelfPid);
+
+				// ② 被闸门挡住时,挂到当前前台线程上重试一次(用完立刻脱开)
+				BOOL bAttached = FALSE;
+				BOOL bOkFg2 = FALSE;
+				if (!bOurFg1 && bCanvasOk)
+				{
+					const DWORD dwFgTid2 = DiagWndTid(hFg1);
+					if (dwFgTid2 != 0 && dwFgTid2 != dwSelfTid)
+					{
+						bAttached = ::AttachThreadInput(dwFgTid2, dwSelfTid, TRUE);
+						if (bAttached)
+						{
+							bOkFg2 = ::SetForegroundWindow(hCanvas);
+							::AttachThreadInput(dwFgTid2, dwSelfTid, FALSE);
+						}
+					}
+				}
+
+				// ③ 前台回来之后再复位焦点(顺序不能反)
+				::SetFocus(m_hWnd);
+				HWND hFg2 = ::GetForegroundWindow();
+				TCHAR szFg2Cls[128] = { 0 };
+				if (hFg2 != NULL && ::IsWindow(hFg2)) ::GetClassName(hFg2, szFg2Cls, _countof(szFg2Cls));
+				// hit = 本进程内第几次执行抢回。判读(这是本轮最关键的一栏):
+				//   · hit 只涨到 1、之后再没出现 ⇒ 抢回后状态**稳住了**,打字应当恢复;
+				//   · hit 每敲一下键盘就 +1 ⇒ 触摸键盘在**每一次点按**时都重新夺走前台,
+				//     抢在投递之前 ⇒ "事后抢回"这条路走不通,必须改成"不让它夺"。
+				static int s_nReclaimHit = 0;
+				++s_nReclaimHit;
+				HWND hFocusNow = ::GetFocus();
+				DuiLibDiag::Log(_T("EDIT-RECLAIM hit=%d shape=%s hwnd=%p owner=%s fgBefore=%p(%s) fgExe=%s fgOk=%d attach=%d fgOk2=%d fgAfter=%p(%s) fgOursAfter=%d nFocus=%p focusIsSelf=%d"),
+					(int)s_nReclaimHit, (hFg == NULL ? _T("fgNULL") : _T("fgKB")),
+					(void*)m_hWnd, sOwnerName.GetData(), (void*)hFg, szFgClsR, szFgExe,
+					(int)bOkFg, (int)bAttached, (int)bOkFg2,
+					(void*)hFg2, szFg2Cls,
+					(int)((hFg2 != NULL && DiagWndPid(hFg2) == dwSelfPid) ? 1 : 0),
+					(void*)hFocusNow, (int)(hFocusNow == m_hWnd ? 1 : 0));
+			}
+		}
 		return lRes;
 	}
 
@@ -425,7 +781,16 @@ namespace DuiLib
 
 	LRESULT CEditWnd::OnEditChanged(UINT /*uMsg*/, WPARAM /*wParam*/, LPARAM /*lParam*/, BOOL& /*bHandled*/)
 	{
-		if( !m_bInit ) return 0;
+		// 【r40 取证】EN_CHANGE 到了、却被"Init 还没走完"挡掉的情形必须留痕。
+		// 否则在日志里,"反射链根本没通"和"反射通了但窗口尚未初始化完"长得一模一样
+		// —— 而这两件事的结论完全相反(前者要查 OCM 反射,后者是正常路径)。
+		if( !m_bInit ) 
+		{
+			CDuiString sOwnerName = (m_pOwner != NULL) ? m_pOwner->GetName() : CDuiString(_T("?"));
+			DuiLibDiag::Log(_T("EDIT-CHANGE 忽略(Init未完成) hwnd=%p owner=%s len=%d"),
+				(void*)m_hWnd, sOwnerName.GetData(), (int)::GetWindowTextLength(m_hWnd));
+			return 0;
+		}
 		if( m_pOwner == NULL ) return 0;
 		// Copy text back
 		int cchLen = ::GetWindowTextLength(m_hWnd) + 1;
@@ -608,6 +973,24 @@ namespace DuiLib
 						pt.y -= m_rcItem.top + rcTextPadding.top;
 						Edit_SetSel(*m_pWindow, 0, 0);
 						::SendMessage(*m_pWindow, WM_LBUTTONDOWN, event.wParam, MAKELPARAM(pt.x, pt.y));
+						// ==========================================================
+						// ★ r40 修复:**这条鼠标按下消息必须配平**(补发配对的抬起)。
+						//
+						// 原生 EDIT 收到 WM_LBUTTONDOWN 后会进入"按下拖选"状态
+						//   (内部 bCaptureState = TRUE,并 SetCapture 自己),
+						// 只有收到**配对的 WM_LBUTTONUP** 才会退出这个状态。
+						//
+						// 而历史上这里只发了按下:本控件的 UIEVENT_BUTTONUP 在下面被
+						//   `return;` 直接丢掉、从不下传。于是编辑框长期停在"鼠标按着"
+						//   的状态里 —— 而这正是**触摸点击专有**的路径:
+						//     · 鼠标点击:落点在原生编辑窗自己身上,DOWN/UP 由系统成对
+						//       投递 ⇒ 状态干净 ⇒ 一切正常;
+						//     · 手指点击:指针消息只到画布窗口,由 DuiLib 合成这条 DOWN
+						//       ⇒ 半截手势 ⇒ 之后编辑框行为异常(真机表现就是"手指点
+						//       键盘打不进字、退格也删不掉,鼠标点同一个键盘却正常")。
+						//   坐标完全相同 ⇒ 光标仍精确落在点击处,与一次普通点击等价。
+						// ==========================================================
+						::SendMessage(*m_pWindow, WM_LBUTTONUP, 0, MAKELPARAM(pt.x, pt.y));
 					}
 				}
 			}

+ 184 - 13
DuiLib/Core/UIManager.cpp

@@ -1431,6 +1431,50 @@ namespace DuiLib
 	//   2) Update/End 在 !m_bTouchSlideActive 时必须安全空转(另一条通道可能没走 Begin)。
 	//   3) 坐标一律是**客户区坐标**(调用方负责把屏幕坐标转好)。
 	// ===========================================================================
+	// ---------------------------------------------------------------------------
+	// 【诊断 · 触摸键盘穿透】把"这一手在系统里究竟落到了谁身上"一次记全。
+	//
+	// 要判的问题(Win10 · 手指点触摸键盘候选词,字进不去;鼠标正常):
+	//   · 手指点候选词时,指针消息到底有没有被路由到本窗口?
+	//     若 wndFromPt 属于本进程、而 ptOnKb=1(触点落在触摸键盘窗口矩形内),
+	//     则"穿透"成立 —— 这一手名义上打的是触摸键盘,实际按在了本窗口上。
+	//   · fg / clsFg 用来确认此刻谁是前台(触摸键盘是不激活窗口,通常仍是我们)。
+	// 只记日志,不参与任何判断。
+	// ---------------------------------------------------------------------------
+	static void DiagLogPointerContext(HWND hHost, POINT ptClient, LPCTSTR pszTag)
+	{
+		POINT ptScreen = ptClient;
+		::ClientToScreen(hHost, &ptScreen);
+
+		HWND hFromPt = ::WindowFromPoint(ptScreen);
+		DWORD dwPidFromPt = 0;
+		TCHAR szClsFromPt[128] = { 0 };
+		if (hFromPt != NULL)
+		{
+			::GetWindowThreadProcessId(hFromPt, &dwPidFromPt);
+			::GetClassName(hFromPt, szClsFromPt, _countof(szClsFromPt));
+		}
+
+		HWND hFg = ::GetForegroundWindow();
+		TCHAR szClsFg[128] = { 0 };
+		if (hFg != NULL) ::GetClassName(hFg, szClsFg, _countof(szClsFg));
+
+		// 系统触摸键盘窗口:Win8/10 是 TabTip 的 IPTip_Main_Window。
+		// (Win10 1809+ 键盘宿主换成 TextInputHost,类名可能不同;找不到就记 NULL,
+		//   此时 ptOnKb 恒为 0,不影响任何行为。)
+		HWND hKb = ::FindWindow(_T("IPTip_Main_Window"), NULL);
+		RECT rcKb = { 0 };
+		const bool bOnKb = (hKb != NULL && ::IsWindowVisible(hKb) != FALSE
+			&& ::GetWindowRect(hKb, &rcKb) != FALSE && ::PtInRect(&rcKb, ptScreen) != FALSE);
+
+		DuiLibDiag::Log(_T("%s pt=%d,%d screen=%d,%d selfPid=%u wndFromPt=%p pidFromPt=%u cls=%s fg=%p clsFg=%s kb=%p kbRect=%d,%d,%d,%d ptOnKb=%d"),
+			pszTag, ptClient.x, ptClient.y, ptScreen.x, ptScreen.y,
+			(unsigned)::GetCurrentProcessId(), (void*)hFromPt, (unsigned)dwPidFromPt, szClsFromPt,
+			(void*)hFg, szClsFg,
+			(void*)hKb, (int)rcKb.left, (int)rcKb.top, (int)rcKb.right, (int)rcKb.bottom,
+			(int)(bOnKb ? 1 : 0));
+	}
+
 	void CPaintManagerUI::BeginTouchSlideGesture(POINT pt)
 	{
 
@@ -1476,12 +1520,17 @@ namespace DuiLib
 		CControlUI* pControl = FindControl(pt);
 		if (pControl == NULL)
 		{
+			// 这一手落在容器空白上 ⇒ 属于"点了别处":照旧把系统键盘焦点收回画布窗口,
+			// 让原生编辑窗(CEditWnd)失焦收起来。
+			// (调用点原来在进入本函数之前无条件做过这件事,见下面主路径的说明。)
+			if (!m_bNoActivate) ::SetFocus(m_hWndPaint);
 			// 没命中控件也要保持 active:随后的 Update 仍需被安全吸收掉,
 			// 由 End 统一收尾(否则 Update 会跑到空指针上)。
 			return;
 		}
 		if (pControl->GetManager() != this)
 		{
+			if (!m_bNoActivate) ::SetFocus(m_hWndPaint);
 			return;
 		}
 
@@ -1503,6 +1552,38 @@ namespace DuiLib
 		// 但 m_ptLastTouchPos / m_ptTouchStart 仍记真实坐标(去重与位移判定按真实手指轨迹走)。
 		const POINT ptEvent = bTouchIsScrollBar ? Touch_SnapIntoScrollBar(pControl, pt) : pt;
 
+		// ==================================================================
+		// ★★★【焦点归属 · 触摸输入进不去的根治点】(2026-09-24)
+		//
+		// 这里接管了原先**两个调用点各有一笔**的无条件动作:
+		//     ::SetFocus(m_hWndPaint);      // 把系统键盘焦点收回画布窗口
+		// 它的原意是"用户点了别处 ⇒ 让原生子窗口(编辑框)失焦收起来"。
+		// 但**触摸链把它做成了无条件的**,于是"点在自己当前的焦点控件上"时,
+		// 这一笔造成的后果是灾难性的、且不可自愈:
+		//
+		//   · 收回 ⇒ 原生编辑窗(CEditWnd。Layered 模式下它是独立顶层窗口)
+		//     失去键盘焦点;
+		//   · 紧接着 pControl->SetFocus() 进 CPaintManagerUI::SetFocus,
+		//     而那里有一条 `pControl == m_pFocus 就早退` 的判据
+		//     ⇒ **不会重发 UIEVENT_SETFOCUS**;
+		//   · ⇒ 没有任何代码把 ::SetFocus(编辑窗) 还回去。
+		//     DuiLib 以为焦点还在输入框上,系统键盘焦点却停在画布窗口。
+		//
+		// 后果就是用户看到的全部现象:输入法正在组合的拼音被撤销、
+		// 之后触摸键盘的**每一个按键(含退格)与候选词提交**都失去落点 ——
+		// 因为触摸键盘是把按键投给"当前焦点窗口"的,而那个窗口已经不是编辑框了。
+		//
+		// 鼠标为什么没事:鼠标点在原生编辑窗上时消息**直接投给那个子窗口**,
+		// 根本不会走到本函数;手指点在屏幕上,指针消息只到画布窗口,必走这里。
+		//
+		// 判据(纯状态,不用时间戳):这一手命中的控件 == 当前 DuiLib 焦点控件
+		// ⇒ 焦点目标没变,不收回;其余情形与原来完全一致。
+		// 注意必须在**滚动条容差调整之后**判 —— pControl 到这里才是最终目标。
+		// ==================================================================
+		if (pControl != m_pFocus && !m_bNoActivate)
+		{
+			::SetFocus(m_hWndPaint);
+		}
 
 		m_pEventTouch = pControl;
 		m_pTouchScrollContainer = bTouchIsScrollBar ? NULL : Touch_FindScrollContainer(pControl);
@@ -2027,7 +2108,13 @@ namespace DuiLib
 				// TOUCHDOWN/MOVE/UP 与鼠标事件等价处理,这里让滚动条走常规接管流程,
 				// 后续 TOUCHMOVE 由它消费(不会冒泡到容器把内容滚走)。
 
-				::SetFocus(m_hWndPaint);
+				// ⚠️【已移除:原来这里有一笔无条件的 ::SetFocus(m_hWndPaint)】
+				// 与指针通道同理(见 OnPointerInput 的 DUI_WM_POINTERDOWN 分支):
+				// "把系统键盘焦点收回画布窗口"这件事必须知道焦点目标会不会变,
+				// 否则会把输入框编辑窗的键盘焦点抢走且还不回来。
+				// 现在统一下沉到 BeginTouchSlideGesture,由它按
+				// "命中控件 != 当前 DuiLib 焦点控件"的判据决定是否收回。
+				DiagLogPointerContext(m_hWndPaint, pt, _T("TOUCH-DOWN ctx"));
 				// [通道无关] 滑动状态机的建立逻辑已抽到 BeginTouchSlideGesture,
 				// 指针通道(OnPointerInput)也调用同一个函数,保证两条通道滑动行为一致。
 				BeginTouchSlideGesture(pt);
@@ -2250,24 +2337,25 @@ namespace DuiLib
 			//
 			// [NIM 风格] 现在归属完全由 m_bPointerGestureActive 这个布尔标记表达,
 			// 超时自愈由专门的 m_dwPointerDownTick 计时,两者职责分离、互不干扰。
-			// 与 WM_LBUTTONDOWN 分支一致:先把焦点给本窗口,否则输入法/键盘焦点
-			// 可能仍停在别的窗口上(真模态下更要注意别把焦点让给 owner)。
+			// ⚠️【已移除:原来这里有一笔无条件的 ::SetFocus(m_hWndPaint)】
+			//
+			// 它的原意是"用户点了别处 ⇒ 把系统键盘焦点收回画布窗口,让原生子窗口
+			// (输入框的 CEditWnd)失焦收起来"。但这笔动作**必须**知道"焦点目标会不会
+			// 变",否则当这一手就落在当前输入框上时,会把编辑窗的键盘焦点抢走,
+			// 而紧接着的 pControl->SetFocus() 又会因 m_pFocus 未变而早退
+			// ⇒ 焦点再也回不到编辑窗 ⇒ 触摸键盘的按键与候选词提交全部落空。
 			//
-			// ⚠️【诊断 · 新建商品弹框「输入文字消失」】这一行会把键盘焦点从当前
-			//   输入框的 CEditWnd 上**抢到主窗口**,而 CEditWnd 一旦失焦就会
-			//   PostMessage(WM_CLOSE) 自毁。紧接着 BeginTouchSlideGesture 里会
-			//   pControl->SetFocus();若目标恰好是"已经持有 DuiLib 焦点"的那个
-			//   控件(m_pFocus 没变),CPaintManagerUI::SetFocus 会**早退**、
-			//   不重发 UIEVENT_SETFOCUS ⇒ 编辑窗口不会被重建 ⇒ 输入永久失效。
-			//   这正是"切到另一个框后就再也输不进去"最可能的断点,必须留痕。
+			// 现在这笔动作已下沉到 BeginTouchSlideGesture(两条通道的唯一公共入口),
+			// 并在那里用"命中控件 != 当前 DuiLib 焦点控件"作为判据 —— 只调顺序与判据,
+			// 没有引入新机制。详见该函数里的说明。
 			{
 				TCHAR szBefore[128] = { 0 };
 				HWND hBefore = ::GetFocus();
 				if (hBefore != NULL && ::IsWindow(hBefore)) ::GetClassName(hBefore, szBefore, _countof(szBefore));
-				DuiLibDiag::Log(_T("PTR-DOWN stealFocus mgrHwnd=%p nFocusBefore=%p clsBefore=%s -> 即将 SetFocus(本窗口)"),
-					(void*)m_hWndPaint, (void*)hBefore, szBefore);
+				DuiLibDiag::Log(_T("PTR-DOWN enter nFocusBefore=%p clsBefore=%s (焦点收回已下沉到 BeginTouchSlideGesture)"),
+					(void*)hBefore, szBefore);
 			}
-			if (!m_bNoActivate) ::SetFocus(m_hWndPaint);
+			DiagLogPointerContext(m_hWndPaint, pt, _T("PTR-DOWN ctx"));
 
 			// ---- [通道无关滑动] 指针通道也要建立滑动状态机 ----
 			// 这是"滑不动"的根治点。原先滑动逻辑只写在 WM_TOUCH 通道里,指针通道一旦
@@ -3421,6 +3509,31 @@ namespace DuiLib
 		return m_trh;
 	}
 
+	// ==================================================================
+	// r37 helper:判断"这一次失去激活,是不是触摸键盘的触摸交互造成的"。
+	//
+	// 为什么需要它:触摸键盘是**另一个进程**的窗口,手指点它时本进程一行代码都不执行,
+	//   所以"手指点键盘没反应"只能在"激活/前台被夺"这条外部事实上找答案。
+	//   真机日志已经给出形态:`WND-ACTIVATE wParam=0(INACTIVE) fg=00000000`
+	//   —— 前台**落空**。前台落空意味着没有任何窗口能接替输入,而触摸键盘注入按键
+	//   时找的正是"前台窗口所在线程的焦点窗口" ⇒ 目标不存在 ⇒ 每次按键都丢掉
+	//   (字母不进、退格删不掉、候选词点了没反应)。
+	//
+	// 判据(纯状态,零时间戳):
+	//   · 前台为空          ⇒ 是。用户主动切应用时前台会变成别的应用,不会为空;
+	//   · 前台类名是触摸键盘的两个宿主 ⇒ 是(TabTip 主窗 / UWP 输入宿主)。
+	// 其余一律返回 false ⇒ **绝不干扰用户正常切换应用**。
+	// ==================================================================
+	static bool DiagTouchKbForeground(HWND hFg)
+	{
+		if (hFg == NULL) return true;
+		if (!::IsWindow(hFg)) return false;
+		TCHAR szCls[128] = { 0 };
+		::GetClassName(hFg, szCls, _countof(szCls));
+		return (_tcsicmp(szCls, _T("IPTip_Main_Window")) == 0
+			|| _tcsicmp(szCls, _T("Windows.UI.Core.CoreWindow")) == 0);
+	}
+
 	bool CPaintManagerUI::PreMessageHandler(UINT uMsg, WPARAM wParam, LPARAM lParam, LRESULT& lRes)
 	{
 		for (int i = 0; i < m_aPreMessageFilters.GetSize(); i++)
@@ -5186,6 +5299,33 @@ namespace DuiLib
 					(unsigned)::GetCurrentThreadId(), (void*)hFg, szFgCls,
 					(unsigned)(hFg != NULL ? ::GetWindowThreadProcessId(hFg, NULL) : 0));
 
+				// ==============================================================
+				// ★【r38 降级为"只观察"—— 原来这里做抢回,已被真机日志证伪】(2026-09-24)
+				//
+				// r37 原来的假设:手指点触摸键盘 ⇒ 本窗口收到 WM_ACTIVATE(WA_INACTIVE)
+				//   ⇒ 前台落空 ⇒ 在这里 SetForegroundWindow 抢回来。
+				//
+				// r37 版真机日志**同时推翻了假设和钩子本身**:
+				//   · 前台没有落空,而是被触摸键盘本人拿走(`fgClass=Windows.UI.Core.CoreWindow
+				//     fgPid=7012 fgOurs=0`);
+				//   · 出事那一刻弹框**早就是 INACTIVE 了**(最后一次 WND-ACTIVATE 是
+				//     531080953 的 wParam=0)—— 系统不会为一个已失活的窗口再补发一次激活
+				//     ⇒ 这个 `if` 条件永假 ⇒ 整段是**死代码**(日志里 ACT-RECLAIM 一行都没有)。
+				//
+				// 真正会到达的信号是 CEditWnd 的 WM_KILLFOCUS(前台一变,系统必然清空旧前台
+				//   线程的键盘焦点 ⇒ 编辑窗必收该消息)。抢回逻辑已挪到
+				//   `DuiLib\Control\UIEdit.cpp` 的 `CEditWnd::OnKillFocus`(见那里的 r38 段),
+				//   判据更严(要求前台进程名是触摸键盘那三个进程之一)⇒ 误抢风险也更低。
+				//
+				// 这里只留一行观察:万一以后某条路径真的让 WM_ACTIVATE 在"键盘夺前台"时到达,
+				//   日志能看见 —— 但**不动手**(避免与 KillFocus 那条重复抢,也避免误抢)。
+				// ==============================================================
+				if (LOWORD(wParam) == WA_INACTIVE && m_pFocus != NULL && DiagTouchKbForeground(hFg))
+				{
+					DuiLibDiag::Log(_T("ACT-INACTIVE-KB mgrHwnd=%p fg=%p(%s) nFocus=%p -> 只观察不动手(抢回已挪到 CEditWnd::OnKillFocus)"),
+						(void*)m_hWndPaint, (void*)hFg, szFgCls, (void*)::GetFocus());
+				}
+
 				// ★ 修复(2026-09-22):重新获得激活时,把焦点"还回"给 DuiLib 当前
 				//   聚焦的控件 —— 与上面 WM_SETFOCUS 分支完全同构。
 				//
@@ -5635,6 +5775,37 @@ namespace DuiLib
 		// Already has focus?
 		if (pControl == m_pFocus)
 		{
+			// ==============================================================
+			// ★★★【焦点已丢但目标没变 · 补还】(2026-09-24)
+			//
+			// 判据只用状态:系统键盘焦点是不是正停在**本窗口自己**身上。
+			//
+			//   · 是 ⇒ 焦点是被"收回画布窗口"这个动作抢来的(触摸链的
+			//        ::SetFocus(m_hWndPaint),或任何等价路径),而 DuiLib 侧
+			//        焦点目标并没有变。此时**不能**往下走"自己给自己派
+			//        KILLFOCUS"的那条路 —— 那会让业务侧收到一次假的失焦
+			//        (它正是触摸键盘显隐的驱动源,会把键盘收起来)。
+			//        所以只补派一次 UIEVENT_SETFOCUS 给控件自身,**不进
+			//        SendNotify()**:CEditUI 的 SETFOCUS 分支会
+			//        ShowWindow + ::SetFocus(编辑窗) 把键盘焦点要回去,
+			//        其余控件只是刷新一下焦点标志,业务侧完全无感。
+			//
+			//   · 否 ⇒ 真正的"已持有焦点"(焦点在控件自己的原生子窗口上,
+			//          或已落在本窗口树之外),维持原有早退,行为不变。
+			//
+			// 这条通道是"抢焦点"那条路径的兜底:即使某处又漏了一笔收回,
+			// 只要用户再点一次这个输入框,键盘焦点就能回来。
+			// ==============================================================
+			if (hFocusWnd == m_hWndPaint)
+			{
+				DuiLibDiag::Log(_T("MGR-SETFOCUS 目标未变但键盘焦点停在画布窗口 -> 补派 UIEVENT_SETFOCUS(不发业务通知)"));
+				TEventUI event = { 0 };
+				event.Type = UIEVENT_SETFOCUS;
+				event.pSender = pControl;
+				event.dwTimestamp = ::GetTickCount();
+				pControl->Event(event);
+				return;
+			}
 			DuiLibDiag::Log(_T("MGR-SETFOCUS 早退(目标已持有焦点) -> 不重发 UIEVENT_SETFOCUS"));
 			return;
 		}

+ 13 - 0
DuiLib/Utils/UIDiag.cpp

@@ -40,6 +40,15 @@ namespace DuiLibDiag
 	{
 		if (!Enabled()) return;
 
+		// ★ 版本横幅 —— 整个日志里只写一次。
+		//
+		// 存在的唯一理由:收银机不是本机,DLL 要手工拷过去。用户说"还是不行"时,
+		// 第一步永远是先确认"他跑的是不是这一轮编出来的 DLL"。
+		// 只要日志第一行有这行横幅,就说明新库确实被加载了。
+		static bool s_bBannerWritten = false;
+		const bool bWriteBanner = !s_bBannerWritten;
+		s_bBannerWritten = true;
+
 		TCHAR path[MAX_PATH] = { 0 };
 		::GetModuleFileName(NULL, path, MAX_PATH);
 		TCHAR* p = _tcsrchr(path, _T('\\'));
@@ -72,6 +81,10 @@ namespace DuiLibDiag
 		// fopen_s 只吃 char*(窄字符路径),这里才需要 UTF-8 的 Narrow(path)
 		if (fopen_s(&f, Narrow(path), "a") == 0 && f != NULL)
 		{
+			if (bWriteBanner)
+			{
+				fprintf(f, "======== DIAG-BUILD 2026-09-24 r40 (pair LButtonDown/Up + edit state probe) ========\n");
+			}
 			fprintf(f, "[%8u][T%5u] %s\n", (unsigned)::GetTickCount(), (unsigned)::GetCurrentThreadId(), utf8);
 			// 立刻冲盘:程序被强杀 / 挂死 / 断电时,日志尾部(往往正是关键现场)不能丢。
 			fflush(f);

BIN
bin/Win32/Debug/zhipuzi_pos_windows/DuiLib_d.dll


BIN
bin/Win32/Release/zhipuzi_pos_windows/DuiLib.dll


BIN
bin/Win32/Release/zhipuzi_pos_windows/zhipuzi_pos_windows.exe


BIN
dll/debug/DuiLib_d.dll


BIN
dll/release/DuiLib.dll


BIN
lib/release/DuiLib.lib


BIN
pdb/release/DuiLib.pdb


+ 1 - 1
zhipuzi_pos_windows/zhipuzi_pos_windows.cpp

@@ -22,7 +22,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
 	{
 		return -1;
 	}
-
+	
 	//初始化日志,这个一定要放在这里,才不会被析构掉
 	CLewaimaiLog log;
 	log.Init();