Browse Source

修复框架的bug,导致按ESC和ENTER无效。

张洋 1 week ago
parent
commit
da466f14f1

+ 10 - 2
DuiLib/Control/UICombo.cpp

@@ -877,7 +877,7 @@ namespace DuiLib {
 		, m_pWindow(NULL)
 		, m_pWindow(NULL)
 		, m_iCurSel(-1)
 		, m_iCurSel(-1)
 		, m_uButtonState(0)
 		, m_uButtonState(0)
-		, m_bScrollSelect(true)
+		, m_bScrollSelect(false)   // [R46] 默认关掉「滚轮切选项」,否则下拉框会吃掉整页滚轮
 		, m_bShowShadow(false)
 		, m_bShowShadow(false)
 	{
 	{
 		m_szDropBox = CDuiSize(0, 150);
 		m_szDropBox = CDuiSize(0, 150);
@@ -1150,13 +1150,21 @@ namespace DuiLib {
 				return;
 				return;
 			}
 			}
 		}
 		}
+		// ★【R46】滚轮不该被收起状态的下拉框吞掉。
+		// 原实现无条件 return(认领)。而收起时光标就在框体上,框体本身没有任何
+		// 可滚内容 —— 展开的候选列表是**独立弹窗**,滚轮由那个弹窗自己的 CListUI
+		// 处理,走不到这里。于是光标一移到下拉框上滚轮就被吃掉,整页滚不动。
+		// 现在只有显式声明 scrollselect="true" 的下拉框才继续用滚轮改选中项
+		// (构造函数里的默认值同时 true → false);否则不 return,落到函数末尾的
+		// CControlUI::DoEvent 继续冒泡给页面级滚动容器。
+		// 想恢复旧的「滚轮即切选项」:给该 Combo 写 scrollselect="true" 即可。
 		if( event.Type == UIEVENT_SCROLLWHEEL )
 		if( event.Type == UIEVENT_SCROLLWHEEL )
 		{
 		{
 			if(GetScrollSelect()) {
 			if(GetScrollSelect()) {
 				bool bDownward = LOWORD(event.wParam) == SB_LINEDOWN;
 				bool bDownward = LOWORD(event.wParam) == SB_LINEDOWN;
 				SelectItem(FindSelectable(m_iCurSel + (bDownward ? 1 : -1), bDownward));
 				SelectItem(FindSelectable(m_iCurSel + (bDownward ? 1 : -1), bDownward));
+				return;
 			}
 			}
-			return;
 		}
 		}
 		if( event.Type == UIEVENT_CONTEXTMENU )
 		if( event.Type == UIEVENT_CONTEXTMENU )
 		{
 		{

+ 78 - 0
DuiLib/Control/UIEdit.cpp

@@ -1,5 +1,6 @@
 #include "StdAfx.h"
 #include "StdAfx.h"
 #include "UIEdit.h"
 #include "UIEdit.h"
+#include "../Core/UIContainer.h"   // [R46] 滚轮转发要用 CContainerUI::GetScrollRange
 #include "../Utils/UIDiag.h"
 #include "../Utils/UIDiag.h"
 
 
 namespace DuiLib
 namespace DuiLib
@@ -396,10 +397,55 @@ namespace DuiLib
 		delete this;
 		delete this;
 	}
 	}
 
 
