Browse Source

win7和win10的触摸测试都没问题了,先保存一下。

张洋 3 weeks ago
parent
commit
068b32984a

+ 28 - 0
DuiLib/Core/UIBase.cpp

@@ -896,6 +896,34 @@ UINT CWindowWnd::ShowModal()
     // 且 manager 已摘除,不会再触发 DuiLib 的绘制路径)。
     if( ::IsWindow(hWndOldCaptured) ) ::DestroyWindow(hWndOldCaptured);
 
+    // ---- 【Win7"咚"声 · 根治】把"在途的提升鼠标副本"从消息队列里摘掉 ----
+    //
+    // 时序(真机日志实证):
+    //   调用方的触摸 UP 已经消费完(**当前正在执行的这段代码就是它的点击回调**),
+    //   但同一次手势被系统提升出来的鼠标副本**还排在消息队列里没被投出来**;
+    //   紧接着下面 DialogBoxIndirectParam 会把 owner `EnableWindow(FALSE)` 禁用,
+    //   之后那条副本才被投递 ⇒ 撞上"点击禁用窗口"的默认处理 ⇒ **MessageBeep(0) 咚**。
+    //
+    // 为什么必须在这里摘、而不是在窗口过程里拦:
+    //   · 消息根本进不了窗口过程(真机日志铁证:MODAL-BEEP 的 `swallowed` 恒为 0)
+    //     ⇒ 写在 MessageHandler 里的任何闸门,判据再准也无从生效;
+    //   · 此处摘除与"创建弹框"之间**没有任何消息泵** ⇒ 摘除对投递而言是**原子**的,
+    //     不存在"刚摘完又被投一条"的窗口期。
+    //
+    // 摘除的是"**owner 窗口**的鼠标类消息",且只在"触摸通道确实收到了本次手势"时摘
+    // (判据见 IsPromotedMouseDuplicatePending)—— 它们本来就是重复的:这次点击已经
+    // 由触摸通道的 tap 判定处理完毕(否则走不到 ShowModal 这里)。因此两条边界都守住:
+    //   · 点主窗口控件点出弹框 ⇒ 摘掉 ⇒ **无声**(用户要求消除的那一声);
+    //   · 弹框已打开后点弹框外空白区 ⇒ 那处的 WM_TOUCH 在**输入阶段**就被系统丢弃、
+    //     触摸通道根本没收到手势 ⇒ 标志为假 ⇒ 不摘 ⇒ **那一声"咚"照旧保留**
+    //     (用户明确要求不改的那一条边界)。
+    if( hWndOwner != NULL && CPaintManagerUI::IsPromotedMouseDuplicatePending() )
+    {
+        const int nDropped = CPaintManagerUI::DropQueuedPromotedMouseMessages(hWndOwner);
+        DuiLibDiag::Log(_T("MODAL-BEEP drop queued promoted mouse n=%d owner=%p"),
+            nDropped, (void*)hWndOwner);
+    }
+
     // ---- 4) 交给系统对话框管理器跑模态循环 ----
     // 这是阻塞调用,返回时模态已结束。
     INT_PTR nRet = ::DialogBoxIndirectParam(CPaintManagerUI::GetInstance(),

+ 334 - 21
DuiLib/Core/UIManager.cpp

@@ -37,6 +37,35 @@ namespace DuiLib
 #define WM_TOUCH 0x0240
 #endif
 
+	// ======================================================================
+	// ★★【悬浮态残留 · 跨 manager】这套"触摸抬手后抑制悬浮复活"的状态必须是
+	//   **线程级(文件级静态)**的,绝不能用 CPaintManagerUI 的成员。★★
+	//
+	// 反面教材(本项目真机日志实证):属性弹框 CDiandanNatureWnd **自带独立的
+	//   CPaintManagerUI m_pm**,与主窗口的 manager 不是同一个对象
+	//   (真机日志:弹框的 `MANAGER-MSG hwnd=000E04BA manager=0A29F5F8`,
+	//     主窗口的 `MANAGER-MSG hwnd=001304D0 manager=07DA8C58`)。
+	//
+	//   用户点弹框里的「选好了」关掉弹框时:
+	//     · 那次触摸由**弹框的 manager** 处理 ⇒ 抑制位与参考点写在弹框那一份上;
+	//     · 弹框一销毁,系统立刻给**主窗口**补投一条 extra=0 / fromtouch=0 的
+	//       WM_MOUSEMOVE(语义"指针真的停在这")⇒ 落在**主窗口的 manager** 上。
+	//   主窗口那份状态压根不知道刚刚发生过触摸:抑制位要么是假,要么参考点还停在
+	//   更早那次主窗口触摸的位置 ⇒ ±8px 判据必然失败 ⇒ 解除抑制 + FindControl(pt)
+	//   命中背后商品卡 ⇒ **悬浮态点亮且此后永不熄灭**。
+	//   (真机日志:`MODAL-DLG exit` → `HOVER-ENTER diandan_fooditem pt=778,630`,
+	//     而主窗口那份参考点还停在 1225,446 —— 相距 447 像素。)
+	//
+	// 与 `s_nPromotedMouseGeneration` 的理由完全相同:这是**线程消息队列**现象,
+	//   与 manager 归属无关 ⇒ 状态必须线程级。
+	//   (本项目 UI 单线程;若将来多线程,把这两个 static 换成 TLS 即可。)
+	//
+	// 参考点存**屏幕坐标**:WM_MOUSEMOVE 的 lParam 是"接收窗口的客户区坐标",
+	//   而触摸可能发生在另一个窗口里 —— 客户区坐标一换窗口就不可比。
+	// ======================================================================
+	static bool  s_bSuppressHoverAfterTouch = false;
+	static POINT s_ptLastTouchScreen = { 0, 0 };
+
 	// ---------------------------------------------------------------------------
 	// WM_POINTER* 消息 ID(Win8+)。参考 nim_duilib NativeWindow_Windows::OnPointerMsgs。
 	//
@@ -654,7 +683,7 @@ namespace DuiLib
 		if (m_bMouseCapture || m_bDragMode)
 			ReleaseCapture();
 		m_bMouseTracking = false;
-		m_bSuppressHoverAfterTouch = true;
+		s_bSuppressHoverAfterTouch = true;
 
 		m_hWndPaint = hWnd;
 		m_hDcPaint = ::GetDC(hWnd);
@@ -833,6 +862,172 @@ namespace DuiLib
 		return s_nPromotedMouseGeneration;
 	}
 
