Browse Source

这个版本目前所有触屏操作都正常了。

张洋 3 weeks ago
parent
commit
cc1a74d33d

+ 80 - 0
DuiLib/Control/UIButton.cpp

@@ -75,6 +75,57 @@ namespace DuiLib
 			}
 			return;
 		}	
+		// ------------------------------------------------------------------
+		// [触屏] 触摸按下:与 UIEVENT_BUTTONDOWN 等价,建立 UISTATE_CAPTURED。
+		//
+		// ⚠️ 这条分支是"触摸能点击"的关键,不能省。
+		//
+		// 背景:触摸通道(CPaintManagerUI::BeginTouchSlideGesture)在 DOWN 阶段**只**派发
+		// UIEVENT_TOUCHDOWN,不会派发 UIEVENT_BUTTONDOWN(那是原生鼠标路径的事,而原生
+		// 路径在触摸手势期间被有意让开了)。所以如果本函数不认 TOUCHDOWN,按钮的
+		// m_uButtonState 里就**永远没有 UISTATE_CAPTURED**。
+		//
+		// 后果:抬手时(无论由触摸链合成的 UIEVENT_BUTTONUP,还是原生 WM_LBUTTONUP 分支
+		// 派发的)都会卡在下面这条守卫上:
+		//     if( (m_uButtonState & UISTATE_CAPTURED) != 0 ) { ... Activate(); }
+		// 因为 CAPTURED 始料未及地没被置位 ⇒ 整块跳过 ⇒ **Activate 根本不被调用**
+		// ⇒ 按钮的 DUI_MSGTYPE_CLICK 永远发不出去(表现为"看得见按下高亮,抬手没反应")。
+		//
+		// 注意:这里必须与 UIEVENT_BUTTONDOWN 一样**顺手置起来 CAPTURED**(而不是只做视觉
+		// 高亮),因为 BUTTONUP 的守卫就是按 CAPTURED 判的 —— 它同时承担了
+		// "这次按键是否由我起头"的记账职责。
+		// ------------------------------------------------------------------
+		if( event.Type == UIEVENT_TOUCHDOWN )
+		{
+			if( ::PtInRect(&m_rcItem, event.ptMouse) && IsEnabled() ) {
+				m_uButtonState |= UISTATE_PUSHED | UISTATE_CAPTURED;
+				Invalidate();
+			}
+			return;
+		}
+		// ------------------------------------------------------------------
+		// [触屏] 触摸抬起:与 UIEVENT_BUTTONUP 等价。
+		//
+		// 为什么不能只靠"合成的 UIEVENT_BUTTONUP":触摸链(EndTouchSlideGesture →
+		// 调用方 OnTouchInput / OnPointerInput 的轻点分支)合成的确实是 BUTTONDOWN/BUTTONUP,
+		// 那条路能走通;但为了让滚动条之外的所有控件在两种驱动方式下行为一致
+		// (TOUCHDOWN/MOVE/UP 与 BUTTONDOWN/UP 语义等价 —— 这与 CScrollBarUI 的处理方式
+		// 保持一致),这里把 TOUCHUP 也认下来。
+		//
+		// 判定与 BUTTONUP 完全对齐:清 PUSHED|CAPTURED、且抬手点仍落在按钮内才 Activate。
+		// 位移越阈值时(m_bTouchMoved)触摸链不会合成点击,但 TOUCHUP 仍会派发到这里 ——
+		// 此时若手指已滑出按钮 rect,PtInRect 自然失败,不会误触发;若恰好滑完还停在按钮上,
+		// 触摸链的 tap 判定(EndTouchSlideGesture 的返回值)已经决定不派发点击,所以这里
+		// **只做状态清理**,不主动 Activate,避免"滑动误点"回归。
+		// ------------------------------------------------------------------
+		if( event.Type == UIEVENT_TOUCHUP )
+		{
+			if( (m_uButtonState & UISTATE_CAPTURED) != 0 ) {
+				m_uButtonState &= ~(UISTATE_PUSHED | UISTATE_CAPTURED);
+				Invalidate();
+			}
+			return;
+		}
 		if( event.Type == UIEVENT_MOUSEMOVE )
 		{
             if ((m_uButtonState & UISTATE_CAPTURED) != 0)
@@ -126,6 +177,35 @@ namespace DuiLib
 
 	bool CButtonUI::Activate()
 	{
+		// [临时诊断] 触摸点击不生效时,用这条日志定位到底卡在哪个守卫上。
+		// 背景:触摸路径(BeginTouchSlideGesture → EndTouchSlideGesture → 调用方合成
+		// BUTTONDOWN/BUTTONUP)与纯鼠标路径不同 —— DOWN 阶段只发 UIEVENT_TOUCHDOWN,
+		// 按钮不会自己进 UISTATE_PUSHED|UISTATE_CAPTURED,所以 BUTTONUP 分支的
+		// `if ((m_uButtonState & UISTATE_CAPTURED) != 0)` 可能直接不进、Activate 压根不被调用;
+		// 而 CControlUI::Activate 里的捕获保护(!IsCaptured() && !CAPTURED)也会拦住。
+		// 这两条都不会在任何现有日志里体现,所以单独打一条。
+		// 定位完成后可整块删除(只影响日志,不影响逻辑)。
+		if (m_pManager != NULL)
+		{
+			const bool bCapturedByMgr = m_pManager->IsCaptured();
+			const bool bStateCaptured = ((m_uButtonState & UISTATE_CAPTURED) != 0);
+			const bool bPushed = ((m_uButtonState & UISTATE_PUSHED) != 0);
+			const bool bWouldPass = (bCapturedByMgr || bStateCaptured);
+			TCHAR diag[MAX_PATH] = { 0 };
+			::GetModuleFileName(NULL, diag, MAX_PATH);
+			TCHAR* pdiag = _tcsrchr(diag, _T('\\'));
+			if (pdiag != NULL) *(pdiag + 1) = _T('\0');
+			_tcscat_s(diag, MAX_PATH, _T("touch_trace.log"));
+			FILE* fdiag = NULL;
+			if (_tfopen_s(&fdiag, diag, _T("a, ccs=UTF-8")) == 0 && fdiag != NULL)
+			{
+				_ftprintf(fdiag, _T("BUTTON-ACTIVATE name=%s mgrCaptured=%d stateCaptured=%d pushed=%d guardPass=%d\n"),
+					GetName().GetData(), (int)bCapturedByMgr, (int)bStateCaptured,
+					(int)bPushed, (int)bWouldPass);
+				fclose(fdiag);
+			}
+		}
+
 		if( !CControlUI::Activate() ) return false;
 		if( m_pManager != NULL )
 		{

+ 601 - 91
DuiLib/Core/UIBase.cpp

@@ -1,4 +1,5 @@
 #include "StdAfx.h"
+#include "UIBase.h"
 #include "UIManager.h"
 
 #ifdef _DEBUG
@@ -13,9 +14,9 @@ namespace DuiLib {
 #ifndef WM_TOUCH
 #define WM_TOUCH 0x0240
 #endif
-// WM_POINTER* 系列(Win8+),同样不在 XP 段里。仅用于模态期间屏蔽 owner 的输入。
-#define DUI_WM_POINTER_FIRST 0x0246
-#define DUI_WM_POINTER_LAST  0x0259
+// 注:WM_POINTER* 常量统一放在 UIManager.cpp 的 PointerMsg 命名空间里(那边才是处理方),
+// 本文件不再重复定义 —— 原先为"模态期间屏蔽 owner 输入"定义的 DUI_WM_POINTER_FIRST/LAST
+// 已随 IsModalBlockedInputMessage 一并删除。
 
 namespace {
     typedef BOOL (WINAPI* PFN_REGISTER_TOUCH_WINDOW)(HWND, ULONG);
@@ -47,6 +48,13 @@ namespace {
             pfnRegister(hWnd, 0);
     }
 
+    // 【已移除】RefreshTouchRegistration(Unregister + Register 强制重算触摸归属)。
+    //
+    // 它曾是"假模态 + WM_TOUCH 贪婪归属权"时代的补丁:试图用注销/重注册把触摸归属权
+    // 从 owner 重新结算到弹框头上。实测无效(弹框依然零 WM_TOUCH),且真模态方案下
+    // 完全没必要 —— 对话框窗口是新 HWND,创建时 RegisterTouchWindowIfAvailable 注册一次即可,
+    // 系统自己就把触摸投给它。留着只会带来 C4505(未引用函数)警告,故整段删除。
+
     // 诊断:WM_TOUCH 究竟落在哪个窗口上(弹框自己?还是被禁用/被挡住的 owner?)。
     // ⚠️ 只 peek,绝不调用 CloseTouchInputHandle:句柄必须交给 CPaintManagerUI::OnTouchInput 关闭。
     void TraceTouchArrival(HWND hWnd, WPARAM wParam, LPARAM lParam)
@@ -73,20 +81,171 @@ namespace {
             (void*)DuiLib::CPaintManagerUI::GetModalWindow(), (void*)::GetForegroundWindow());
     }
 
-    // 上一次"抢回前台"的时间戳(限流用,避免频繁改激活状态打断输入)
-    DWORD s_dwLastModalFocusFix = 0;
+    // ---------------------------------------------------------------------------
+    // 【临时全量诊断】记录系统到底把哪些"输入类消息"发给了哪个窗口。
+    //
+    // 背景:弹框打开后,日志里既没有 WM_TOUCH(任何窗口都没有),也没有 WM_LBUTTONDOWN/UP,
+    // 却能看到弹框收到 WM_MOUSEMOVE(hover)。这说明触摸可能被系统降级成了鼠标模拟,
+    // 或者走了 WM_GESTURE / WM_POINTER* 另外两条通道。这三个通道目前都没被记录到,
+    // 因此需要一段覆盖面更广的诊断来确定"消息到底有没有发出来、发给了谁"。
+    //
+    // 覆盖:WM_TOUCH(0x0240) / WM_GESTURE(0x0119) / WM_POINTER*(0x0245~0x025F 及 0x0246/0x0247 变体)
+    //       WM_MOUSEMOVE(0x0200) / WM_LBUTTONDOWN(0x0201) / WM_LBUTTONUP(0x0202)
+    //       WM_NCMOUSEMOVE(0x00A0) / WM_MOUSEACTIVATE(0x0021) / WM_SETCURSOR(0x0020)
+    //
+    // ⚠️ 纯只读诊断:不改消息流向、不返回值、不消费句柄。只为定位问题,验证完请删除。
+    // ---------------------------------------------------------------------------
+    // MSG-ID 常量(不引用任何 WM_* 宏,避免 _WIN32_WINNT 版本段差异导致编译不过)
+    const UINT DUI_MSG_WM_TOUCH              = 0x0240;
+    const UINT DUI_MSG_WM_GESTURE            = 0x0119;
+    const UINT DUI_MSG_WM_GESTURENOTIFY      = 0x011A;
+    const UINT DUI_MSG_WM_MOUSEMOVE          = 0x0200;
+    const UINT DUI_MSG_WM_LBUTTONDOWN        = 0x0201;
+    const UINT DUI_MSG_WM_LBUTTONUP          = 0x0202;
+    const UINT DUI_MSG_WM_LBUTTONDBLCLK      = 0x0203;
+    const UINT DUI_MSG_WM_RBUTTONDOWN        = 0x0204;
+    const UINT DUI_MSG_WM_RBUTTONUP          = 0x0205;
+    const UINT DUI_MSG_WM_NCMOUSEMOVE        = 0x00A0;
+    const UINT DUI_MSG_WM_NCLBUTTONDOWN      = 0x00A1;
+    const UINT DUI_MSG_WM_MOUSEACTIVATE      = 0x0021;
+    const UINT DUI_MSG_WM_SETCURSOR          = 0x0020;
+    const UINT DUI_MSG_WM_NCHITTEST          = 0x0084;
+    const UINT DUI_MSG_WM_ACTIVATE           = 0x0006;
+    const UINT DUI_MSG_WM_ACTIVATEAPP        = 0x001C;
+    const UINT DUI_MSG_WM_SETFOCUS           = 0x0007;
+    const UINT DUI_MSG_WM_KILLFOCUS          = 0x0008;
+
+    void TraceInputMessage(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
+    {
+        LPCTSTR pszName = NULL;
+        switch (uMsg)
+        {
+        // ⚠️ case 必须是"编译期常量表达式"。这些常量在匿名 namespace 里是 const UINT
+        //    且已初始化,属于 ICE,可以安全用作 case 标签。
+        case DUI_MSG_WM_TOUCH:         pszName = _T("WM_TOUCH"); break;
+        case DUI_MSG_WM_GESTURE:       pszName = _T("WM_GESTURE"); break;
+        case DUI_MSG_WM_GESTURENOTIFY: pszName = _T("WM_GESTURENOTIFY"); break;
+        case DUI_MSG_WM_MOUSEMOVE:     pszName = _T("WM_MOUSEMOVE"); break;
+        case DUI_MSG_WM_LBUTTONDOWN:   pszName = _T("WM_LBUTTONDOWN"); break;
+        case DUI_MSG_WM_LBUTTONUP:     pszName = _T("WM_LBUTTONUP"); break;
+        case DUI_MSG_WM_LBUTTONDBLCLK: pszName = _T("WM_LBUTTONDBLCLK"); break;
+        case DUI_MSG_WM_RBUTTONDOWN:   pszName = _T("WM_RBUTTONDOWN"); break;
+        case DUI_MSG_WM_RBUTTONUP:     pszName = _T("WM_RBUTTONUP"); break;
+        case DUI_MSG_WM_NCMOUSEMOVE:   pszName = _T("WM_NCMOUSEMOVE"); break;
+        case DUI_MSG_WM_NCLBUTTONDOWN: pszName = _T("WM_NCLBUTTONDOWN"); break;
+        case DUI_MSG_WM_MOUSEACTIVATE: pszName = _T("WM_MOUSEACTIVATE"); break;
+        case DUI_MSG_WM_SETCURSOR:     pszName = _T("WM_SETCURSOR"); break;
+        case DUI_MSG_WM_NCHITTEST:     pszName = _T("WM_NCHITTEST"); break;
+        case DUI_MSG_WM_ACTIVATE:      pszName = _T("WM_ACTIVATE"); break;
+        case DUI_MSG_WM_ACTIVATEAPP:   pszName = _T("WM_ACTIVATEAPP"); break;
+        case DUI_MSG_WM_SETFOCUS:      pszName = _T("WM_SETFOCUS"); break;
+        case DUI_MSG_WM_KILLFOCUS:     pszName = _T("WM_KILLFOCUS"); break;
+        default: break;
+        }
+
+        // 覆盖 WM_POINTER* 一段(0x0245~0x0260,不同 SDK 版本消息号有差异,统一按范围判断)
+        const bool bPointerMsg = (uMsg >= 0x0245 && uMsg <= 0x0260);
+        if (pszName == NULL && !bPointerMsg) return;
+
+        // WM_MOUSEMOVE / WM_NCMOUSEMOVE / WM_NCHITTEST / WM_SETCURSOR 极其频繁,
+        // 全量记录会瞬间刷爆日志,这里每类限流 300ms。
+        const bool bNoisy = (uMsg == DUI_MSG_WM_MOUSEMOVE || uMsg == DUI_MSG_WM_NCMOUSEMOVE
+            || uMsg == DUI_MSG_WM_NCHITTEST || uMsg == DUI_MSG_WM_SETCURSOR);
+        if (bNoisy)
+        {
+            static DWORD s_dwLastNoisyTick = 0;
+            static UINT  s_uLastNoisyMsg = 0;
+            const DWORD dwNow = ::GetTickCount();
+            if (s_uLastNoisyMsg == uMsg && (LONG)(dwNow - s_dwLastNoisyTick) < 300) return;
+            s_dwLastNoisyTick = dwNow;
+            s_uLastNoisyMsg = uMsg;
+        }
+
+        DWORD dwExtra = (DWORD)(DWORD_PTR)::GetMessageExtraInfo();
+        DuiLib::TouchTrace::Log(_T("INPUT-TRACE target=%p msg=0x%04X %s w=0x%08X l=0x%08X extra=0x%08X fromtouch=%d modal=%p fg=%p enable=%d"),
+            (void*)hWnd, uMsg, pszName != NULL ? pszName : _T("WM_POINTER*"),
+            (unsigned)wParam, (unsigned)lParam, (unsigned)dwExtra,
+            (int)((dwExtra & 0xFFFFFF80) == 0xFF515700),
+            (void*)DuiLib::CPaintManagerUI::GetModalWindow(),
+            (void*)::GetForegroundWindow(),
+            (int)::IsWindowEnabled(hWnd));
+    }
+
+    // 【已移除】LogProcessWindowList / EnumProc_LogWnd(本进程顶层窗口清单,MODAL-WNDLIST)。
+    // 它们是排查"WM_TOUCH 究竟投给了哪个陌生 HWND"的临时诊断,只被已删除的假模态消息循环调用。
+    // 真模态方案下弹框自己就是触摸目标,这份清单不再需要,删除以免 C4505。
+
+    // ---------------------------------------------------------------------------
+    // 真模态(DialogBox)辅助:按 paint window 找到对应的 CPaintManagerUI。
+    //
+    // 用途:ShowModal 里要把 manager 从旧 HWND 迁到对话框新 HWND,需要先找到它。
+    // 遍历 CPaintManagerUI::GetPaintManagers(),比对 GetPaintWindow()。
+    // ---------------------------------------------------------------------------
+    DuiLib::CPaintManagerUI* FindManagerByPaintWindow(HWND hWnd)
+    {
+        if (hWnd == NULL) return NULL;
+        CStdPtrArray* pManagers = DuiLib::CPaintManagerUI::GetPaintManagers();
+        if (pManagers == NULL) return NULL;
+        for (int i = 0; i < pManagers->GetSize(); ++i)
+        {
+            DuiLib::CPaintManagerUI* pManager = static_cast<DuiLib::CPaintManagerUI*>(pManagers->GetAt(i));
+            if (pManager != NULL && pManager->GetPaintWindow() == hWnd) return pManager;
+        }
+        return NULL;
+    }
+
+    // ---------------------------------------------------------------------------
+    // [WM_TOUCH 归属权] "补调原窗口过程"的中转函数。
+    //
+    // 为什么需要它:
+    //   WM_TOUCH 不是被系统直接投递的。系统先发一条"特殊标记的 WM_MOUSEMOVE"
+    //   (开了 raw input 时是 WM_INPUT),只有这条消息走到 DefWindowProc 里,
+    //   DefWindowProc 才会把驻留的 WM_TOUCH "翻译"并派发出来
+    //   (来源:The Witness 作者的逆向结论;MSDN《Architectural Overview》的
+    //   greedy 段落也印证了这套投递模型)。
+    //   本库的 CPaintManagerUI::MessageHandler 为了不让"触摸提升出来的鼠标移动"
+    //   破坏拖拽/悬浮状态机,会吞掉它(return true),从而跳过了 DefWindowProc
+    //   ⇒ 窗口(尤其 #32770 真模态弹框,它依赖 DefDlgProc→DefWindowProc 这条链)
+    //   后续永远收不到 WM_TOUCH。
+    //
+    // 这个中转函数由 CPaintManagerUI::RelayDefWindowProc 回调,
+    // 作用就是"把消息喂给原窗口过程一次",补上被跳过的那次副作用。
+    // 用静态变量保存"当前转发目标":因为回调签名不带 this,
+    // 而每个窗口过程在同一时刻只有一个绑定关系(主窗口 or 某个弹框)。
+    // 真模态切换 HWND 时通过 SetDefProcRelay 重新绑定,不会串。
+    //
+    // ⚠️ 位置要求:必须定义在 __DialogWndProc 之前(它比 __WndProc 先出现)。
+    // ---------------------------------------------------------------------------
+    WNDPROC g_pfnRelayTarget = NULL;
+    HWND    g_hWndRelayBound = NULL;
+
+    LRESULT CALLBACK RelayWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
+    {
+        // 只有"当前正在转发的窗口"才有效:避免悬垂到已销毁窗口的过程上。
+        if (g_pfnRelayTarget == NULL || g_hWndRelayBound != hWnd)
+            return 0;
+        return ::CallWindowProc(g_pfnRelayTarget, hWnd, uMsg, wParam, lParam);
+    }
 
-    // 模态期间需要屏蔽的输入类消息(发给被弹框挡住的 owner 时直接丢弃,不再派发)
-    bool IsModalBlockedInputMessage(UINT uMsg)
+    // 把"原窗口过程"登记为转发目标(绑定到指定 HWND)。
+    void BindRelayTarget(WNDPROC pfnOldProc, HWND hWnd)
     {
-        if (uMsg == WM_TOUCH) return true;                                  // 0x0240
-        if (uMsg >= DUI_WM_POINTER_FIRST && uMsg <= DUI_WM_POINTER_LAST) return true;
-        if (uMsg >= 0x0200 && uMsg <= 0x020E) return true;                  // WM_MOUSEMOVE .. WM_MOUSELAST
-        if (uMsg >= 0x00A0 && uMsg <= 0x00AD) return true;                  // WM_NCMOUSEMOVE .. WM_NCXBUTTONDBLCLK
-        if (uMsg >= WM_KEYFIRST && uMsg <= WM_KEYLAST) return true;         // 键盘
-        if (uMsg == WM_MOUSEACTIVATE) return true;
-        return false;
+        g_pfnRelayTarget = pfnOldProc;
+        g_hWndRelayBound = hWnd;
     }
+
+    // DetachPaintWindow 之后、AttachPaintWindow 之前,用这个取出"待挂载的 manager"。
+    // 因为此时 manager 已从 m_aPreMessages 摘除,FindManagerByPaintWindow 找不到它了,
+    // 所以用一个文件级静态变量中转(只在 ShowModal 的单线程窗口过程里使用,无需同步)。
+    static DuiLib::CPaintManagerUI* g_pDetachedManager = NULL;
+
+    void SetDetachedManager(DuiLib::CPaintManagerUI* pManager) { g_pDetachedManager = pManager; }
+    DuiLib::CPaintManagerUI* GetDetachedManager() { return g_pDetachedManager; }
+
+    // 【已移除】IsModalBlockedInputMessage —— 假模态时代的"在消息循环里丢弃发给 owner 的输入消息"。
+    // 现在 ShowModal 走真模态(DialogBoxIndirectParam),系统对话框管理器自带 owner 禁用,
+    // 这条兜底不但多余,还会误伤:真模态期间 owner 本就需要收到 WM_ACTIVATE/WM_KILLFOCUS 之类的
+    // 通知消息,无差别丢弃会让 owner 的状态机错乱。故整段删除,不再保留。
 }
 
 /////////////////////////////////////////////////////////////////////////////////////
@@ -295,7 +454,7 @@ void CNotifyPump::NotifyPump(TNotifyUI& msg)
 
 //////////////////////////////////////////////////////////////////////////
 ///
-CWindowWnd::CWindowWnd() : m_hWnd(NULL), m_OldWndProc(::DefWindowProc), m_bSubclassed(false), m_bUnicode(false), m_bFakeModal(false)
+CWindowWnd::CWindowWnd() : m_hWnd(NULL), m_OldWndProc(::DefWindowProc), m_bSubclassed(false), m_bUnicode(false), m_bFakeModal(false), m_bDoModal(false), m_nModalRet(0), m_pfnOldDlgWndProc(NULL), m_rcModalTarget()
 {
 }
 
@@ -390,101 +549,408 @@ void CWindowWnd::ShowWindow(bool bShow /*= true*/, bool bTakeFocus /*= false*/)
 
 UINT CWindowWnd::ShowModal()
 {
+    // =========================================================================
+    // 【真模态】改用 Windows 的对话框机制(DialogBoxIndirectParam)驱动模态。
+    //
+    // 参考:网易 nim_duilib 的 NativeWindow_Windows::DoModal()
+    //       (duilib/Core/NativeWindow_Windows.cpp)
+    //
+    // ── 为什么必须换掉旧写法 ──────────────────────────────────────────────
+    // 旧实现是"假模态":自己 ShowWindow + EnableWindow(owner,FALSE) + TOPMOST +
+    // 抢前台 + 自写 while(GetMessage) 嵌套循环。在 Win10 触摸屏上必然失败,根因是
+    // 触摸消息的投递目标规则(MSDN, Getting Started with Windows Touch Messages):
+    //
+    //   "WM_TOUCH messages are currently 'greedy.' After the first touch message is
+    //    received on a window, all touch messages are sent to that window until
+    //    another window receives focus."
+    //
+    // 主窗口早就 RegisterTouchWindow 过、也早就收到过 WM_TOUCH,所以触摸归属权长期
+    // 在它手里;弹框是自己 Create 出来的普通窗口,**不是**系统的触摸目标。
+    // 我们为此打了一堆补丁(手动焦点切换、刷新注册、消息循环转发、__WndProc 转发兜底),
+    // 实测全部无效:日志里弹框始终零 WM_TOUCH、零 WM_LBUTTONDOWN/UP。
+    //
+    // ── 对话框为什么能解决 ────────────────────────────────────────────────
+    // DialogBox 是内核实现的模态机制,它:
+    //   · 由系统跑消息循环(不需要我们自写嵌套循环);
+    //   · 由系统按正确时机禁用 owner(不需要我们 EnableWindow);
+    //   · 由系统维持前台/激活(不需要 TOPMOST / SetForegroundWindow / 焦点切换);
+    //   · **把对话框窗口本身变成输入目标**,触摸自然投给它、提升的鼠标消息也落在它身上。
+    //
+    // 于是"贪婪归属权""提升鼠标被吞""owner 抢前台""禁用窗口收不到消息"这几类问题
+    // 全部不存在 —— 不是被绕过,而是压根不成立。
+    //
+    // ── 如何与既有代码兼容 ────────────────────────────────────────────────
+    // 调用方约定是:new → Create(hwnd, ..., UI_WNDSTYLE_DIALOG, ...) → SetIcon →
+    // CenterWindow() → ShowModal()。也就是说**窗口在我们被调用之前就已经 Create 好了**,
+    // CPaintManagerUI 也已 Init 到那个 HWND 上。所以这里不能另起一个对话框窗口,
+    // 而是:用当前窗口的几何/样式造一个 DLGTEMPLATE,让 DialogBoxIndirectParam 去创建
+    // 对话框窗口,再把旧的 HWND 销毁、把 DuiLib 的 manager 迁移到新 HWND —— 这样
+    // 调用方完全无感知,`m_hWnd` 语义不变,30+ 个调用点一行都不用改。
+    // =========================================================================
+
     ASSERT(::IsWindow(m_hWnd));
-    UINT nRet = 0;
-    HWND hWndParent = GetWindowOwner(m_hWnd);
-    const bool bBlockParent = (hWndParent != NULL && ::IsWindow(hWndParent));
+    if( !::IsWindow(m_hWnd) ) return 0;
 
-    RegisterTouchWindowIfAvailable(m_hWnd);
+    HWND hWndOwner = ::GetWindow(m_hWnd, GW_OWNER);
+    if( hWndOwner == NULL ) hWndOwner = ::GetActiveWindow();
 
-    // -------------------------------------------------------------------------
-    // 【重要】这里不再调用 ::EnableWindow(hWndParent, FALSE)。
+    // ---- 1) 采集旧窗口的几何与样式,用于构造对话框模板 ----
+    RECT rcWnd = { 0 };
+    ::GetWindowRect(m_hWnd, &rcWnd);
+    const int nW = rcWnd.right - rcWnd.left;
+    const int nH = rcWnd.bottom - rcWnd.top;
+
+    // 【尺寸精确性】旧窗口的客户区尺寸 —— 这是迁移后必须保持不变的量。
     //
-    // 原因:Windows 8/10 把触摸输入改成了 Pointer 栈,触摸消息(WM_TOUCH / WM_POINTER*)
-    // 的投递目标与"窗口是否被禁用 / 是否是活动窗口"强相关。实测在 Win10 上,只要 owner
-    // 被 EnableWindow(FALSE) 禁用,手势就会被系统转投给被禁用的 owner 而不再到达弹框,
-    // 于是弹框既收不到 WM_TOUCH(滚动条无法触摸滑动)、也没有可靠的提升鼠标消息可用
-    // (点击无反应);而在 Win7 上鼠标/触摸提升路径不受此影响,所以旧写法一直正常。
+    // 为什么不能直接用窗口矩形(nW/nH)去填 DLGTEMPLATE.cx/cy:
+    //   1. DLGTEMPLATE 的 cx/cy 描述的是**客户区**尺寸,不是窗口外框尺寸;
+    //   2. 我们给模板设的样式(WS_CAPTION/WS_DLGFRAME/DS_MODALFRAME…)会让系统
+    //      在此基础上再往外加一圈非客户区边框。
+    //   若直接填 nW/nH,新窗口 = 旧窗口 + 边框 ⇒ 弹框整体变大、位置也偏。
     //
-    // 模态性改用两条等价手段保证:
-    //   ① 弹框置顶 + 激活,抢到前台;
-    //   ② 消息循环里丢弃所有发给 owner(及其子窗口)的输入类消息(见 IsModalBlockedInputMessage)。
-    //      —— owner 保持 enabled,它的鼠标/键盘/触摸都不会被派发,等价于"点不动";
-    //   ③ CPaintManagerUI 层再加一道:有模态弹框时,非弹框窗口收到 WM_TOUCH 直接忽略。
-    // -------------------------------------------------------------------------
-    ::ShowWindow(m_hWnd, SW_SHOWNORMAL);
-    ::SetWindowPos(m_hWnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
-    ::BringWindowToTop(m_hWnd);
-    ::SetForegroundWindow(m_hWnd);
-    ::SetActiveWindow(m_hWnd);
-    ::SetFocus(m_hWnd);
-    // 显示之后再注册一次(幂等,重复调用无害):确保最终窗口样式下的触摸注册生效。
-    RegisterTouchWindowIfAvailable(m_hWnd);
-
-    // 支持弹框嵌套:保存外层模态窗口,退出时恢复
+    // 因此这里取"旧窗口客户区尺寸"作为目标客户区尺寸;创建完成后再在 __DialogProc 里
+    // 用 AdjustWindowRectEx 反算目标外框并 SetWindowPos 精确校正(见 __DialogProc)。
+    RECT rcClient = { 0 };
+    ::GetClientRect(m_hWnd, &rcClient);
+    const int nClientW = rcClient.right - rcClient.left;
+    const int nClientH = rcClient.bottom - rcClient.top;
+
+    DWORD dwStyle   = (DWORD)::GetWindowLong(m_hWnd, GWL_STYLE);
+    DWORD dwExStyle = (DWORD)::GetWindowLong(m_hWnd, GWL_EXSTYLE);
+
+    // 对话框专用的样式:把我们自己那套 WS_POPUPWINDOW/WS_CAPTION/WS_DLGFRAME 换成
+    // 对话框该有的组合。WS_POPUP 必须保留(否则会变成带系统标题栏的普通窗口)。
+    // WS_VISIBLE 让 DialogBox 创建后直接显示。
+    //
+    // ⚠️ 关于 WS_CAPTION / DS_MODALFRAME:
+    //   本项目的弹框(UI_WNDSTYLE_DIALOG)是**无系统标题栏的自绘弹框**,且调用方
+    //   均未传 WS_THICKFRAME(固定尺寸)。所以这里**不能**自作主张加 DS_MODALFRAME 或
+    //   WS_THICKFRAME —— 那会给弹框加一圈可调整大小的边框,既改变外观又改变尺寸。
+    //   保持 WS_POPUP + 原有的 WS_CAPTION/WS_SYSMENU 即可(自绘弹框本来就不显示系统标题栏)。
+    DWORD dwDlgStyle = WS_POPUP | WS_VISIBLE | WS_CLIPSIBLINGS | WS_CLIPCHILDREN;
+    if( dwStyle & WS_CAPTION )      dwDlgStyle |= WS_CAPTION | WS_SYSMENU;
+    if( dwStyle & WS_THICKFRAME )   dwDlgStyle |= WS_THICKFRAME;
+    if( dwStyle & WS_MINIMIZEBOX )  dwDlgStyle |= WS_MINIMIZEBOX;
+    if( dwStyle & WS_MAXIMIZEBOX )  dwDlgStyle |= WS_MAXIMIZEBOX;
+
+    // ex-style 基本原样透传(WS_EX_TOOLWINDOW / WS_EX_WINDOWEDGE 等),但清掉两类:
+    //   · WS_EX_APPWINDOW     —— 对话框不该出现在任务栏;
+    //   · WS_EX_DLGMODALFRAME —— 会给弹框加一圈双线边框,固定尺寸自绘弹框不需要(会改变外框尺寸)。
+    dwExStyle &= ~(WS_EX_APPWINDOW | WS_EX_DLGMODALFRAME);
+
+    // ---- 2) 构造 DLGTEMPLATE 资源 ----
+    // 只用 DLGTEMPLATE 本体(不含菜单/类名/标题/字体),微软文档允许这样,
+    // 系统会套用默认的对话框字体与类(#32770)。
+    // 参考 nim_duilib:sizeof(DLGTEMPLATE) + 32 字节余量。
+    const size_t nDataSize = sizeof(DLGTEMPLATE) + 32;
+    HGLOBAL hResource = ::GlobalAlloc(GMEM_MOVEABLE, nDataSize);
+    if( hResource == NULL )
+    {
+        DuiLib::TouchTrace::Log(_T("MODAL-DLG GlobalAlloc failed err=%u"), ::GetLastError());
+        return 0;
+    }
+    LPDLGTEMPLATE pTemplate = (LPDLGTEMPLATE)::GlobalLock(hResource);
+    if( pTemplate == NULL )
+    {
+        ::GlobalFree(hResource);
+        return 0;
+    }
+    ::ZeroMemory(pTemplate, nDataSize);
+    pTemplate->style = dwDlgStyle;
+    pTemplate->dwExtendedStyle = dwExStyle;
+    pTemplate->cdit = 0;                                  // 不使用模板内控件(控件由 DuiLib 自绘)
+    // DLGTEMPLATE 的 cx/cy 单位是对话框单位(DLU),且语义为**客户区**尺寸。
+    // 这里只是给系统一个初始值(避免 0 尺寸导致奇怪的默认值),真正的精确尺寸
+    // 由 __DialogProc 里创建完成后按旧客户区尺寸 + AdjustWindowRectEx 反算并 SetWindowPos 校正。
+    pTemplate->x = 0;
+    pTemplate->y = 0;
+    pTemplate->cx = (short)(nClientW > 0 ? nClientW : nW);
+    pTemplate->cy = (short)(nClientH > 0 ? nClientH : nH);
+
+    // ---- 3) 迁移前先把旧的 HWND 记录下来,并让 manager 做好切换准备 ----
+    HWND hWndOld = m_hWnd;
+
+    m_bDoModal = true;
+    m_nModalRet = 0;
+
+    // 把"目标位置 + 目标客户区尺寸"暂存到成员,供 __DialogProc 在创建完成后精确校正。
+    // 之所以不在模板里一次算准:对话框的非客户区厚度取决于实际样式(WS_CAPTION/WS_DLGFRAME/
+    // WS_EX_* 组合),预估值容易差几个像素;创建后用 AdjustWindowRectEx 反算再 SetWindowPos
+    // 是唯一能保证"客户区尺寸与旧窗口逐像素一致"的做法。
+    m_rcModalTarget.left   = rcWnd.left;
+    m_rcModalTarget.top    = rcWnd.top;
+    m_rcModalTarget.right  = rcWnd.left + nClientW;
+    m_rcModalTarget.bottom = rcWnd.top  + nClientH;
+
+    DuiLib::TouchTrace::Log(_T("MODAL-DLG enter old=%p owner=%p wndrect=%d,%d,%dx%d client=%dx%d dlgstyle=0x%08X ex=0x%08X"),
+        (void*)hWndOld, (void*)hWndOwner, rcWnd.left, rcWnd.top, nW, nH, nClientW, nClientH,
+        (unsigned)dwDlgStyle, (unsigned)dwExStyle);
+
+    // 记录"当前模态窗口",供库内其它地方判断(如 OnTouchInput 的老兜底逻辑)。
+    // 注意:此时还不知道新 HWND,__DialogProc 里会再次 SetModalWindow。
     HWND hPrevModal = CPaintManagerUI::GetModalWindow();
-    CPaintManagerUI::SetModalWindow(m_hWnd);
-    DuiLib::TouchTrace::Log(_T("MODAL-ENTER hwnd=%p owner=%p foreground=%p active=%p focus=%p enabled=%d style=0x%08X ex=0x%08X"),
-        (void*)m_hWnd, (void*)hWndParent, (void*)::GetForegroundWindow(), (void*)::GetActiveWindow(),
-        (void*)::GetFocus(), (int)::IsWindowEnabled(m_hWnd),
-        (unsigned)::GetWindowLong(m_hWnd, GWL_STYLE), (unsigned)::GetWindowLong(m_hWnd, GWL_EXSTYLE));
-
-    MSG msg = { 0 };
-    while( ::IsWindow(m_hWnd) && ::GetMessage(&msg, NULL, 0, 0) ) {
-        if( msg.message == WM_QUIT ) break;
-
-        // 模态期间:owner 及其子窗口的任何输入消息都不派发(等价于原来的禁用效果)
-        if( bBlockParent && msg.hwnd != NULL
-            && (msg.hwnd == hWndParent || ::IsChild(hWndParent, msg.hwnd))
-            && IsModalBlockedInputMessage(msg.message) )
+
+    // 关键:先把旧窗口销毁、把 manager 解绑,再让 DialogBox 创建新的对话框窗口,
+    // 这样 manager 不会出现"两个 HWND 同时活着"的错乱状态。
+    // 说明:调用方是在 ShowModal() 之前 Create 的,所以这里必须主动清理旧窗口。
+    HWND hWndOldCaptured = m_hWnd;
+    m_hWnd = NULL;   // 先置空:__DialogProc 会在 WM_INITDIALOG 里填入新句柄
+
+    // 把 manager 从旧窗口上摘下来(保留它的控件树/资源,只换 paint window)
+    CPaintManagerUI* pManager = FindManagerByPaintWindow(hWndOldCaptured);
+    SetDetachedManager(pManager);
+    if( pManager != NULL )
+    {
+        pManager->DetachPaintWindow();
+        DuiLib::TouchTrace::Log(_T("MODAL-DLG detach manager=%p from old=%p"),
+            (void*)pManager, (void*)hWndOldCaptured);
+    }
+    // 销毁旧窗口(旧窗口上的窗口过程会把 m_hWnd 清空,但我们已经在上面置 NULL 了,
+    // 且 manager 已摘除,不会再触发 DuiLib 的绘制路径)。
+    if( ::IsWindow(hWndOldCaptured) ) ::DestroyWindow(hWndOldCaptured);
+
+    // ---- 4) 交给系统对话框管理器跑模态循环 ----
+    // 这是阻塞调用,返回时模态已结束。
+    INT_PTR nRet = ::DialogBoxIndirectParam(CPaintManagerUI::GetInstance(),
+                                            pTemplate,
+                                            hWndOwner,
+                                            CWindowWnd::__DialogProc,
+                                            (LPARAM)this);
+
+    // 资源可以释放了(DialogBoxIndirectParam 内部已把模板复制走)——
+    // 但谨慎起见放在 EndDialog 之后,与 nim_duilib 一致。
+    ::GlobalUnlock(hResource);
+    ::GlobalFree(hResource);
+
+    // 兜底:若 __DialogProc 因故没跑(创建失败 / WM_INITDIALOG 里 early-return),
+    // 这里把中转变量清干净,避免悬垂指针影响后续 ShowModal。
+    SetDetachedManager(NULL);
+
+    m_bDoModal = false;
+    CPaintManagerUI::SetModalWindow(hPrevModal);
+
+    DuiLib::TouchTrace::Log(_T("MODAL-DLG exit hwnd=%p ret=%d m_nModalRet=%u"),
+        (void*)m_hWnd, (int)nRet, m_nModalRet);
+
+    // DialogBoxIndirectParam 返回值:-1 表示创建失败
+    if( nRet == -1 )
+    {
+        DuiLib::TouchTrace::Log(_T("MODAL-DLG failed err=%u"), ::GetLastError());
+        return 0;
+    }
+
+    return m_nModalRet;
+}
+
+// -----------------------------------------------------------------------------
+// 真模态:对话框过程。
+//
+// 生命周期:
+//   DialogBoxIndirectParam → 创建 #32770 窗口 → WM_INITDIALOG(此时才有真 HWND)
+//                          → 把 manager 挂到新 HWND、换掉 GWLP_WNDPROC
+//                          → return TRUE(表示我们已经处理了焦点)
+// -----------------------------------------------------------------------------
+INT_PTR CALLBACK CWindowWnd::__DialogProc(HWND hDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
+{
+    if( uMsg == WM_INITDIALOG )
+    {
+        // 这是对话框的第一个消息;lParam 是我们传进来的 this
+        CWindowWnd* pThis = reinterpret_cast<CWindowWnd*>(lParam);
+        if( pThis == NULL ) return TRUE;
+
+        ASSERT(pThis->m_hWnd == NULL);
+        pThis->m_hWnd = hDlg;
+
+        // ---- 换掉对话框默认的窗口过程 ----
+        // 这样后续所有消息(含 WM_PAINT / WM_TOUCH / 鼠标)都会走到
+        // CWindowWnd::HandleMessage → CPaintManagerUI,DuiLib 的自绘与路由完全保留。
+        ASSERT(pThis->m_pfnOldDlgWndProc == NULL);
+        pThis->m_pfnOldDlgWndProc = (WNDPROC)::GetWindowLongPtr(hDlg, GWLP_WNDPROC);
+        ::SetWindowLongPtr(hDlg, GWLP_WNDPROC, (LONG_PTR)(WNDPROC)CWindowWnd::__DialogWndProc);
+        ::SetWindowLongPtr(hDlg, GWLP_USERDATA, (LONG_PTR)pThis);
+
+        // ---- 登记为当前模态窗口 ----
+        CPaintManagerUI::SetModalWindow(hDlg);
+
+        // ---- 触摸注册:对话框是全新的 HWND,必须注册,否则收不到 WM_TOUCH ----
+        RegisterTouchWindowIfAvailable(hDlg);
+
+        DuiLib::TouchTrace::Log(_T("MODAL-DLG init dlg=%p oldwndproc=%p focus=%p"),
+            (void*)hDlg, (void*)pThis->m_pfnOldDlgWndProc, (void*)::GetFocus());
+
+        // ---- 把旧的 CPaintManagerUI 挂到对话框窗口上 ----
+        // 调用方在 ShowModal() 之前已 Create + manager.Init 到旧 HWND;这里把 manager 迁移过来,
+        // 让 DuiLib 的全部既有逻辑(绘制 / 消息 / 控件树)在新窗口上继续工作。
+        CPaintManagerUI* pManager = GetDetachedManager();
+        if( pManager != NULL )
+        {
+            pManager->AttachPaintWindow(hDlg);
+            DuiLib::TouchTrace::Log(_T("MODAL-DLG attach manager=%p to dlg=%p"), (void*)pManager, (void*)hDlg);
+            // 用掉即清:避免这个文件级静态变量残留一个已失效的指针,
+            // 影响下一次 ShowModal(尤其嵌套弹框:A 弹框里再开 B 弹框)。
+            SetDetachedManager(NULL);
+        }
+        else
         {
-            continue;
+            DuiLib::TouchTrace::Log(_T("MODAL-DLG no detached manager (dialog will Init itself)"));
         }
 
-        if( msg.message == WM_CLOSE && msg.hwnd == m_hWnd ) {
-            nRet = (int)msg.wParam;
-            ::SetFocus(hWndParent);
+        // ---- [WM_TOUCH 归属权] 立刻把"补调原窗口过程"的钩子绑到本弹框 ----
+        // 为什么必须在这里(而不是等 __DialogWndProc 首次进入时再绑):
+        //   __DialogProc 在 WM_INITDIALOG 里直接调 pThis->HandleMessage(见下方),
+        //   这条路径**不经过** __DialogWndProc,所以"首次进入窗口过程时绑定"
+        //   在这条路上不会触发。而 AttachPaintWindow 之后 manager 已经指向 hDlg,
+        //   一旦有 WM_MOUSEMOVE 到达就可能需要补调 ⇒ 必须现在就绑好。
+        //   绑定对象是 m_pfnOldDlgWndProc(即 #32770 原过程 / DefDlgProc)。
+        if( pManager != NULL )
+        {
+            // m_pfnOldDlgWndProc 本身就是 WNDPROC;只有它意外为空时才回落到 DefDlgProc
+            // (签名是 INT_PTR 返回,需显式转换,不能与 WNDPROC 混在三目里)。
+            WNDPROC pfnDlgTarget = pThis->m_pfnOldDlgWndProc;
+            if( pfnDlgTarget == NULL )
+                pfnDlgTarget = reinterpret_cast<WNDPROC>(::DefDlgProc);
+            BindRelayTarget(pfnDlgTarget, hDlg);
+            pManager->SetDefProcRelay(&RelayWndProc, hDlg);
         }
 
-        // Win10 前台锁比 Win7 严格:弹框偶尔会丢掉前台/激活状态,之后触摸就会被路由到别处。
-        // 这里做限流补偿(≥800ms 一次,且【绝不在手势消息期间】执行,避免打断输入路由)。
-        if( !IsModalBlockedInputMessage(msg.message) )
+        // ---- 尺寸校正(关键)----
+        // DLGTEMPLATE 里给的 cx/cy 是对话框单位(DLU)、且语义为「客户区」,系统创建时还会
+        // 按实际样式额外加一圈非客户区边框。所以不能指望"模板尺寸 == 目标尺寸"。
+        // 这里用 AdjustWindowRectEx 按对话框**真实**的样式反算目标外框,再 SetWindowPos 精确校正,
+        // 保证客户区尺寸与原窗口逐像素一致 —— 这正是"弹框变大了"的修法。
+        //
+        // 放在 AttachPaintWindow 之后、HandleMessage(WM_INITDIALOG) 之前:
+        // 此时 manager 已绑定新 HWND,尺寸变化会走正常的布局流程;且业务层 OnCreate 之前
+        // 窗口就已经是最终尺寸,避免"先小后大"的闪动与一次性布局错位。
         {
-            const DWORD dwNow = ::GetTickCount();
-            if( (LONG)(dwNow - s_dwLastModalFocusFix) >= 800
-                && (::GetActiveWindow() != m_hWnd || ::GetForegroundWindow() != m_hWnd) )
-            {
-                s_dwLastModalFocusFix = dwNow;
-                DuiLib::TouchTrace::Log(_T("MODAL-REACTIVATE hwnd=%p active=%p foreground=%p"),
-                    (void*)m_hWnd, (void*)::GetActiveWindow(), (void*)::GetForegroundWindow());
-                ::BringWindowToTop(m_hWnd);
-                ::SetForegroundWindow(m_hWnd);
-                ::SetActiveWindow(m_hWnd);
-                ::SetFocus(m_hWnd);
-            }
+            const DWORD dwRealStyle   = (DWORD)::GetWindowLong(hDlg, GWL_STYLE);
+            const DWORD dwRealExStyle = (DWORD)::GetWindowLong(hDlg, GWL_EXSTYLE);
+            RECT rcAdjust = { 0, 0,
+                              pThis->m_rcModalTarget.right  - pThis->m_rcModalTarget.left,
+                              pThis->m_rcModalTarget.bottom - pThis->m_rcModalTarget.top };
+            const int nClientW = rcAdjust.right;
+            const int nClientH = rcAdjust.bottom;
+            // 反算:以"目标客户区尺寸"为输入,得到需要的外框尺寸
+            ::AdjustWindowRectEx(&rcAdjust, dwRealStyle, FALSE, dwRealExStyle);
+
+            const int nWndW = rcAdjust.right  - rcAdjust.left;
+            const int nWndH = rcAdjust.bottom - rcAdjust.top;
+
+            RECT rcCur = { 0 };
+            ::GetWindowRect(hDlg, &rcCur);
+            DuiLib::TouchTrace::Log(_T("MODAL-DLG resize before cur=%dx%d clientTarget=%dx%d -> wnd=%dx%d hide=%d"),
+                rcCur.right - rcCur.left, rcCur.bottom - rcCur.top,
+                nClientW, nClientH, nWndW, nWndH,
+                (int)((dwRealStyle & WS_VISIBLE) == 0));
+
+            // SWP_FRAMECHANGED 让系统重新计算非客户区(样式刚被 SetWindowLongPtr 换过)
+            ::SetWindowPos(hDlg, NULL,
+                           pThis->m_rcModalTarget.left, pThis->m_rcModalTarget.top,
+                           nWndW, nWndH,
+                           SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
+
+            RECT rcAfter = { 0 };
+            ::GetClientRect(hDlg, &rcAfter);
+            DuiLib::TouchTrace::Log(_T("MODAL-DLG resize after client=%dx%d (target=%dx%d)"),
+                rcAfter.right - rcAfter.left, rcAfter.bottom - rcAfter.top, nClientW, nClientH);
         }
 
-        if( !CPaintManagerUI::TranslateMessage(&msg) ) {
-            ::TranslateMessage(&msg);
-            ::DispatchMessage(&msg);
+        // 交给 CWindowWnd::HandleMessage 处理 WM_INITDIALOG,业务层的 OnCreate 等由此触发。
+        pThis->HandleMessage(uMsg, wParam, lParam);
+
+        ::SetFocus(hDlg);
+        return TRUE;    // TRUE = 我们自己设置了焦点
+    }
+
+    return FALSE;
+}
+
+// -----------------------------------------------------------------------------
+// 真模态:对话框窗口的窗口过程。
+// 几乎就是 __WndProc 的对话框版本。
+// -----------------------------------------------------------------------------
+LRESULT CALLBACK CWindowWnd::__DialogWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
+{
+    CWindowWnd* pThis = reinterpret_cast<CWindowWnd*>(::GetWindowLongPtr(hWnd, GWLP_USERDATA));
+
+    if( uMsg == WM_NCDESTROY && pThis != NULL )
+    {
+        // 顺序很关键:
+        //   1) 先把窗口过程还原(此后系统调用会回到对话框默认过程);
+        //   2) 再让 DefWindowProc 做标准清理;
+        //   3) 清 GWLP_USERDATA —— 必须在 HandleMessage 之前,防止重入时又拿到 this;
+        //   4) 清模态登记;
+        //   5) m_hWnd 置空【要在 Unsubclass 之前】—— 注意本项目弹框并不 Subclass,
+        //      m_bSubclassed 恒为 false,这里只是保持与 __WndProc 一致的写法,
+        //      并让 OnFinalMessage 里看到的 m_hWnd 为空(与旧行为一致)。
+        if( pThis->m_pfnOldDlgWndProc != NULL )
+        {
+            ::SetWindowLongPtr(hWnd, GWLP_WNDPROC, (LONG_PTR)pThis->m_pfnOldDlgWndProc);
+            pThis->m_pfnOldDlgWndProc = NULL;
+        }
+        LRESULT lRes = ::DefWindowProc(hWnd, uMsg, wParam, lParam);
+        // [WM_TOUCH 归属权] 解绑转发目标,避免悬垂到已销毁的对话框窗口过程。
+        {
+            DuiLib::CPaintManagerUI* pMgrNc = FindManagerByPaintWindow(hWnd);
+            if( pMgrNc != NULL ) pMgrNc->ClearDefProcRelay(hWnd);
+            BindRelayTarget(NULL, NULL);
         }
+        ::SetWindowLongPtr(hWnd, GWLP_USERDATA, 0L);
+        CPaintManagerUI::SetModalWindow(NULL);
+        pThis->m_hWnd = NULL;
+        if( pThis->m_bSubclassed ) pThis->Unsubclass();
+        pThis->OnFinalMessage(hWnd);
+        return lRes;
     }
 
-    CPaintManagerUI::SetModalWindow(hPrevModal);
-    if( ::IsWindow(m_hWnd) )
-        ::SetWindowPos(m_hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
-    if( hWndParent != NULL ) {
-        if( ::IsWindow(hWndParent) ) {
-            ::EnableWindow(hWndParent, TRUE);
-            ::SetForegroundWindow(hWndParent);
-            ::SetFocus(hWndParent);
+    // WM_INITDIALOG 已经由 __DialogProc 处理并 return TRUE,
+    // 一般不会再走到这里;若系统仍投递(例如 EndDialog 之后的收尾路径),
+    // 直接交回对话框默认过程,避免被 HandleMessage 当成普通消息误处理。
+    if( uMsg == WM_INITDIALOG )
+        return ::DefDlgProc(hWnd, uMsg, wParam, lParam);
+
+    // 诊断:确认 WM_TOUCH 到底投递给了哪个窗口。
+    if( uMsg == WM_TOUCH ) TraceTouchArrival(hWnd, wParam, lParam);
+    if( pThis != NULL ) TraceInputMessage(hWnd, uMsg, wParam, lParam);
+
+    if( pThis != NULL )
+    {
+        // ---------------------------------------------------------------
+        // [WM_TOUCH 归属权] 注册"补调原窗口过程"的钩子。
+        //
+        // 真模态弹框的窗口类是系统对话框类 #32770,它把"把特殊标记的
+        // WM_MOUSEMOVE 翻译成 WM_TOUCH"这件事放在 DefDlgProc → DefWindowProc 里做。
+        // 而 __DialogWndProc 一旦 pThis != NULL 就全部交给 HandleMessage,
+        // 再也不回落 DefDlgProc ⇒ 弹框永远收不到 WM_TOUCH(与实测吻合)。
+        //
+        // 这里登记的原过程是 m_pfnOldDlgWndProc(也就是 DefDlgProc),
+        // 使 manager 在吞掉触摸提升鼠标消息时能补调它一次,把翻译链接上。
+        // ---------------------------------------------------------------
+        // ⚠️ 判据必须是"当前绑定的窗口不是本窗口",不能写 `GetDefProcRelay() == NULL`:
+        // 真模态弹框复用的是**同一个** manager 对象(从旧窗口 Detach、再 Attach 到弹框),
+        // 主窗口早已注册过钩子 ⇒ 按 NULL 判断则弹框永远不重新绑定,
+        // 转发目标会一直指向主窗口的原过程,补调无效甚至喂错窗口。
+        DuiLib::CPaintManagerUI* pMgr = FindManagerByPaintWindow(hWnd);
+        if( pMgr != NULL && pMgr->GetDefProcRelayBoundWindow() != hWnd )
+        {
+            // ⚠️ DefDlgProc 的签名是 `INT_PTR (CALLBACK*)(...)`,与 WNDPROC
+            // (`LRESULT (CALLBACK*)(...)`)返回类型不同,三目运算符会类型不匹配。
+            // 用一个显式分支赋值,避免 C2446。
+            WNDPROC pfnTarget = NULL;
+            if( pThis->m_pfnOldDlgWndProc != NULL )
+                pfnTarget = pThis->m_pfnOldDlgWndProc;
+            else
+                pfnTarget = reinterpret_cast<WNDPROC>(::DefDlgProc);
+            BindRelayTarget(pfnTarget, hWnd);
+            pMgr->SetDefProcRelay(&RelayWndProc, hWnd);
         }
+        return pThis->HandleMessage(uMsg, wParam, lParam);
     }
-    DuiLib::TouchTrace::Log(_T("MODAL-EXIT hwnd=%p owner=%p ret=%u"), (void*)m_hWnd, (void*)hWndParent, nRet);
-    if( msg.message == WM_QUIT ) ::PostQuitMessage((int)msg.wParam);
-    return nRet;
+
+    return ::DefDlgProc(hWnd, uMsg, wParam, lParam);
 }
 
+
 void CWindowWnd::ShowModalFake()
 {
     ASSERT(::IsWindow(m_hWnd));
@@ -509,6 +975,24 @@ void CWindowWnd::Close(UINT nRet)
 
     ASSERT(::IsWindow(m_hWnd));
     if (!::IsWindow(m_hWnd)) return;
+
+    // -------------------------------------------------------------------------
+    // 真模态:走 EndDialog 结束系统对话框循环。
+    //
+    // 旧的假模态是 PostMessage(WM_CLOSE) 让自写循环退出;真模态下必须用 EndDialog,
+    // 否则 DialogBoxIndirectParam 不会返回。返回值写入 m_nModalRet,由 ShowModal 读走。
+    //
+    // 注意:EndDialog 会销毁对话框窗口 → 触发 WM_NCDESTROY → __DialogWndProc 收尾
+    // (还原窗口过程、清 USERDATA、调 OnFinalMessage)。所以这里不要再 DestroyWindow。
+    // -------------------------------------------------------------------------
+    if (m_bDoModal)
+    {
+        m_nModalRet = nRet;
+        DuiLib::TouchTrace::Log(_T("MODAL-DLG EndDialog hwnd=%p ret=%u"), (void*)m_hWnd, nRet);
+        ::EndDialog(m_hWnd, (INT_PTR)nRet);
+        return;
+    }
+
     PostMessage(WM_CLOSE, (WPARAM)nRet, 0L);
 }
 
@@ -639,9 +1123,11 @@ bool CWindowWnd::RegisterSuperclass()
 	}
 }
 
+// 注:RelayWndProc / BindRelayTarget 已上移到文件前部(FindManagerByPaintWindow 之后),
+// 因为 __DialogWndProc 比本函数先出现、也要用到它们。
+
 LRESULT CALLBACK CWindowWnd::__WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
-{
-    CWindowWnd* pThis = NULL;
+{    CWindowWnd* pThis = NULL;
     if( uMsg == WM_NCCREATE ) {
         LPCREATESTRUCT lpcs = reinterpret_cast<LPCREATESTRUCT>(lParam);
         pThis = static_cast<CWindowWnd*>(lpcs->lpCreateParams);
@@ -652,6 +1138,10 @@ LRESULT CALLBACK CWindowWnd::__WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPAR
         pThis = reinterpret_cast<CWindowWnd*>(::GetWindowLongPtr(hWnd, GWLP_USERDATA));
         if( uMsg == WM_NCDESTROY && pThis != NULL ) {
             LRESULT lRes = ::CallWindowProc(pThis->m_OldWndProc, hWnd, uMsg, wParam, lParam);
+            // [WM_TOUCH 归属权] 解绑转发目标,避免悬垂到已销毁窗口的窗口过程。
+            DuiLib::CPaintManagerUI* pMgrNc = FindManagerByPaintWindow(hWnd);
+            if( pMgrNc != NULL ) pMgrNc->ClearDefProcRelay(hWnd);
+            BindRelayTarget(NULL, NULL);
             ::SetWindowLongPtr(pThis->m_hWnd, GWLP_USERDATA, 0L);
             if( pThis->m_bSubclassed ) pThis->Unsubclass();
             pThis->m_hWnd = NULL;
@@ -662,7 +1152,27 @@ LRESULT CALLBACK CWindowWnd::__WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPAR
     // 诊断:确认 WM_TOUCH 到底投递给了哪个窗口(弹框 / owner / 其它)。
     if( uMsg == WM_TOUCH ) TraceTouchArrival(hWnd, wParam, lParam);
 
+    // 【临时全量诊断】所有输入类消息(含 WM_TOUCH / WM_GESTURE / WM_POINTER* / 鼠标 / 激活)
+    // 都记一条,用来判断系统到底把消息发给了谁。
+    if( pThis != NULL ) TraceInputMessage(hWnd, uMsg, wParam, lParam);
+
     if( pThis != NULL ) {
+        // ---------------------------------------------------------------
+        // [WM_TOUCH 归属权] 注册"补调原窗口过程"的钩子。
+        // 把 m_OldWndProc(本窗口类的原过程)登记为转发目标,并交给 manager。
+        // manager 在吞掉"触摸提升 WM_MOUSEMOVE"时会回调它一次,
+        // 让系统的 WM_TOUCH 翻译链不断掉。
+        //
+        // 判据用"绑定窗口 != 本窗口"而非"钩子为空":真模态弹框复用同一个
+        // manager,换 HWND 时必须能重新绑定到对话框架自己的原过程。
+        // 开销:仅在窗口首个消息时走一遍查找,之后靠 manager 内的指针直连。
+        // ---------------------------------------------------------------
+        DuiLib::CPaintManagerUI* pMgr = FindManagerByPaintWindow(hWnd);
+        if( pMgr != NULL && pMgr->GetDefProcRelayBoundWindow() != hWnd )
+        {
+            BindRelayTarget(pThis->m_OldWndProc, hWnd);
+            pMgr->SetDefProcRelay(&RelayWndProc, hWnd);
+        }
         return pThis->HandleMessage(uMsg, wParam, lParam);
     } 
     else {

+ 21 - 0
DuiLib/Core/UIBase.h

@@ -43,6 +43,11 @@ namespace DuiLib {
 	void UILIB_API DUI__Trace(LPCTSTR pstrFormat, ...);
 	LPCTSTR UILIB_API DUI__TraceMsg(UINT uMsg);
 
+	namespace TouchTrace
+	{
+		void UILIB_API Log(LPCTSTR pstrFormat, ...);
+	}
+
 	/////////////////////////////////////////////////////////////////////////////////////
 	//
 
@@ -99,12 +104,28 @@ namespace DuiLib {
 		static LRESULT CALLBACK __WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
 		static LRESULT CALLBACK __ControlProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
 
+		// 真模态(DialogBox)用的对话框过程。见 UIBase.cpp 里 ShowModal 的大段说明。
+		// 参考 nim_duilib NativeWindow_Windows::__DialogProc。
+		static INT_PTR CALLBACK __DialogProc(HWND hDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
+		// 对话框窗口的窗口过程:接管 GWLP_WNDPROC,把消息喂给 CWindowWnd::HandleMessage,
+		// 从而既复用系统对话框的消息循环(模态/触摸投递语义),又保留 DuiLib 的自绘与路由。
+		static LRESULT CALLBACK __DialogWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
+
 	protected:
 		HWND m_hWnd;
 		WNDPROC m_OldWndProc;
 		bool m_bSubclassed;
 		bool m_bUnicode;
 		bool m_bFakeModal;
+		// ---- 真模态(DialogBox)状态 ----
+		bool m_bDoModal;            // 当前正处在 DialogBoxIndirectParam 阻塞循环中
+		UINT m_nModalRet;           // 模态返回值(EndDialog 时写入)
+		// 对话框窗口的原始窗口过程(WM_INITDIALOG 时保存、WM_NCDESTROY 时还原)
+		WNDPROC m_pfnOldDlgWndProc;
+		// 目标几何:left/top = 目标窗口左上角(屏幕坐标),right/bottom = left/top + 目标**客户区**尺寸。
+		// 由 ShowModal 填写,__DialogProc 在 WM_INITDIALOG 里据此校正对话框的实际大小,
+		// 保证迁移后客户区尺寸与原窗口逐像素一致(否则弹框会因边框换算而变大)。
+		RECT m_rcModalTarget;
 	};
 
 } // namespace DuiLib

File diff suppressed because it is too large
+ 1034 - 215
DuiLib/Core/UIManager.cpp


+ 212 - 6
DuiLib/Core/UIManager.h

@@ -248,6 +248,22 @@ namespace DuiLib {
 
 	public:
 		void Init(HWND hWnd, LPCTSTR pstrName = NULL);
+
+		// ------------------------------------------------------------------
+		// 真模态(DialogBox)支持:把本 manager 从原 paint window 上摘下来 / 挂到新窗口上。
+		//
+		// 背景:Windows 的 DialogBox 机制不允许调用方指定 HWND,只能由系统创建对话框窗口。
+		// 而调用方是「先 Create 再 ShowModal」,所以 ShowModal 里必须把已经 Init 好的 manager
+		// 从旧 HWND 迁移到对话框的新 HWND —— 但**不能**重新走 Init(Init 会清空字体/图片/样式
+		// 等已加载资源,且会重复 Add 到 m_aPreMessages)。因此这里提供一对轻量的挂载/摘除。
+		//
+		// DetachPaintWindow:释放旧 DC、从 m_aPreMessages 摘除,但**保留** m_hWndPaint 之外的
+		//                    全部状态(控件树、资源、定时器、字体…)。
+		// AttachPaintWindow:绑定新 HWND 与 DC,重新加入 m_aPreMessages。
+		// ------------------------------------------------------------------
+		void DetachPaintWindow();
+		void AttachPaintWindow(HWND hWnd);
+
 		bool IsUpdateNeeded() const;
 		void NeedUpdate();
 		void Invalidate();
@@ -471,19 +487,105 @@ namespace DuiLib {
 		void UsedVirtualWnd(bool bUsed);
 
 		// 触屏支持(参考 NIM Duilib 模型,库级统一接管:主窗口 + 所有弹框通用)。
-		// WM_TOUCH 在 MessageHandler 里解析为原生 DuiLib 触摸事件,直接派发给命中的控件:
+		//
+		// 【重要】滑动逻辑与输入通道解耦(Begin/Update/End 三个共享函数):
+		//   Win10 上同一次触摸会在**两条互相独立**的通道上产生消息 ——
+		//   WM_TOUCH 与 WM_POINTER*(Win8+ 指针栈)。哪条通道驱动本次手势是运行时决定的
+		//   (本台收银机:主窗口常常 WM_TOUCH 先到;弹框 / 被指针抢走的手势只有指针)。
+		//
+		//   早期版本把"滑动"的全部实现(1:1 跟手平移 / 拖滚动条滑块 / Δy→滚轮)只写在
+		//   WM_TOUCH 通道里。于是指针通道一旦抢到手势,就出现两个症状:
+		//     · 「滑不动」—— 没有任何滑动逻辑被执行;
+		//     · 「滑动反倒触发点击」—— 指针把 UP 归一化成 WM_LBUTTONUP 走原生点击路径,
+		//       而 tap/drag 判定所需的 m_bTouchMoved 从未被置真。
+		//   现在抽成通道无关的 Begin/Update/End,两条通道都调用同一套 —— 行为必然一致。
+		//
+		// 派发给命中控件的事件语义(两通道统一):
 		//   DOWN → UIEVENT_TOUCHDOWN   滚动条据此抓滑块;容器据此记手势起点(其余控件忽略)
 		//   MOVE → UIEVENT_TOUCHMOVE   CScrollBarUI 消费则拖滑块;否则冒泡给 CContainerUI 做 1:1 跟手平移
 		//   UP   → UIEVENT_TOUCHUP     滚动条据此松开滑块;容器收尾(其余控件忽略)
-		// 按钮“点击”【不】在此合成:它来自 Windows 为触摸自动提升(promote)的 WM_LBUTTONDOWN/UP,
-		// 走 MessageHandler 原生鼠标路径 → CButtonUI 等据此 Activate() 触发 click。
-		// 不做任何“触摸→鼠标”归一化,也不做 NIM 那种 m_pEventTouch!=NULL 通用守卫(旧版 DuiLib 的
-		// 点击状态机强依赖原生 WM_LBUTTONDOWN/UP,照搬守卫会让点击全灭,已实测);仅用
-		// m_bTouchMoved 标记“本次手势是否发生位移”,位移则松开时不触发点击(避免滚动又误点)。
+		//
+		// 按钮"点击"由 **End 的返回值(tap 判定)** 统一决定是否合成 BUTTONDOWN/BUTTONUP:
+		//   位移超过 8px 阈值 ⇒ 判为拖拽 ⇒ **不**触发点击(这是"滑动误点"的防线);
+		//   例外:位移虽大但抬起时仍落在同一控件上、且该控件不在可滚动容器内 ⇒ 仍算轻点
+		//   (吸收手指按下时的轻微抖动,否则弹框里的小按钮会点不动)。
+		//
 		// 注册在 Init 中完成,无需逐窗口样板。
 		void InitTouchSupport();
 		void OnTouchInput(WPARAM wParam, LPARAM lParam);
+		// ------------------------------------------------------------------
+		// [手势滑动] 与输入通道无关的"手指拖动"核心逻辑。
+		//
+		// 为什么需要把它单独抽出来:WM_TOUCH 与 WM_POINTER* 是两条**互相独立**的通道
+		// (本台收银机上弹框只拿得到指针通道,主窗口有时也被指针抢先),而"滑动"的全部
+		// 实现(1:1 跟手平移 / 拖滚动条滑块 / Δy→滚轮)原先只写在 WM_TOUCH 的
+		// OnTouchInput 里。于是指针通道一旦抢到本次手势:
+		//   · WM_TOUCH 整条被 OnTouchInput 忽略 ⇒ 没有任何滑动逻辑被执行 ⇒ 「滑不动」;
+		//   · 指针通道把 UP 归一化成 WM_LBUTTONUP 走原生点击路径,
+		//     tap/drag 判定(m_bTouchMoved)从未被触摸链置真 ⇒「滑动反倒触发点击」。
+		// 现在把这段逻辑抽成通道无关的公共函数,两条通道都调用它,行为立刻一致。
+		//
+		// 调用约定(两条通道都必须按这个顺序调):
+		//   BeginSlideGesture(pt)  —— DOWN 时调一次:记起点、命中控件、找可滚动容器;
+		//   UpdateSlideGesture(pt) —— MOVE/UPDATE 时调:按 Δ 派发 UIEVENT_TOUCHMOVE(+合成滚轮);
+		//   EndSlideGesture(pt)    —— UP 时调:派发 UIEVENT_TOUCHUP 并返回"是否算一次轻点"。
+		// 详见各函数实现处的长注释。
+		// ------------------------------------------------------------------
+		void BeginTouchSlideGesture(POINT pt);
+		void UpdateTouchSlideGesture(POINT pt);
+		// 返回 true 表示"本次手势应当按轻点处理"(未越过拖拽阈值)。
+		bool EndTouchSlideGesture(POINT pt);
+		// [对标 nim_duilib] WM_POINTER*(Win8+ 指针栈)处理:把指针消息归一化成鼠标消息,
+		// 与 WM_TOUCH 归一化形成互补通道。返回 true 表示已消费该消息。
+		// 说明:本项目 _WIN32_WINNT=0x0601,WM_POINTER* 宏在编译期不可见,
+		// 故 uMsg 用字面量常量比较(见 UIManager.cpp 的 PointerMsg 命名空间)。
+		bool OnPointerInput(UINT uMsg, WPARAM wParam, LPARAM lParam);
 		void ClearTouchHoverState(POINT pt);
+
+		// ------------------------------------------------------------------
+		// [WM_TOUCH 归属权] 未被文档化的关键机制与"补调 DefWindowProc"钩子。
+		//
+		// 机制(来源:The Witness 作者逆向结论 + MSDN Architectural Overview):
+		//   `WM_TOUCH` **不会**由系统直接投递。系统先发一条"特殊标记的"
+		//   `WM_MOUSEMOVE`(开了 raw input 时是 `WM_INPUT`);
+		//   **只有这条消息一路走到 `DefWindowProc` 里**,DefWindowProc 才会
+		//   把驻留在窗口上的 `WM_TOUCH` "翻译"出来并派发。
+		//
+		//   推论:任何一层**吞掉**这条被标记的 `WM_MOUSEMOVE` 且不回落到
+		//   `DefWindowProc`,就会**永久掐断该窗口后续 WM_TOUCH 的派发链**。
+		//
+		// 本项目的两个真实断点(都在 MessageHandler 的 WM_MOUSEMOVE 分支):
+		//   · `if (bPromotedTouchMove && m_pEventTouch != NULL) { ... return true; }`
+		//     —— 触摸手势进行中,丢弃"提升出来的鼠标移动";
+		//   · `if (m_bSuppressHoverAfterTouch) { ... return true; }`
+		//     —— 触摸抬手后抑制悬浮态复活,丢弃合成移动。
+		//   两者都 `return true`,顺着 MessageHandler → HandleMessage 直接返回,
+		//   **跳过了 DefWindowProc** ⇒ 弹框(#32770 对话框类就是靠 DefDlgProc→DefWindowProc
+		//   这条链来"翻译"WM_TOUCH 的)永远收不到 WM_TOUCH。
+		//   这与实测完全吻合:主窗口有 `WNDPROC-TOUCH`、弹框零条。
+		//
+		// 钩子用法:窗口过程(__WndProc / __DialogWndProc)在构造时把自己的
+		// "原窗口过程调用入口"注册进来;MessageHandler 在**吞掉那条被标记的
+		// WM_MOUSEMOVE 之前**调用一次 `RelayDefWindowProc(...)`,
+		// 让系统的"翻译"链继续跑 —— 我们仍然吃掉消息、不破坏 DuiLib 状态机,
+		// 只是补一次 DefWindowProc 的副作用。
+		//
+		// 默认**关闭**(见 UIManager.cpp 的 m_bRelayDefProcForTouchMove):这是一个
+		// 尚未在本机验证的机制假设,先做成可开关 + 带日志,验证通过再考虑默认开启。
+		// ------------------------------------------------------------------
+		// 钩子签名:就是一个"把消息喂给原窗口过程"的调用入口。
+		// 参数与普通 WNDPROC 一致;返回值同样是 LRESULT(本场景只关心副作用)。
+		typedef LRESULT (CALLBACK* PFN_RELAY_DEFPROC)(HWND, UINT, WPARAM, LPARAM);
+		void SetDefProcRelay(PFN_RELAY_DEFPROC pfnRelay, HWND hWndTarget);
+		void ClearDefProcRelay(HWND hWndTarget);
+		PFN_RELAY_DEFPROC GetDefProcRelay() const { return m_pfnDefProcRelay; }
+		// 当前钩子绑定的窗口。窗口过程用它判断"是否需要为本窗口(重新)绑定"——
+		// 真模态弹框复用的是同一个 manager 对象,必须靠这个判据在换 HWND 时重新绑定。
+		HWND GetDefProcRelayBoundWindow() const { return m_hWndDefProcRelay; }
+		// 让"被吞掉的那条触摸提升鼠标消息"补走一次原窗口过程(仅当开关开启)。
+		void RelayDefWindowProc(UINT uMsg, WPARAM wParam, LPARAM lParam, LPCTSTR pszWhy);
+		bool IsDefProcRelayEnabled() const { return m_bRelayDefProcForTouchMove; }
+		void SetDefProcRelayEnabled(bool bEnable) { m_bRelayDefProcForTouchMove = bEnable; }
 		// 当前处于模态状态的弹框句柄(由 CWindowWnd::ShowModal 设置/清除)。
 		// 目的是:万一 Windows 把 WM_TOUCH 错投给了被弹框挡住的 owner(主窗口),
 		// 非模态窗口一律忽略触摸,避免“弹框还开着、主窗口却在响应手指”的穿透。
@@ -533,6 +635,15 @@ namespace DuiLib {
 		POINT m_ptTouchStart = { 0, 0 };    // 本次手势起点:用于判断是否发生位移(拖拽 vs 轻点)
 		bool m_bTouchMoved = false;         // 本次触摸是否发生位移(>阈值视为拖拽),松开时抑制点击
 		bool m_bSuppressPromotedTouchMouse = false; // 触摸点击已由 TOUCHUP 处理,丢弃随后提升的鼠标点击
+		// 本次 TOUCHDOWN 的时间戳。若 TOUCHUP 丢失(激活/前台被抢、手势被系统中断、窗口被遮挡等),
+		// m_pEventTouch / m_bSuppressPromotedTouchMouse 会永久残留 → 之后所有提升的鼠标消息都被吞,
+		// 该窗口表现为“突然完全点不动”。用这个时间戳做超时自愈(见 MessageHandler 开头)。
+		DWORD m_dwTouchDownTick = 0;
+		// m_bSuppressPromotedTouchMouse 被置真时的时刻。
+		// 用途:该抑制位有可能在【没有 TOUCHDOWN】的情况下被置真(两条输入流交叉时),
+		// 此时 m_dwTouchDownTick 仍为 0,若只按它计时则自愈永远不触发 → 点击永久失效。
+		// 所以单独记一个时间戳,保证"只要有抑制位,就一定会被超时复位"。
+		DWORD m_dwSuppressTick = 0;
 		// 手指抬起后,Windows 仍会补发合成的 WM_MOUSEMOVE;且真实 WM_MOUSELEAVE 在
 		// m_bMouseTracking 为真时会【自己给自己重发 WM_MOUSEMOVE】(UIManager.cpp WM_MOUSELEAVE 分支),
 		// 导致刚清掉的悬浮态立刻被重新点亮 → “悬浮态不消失 / 多个控件同时悬浮”。
@@ -542,6 +653,101 @@ namespace DuiLib {
 		// 标记的“触摸提升出来的鼠标消息”一律不参与悬浮 enter/leave —— 触摸没有“光标停留”语义,
 		// 让这类消息参与必然出现“点过的控件一直高亮 / 多点几个后多个同时高亮”。
 		bool m_bSuppressHoverAfterTouch = false;
+		// [对标 nim_duilib] 竖向滑动 → WM_MOUSEWHEEL 的累加器。
+		// 触摸只有位移增量,没有滚轮事件;把 TOUCHEVENTF_MOVE 的 Δy 累加,每满一个
+		// WHEEL_DELTA(120) 就合成一次 UIEVENT_SCROLLWHEEL,让只认滚轮的控件在触摸下也能滚动。
+		// 手势开始时(TOUCHDOWN)与手势结束时(TOUCHUP)都应清零,避免余量串到下一次手势。
+		int m_nTouchWheelAccum = 0;
+		// [通道无关滑动] 本次手势的滑动状态是否已由 BeginTouchSlideGesture 建立。
+		//
+		// 用途:指针通道(WM_POINTER*)在 DOWN 时也要走一遍 BeginTouchSlideGesture,
+		// 才能在 UPDATE 里正确算出 Δ 并驱动容器/滚动条;UP 时也要走
+		// EndTouchSlideGesture 来收尾并拿到"是否算轻点"。
+		// 这个标志把"滑动状态机是否活着"与"本次手势由哪条通道驱动"解耦 ——
+		// 两条通道都只是驱动者,滑动逻辑本身是共享的。
+		bool m_bTouchSlideActive = false;
+		// [通道混合] 是否启用 WM_POINTER*(Win8+ 指针栈)归一化。
+		//
+		// 为什么需要这个开关:Win10 上同一次触摸可能同时产生
+		//   ① 提升出来的鼠标消息 WM_LBUTTONDOWN/UP
+		//   ② 指针消息       WM_POINTERDOWN/UP
+		//   ③ legacy 触摸消息 WM_TOUCH
+		// 如果把这些一起归一化成鼠标消息,同一次点击会驱动多套点击状态机 ⇒ 互相吞 →
+		// 【所有窗口点击全失效】(含主窗口、含真实鼠标)。实测踩过这个坑。
+		//
+		// ⚠️ 历史教训(务必别再改回"二选一"):
+		//   第一版策略是"默认关闭,收到真实 WM_TOUCH 就【永久】关闭本窗口的指针通道"。
+		//   结果主窗口修好了,真模态弹框却彻底没救:
+		//   本台收银机上 WM_TOUCH 的"贪婪归属权"(MSDN:收到第一条触摸消息后,
+		//   后续所有触摸消息都发给该窗口,直到【另一个窗口获得焦点】)卡在主窗口身上,
+		//   弹框(被程序 SetFocus,不是触摸点出来的焦点)永远拿不到 WM_TOUCH,
+		//   只拿得到 WM_POINTER*(指针消息按窗口命中投递,不受贪婪归属权约束);
+		//   而指针通道被永久关掉 ⇒ 弹框既无 WM_TOUCH 又无指针 ⇒ 完全点不动。
+		//
+		// 现在的策略:【始终开启 + 手势级让路(handover)】
+		//   · 指针通道永远开着(本开关默认 true),保证弹框这条"只有指针"的窗口有活路;
+		//   · 谁先驱动本次手势,谁就赢:指针先 DOWN ⇒ 本次手势内吃掉 WM_TOUCH;
+		//     WM_TOUCH 先 DOWN ⇒ 本次手势内拒绝指针;
+		//   · 手势结束(UP/超时/被夺)后立刻解禁 —— 下一个手势可以换通道。
+		//   让路只加在**开始**(DOWN)上,因为同一手势两条通道的 DOWN 才是一对重复;
+		//   UP/MOVE 侧不做让路,避免某个通道的 UP 被误丢导致点一直悬着、成对状态卡死。
+		//   ⚠️ 这条规则是踩过坑之后补上的、不可省:曾经让路连 WM_POINTERUP 一起吞,
+		//   而 UP 是唯一能解除 m_bPointerGestureActive 的消息 ⇒ 状态永久为真
+		//   ⇒ 之后每个 WM_POINTERDOWN 都被当"重复按下"挡掉 ⇒【整个窗口触摸点击全灭】。
+		//   现在 UP 一律放行到 OnPointerInput,由它决定"只清状态"(让路中)还是"派发点击"。
+		//   状态的"自愈"由 3 秒超时兜底(见下面的 m_dwPointerDownTick)。
+		bool m_bPointerNormalizeEnabled = true;
+
+		// ---- [NIM 风格] 通道归属:两个标记 + 一个计时器 ----
+		//
+		// 这套结构对照 nim_duilib Core/Window.cpp 的 OnPointerMsgs / HandleMessage:
+		//   · nim 用 m_pEventTouch(WM_TOUCH 占用)与 m_pEventPointer(指针占用)两个
+		//     **控件指针**做归属标记,谁先建立谁赢,另一条通道看到标记即退让;
+		//   · 本项目的等价物:m_pEventTouch(触摸占用)与 m_bPointerGestureActive(指针占用)。
+		//
+		// ⚠️⚠️ 最关键的教训:【不要让同一个字段同时表示"归属"和"计时"】⚠️⚠️
+		//   曾经用一个 m_dwSuppressPointerUntilUpTick 时间戳身兼两职,结果
+		//   (GetTickCount() - 该值) < 3000 恒成立 ⇒ 指针通道自己驱动的手势被自己判定为
+		//   "让路中" ⇒ UP 走"只清状态、不合成点击"的早退路 ⇒ 点击全灭(真机日志可复现)。
+		//   现在严格分离:归属用 bool 标记,计时用专用 tick 字段,互不干扰。
+		//
+		// m_bPointerGestureActive:
+		//   本窗口当前正在由【指针通道】驱动一次手势(已 DOWN、还没 UP)。
+		//   为真期间,进入 OnTouchInput 的 WM_TOUCH 一律忽略(含其 TOUCHDOWN/TOUCHUP)。
+		bool m_bPointerGestureActive = false;
+		//
+		// m_dwActivePointerId:
+		//   m_bPointerGestureActive 为真时,对应的是哪个 pointerId(wParam 低 16 位)。
+		//   多指时不相关的第二、三指消息直接放行/忽略,不会把状态机搅乱。
+		DWORD m_dwActivePointerId = 0;
+		//
+		// m_dwPointerDownTick:
+		//   指针手势的开始时刻,**仅**用于 3 秒超时自愈。
+		//   ⚠️ 与 m_dwActivePointerId 配套,只在指针 DOWN 时置、在 UP/CAPTURECHANGED/超时时清。
+		//   绝不参与任何"归属判定"——归属只看 m_bPointerGestureActive。
+		DWORD m_dwPointerDownTick = 0;
+
+		// ---- "补调 DefWindowProc" 钩子(修复弹框收不到 WM_TOUCH)----
+		// 见上方 SetDefProcRelay / RelayDefWindowProc 的长注释:
+		// 被吞掉的触摸提升 WM_MOUSEMOVE 需要补走一次原窗口过程,
+		// 否则系统的 WM_TOUCH "翻译"链会断掉,该窗口永远收不到 WM_TOUCH。
+		PFN_RELAY_DEFPROC m_pfnDefProcRelay = NULL;   // 由窗口过程注册(一般为 m_OldWndProc 的包装)
+		HWND              m_hWndDefProcRelay = NULL;  // 该钩子绑定的窗口(换窗口时校验用)
+		// 开关:**默认关闭**。开启后 MessageHandler 在吞掉那两条触摸提升
+		// WM_MOUSEMOVE 前会补调一次原窗口过程,把系统的 WM_TOUCH 翻译链接上。
+		//
+		// 为什么默认关:这是一个"未经本机验证"的机制假设,贸然默认开启风险不可控
+		// (最坏情况是主窗口本来正常的 WM_TOUCH 反而被干扰)。
+		//
+		// 如何开启(无需重新编译):
+		//   设环境变量 `DUI_RELAY_DEFPROC=1` 再启动程序即可(见构造函数里的读取)。
+		//   也可以在代码里调 SetDefProcRelayEnabled(true)。
+		// 验证要点(看 touch_trace.log):
+		//   开启后弹框里应出现 `DEFPROC-RELAY call` 与随后的 `WNDPROC-TOUCH`;
+		//   若出现但点击异常,把环境变量去掉即可立刻回到当前已可用的行为。
+		bool m_bRelayDefProcForTouchMove = false;
+		// 诊断计数:补调发生了多少次、对应的窗口。便于在日志里确认机制是否生效。
+		unsigned m_uDefProcRelayCount = 0;
 
 		//
 		CControlUI* m_pRoot;

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


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


BIN
dll/release/DuiLib.dll


BIN
lib/release/DuiLib.lib


BIN
pdb/release/DuiLib.pdb


Some files were not shown because too many files changed in this diff