+	// ==================================================================
+	// 【R49】把落在原生 EDIT 上的「对话框级导航键」(ESC / 回车)转发给真模态对话框。
+	//
+	// 根因链(逐环坐实):
+	//   ① 焦点在原生 EDIT 上时,WM_KEYDOWN 只投给 EDIT;EDIT 既不处理这两个键,
+	//      也不把它们上抛给父对话框(DefWindowProc 不做转发)⇒ 弹框根本看不到;
+	//   ② 真模态(CWindowWnd::ShowModal -> DialogBoxIndirectParam,UIBase.cpp:659)
+	//      期间跑的是系统对话框循环,主循环 CPaintManagerUI::MessageLoop 整个被阻塞
+	//      ⇒ 那条「对每一条队列消息都跑 PreMessageHandler、且不看收件窗口是谁」的
+	//      通道(UIManager.cpp:8012 TranslateMessage)彻底不存在;
+	//   ③ 于是挂在 IMessageFilterUI::MessageHandler 上的老弹框(商品新建/修改等
+	//      38 个)收不到 ESC / 回车 —— 而它们的按钮照常能用,因为按钮是自绘控件、
+	//      焦点在对话框上,消息本来就到得了 __DialogProc。
+	//   假模态时代没有这个问题:ShowModalFake 不建自己的消息循环,走的就是主循环。
+	//
+	// 判据:只在 paint window 的窗口类是系统对话框类 "#32770" 时转发 ⇒ 只有真模态
+	//       才转发。假模态下主循环已经跑过 pre-filter,再转发会双触发
+	//       (StartSave() 跑两次、连发两次请求)。
+	//
+	// 用 PostMessage 而不是 SendMessage:弹框可能在本消息的处理过程中 Close()
+	// 把窗口连同这个 EDIT 一起销毁,异步投递可以彻底避开「回来时 this 已析构」。
+	// ==================================================================
+	static void ForwardNavKeyToModalDialog(CPaintManagerUI* pMgr, HWND hEdit, UINT uMsg, WPARAM wParam, LPARAM lParam)
+	{
+		if( pMgr == NULL ) return;
+
+		HWND hDlg = pMgr->GetPaintWindow();
+		if( hDlg == NULL || hDlg == hEdit ) return;
+
+		TCHAR szClass[64] = { 0 };
+		if( ::GetClassName(hDlg, szClass, 63) == 0 ) return;
+		if( _tcscmp(szClass, _T("#32770")) != 0 ) return;
+
+		::PostMessage(hDlg, uMsg, wParam, lParam);
+	}
+
 	LRESULT CEditWnd::HandleMessage(UINT uMsg, WPARAM wParam, LPARAM lParam)
 	LRESULT CEditWnd::HandleMessage(UINT uMsg, WPARAM wParam, LPARAM lParam)
 	{
 	{
 		LRESULT lRes = 0;
 		LRESULT lRes = 0;
 		BOOL bHandled = TRUE;
 		BOOL bHandled = TRUE;
+
+		// ★【R49】把「对话框级导航键」转发给真模态对话框(见上面 helper 的注释)。
+		//   放在最前面、且不参与下面的 else-if 链 ⇒ 原有的 VK_RETURN / VK_TAB
+		//   分支语义一字不动(VK_RETURN 仍照旧给控件发 DUI_MSGTYPE_RETURN)。
+		if( uMsg == WM_KEYDOWN && (TCHAR(wParam) == VK_ESCAPE || TCHAR(wParam) == VK_RETURN) ) {
+			ForwardNavKeyToModalDialog(m_pOwner != NULL ? m_pOwner->GetManager() : NULL,
+				m_hWnd, uMsg, wParam, lParam);
+		}
+
 		if( uMsg == WM_CREATE ) {
 		if( uMsg == WM_CREATE ) {
 			bHandled = FALSE;
 			bHandled = FALSE;
 		}
 		}
@@ -421,6 +467,38 @@ namespace DuiLib
 				m_pOwner->GetManager()->SetNextTabControl();
 				m_pOwner->GetManager()->SetNextTabControl();
 			}
 			}
 		}
 		}