+	// ======================================================================
+	// ★★【第二次合成点击 · 根治】"触摸通道处理过的这次手势,其提升鼠标副本一律作废" ★★
+	// ======================================================================
+	//
+	// 【背景:一次手势会被处理两遍】
+	//   手指点一下,DuiLib 会经历两遍:
+	//     ① 触摸通道(WM_TOUCH / WM_POINTER*)合成 BUTTONDOWN/BUTTONUP —— 业务的
+	//        点击回调在这里跑,`ShowModal()` 的嵌套模态循环也在这里开;
+	//     ② Windows 随后把**同一次触摸**提升成鼠标消息(MOVE + LBUTTONDOWN +
+	//        LBUTTONUP,extra 带 MOUSEEVENTF_FROMTOUCH)。它是纯重复,必须作废。
+	//
+	// 【为什么 ② 一定还会来,但 ② 投给谁却会变】
+	//   ② 投给谁取决于**投递那一刻**的窗口状态,与 ① 时刻可能完全不同:
+	//     · 该点此刻已被弹框覆盖 ⇒ 投给**弹框**(前台且启用)⇒ 窗口过程收得到,能拦;
+	//     · 该点此刻仍在 owner 上 ⇒ 投给**被禁用的 owner** ⇒ 系统直接
+	//       MessageBeep(0) + 丢弃 ⇒ **窗口过程根本收不到**(真机日志实证:该坐标
+	//       附近 0 条提升消息)⇒ 这就是 Win7 上"弹框一出来就咚一声"的全部成因。
+	//
+	// 【旧判据为什么在 Win7 上必然漏】(真机日志 touch_trace.log,2451 行 / 25 次手势)
+	//   · "代数水位":水位在**任意一条**提升消息到达时就追平。于是"点『选好了』
+	//     关闭弹框"这一次手势的副本到达时,脏已被前一次手势用掉 ⇒ 无脏可判 ⇒
+	//     **放行** ⇒ 透传到背后商品卡 ⇒ 弹框被重新点开。25 次里漏了 6 次,
+	//     全部是"关闭弹框的那一次手势"。
+	//   · "extra 指纹":Win7 上 extra **恒为 0xFF51578A**(日志中 59/59 条完全
+	//     相同),指纹去重彻底失效 —— `close-by-extra` 一次都没触发。
+	//     (Win10 那边 extra 低字节每次手势都变,所以同一套方案在 Win10 上
+	//      "看起来是对的"。这正是"Win10 好了、Win7 没好"的分界。)
+	//
+	// 【新判据:只依赖一件事实 —— 触摸通道收到了这次手势】
+	//   收到了 ⇒ 提升副本必是重复 ⇒ 作废。与模态边界、代数、extra 全都无关,
+	//   因此对"关闭弹框的那一次手势"同样有效(那正是旧判据唯一漏掉的一类)。
+	//
+	//   ⚠️ 标志**只由触摸通道**置位(BeginTouchSlideGesture —— 两条通道共用的
+	//       "新手势开始"入口)。若某台机器两条通道都收不到(只会收到提升鼠标),
+	//      标志恒假 ⇒ 提升鼠标照常放行 ⇒ **不会把整机点不动**。
+	//      这条自限边界是本方案敢用的前提,务必保留。
+	//
+	// 【为什么还要比坐标】
+	//   若某次手势的提升副本被系统吃掉(上面那种"咚"的情况),标志会一直挂着。
+	//   此时若出现"触摸通道收不到、只能靠提升鼠标"的窗口(例如弹框的触摸注册
+	//   被样式变更隐式注销),不加坐标判据就会把它的真实点击一起吞掉 ⇒ 点不动。
+	//   加上 ±16px(与滑动阈值同一量级)判据后,陈旧标志不会误伤无关位置的点击。
+	//   坐标一律换算到**屏幕坐标**再比 —— ② 可能落在**另一个窗口的客户区**里
+	//   (弹框会拦截同一物理点的消息,lParam 是弹框的客户区坐标)。
+	// ======================================================================
+	static bool  s_bPromotedMouseDuplicate = false;      // 有"待作废的提升副本"
+	static POINT s_ptPromotedMouseDuplicate = { 0, 0 };  // 那次手势的**屏幕**坐标
+	static const int kPromotedDuplicateSlop = 16;        // 手指抖动容差(像素)
+
+	// 三者都只在本文件内使用 ⇒ 一律 static(内部链接),避免与别的 TU 里的
+	// 同名符号在 DuiLib 命名空间里撞车(C2668 的典型成因)。
+	static void ArmPromotedMouseDuplicate(const POINT& ptScreen)
+	{
+		s_bPromotedMouseDuplicate = true;
+		s_ptPromotedMouseDuplicate = ptScreen;
+	}
+
+	static void DisarmPromotedMouseDuplicate()
+	{
+		s_bPromotedMouseDuplicate = false;
+	}
+
+	// 本条提升鼠标消息是否属于"待作废的那次手势副本"。
+	// 参数 ptClient 是消息 lParam 里的**接收窗口客户区**坐标。
+	static bool IsPromotedMouseDuplicate(HWND hWndPaint, const POINT& ptClient)
+	{
+		if (!s_bPromotedMouseDuplicate) return false;
+		POINT ptScreen = ptClient;
+		if (hWndPaint != NULL && ::IsWindow(hWndPaint))
+			::ClientToScreen(hWndPaint, &ptScreen);
+		const LONG dx = ptScreen.x - s_ptPromotedMouseDuplicate.x;
+		const LONG dy = ptScreen.y - s_ptPromotedMouseDuplicate.y;
+		return (dx <= kPromotedDuplicateSlop && dx >= -kPromotedDuplicateSlop
+			&& dy <= kPromotedDuplicateSlop && dy >= -kPromotedDuplicateSlop);
+	}
+	// ======================================================================
+	// ★★【Win7"咚"声 · 根治】进模态循环之前,把"在途的提升鼠标副本"从消息队列里摘掉 ★★
+	//
+	// 【现象】
+	//   Win7 上"点主窗口商品卡 → 弹出弹框"时会听到一声"咚"。它是**系统**发出的
+	//   `MessageBeep(0)`:属于**弹框出现之前**那一次手势的提升鼠标副本,在弹框已经把
+	//   owner `EnableWindow(FALSE)` 禁用之后才被投递 ⇒ 撞上"点击禁用窗口"的默认处理。
+	//
+	// 【已经逐条排除的方向(都有真机日志支撑,不要再回头走)】
+	//   · 写在窗口过程里的闸门(MODAL-BEEP / PROMOTED-DUP):`swallowed` 恒为 0 ——
+	//     消息**根本没进窗口过程**,判据再准也无从生效。
+	//   · WH_MOUSE_LL 低级鼠标钩子:钩子过程运行在**安装它的线程**(正是消息接收线程)
+	//     上,队列里的鼠标消息先于钩子被抽出 ⇒ 真机日志里提升副本照旧投递,一条没少。
+	//   · 让 owner 在"副本在途"期间保持启用(能消音,但不可接受):owner 的鼠标消息
+	//     会因此落到 manager 上,而真模态期间 manager 已经搬到弹框
+	//     (DetachPaintWindow → AttachPaintWindow)⇒ 此时 `m_hWndPaint` 指向**弹框**,
+	//     owner 的客户区坐标会被拿去**弹框的控件树**里 FindControl,可能凭空命中
+	//     弹框上的某个控件。
+	//
+	// 【根治点:让它根本不存在】
+	//   既然拦不住"投递阶段"的系统行为,就在**投递之前**把它从队列里摘掉。
+	//   调用点在 `CWindowWnd::ShowModal` 进模态循环之前(见那里的长注释)。
+	//   为什么那个时刻一定赶得上:这条副本是在**同一次触摸**的输入处理里被排进队列的,
+	//   而调用方的触摸 UP 此刻**已经被处理完**(业务回调就是正在执行的这段代码)
+	//   ⇒ 副本必然已经在队列里(真机日志亦实证:它被卡在队列里,直到弹框关闭、
+	//   owner 重新启用之后才被投递)。而"摘除"与"创建弹框"之间没有任何消息泵
+	//   ⇒ 摘除对投递而言是**原子**的。
+	//
+	// 【为什么不会误伤】
+	//   · 判据只有一件事:触摸通道确实收到了这次手势
+	//     (IsPromotedMouseDuplicatePending —— 与 PROMOTED-DUP 同源、同一处置位),
+	//     与模态边界 / 代数水位 / extra 指纹全都无关。
+	//   · 摘除范围只限"**owner 窗口**的鼠标类消息",且只在这一个瞬间摘一次。
+	//   · 被摘掉的本来就是**重复**的:这次点击已经由触摸通道的 tap 判定处理完毕
+	//     (否则根本不会走到 ShowModal 这里)。
+	//
+	// 【用户的两条边界同时守住】
+	//   · 场景①【从主窗口控件点出弹框 ⇒ 咚 必须消除】:触摸通道收到了手势 ⇒ 副本在
+	//     队列里 ⇒ 摘掉 ⇒ **无声**。
+	//   · 场景②【弹框已打开后,点弹框外空白区 ⇒ 咚 属正常,用户明确要求不改】:
+	//     那一处属于被禁用的 owner,它的 WM_TOUCH 在**输入阶段**就被系统丢弃
+	//     ⇒ BeginTouchSlideGesture 不会被调用 ⇒ 标志为假 ⇒ 不摘 ⇒ 那一声"咚"照旧。
+	//
+	// 【自限】
+	//   若某台机器压根不产生提升副本(本项目 Win10 走 WM_POINTER*,fromtouch 消息 0 条),
+	//   标志恒假 ⇒ 本机制**从不启用** ⇒ 零影响。
+	// ======================================================================
+
+	// 【同源访问器】把"提升副本在途"这一事实暴露给 UIBase.cpp(摘除的时机在那边)。
+	bool CPaintManagerUI::IsPromotedMouseDuplicatePending()
+	{
+		return s_bPromotedMouseDuplicate;
+	}
+
+	// ★把"本次手势在途的提升鼠标副本"从本线程消息队列里就地摘掉,返回摘掉的条数。
+	// 调用点:CWindowWnd::ShowModal 进模态循环之前(那里的长注释解释了为什么必须摘)。
+	//
+	// ⚠️ 这里**故意不销账**(不调 DisarmPromotedMouseDuplicate):
+	//   标志继续挂着,PROMOTED-DUP 那道闸门对"稍后才到的提升副本"仍然有效 ——
+	//   摘除动作只是把"已经排在队列里的那些"提前清掉,不该顺带削弱仍在生效的保护。
+	//   陈旧标志本身不会误伤:IsPromotedMouseDuplicate() 还要比屏幕坐标(±16px)。
+	int CPaintManagerUI::DropQueuedPromotedMouseMessages(HWND hWndTarget)
+	{
+		if (hWndTarget == NULL || !::IsWindow(hWndTarget)) return 0;
+
+		int nDropped = 0;
+		MSG msg;
+		// 范围 WM_MOUSEFIRST..WM_MOUSELAST 覆盖 MOVE / 左中右键 DOWN-UP-DBLCLK /
+		// WHEEL / XBUTTON 全族 —— 提升副本只在其中。
+		// 只摘"投给该窗口"的(弹框出现之前那次手势的副本,其归属窗口必然是这个 owner:
+		// 消息的归属在**投递那一刻**才按"光标下的窗口"结算,而那时弹框还不存在)。
+		while (::PeekMessage(&msg, hWndTarget, WM_MOUSEFIRST, WM_MOUSELAST, PM_REMOVE))
+			++nDropped;
+
+		return nDropped;
+	}
+
+	// 【悬浮态残留 · 跨窗口参考点】把"本窗口客户区点"记成**屏幕**坐标后,写进
+	// **线程级**参考点 `s_ptLastTouchScreen`(见文件头那一大段说明:这套状态不能是
+	// manager 成员,因为补投的 MOVE 会落在另一个 manager 上)。
+	// 用屏幕坐标的原因:抑制"抬起后悬浮态复活"的 ±8px 判据必须用与窗口无关的坐标系,
+	// 否则窗口一换(尤其弹框销毁、消息改投主窗口)判据就会失准。
+	// 调用约定:凡写 `m_ptLastTouchPos` 的**触摸**路径,都应同步调一次本函数。
+	void CPaintManagerUI::SetLastTouchScreenPoint(POINT ptClient)
+	{
+		POINT ptScreen = ptClient;
+		if (m_hWndPaint != NULL && ::IsWindow(m_hWndPaint))
+			::ClientToScreen(m_hWndPaint, &ptScreen);
+		s_ptLastTouchScreen = ptScreen;
+	}
+
 	namespace
 	{
 		const int kScrollBarTouchSlop = 12;   // 手指离滚动条 rect 的最大容差(像素)
@@ -1226,6 +1421,24 @@ namespace DuiLib
 		// 本标志是"滑动状态机活着"的唯一判据(End 之前一直为真)。
 		m_bTouchSlideActive = true;
 
+		// ★【第二次合成点击 · 根治】触摸通道接手了这次手势 ⇒ 它的提升鼠标副本要作废。
+		//
+		// 只在这一处置位(连同 EndTouchSlideGesture 里的销账),理由:
+		//   · WM_TOUCH 与 WM_POINTER* 两条通道**都经由本函数**开始手势 ⇒ 一处即全覆盖;
+		//   · 由"触摸通道确实收到了手势"来驱动,而不是由"模态边界/代数"来猜 ⇒
+		//     对"关闭弹框的那一次手势"同样生效(旧判据唯一漏掉的一类)。
+		//   · pt 是**本窗口客户区**坐标,提升副本可能落在别的窗口客户区里,
+		//     所以这里换算成屏幕坐标存起来,供 MessageHandler 侧比对。
+		// 详见文件上方 ArmPromotedMouseDuplicate 那段注释。
+		{
+			POINT ptScreen = pt;
+			::ClientToScreen(m_hWndPaint, &ptScreen);
+			ArmPromotedMouseDuplicate(ptScreen);
+			// 同一个触点的**屏幕坐标**副本:抑制"抬起后悬浮态复活"的 ±8px 判据必须用它
+			// (客户区坐标一换窗口就不可比,见 s_ptLastTouchScreen 的注释)。
+			s_ptLastTouchScreen = ptScreen;
+		}
+
 		CControlUI* pControl = FindControl(pt);
 		if (pControl == NULL)
 		{
@@ -1270,7 +1483,7 @@ namespace DuiLib
 		if (!bTouchIsScrollBar)
 			m_pEventClick = pControl;
 		// 触摸没有悬浮语义;整个手势期间以及抬起后的合成鼠标移动都不应重建 HOT。
-		m_bSuppressHoverAfterTouch = true;
+		s_bSuppressHoverAfterTouch = true;
 		pControl->SetFocus();
 		SetCapture();                              // mgr 级:m_bMouseCapture=true + ::SetCapture
 
@@ -1309,6 +1522,33 @@ namespace DuiLib
 		event.dwTimestamp = ::GetTickCount();
 		event.wKeyState = MapKeyState();
 		pControl->Event(event);
+
+		// ------------------------------------------------------------------
+		// ★★【触摸按压态 · 主动冲刷一次绘制】★★
+		//
+		// 现象(Win7 真机实测):点商品卡时"很多商品没有按压态显示"。
+		// 成因**不是**控件没置状态,而是**没机会把它画出来**:
+		//   · WM_PAINT 是**低优先级**消息 —— 只有消息队列空下来,GetMessage 才会合成它;
+		//   · 真机上这次手势的 DOWN→UP 只差 0~16ms(同一个时钟滴答)。TOUCHDOWN 这一发
+		//     把 pressed 置上并 Invalidate 之后,队列里紧接着就排着 UP,WM_PAINT 一直
+		//     轮不上;等 UP 处理完,状态早被复位成 normal 了 ⇒ 画出来的是普通帧。
+		//   · 反过来,**滑动时按压态反而看得见** —— 因为手指按住不放,队列有空隙,
+		//     WM_PAINT 合成得出来。这与用户此前"滑动起手就显示按压态"的反馈完全吻合,
+		//     也反过来证明"置/复位是配对的、只是没机会绘制"这一判断成立。
+		//
+		// 修法:TOUCHDOWN 派发完,立刻用 ::UpdateWindow 把已经堆起来的脏区**同步**刷一次。
+		//   · 与库内既有写法一致:CContainerUI::EndDown / EndRight 就是这么刷的;
+		//   · ::UpdateWindow 只在"本窗口已有脏区"时才发 WM_PAINT,不制造多余重绘;
+		//   · 不碰任何状态、不派发任何事件,纯显示层。
+		//
+		// ⚠️ 副作用(已知并接受):滑动起手那一帧的按压态也会**真的画出来**。
+		//    这不是本行引入的新语义 —— 清扫时机是"抬手时才清扫",用户已明确选择过。
+		//    若将来要让它不显示,改的是清扫时机,不是这里。
+		// ------------------------------------------------------------------
+		if (m_hWndPaint != NULL && ::IsWindow(m_hWndPaint))
+		{
+			::UpdateWindow(m_hWndPaint);
+		}
 	}
 
 	void CPaintManagerUI::UpdateTouchSlideGesture(POINT pt)
@@ -1445,10 +1685,11 @@ namespace DuiLib
 		if (m_pEventTouch == NULL)
 		{
 			m_ptLastTouchPos = pt;
+			SetLastTouchScreenPoint(pt);
 			m_pTouchScrollContainer = NULL;
 			m_bTouchMoved = false;
 			m_nTouchWheelAccum = 0;
-			m_bSuppressHoverAfterTouch = true;
+			s_bSuppressHoverAfterTouch = true;
 			m_bMouseTracking = false;
 			// [NIM 风格] 这里不需要"解除让路"——让路已改由 m_pEventTouch 这个**标记**表达。
 			// ⚠️ 可变参数必须在 _T 宏的括号**之外**:_T 是单参数宏,
@@ -1462,6 +1703,7 @@ namespace DuiLib
 			(int)m_bTouchMoved);
 
 		m_ptLastTouchPos = pt;
+		SetLastTouchScreenPoint(pt);
 
 		// 注:`m_pEventTouch == NULL` 的早退路已提到本函数最开头(见上),
 		//     到这里 m_pEventTouch 必然非空,可以安全解引用。
@@ -1517,7 +1759,7 @@ namespace DuiLib
 		// Windows 会为触摸补发合成的 WM_MOUSEMOVE;且真实 WM_MOUSELEAVE 在 m_bMouseTracking
 		// 为真时会自己给自己重发 WM_MOUSEMOVE(见 WM_MOUSELEAVE 分支)→ 刚清掉的悬浮态立刻
 		// 被重新点亮。这里置抑制位 + 主动撤下鼠标跟踪,直到用户真的移动鼠标才恢复。
-		m_bSuppressHoverAfterTouch = true;
+		s_bSuppressHoverAfterTouch = true;
 		m_bMouseTracking = false;
 
 		// ------------------------------------------------------------------
@@ -1653,7 +1895,7 @@ namespace DuiLib
 		//      "轻点 vs 拖拽"只用 m_bTouchMoved 在 UP 处抑制误点。
 		//   5) 触摸**不参与悬浮态**:WM_MOUSEMOVE 里用 MOUSEEVENTF_FROMTOUCH 标记把"提升出来的鼠标消息"
 		//      挡在悬浮 enter/leave 之外(触摸没有"光标停留"语义)——这是"点过的控件一直高亮 /
-		//      多个控件同时高亮"的根治手段;此处再叠加 m_bSuppressHoverAfterTouch 抑制抬起后的复活。
+		//      多个控件同时高亮"的根治手段;此处再叠加 s_bSuppressHoverAfterTouch 抑制抬起后的复活。
 		TouchApi::PFN_GetTouchInputInfo pfnGetInfo = TouchApi::GetGetTouchInputInfo();
 
 		// 【已移除】"把 WM_TOUCH 同步转发给模态弹框"的兜底块。
@@ -2398,7 +2640,7 @@ namespace DuiLib
 		//   而且回调返回时前台已经变了 ⇒ 复位动作打在错的对象/时机上。
 		//
 		// 鼠标路径为什么没这问题:它的清理动作全部在业务回调**之前**:
-		//     ClearTouchHoverState / ClearPushedStateAt / m_bSuppressHoverAfterTouch
+		//     ClearTouchHoverState / ClearPushedStateAt / s_bSuppressHoverAfterTouch
 		//     → 才轮到 `pClick->Event(BUTTONUP)` → `SendNotify(CLICK)` → `ShowModal()`
 		//
 		// 但**触摸链不能照搬"事前 ClearPushedStateAt"**!
@@ -3470,6 +3712,48 @@ namespace DuiLib
 					|| uMsg == WM_LBUTTONDBLCLK);
 				const bool bUpLike = (uMsg == WM_LBUTTONUP || uMsg == WM_LBUTTONDBLCLK);
 
+				// ==============================================================
+				// ★★【第二次合成点击 · 根治】第一道,也是唯一必需的一道闸 ★★
+				//
+				// 判据:本条提升消息,是不是"触摸通道刚刚处理过的那次手势"的副本。
+				//   · 依据只有"触摸通道收到了这次手势"这一个事实 + 屏幕坐标吻合,
+				//     **与模态边界 / 代数水位 / extra 指纹全都无关** ⇒ 对"关闭弹框
+				//     的那一次手势"同样有效(旧判据在 Win7 上唯一漏掉的就是它)。
+				//   · 命中的处理:点击类一律吞(并且不建立候选点击);抬手类到达即销账。
+				//     移动类放行(不产生点击;仍需维持重绘/光标,悬浮态由"提升移动
+				//     不参与 hover"的总闸挡掉)。
+				//
+				// ⚠️ 必须放在下面"代数水位"那套**之前**:那套是按模态边界近似出来的
+				//    版本,在 Win7 上(extra 恒定 ⇒ 指纹失效;水位一次手势就追平)
+				//    必然漏掉"关闭弹框那一次"。本闸门是它的严格超集。
+				//    先不删旧的,等这两台机器都验过再决定去留。
+				// ==============================================================
+				{
+					POINT ptMsg = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
+					if (IsPromotedMouseDuplicate(m_hWndPaint, ptMsg) && bClickLike)
+					{
+						DuiLibDiag::Log(_T("PROMOTED-DUP swallow msg=0x%04X hwnd=%p extra=0x%08X pt=%d,%d"),
+							uMsg, (void*)m_hWndPaint, (unsigned)(dwExtra & 0xFFFFFFFF),
+							ptMsg.x, ptMsg.y);
+						// 从根上不产生候选点击(抬手自然也不会派发)。
+						if (uMsg == WM_LBUTTONDOWN)
+							m_pEventClick = NULL;
+						if (m_bMouseCapture)
+							ReleaseCapture();
+						// 【悬浮态残留】把"抑制参考点"钉在本条坐标上:残留序列之后
+						// 系统还会补投一条 extra=0 的普通 MOVE,参考点不挪的话那条
+						// MOVE 会解除抑制并重建 hover。
+						s_bSuppressHoverAfterTouch = true;
+						m_bMouseTracking = false;
+						m_ptLastTouchPos = ptMsg;
+						SetLastTouchScreenPoint(ptMsg);
+						// 抬手类到达 ⇒ 这次手势的提升副本已配完 ⇒ 销账。
+						if (bUpLike)
+							DisarmPromotedMouseDuplicate();
+						return true;
+					}
+				}
+
 				// 跨代 ⇒ 打开"作废窗口"并记下 extra 指纹。
 				//
 				// ⚠️ 第三版:**移动也开窗**。真机残留是三件套 MOVE+DOWN+UP,