+		else if( uMsg == WM_MOUSEWHEEL ) {
+			// ★【R46】原生 EDIT 窗口不会把滚轮冒泡给父窗口 —— Windows 不转发
+			//   WM_MOUSEWHEEL,所以光标停在输入框上时整页滚不动。这里把滚轮
+			//   直接交给「本输入框的第一个可滚祖先」(就是页面级滚动容器),
+			//   绕过中途会认领滚轮的编辑框自身。
+			//   最坏情况是找不到可滚祖先 ⇒ 什么都不做,与改动前完全一致。
+			bool bWheelSent = false;
+			if( m_pOwner != NULL && m_pOwner->GetManager() != NULL ) {
+				CPaintManagerUI* pMgr = m_pOwner->GetManager();
+				POINT ptWheel = { (short)LOWORD(lParam), (short)HIWORD(lParam) };
+				::ScreenToClient(pMgr->GetPaintWindow(), &ptWheel);
+				for( CControlUI* p = m_pOwner->GetParent(); p != NULL; p = p->GetParent() ) {
+					CContainerUI* pContent = dynamic_cast<CContainerUI*>(p);
+					if( pContent == NULL ) continue;
+					if( pContent->GetScrollRange().cy <= 0 ) continue;
+					int zDelta = (int)(short)HIWORD(wParam);
+					TEventUI event = { 0 };
+					event.Type = UIEVENT_SCROLLWHEEL;
+					event.pSender = p;
+					event.wParam = MAKEWPARAM(zDelta < 0 ? SB_LINEDOWN : SB_LINEUP, zDelta);
+					event.lParam = lParam;
+					event.ptMouse = ptWheel;
+					event.wKeyState = (WORD)wParam;
+					event.dwTimestamp = ::GetTickCount();
+					p->Event(event);
+					bWheelSent = true;
+					break;
+				}
+			}
+			if( bWheelSent ) return 0;
+			bHandled = FALSE;   // 没有可滚祖先 ⇒ 原样交给 DefWindowProc(与改前一致)
+		}
 		else if( uMsg == OCM__BASE + WM_CTLCOLOREDIT  || uMsg == OCM__BASE + WM_CTLCOLORSTATIC ) {
 		else if( uMsg == OCM__BASE + WM_CTLCOLOREDIT  || uMsg == OCM__BASE + WM_CTLCOLORSTATIC ) {
 			::SetBkMode((HDC)wParam, TRANSPARENT);
 			::SetBkMode((HDC)wParam, TRANSPARENT);
 			DWORD dwTextColor = m_pOwner->GetTextColor();
 			DWORD dwTextColor = m_pOwner->GetTextColor();

+ 22 - 6
DuiLib/Control/UIList.cpp

@@ -411,15 +411,31 @@ namespace DuiLib {
 			break;
 			break;
 		case UIEVENT_SCROLLWHEEL:
 		case UIEVENT_SCROLLWHEEL:
 			{
 			{
+				// ★【R46】滚轮不该被「滚不动」的列表吞掉。
+				// 原实现无论列表有没有滚动条都无条件 return(认领)。于是列表
+				// vscrollbar="false" 时(库存台账就是这样:高度按行数写死、整页交给外层
+				// 滚动容器滚),光标一移到表格上滚轮就被这里吃掉 —— LineUp/LineDown
+				// 内部因 m_pVerticalScrollBar==NULL 直接 return false,什么也没发生,
+				// 而事件**再也冒泡不上去**(CControlUI::DoEvent 末尾那条 m_pParent->DoEvent
+				// 链被这里的 return 跳过了)。
+				// 现在只有本列表真的能上下滚时才认领;否则落到函数末尾的
+				// CVerticalLayoutUI::DoEvent → CControlUI::DoEvent 继续冒泡给父容器,
+				// 由页面级滚动容器接手(等价于浏览器的 scroll chaining)。
+				// m_bScrollSelect(滚轮挪选中项)的原语义保持优先 —— 它默认 false。
 				switch (LOWORD(event.wParam)) {
 				switch (LOWORD(event.wParam)) {
 				case SB_LINEUP:
 				case SB_LINEUP:
-					if (m_bScrollSelect && !IsMultiSelect()) SelectItem(FindSelectable(m_iCurSel - 1, false), true);
-					else LineUp();
-					return;
+					if (m_bScrollSelect && !IsMultiSelect()) { SelectItem(FindSelectable(m_iCurSel - 1, false), true); return; }
+					break;
 				case SB_LINEDOWN:
 				case SB_LINEDOWN:
-					if (m_bScrollSelect && !IsMultiSelect()) SelectItem(FindSelectable(m_iCurSel + 1, true), true);
-					else LineDown();
-					return;
+					if (m_bScrollSelect && !IsMultiSelect()) { SelectItem(FindSelectable(m_iCurSel + 1, true), true); return; }
+					break;
+				}
+				if (m_pVerticalScrollBar != NULL && m_pVerticalScrollBar->IsVisible()
+					&& m_pVerticalScrollBar->IsEnabled()) {
+					switch (LOWORD(event.wParam)) {
+					case SB_LINEUP:   LineUp();   return;
+					case SB_LINEDOWN: LineDown(); return;
+					}
 				}
 				}
 			}
 			}
 			break;
 			break;

+ 168 - 0
DuiLib/Core/UIBase.cpp

@@ -112,6 +112,75 @@ namespace {
     void SetDetachedManager(DuiLib::CPaintManagerUI* pManager) { g_pDetachedManager = pManager; }
     void SetDetachedManager(DuiLib::CPaintManagerUI* pManager) { g_pDetachedManager = pManager; }
     DuiLib::CPaintManagerUI* GetDetachedManager() { return g_pDetachedManager; }
     DuiLib::CPaintManagerUI* GetDetachedManager() { return g_pDetachedManager; }
 
 
+    // -------------------------------------------------------------------------
+    // 【R49d · 临时诊断】"ESC / 回车到底被谁收走了" —— 线程局部 WH_GETMESSAGE 嗅探。
+    //
+    // 真模态弹框的 ESC / 回车有三条消失路径,前两条在窗口过程里能看见,
+    // **第三条只有在"消息出队、但还没被派发"时才能看见**:
+    //   ① DispatchMessage 直接派发给弹框
+    //      (我们回答 WM_GETDLGCODE = DLGC_WANTALLKEYS 之后就应该走这条);
+    //   ② 被 IsDialogMessage 改写成 WM_COMMAND(IDCANCEL / IDOK)
+    //      (__DialogWndProc 里再翻译回合成的 WM_KEYDOWN);
+    //   ③ 压根没投给弹框 —— 键盘焦点跑去了别的窗口(主窗口 / 屏幕键盘 / 别的进程)。
+    // WH_GETMESSAGE 钩子正好卡在 GetMessage 返回之前,是唯一能分辨 ③ 的观察点。
+    //
+    // 只在真模态弹框存活期间安装(引用计数,嵌套弹框只装一次),只记录
+    // ESC / 回车 / Tab 三个键 ⇒ 日志量极小。输出:exe 同目录 dlgkey_trace.log。
+    //
+    // ⚠️ 纯诊断,与业务逻辑无关。问题定位后整体删除:
+    //    连同 SpawnKeySpy / StopKeySpy 两个调用点、以及 UIDiag 里的 LogKey 一起删。
+    // -------------------------------------------------------------------------
+    int   g_nKeySpyRef = 0;
+    HHOOK g_hKeySpy    = NULL;
+
+    LRESULT CALLBACK KeySpyProc(int nCode, WPARAM wParam, LPARAM lParam)
+    {
+        if (nCode == HC_ACTION)
+        {
+            const MSG* pMsg = reinterpret_cast<const MSG*>(lParam);
+            if (pMsg != NULL && (pMsg->message == WM_KEYDOWN || pMsg->message == WM_SYSKEYDOWN))
+            {
+                const unsigned nKey = (unsigned)pMsg->wParam;
+                if (nKey == VK_ESCAPE || nKey == VK_RETURN || nKey == VK_TAB)
+                {
+                    TCHAR szCls[64] = { 0 };
+                    ::GetClassName(pMsg->hwnd, szCls, 63);
+                    DuiLibDiag::LogKey(
+                        _T("[SPY] key=0x%02X target=%p cls=%s focus=%p active=%p fg=%p"),
+                        nKey, pMsg->hwnd, szCls,
+                        ::GetFocus(), ::GetActiveWindow(), ::GetForegroundWindow());
+                }
+            }
+        }
+        return ::CallNextHookEx(g_hKeySpy, nCode, wParam, lParam);
+    }
+
+    void SpawnKeySpy()
+    {
+        ++g_nKeySpyRef;
+        if (g_hKeySpy == NULL)
+        {
+            g_hKeySpy = ::SetWindowsHookEx(WH_GETMESSAGE, KeySpyProc, NULL, ::GetCurrentThreadId());
+            DuiLibDiag::LogKey(_T("[SPY] install ref=%d hook=%p tid=%u"),
+                g_nKeySpyRef, g_hKeySpy, (unsigned)::GetCurrentThreadId());
+        }
+    }
+
+    void StopKeySpy()
+    {
+        if (g_nKeySpyRef > 0) --g_nKeySpyRef;
+        if (g_nKeySpyRef <= 0)
+        {
+            g_nKeySpyRef = 0;
+            if (g_hKeySpy != NULL)
+            {
+                ::UnhookWindowsHookEx(g_hKeySpy);
+                DuiLibDiag::LogKey(_T("[SPY] uninstall hook=%p"), g_hKeySpy);
+                g_hKeySpy = NULL;
+            }
+        }
+    }
+
     // 【已移除】IsModalBlockedInputMessage —— 假模态时代的"在消息循环里丢弃发给 owner 的输入消息"。
     // 【已移除】IsModalBlockedInputMessage —— 假模态时代的"在消息循环里丢弃发给 owner 的输入消息"。
     // 现在 ShowModal 走真模态(DialogBoxIndirectParam),系统对话框管理器自带 owner 禁用,
     // 现在 ShowModal 走真模态(DialogBoxIndirectParam),系统对话框管理器自带 owner 禁用,
     // 这条兜底不但多余,还会误伤:真模态期间 owner 本就需要收到 WM_ACTIVATE/WM_KILLFOCUS 之类的
     // 这条兜底不但多余,还会误伤:真模态期间 owner 本就需要收到 WM_ACTIVATE/WM_KILLFOCUS 之类的
@@ -746,6 +815,12 @@ INT_PTR CALLBACK CWindowWnd::__DialogProc(HWND hDlg, UINT uMsg, WPARAM wParam, L
         {
         {
             pManager->AttachPaintWindow(hDlg);
             pManager->AttachPaintWindow(hDlg);
 
 
+            // ★【R50】补一次"全新窗口"的首次布局 + 首次布局完成通知。
+            //   壳窗口阶段 AttachDialog 已经把 m_bFirstLayout / m_bUpdateNeeded 置过 true,
+            //   一旦在壳窗口那边被消费掉,弹框这个新 HWND 上就再也不会发 windowinit
+            //   ⇒ 业务层挂 windowinit 的初始化(默认焦点等)不执行。
+            pManager->RewindForModalPaint();
+
             // ---- [窗口阴影] 在对话框的新 HWND 上重建阴影窗口 ----
             // ---- [窗口阴影] 在对话框的新 HWND 上重建阴影窗口 ----
             // 背景:CShadowUI 的阴影窗口(PerryShadowWnd)是**绑定父窗口 HWND** 的 ——
             // 背景:CShadowUI 的阴影窗口(PerryShadowWnd)是**绑定父窗口 HWND** 的 ——
             //   · Create() 把 (父HWND → CShadowUI*) 记进 GetShadowMap(),以该 HWND 为
             //   · Create() 把 (父HWND → CShadowUI*) 记进 GetShadowMap(),以该 HWND 为
@@ -840,6 +915,10 @@ INT_PTR CALLBACK CWindowWnd::__DialogProc(HWND hDlg, UINT uMsg, WPARAM wParam, L
         pThis->HandleMessage(uMsg, wParam, lParam);
         pThis->HandleMessage(uMsg, wParam, lParam);
 
 
         ::SetFocus(hDlg);
         ::SetFocus(hDlg);
+
+        // [R49d 诊断] 装键盘嗅探钩子(引用计数;见 KeySpyProc 的注释)。
+        SpawnKeySpy();
+
         return TRUE;    // TRUE = 我们自己设置了焦点
         return TRUE;    // TRUE = 我们自己设置了焦点
     }
     }
 
 
@@ -856,6 +935,8 @@ LRESULT CALLBACK CWindowWnd::__DialogWndProc(HWND hWnd, UINT uMsg, WPARAM wParam
 
 
     if( uMsg == WM_NCDESTROY && pThis != NULL )
     if( uMsg == WM_NCDESTROY && pThis != NULL )
     {
     {
+        // [R49d 诊断] 弹框销毁即卸载键盘嗅探(引用计数归零才真正 Unhook)。
+        StopKeySpy();
         // 顺序很关键:
         // 顺序很关键:
         //   1) 先把窗口过程还原(此后系统调用会回到对话框默认过程);
         //   1) 先把窗口过程还原(此后系统调用会回到对话框默认过程);
         //   2) 再让 DefWindowProc 做标准清理;
         //   2) 再让 DefWindowProc 做标准清理;
@@ -895,6 +976,78 @@ LRESULT CALLBACK CWindowWnd::__DialogWndProc(HWND hWnd, UINT uMsg, WPARAM wParam
 
 
     if( pThis != NULL )
     if( pThis != NULL )
     {
     {
+    // -------------------------------------------------------------------------
+    // ★★★【R49c】把「对话框级导航键」从对话框管理器手里要回来
+    //        —— 根治「真模态弹框 ESC / 回车完全没反应」。
+    //
+    // 【现象】真模态弹框(#32770)里按 ESC / 回车毫无反应;
+    //   而全库 39 个弹框都把
+    //   「WM_KEYDOWN + VK_ESCAPE / VK_RETURN」分支写在
+    //   IMessageFilterUI::MessageHandler(pre-filter)里,看上去一点问题都没有。
+    //
+    // 【根因】不是我们的代码,是 Windows 对话框管理器的既定行为:
+    //   DialogBoxIndirectParam 内部跑的是
+    //       while(GetMessage()) {
+    //           if (!IsDialogMessage(hDlg, &msg)) { TranslateMessage; DispatchMessage; }
+    //       }
+    //   ⇒ 键盘消息先过 IsDialogMessage。它会(参考 Raymond Chen
+    //     《Exploring the signals the dialog manager uses for dismissing a dialog》):
+    //       · VK_ESCAPE -> 改写成 WM_COMMAND(wParam=LOWORD(IDCANCEL)=2, HIWORD=BN_CLICKED)
+    //       · VK_RETURN -> 改写成 WM_COMMAND(wParam=LOWORD(IDOK)=1,      HIWORD=BN_CLICKED)
+    //     然后 **return TRUE**(消息已被吃,不再 DispatchMessage)
+    //   ⇒ 窗口过程永远收不到这两个 WM_KEYDOWN。Petzold《Programming Windows》
+    //     原文也点明:“there is no requirement that controls with IDOK or
+    //     IDCANCEL IDs exist” —— 即弹框一个子控件都没有(本项目
+    //     DLGTEMPLATE 的 cdit=0)也照样发。
+    //
+    // 【为什么上一轮(#46)没顺带修好】那一轮补的 PreMessageHandler
+    //   只解决「PostMessage 过来的自定义消息(WM_SHANGPIN_HANDLE_SUCCESS 等)
+    //   到不了 pre-filter」—— 那些是**投递消息**,不经过
+    //   IsDialogMessage;而 ESC / 回车是**键盘消息**,在更前面就被
+    //   改写掉了。⇒ 现象永远对不上:保存成功能收到消息,ESC 却毫无反应。
+    //
+    // 【修法】两条路一起上,互为兜底,且**绝不可能重复触发**:
+    //   ① 回答 WM_GETDLGCODE = DLGC_WANTALLKEYS —— 这是对话框管理器
+    //      官方的“逃生口”:按 Raymond Chen 原文,只要焦点控件在
+    //      WM_GETDLGCODE 里声明 DLGC_WANTALLKEYS / DLGC_WANTMESSAGE,
+    //      IsDialogMessage 就**不做**上面那套改写,把 WM_KEYDOWN 原样
+    //      DispatchMessage 下来。(顺带把 Tab / 方向键导航还给 DuiLib
+    //      自己的 PreMessageHandler。)
+    //   ② 万一系统没理会 ①(或焦点落在不声明 DLGCODE 的**原生 EDIT
+    //      子窗口**上 —— 那时查询对象是那个子窗口而不是本弹框),
+    //      ESC / 回车就会被改写成 WM_COMMAND。那就把
+    //      WM_COMMAND(IDCANCEL / IDOK) 翻译回一条合成的 WM_KEYDOWN,
+    //      先喂给 pre-filter 链 —— 39 个弹框里既有的分支原样生效,
+    //      业务代码一行不用改。判据与对话框管理器一致:
+    //      只在「弹框里没有 ID == IDCANCEL / IDOK 的控件」时才翻译
+    //      (本项目 DLGTEMPLATE 无任何控件,恒成立;有真实控件时放手
+    //      不管,绝不误伤)。
+    //
+    // 【为什么两条路不可能同时生效】同一次按键要么被
+    //   IsDialogMessage 吃掉(走 ②),要么被 DispatchMessage 下来(走 ①)
+    //   —— 互斥,所以不会一次按键关两次弹框。
+    // -------------------------------------------------------------------------
+    // [R49d 诊断] 弹框窗口过程实际收到了什么(量极小,只在 ESC/回车/Tab 与
+    //   WM_COMMAND(IDCANCEL/IDOK) 上落笔)—— 用来分辨"被 IsDialogMessage 改写了"
+    //   还是"原样派发下来了"。
+    if( uMsg == WM_KEYDOWN || uMsg == WM_SYSKEYDOWN )
+    {
+        const unsigned nKey = (unsigned)wParam;
+        if( nKey == VK_ESCAPE || nKey == VK_RETURN || nKey == VK_TAB )
+            DuiLibDiag::LogKey(_T("[DLGWP] KEYDOWN key=0x%02X hwnd=%p focus=%p"),
+                nKey, hWnd, ::GetFocus());
+    }
+    else if( uMsg == WM_COMMAND )
+    {
+        const unsigned nId = (unsigned)LOWORD(wParam);
+        if( nId == IDCANCEL || nId == IDOK )
+            DuiLibDiag::LogKey(_T("[DLGWP] COMMAND id=%u hi=0x%04X hwnd=%p"),
+                nId, (unsigned)HIWORD(wParam), hWnd);
+    }
+
+    if( uMsg == WM_GETDLGCODE )
+        return DLGC_WANTALLKEYS;
+
         // ---------------------------------------------------------------
         // ---------------------------------------------------------------
         // [WM_TOUCH 归属权] 注册"补调原窗口过程"的钩子。
         // [WM_TOUCH 归属权] 注册"补调原窗口过程"的钩子。
         //
         //
@@ -933,6 +1086,21 @@ LRESULT CALLBACK CWindowWnd::__DialogWndProc(HWND hWnd, UINT uMsg, WPARAM wParam
         // 这里在分发 WNDPROC 之前补跑本窗口的 pre-filter 链(pMgr->PreMessageHandler),
         // 这里在分发 WNDPROC 之前补跑本窗口的 pre-filter 链(pMgr->PreMessageHandler),
         // 完全等价于主循环行为(pre-filter 优先,未处理再走 HandleMessage),彻底根除该回归。
         // 完全等价于主循环行为(pre-filter 优先,未处理再走 HandleMessage),彻底根除该回归。
         // 取不到 manager 时降级为原行为,不引入新回归;WM_TOUCH 等 pre-filter 不处理的消息照常回落。
         // 取不到 manager 时降级为原行为,不引入新回归;WM_TOUCH 等 pre-filter 不处理的消息照常回落。
+        // ★【R49c】见上方 WM_GETDLGCODE 处的长注释 ②:把对话框管理器
+        //   改写出来的 WM_COMMAND(IDCANCEL / IDOK) 翻译回一条合成的
+        //   WM_KEYDOWN,先喂 pre-filter 链。两条路互斥,不会重复触发。
+        if( uMsg == WM_COMMAND && pMgr != NULL )
+        {
+            const WORD wId = LOWORD(wParam);
+            if( ( wId == IDCANCEL || wId == IDOK ) && ::GetDlgItem(hWnd, wId) == NULL )
+            {
+                const WPARAM wKey = ( wId == IDCANCEL ) ? (WPARAM)VK_ESCAPE : (WPARAM)VK_RETURN;
+                LRESULT lPreKey = 0;
+                if( pMgr->PreMessageHandler(WM_KEYDOWN, wKey, lParam, lPreKey) )
+                    return lPreKey;
+            }
+        }
+
         if (pMgr != NULL)
         if (pMgr != NULL)
         {
         {
             LRESULT lPre = 0;
             LRESULT lPre = 0;

+ 13 - 0
DuiLib/Core/UIManager.cpp

@@ -996,6 +996,19 @@ namespace DuiLib
 
 
 	}
 	}
 
 
+	// ---------------------------------------------------------------------------
+	// 【R50】真模态弹框:重新武装"首次布局 + 需要重新布局"。说明见 UIManager.h。
+	//
+	// 为什么必须两个一起置:DUI_MSGTYPE_WINDOWINIT 只在 WM_PAINT 的
+	//   `if (m_bUpdateNeeded)` 分支里、且 `if (m_bFirstLayout)` 成立时才发
+	//   (见本文件 MessageHandler 的 WM_PAINT case)——只置 m_bFirstLayout 发不出来。
+	// ---------------------------------------------------------------------------
+	void CPaintManagerUI::RewindForModalPaint()
+	{
+		m_bFirstLayout = true;
+		m_bUpdateNeeded = true;
+	}
+
 	// ---------------------------------------------------------------------------
 	// ---------------------------------------------------------------------------
 	// 触摸 API 懒加载包装(对标 NIM Duilib 的 RegisterTouchWindowWrapper 系列)。
 	// 触摸 API 懒加载包装(对标 NIM Duilib 的 RegisterTouchWindowWrapper 系列)。
 	//
 	//

+ 18 - 0
DuiLib/Core/UIManager.h

@@ -264,6 +264,24 @@ namespace DuiLib {
 		void DetachPaintWindow();
 		void DetachPaintWindow();
 		void AttachPaintWindow(HWND hWnd);
 		void AttachPaintWindow(HWND hWnd);
 
 
+		// 【R50】真模态弹框:把"首次布局"与"需要重新布局"两个标志重新武装一次。
+		//
+		// 背景:调用方是「Create(壳窗口) → manager.Init(壳窗口) → ShowModal()」。真模态把
+		//   同一个 manager 从壳窗口 Detach、再 Attach 到系统新建的 #32770 对话框上。
+		//   而 AttachDialog(在壳窗口阶段跑的)把 m_bFirstLayout / m_bUpdateNeeded 置了 true,
+		//   这两个标志在壳窗口那边就可能已被消费,且**被消费后不会自动复位** ⇒ 弹框这个
+		//   **全新 HWND** 上既不会重新布局,也不会再发 DUI_MSGTYPE_WINDOWINIT ⇒ 业务层挂在
+		//   windowinit 上的初始化(例如"默认把焦点放到商品名输入框")永远不执行。
+		//
+		// 为什么这不算硬塞:对一个刚创建、客户区尺寸刚被 SetWindowPos 校正过的**新 HWND**
+		//   来说,"重新布局一次 + 发一次首次布局完成通知"本来就是正确语义 —— 与 AttachDialog
+		//   之后新窗口得到的待遇一致。
+		// ⚠️ 两个标志必须**一起**置:WINDOWINIT 只在 WM_PAINT 的 m_bUpdateNeeded 分支里发,
+		//   只置 m_bFirstLayout 是发不出来的。
+		//
+		// 幂等:两者都只在 WM_PAINT 里读一次、随即置 false ⇒ 最多多补一次通知。
+		void RewindForModalPaint();
+
 		bool IsUpdateNeeded() const;
 		bool IsUpdateNeeded() const;
 		void NeedUpdate();
 		void NeedUpdate();
 		void Invalidate();
 		void Invalidate();

+ 45 - 0
DuiLib/Utils/UIDiag.cpp

@@ -104,6 +104,51 @@ namespace DuiLibDiag
 		}
 		}
 	}
 	}
 
 
+	// ★【R49d】键盘追踪开关:与 Enabled() 独立。要彻底关掉键盘追踪就把它改回 false。
+	inline bool KeyTraceEnabled()
+	{
+		return true;
+	}
+
+	// 与 Log() 同构,只换文件名与开关;逻辑一字不改(上限改成 4MB,
+	// 因为它只记录 ESC / 回车 / Tab,正常一天也就几十到几百行)。
+	void LogKey(LPCTSTR fmt, ...)
+	{
+		if (!KeyTraceEnabled()) return;
+
+		TCHAR path[MAX_PATH] = { 0 };
+		::GetModuleFileName(NULL, path, MAX_PATH);
+		TCHAR* p = _tcsrchr(path, _T('\\'));
+		if (p != NULL) *(p + 1) = _T('\0');
+		_tcscat_s(path, MAX_PATH, _T("dlgkey_trace.log"));
+
+		TCHAR msg[1024] = { 0 };
+		va_list ap;
+		va_start(ap, fmt);
+		_vsntprintf_s(msg, _countof(msg), _TRUNCATE, fmt, ap);
+		va_end(ap);
+
+		char utf8[2048] = { 0 };
+#ifdef _UNICODE
+		if (::WideCharToMultiByte(CP_UTF8, 0, msg, -1, utf8, (int)sizeof(utf8) - 1, NULL, NULL) <= 0)
+			return;
+#else
+		_tcscpy_s(utf8, _countof(utf8), msg);
+#endif
+
+		FILE* f = NULL;
+		WIN32_FILE_ATTRIBUTE_DATA fad = { 0 };
+		if (::GetFileAttributesEx(path, GetFileExInfoStandard, &fad)
+			&& fad.nFileSizeHigh == 0 && fad.nFileSizeLow >= (4u * 1024u * 1024u))
+			return;
+		if (fopen_s(&f, Narrow(path), "a") == 0 && f != NULL)
+		{
+			fprintf(f, "[%8u][T%5u] %s\n", (unsigned)::GetTickCount(), (unsigned)::GetCurrentThreadId(), utf8);
+			fflush(f);
+			fclose(f);
+		}
+	}
+
 	void LogButtonActivate(LPCTSTR name, bool captureState, unsigned buttonState, bool pushed)
 	void LogButtonActivate(LPCTSTR name, bool captureState, unsigned buttonState, bool pushed)
 	{
 	{
 		// 位域含义(取自 UIManager.h 的 #define,勿凭记忆写):
 		// 位域含义(取自 UIManager.h 的 #define,勿凭记忆写):

+ 9 - 0
DuiLib/Utils/UIDiag.h

@@ -29,6 +29,15 @@ namespace DuiLibDiag
 	// 写 exe 同目录 touch_trace.log。
 	// 写 exe 同目录 touch_trace.log。
 	void Log(LPCTSTR fmt, ...);
 	void Log(LPCTSTR fmt, ...);
 
 
+	// 【R49d · 临时诊断】键盘"谁收到了"追踪专用通道。
+	//
+	// 与 Log() 的区别:
+	//   · **不受 Enabled() 开关控制** —— 由 UIDiag.cpp 里的 kKeyTrace 独立开关(默认开);
+	//   · 写**另一个文件** dlgkey_trace.log(与 touch_trace.log 分开,便于单独回传);
+	//   · 只被极少量调用点使用(真模态弹框的键盘 / 命令消息),量极小,长期开着也无妨。
+	// 问题定位后连同调用点一起删除。
+	void LogKey(LPCTSTR fmt, ...);
+
 	// 按钮被激活时的现场快照。用途:
 	// 按钮被激活时的现场快照。用途:
 	//   · 定位"弹框刚出来就被自动点掉"——会看到弹框内的按钮在弹框创建后
 	//   · 定位"弹框刚出来就被自动点掉"——会看到弹框内的按钮在弹框创建后
 	//     极短时间内被激活,且 mgrCaptured=0;
 	//     极短时间内被激活,且 mgrCaptured=0;