@@ -3502,15 +3786,16 @@ namespace DuiLib
 						//   (表示"指针真的停在这")。它不是提升消息,绕不过悬浮总闸;
 						//   而此时 m_ptLastTouchPos 还停在弹框内"选好了"的位置(如 496,561),
 						//   与残留点(如 779,645)相距远超 8px ⇒ WM_MOUSEMOVE 里那条
-						//   ±8px 判据会**主动解除** m_bSuppressHoverAfterTouch,
+						//   ±8px 判据会**主动解除** s_bSuppressHoverAfterTouch,
 						//   于是 FindControl(pt) 命中背后商品并建立 hover(HOVER-ENTER)。
 						//   这里先把抑制位重新立起来、并把参考点挪到残留点上,那条补投的
 						//   MOVE 坐标与参考点几乎重合 ⇒ 落在 ±8px 内被 break,不建立悬浮态。
 						//   纯状态判据,不看时间。
-						m_bSuppressHoverAfterTouch = true;
+						s_bSuppressHoverAfterTouch = true;
 						m_bMouseTracking = false;
-						m_ptLastTouchPos.x = GET_X_LPARAM(lParam);
-						m_ptLastTouchPos.y = GET_Y_LPARAM(lParam);
+						POINT ptResidue = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
+						m_ptLastTouchPos = ptResidue;
+						SetLastTouchScreenPoint(ptResidue);
 						// 抬手类到达 ⇒ 序列收尾 ⇒ 关窗(纯状态,不看时间)。
 						if (bUpLike)
 							ClearPromotedMouseGenTaint();
@@ -3530,10 +3815,11 @@ namespace DuiLib
 					ClearPromotedMouseGenTaint();
 					// ★【悬浮态残留】同上:新手势的残留点也要成为抑制参考点,
 					//   否则紧跟的 extra=0 补投 MOVE 会解除抑制并建立悬浮态。
-					m_bSuppressHoverAfterTouch = true;
+					s_bSuppressHoverAfterTouch = true;
 					m_bMouseTracking = false;
-					m_ptLastTouchPos.x = GET_X_LPARAM(lParam);
-					m_ptLastTouchPos.y = GET_Y_LPARAM(lParam);
+					POINT ptNewGen = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
+					m_ptLastTouchPos = ptNewGen;
+					SetLastTouchScreenPoint(ptNewGen);
 					// 关窗后本条按正常流程继续处理(它属于新手势,应当放行)。
 				}
 			}
@@ -4321,7 +4607,7 @@ namespace DuiLib
 			break;
 			case WM_MOUSEHOVER:
 			{
-				if (m_bSuppressHoverAfterTouch) return true;
+				if (s_bSuppressHoverAfterTouch) return true;
 				m_bMouseTracking = false;
 				POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
 				CControlUI* pHover = FindControl(pt);
@@ -4373,7 +4659,7 @@ namespace DuiLib
 				if (m_hwndTooltip != NULL) ::SendMessage(m_hwndTooltip, TTM_TRACKACTIVATE, FALSE, (LPARAM)&m_ToolTip);
 				// 触屏抬起刚清过悬浮态时,不要执行下面这段“自回发 WM_MOUSEMOVE”:
 				// 光标通常仍在窗口内 → 会立刻重建悬浮态,导致“悬浮态不消失/多控件同时悬浮”。
-				if (!m_bSuppressHoverAfterTouch && m_bMouseTracking)
+				if (!s_bSuppressHoverAfterTouch && m_bMouseTracking)
 				{
 					POINT pt = { 0 };
 					RECT rcWnd = { 0 };
@@ -4415,7 +4701,7 @@ namespace DuiLib
 					RelayDefWindowProc(uMsg, wParam, lParam, _T("promoted-touch-move/tracking"));
 					return true;
 				}
-				if (m_bSuppressHoverAfterTouch)
+				if (s_bSuppressHoverAfterTouch)
 				{
 					// [第十一轮修正] 这里曾经无条件丢弃消息(return true)。
 					// 后果:弹框弹出时 AttachPaintWindow 会置起本抑制位(为了让新弹框
@@ -4445,15 +4731,42 @@ namespace DuiLib
 					break;
 
 				POINT ptCheck = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
-				if (m_bSuppressHoverAfterTouch)
+				if (s_bSuppressHoverAfterTouch)
 				{
 					// 合成消息通常仍停在触摸结束点;只有明显离开该点的鼠标移动
 					// 才能重新建立鼠标悬浮语义。
-					const int dx = ptCheck.x - m_ptLastTouchPos.x;
-					const int dy = ptCheck.y - m_ptLastTouchPos.y;
+					//
+					// ⚠️⚠️ 这里有两个**必须同时成立**的前提,缺一个就会让"点『选好了』
+					//   关掉弹框后,背后商品卡保持悬浮态不消失"复现。真机日志两次实证:
+					//
+					// ① 参考点必须换算到**屏幕坐标**再比 —— lParam 是"**接收窗口**的
+					//    客户区坐标",而触摸可能发生在**另一个窗口**里。
+					//    用户点弹框里的「选好了」时,同一物理点在
+					//      · 弹框的客户区里是 (496,547)
+					//      · 主窗口的客户区里是 (778,630)
+					//    若按客户区比较,等于拿两个不同原点的数相减 ⇒ 判据必然失准。
+					//
+					// ② 抑制位与参考点必须是**线程级**的(见文件头 `s_bSuppressHoverAfterTouch`
+					//    那段说明)—— 因为属性弹框**自带独立的 CPaintManagerUI m_pm**,
+					//    与主窗口的 manager 不是同一个对象(真机日志:弹框 0A29F5F8 /
+					//    主窗口 07DA8C58)。那次触摸记在**弹框**那份状态上;而弹框一销毁,
+					//    系统把补投的 `extra=0` WM_MOUSEMOVE 发给**主窗口** ⇒ 落到主窗口的
+					//    manager 上。若状态是成员,主窗口那份要么抑制位为假、要么参考点
+					//    还停在更早那次触摸(真机日志里是 1225,446,与本次点相距 447px)
+					//    ⇒ 判据失败 ⇒ 解除抑制 + FindControl(pt) 命中背后商品卡 ⇒ 悬浮态
+					//    点亮且此后永不熄灭(`MODAL-DLG exit` → `HOVER-ENTER diandan_fooditem`)。
+					//    所以这两个状态已提升为文件级静态变量,与 manager 无关。
+					//
+					// 注意那条补投 MOVE 的 extra=0x00000000,它绕不过"提升移动不参与 hover"
+					//   的总闸,只能靠**提前把状态改好**来防(此处判据 + 各触摸路径的写入)。
+					POINT ptCheckScreen = ptCheck;
+					if (m_hWndPaint != NULL && ::IsWindow(m_hWndPaint))
+						::ClientToScreen(m_hWndPaint, &ptCheckScreen);
+					const int dx = ptCheckScreen.x - s_ptLastTouchScreen.x;
+					const int dy = ptCheckScreen.y - s_ptLastTouchScreen.y;
 					if (dx <= 8 && dx >= -8 && dy <= 8 && dy >= -8)
 						break;
-					m_bSuppressHoverAfterTouch = false;
+					s_bSuppressHoverAfterTouch = false;
 				}
 
 				// Start tracking this entire window again...
@@ -4754,7 +5067,7 @@ namespace DuiLib
 					//        DoEvent(BUTTONUP) 复位的。
 					ClearPushedStateAt(NULL, ptButtonUp);
 					// (c) 抬起后禁止"悬浮态复活"(系统会补发合成的 WM_MOUSEMOVE)。
-					m_bSuppressHoverAfterTouch = true;
+					s_bSuppressHoverAfterTouch = true;
 					m_bMouseTracking = false;
 				}
 

+ 48 - 3
DuiLib/Core/UIManager.h

@@ -616,6 +616,13 @@ namespace DuiLib {
 		// ------------------------------------------------------------------
 		bool TouchUp(const POINT& pt, bool bTapLike);
 
+		// 记录"跨窗口通用的抑制参考点":把本窗口客户区点换算成屏幕坐标后写进
+		// **线程级**参考点 `s_ptLastTouchScreen`(定义在 UIManager.cpp 文件头,
+		// 那里有"为什么不能用 manager 成员"的完整说明)。
+		// 约定:凡是写了 m_ptLastTouchPos 的**触摸**路径,都应同步调一次本函数 ——
+		// 二者是同一个物理点的两种坐标表达,缺了屏幕坐标那份,"窗口一换判据就失准"。
+		void SetLastTouchScreenPoint(POINT ptClient);
+
 		// ------------------------------------------------------------------
 		// [WM_TOUCH 归属权] 未被文档化的关键机制与"补调 DefWindowProc"钩子。
 		//
@@ -631,8 +638,9 @@ namespace DuiLib {
 		// 本项目的两个真实断点(都在 MessageHandler 的 WM_MOUSEMOVE 分支):
 		//   · `if (bPromotedTouchMove && m_pEventTouch != NULL) { ... return true; }`
 		//     —— 触摸手势进行中,丢弃"提升出来的鼠标移动";
-		//   · `if (m_bSuppressHoverAfterTouch) { ... return true; }`
-		//     —— 触摸抬手后抑制悬浮态复活,丢弃合成移动。
+		//   · `if (s_bSuppressHoverAfterTouch) { ... return true; }`
+		//     —— 触摸抬手后抑制悬浮态复活,丢弃合成移动(该状态是**线程级**的,
+		//        定义在 UIManager.cpp 文件头;不是本类的成员)。
 		//   两者都 `return true`,顺着 MessageHandler → HandleMessage 直接返回,
 		//   **跳过了 DefWindowProc** ⇒ 弹框(#32770 对话框类就是靠 DefDlgProc→DefWindowProc
 		//   这条链来"翻译"WM_TOUCH 的)永远收不到 WM_TOUCH。
@@ -701,6 +709,28 @@ namespace DuiLib {
 		static void SetModalWindow(HWND hWnd);
 		static HWND GetModalWindow();
 
+		// ------------------------------------------------------------------
+		// ★【Win7"咚"声 · 根治】"提升副本在途"标志的只读快照。
+		//
+		// 真 ⇒ 触摸通道确实收到了本次手势,它的提升鼠标副本正排在队列里等着被投递。
+		// 用途:`CWindowWnd::ShowModal` 进模态循环之前用它决定"要不要把队列里那份
+		//   在途副本摘掉" —— 因为系统对"投给**禁用**窗口的鼠标消息"的默认处理是
+		//   `MessageBeep(0)` + 退回队列,那就是 Win7 上那一声"咚"。
+		//
+		// 判据只依赖"触摸通道收到了这次手势"这一件事(与 PROMOTED-DUP 同源、同一处置位),
+		// 与模态边界 / 代数水位 / extra 指纹全都无关。详见 UIManager.cpp 里
+		// `IsPromotedMouseDuplicatePending` / `DropQueuedPromotedMouseMessages` 的定义处。
+		// ------------------------------------------------------------------
+		static bool IsPromotedMouseDuplicatePending();
+
+		// ★【Win7"咚"声 · 根治】把"本次手势在途的提升鼠标副本"从本线程消息队列里
+		// **就地摘掉**,返回摘掉的条数(0 = 队列里本来就没有)。
+		//
+		// 调用点:`CWindowWnd::ShowModal` 进模态循环之前 —— 那一刻副本必然已排在队列里,
+		// 而"摘除"与"创建弹框"之间没有消息泵 ⇒ 摘除对投递而言是原子的。
+		// 原理与"为什么这样不会误伤",见 UIManager.cpp 里本函数的定义处。
+		static int DropQueuedPromotedMouseMessages(HWND hWndTarget);
+
 		// ------------------------------------------------------------------
 		// [第十一轮诊断] 只读快照访问器:给 UIBase.cpp 的模态迁移点打日志用。
 		//
@@ -757,6 +787,16 @@ namespace DuiLib {
 		CControlUI* m_pEventTouch = NULL;   // 本次触摸命中的控件
 		CContainerUI* m_pTouchScrollContainer = NULL; // 本次触摸对应的最近可滚动容器
 		POINT m_ptLastTouchPos = { 0, 0 };  // 上一包触点:用于去重 + 计算跟手滚动的位移增量
+		// 【已上移为线程级】原先这里有一个 `POINT m_ptLastTouchScreen` 成员,以及
+		// 下方一个 `bool m_bSuppressHoverAfterTouch` 成员。二者现在都是
+		// UIManager.cpp **文件级静态变量**(`s_ptLastTouchScreen` /
+		// `s_bSuppressHoverAfterTouch`)。
+		//
+		// 为什么不能用成员:属性弹框**自带独立的 CPaintManagerUI m_pm**,与主窗口的
+		// manager 不是同一个对象。在弹框上点「选好了」那次触摸记在**弹框**那份状态上;
+		// 而弹框一销毁,系统补投的 `extra=0` WM_MOUSEMOVE 发给**主窗口**,落在主窗口的
+		// manager 上 —— 那份状态不知道刚发生过触摸,±8px 判据必然失败 ⇒ 解除抑制 ⇒
+		// 背后商品卡悬浮态残留。详见 UIManager.cpp 文件头的完整说明。
 		POINT m_ptTouchStart = { 0, 0 };    // 本次手势起点:用于判断是否发生位移(拖拽 vs 轻点)
 		bool m_bTouchMoved = false;         // 本次触摸是否发生位移(>阈值视为拖拽),松开时抑制点击
 		bool m_bSuppressPromotedTouchMouse = false; // 触摸点击已由 TOUCHUP 处理,丢弃随后提升的鼠标点击
@@ -853,7 +893,12 @@ namespace DuiLib {
 		// 注:这是【第二道】保险。第一道(根治)在 WM_MOUSEMOVE 里:凡带 MOUSEEVENTF_FROMTOUCH
 		// 标记的“触摸提升出来的鼠标消息”一律不参与悬浮 enter/leave —— 触摸没有“光标停留”语义,
 		// 让这类消息参与必然出现“点过的控件一直高亮 / 多点几个后多个同时高亮”。
-		bool m_bSuppressHoverAfterTouch = false;
+		//
+		// 【已上移为线程级】该标志原为成员 `m_bSuppressHoverAfterTouch`,现改为
+		// UIManager.cpp 文件级静态变量 `s_bSuppressHoverAfterTouch`。
+		// 原因:补投的 `extra=0` WM_MOUSEMOVE 可能落在**另一个 manager** 上
+		// (属性弹框自带独立 m_pm),成员级状态会“看不见刚刚的触摸”。
+		// 完整说明见 UIManager.cpp 文件头,以及本头文件 `m_ptLastTouchPos` 上方的注释。
 		// [对标 nim_duilib] 竖向滑动 → WM_MOUSEWHEEL 的累加器。
 		// 触摸只有位移增量,没有滚轮事件;把 TOUCHEVENTF_MOVE 的 Δy 累加,每满一个
 		// WHEEL_DELTA(120) 就合成一次 UIEVENT_SCROLLWHEEL,让只认滚轮的控件在触摸下也能滚动。

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