mousealert_windows.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
  1. //go:build windows
  2. // ============================================================================
  3. // 系统级鼠标提醒(Windows 原生实现)—— 可靠方案 v2
  4. //
  5. // 需求:平时鼠标是正常样式;有消息来(通过 API 触发)时,鼠标旁边浮出
  6. // 半透明气泡提示文字,跟随鼠标移动、闪烁,可覆盖任意窗口(系统级),
  7. // 默认 8 秒自动消退。
  8. //
  9. // v1 的失败原因:全局低层鼠标钩子 WH_MOUSE_LL 的回调由系统在非 Go 线程
  10. // 上调用,经 syscall.NewCallback 进入 Go runtime 时会导致进程静默退出;
  11. // 且 DIB 自绘 + UpdateLayeredWindow 尺寸不匹配时绘制失效。v2 全部移除:
  12. //
  13. // 1. 不装任何钩子(去掉唯一危险源),位置跟随改为窗口线程定时器轮询
  14. // GetCursorPos(GetMessage 消息循环所在线程,安全)。
  15. // 2. 用 SetLayeredWindowAttributes(LWA_COLORKEY) 把背景色抠成透明(背景透明,
  16. // 只留白色文字),WM_PAINT 用普通 GDI(FillRect + TextOutW)绘制气泡,
  17. // 不再用 DIB/UpdateLayeredWindow。
  18. // 3. 窗口过程 WndProc 的 syscall.NewCallback 由 DispatchMessageW 在同线程
  19. // 同步调用,Go runtime 完全支持(安全)。
  20. // 4. 超长文字:TextOutW 单行绘制 + 定时器推进 scrollX 偏移,实现水平循环滚动。
  21. //
  22. // 说明:本文件仅在 Windows 编译。其他平台见 mousealert_other.go 空实现。
  23. // ============================================================================
  24. package main
  25. import (
  26. "fmt"
  27. "os"
  28. "runtime"
  29. "strings"
  30. "sync"
  31. "syscall"
  32. "time"
  33. "unsafe"
  34. )
  35. // Windows API 常量
  36. const (
  37. wmTimer = 0x0113
  38. wmDestroy = 0x0002
  39. wmNcDestroy = 0x0082
  40. wmPaint = 0x000F
  41. wmRefresh = 0x8001 // 自定义:立即刷新(trigger 唤醒窗口线程)
  42. wndExLayered = 0x00080000 // WS_EX_LAYERED 分层窗口(支持透明)
  43. wndExTool = 0x00000080 // WS_EX_TOOLWINDOW 不占任务栏
  44. wndExTopMost = 0x00000008 // WS_EX_TOPMOST 置顶
  45. wndExNoActivate = 0x08000000 // WS_EX_NOACTIVATE 不抢焦点
  46. swpNoActivate = 0x0010 // SWP_NOACTIVATE
  47. swpShowWindow = 0x0040 // SWP_SHOWWINDOW
  48. lwaAlpha = 0x2 // LWA_ALPHA:SetLayeredWindowAttributes 设置整体 alpha
  49. lwaColorKey = 0x1 // LWA_COLORKEY:把指定颜色像素抠成透明(背景透明)
  50. timerFollow = 1 // 位置跟随 + 闪烁定时器 ID
  51. overlayWidth = 200
  52. overlayHeight = 54
  53. alertDuration = 8 * time.Second
  54. followIntervalMs = 60 // 跟随/闪烁轮询间隔 60ms
  55. )
  56. // hwndTopMost HWND_TOPMOST = (HWND)-1
  57. const hwndTopMost uintptr = ^uintptr(0)
  58. // 气泡背景色(深蓝黑):COLORREF = 0x00BBGGRR(LWA_COLORKEY 抠成透明,只留文字)
  59. const bgColorRef = 0x001E1E3C
  60. // cfgDurationOrDefault 返回配置的显示时长(秒),非法时用默认 8 秒
  61. func cfgDurationOrDefault(sec int) time.Duration {
  62. if sec < 2 || sec > 60 {
  63. sec = 8
  64. }
  65. return time.Duration(sec) * time.Second
  66. }
  67. // estimateTextWidth 估算文字像素宽度(微软雅黑:全角/中文≈字号,半角/ASCII≈0.55*字号)。
  68. // 用于在触发时预判文字是否超长,从而智能延长显示时长。
  69. func estimateTextWidth(text string, fontSize int32) int32 {
  70. if text == "" || fontSize <= 0 {
  71. return 0
  72. }
  73. var w float32
  74. for _, r := range text {
  75. if r < 0x2E80 { // 半角 / ASCII / 拉丁
  76. w += 0.55 * float32(fontSize)
  77. } else { // 全角 / 中日韩
  78. w += float32(fontSize)
  79. }
  80. }
  81. return int32(w)
  82. }
  83. // hexColorToRef 把 #RRGGBB 或 #RGB 颜色字符串转 COLORREF(0x00BBGGRR)。
  84. // 解析失败时回退 fallback。
  85. func hexColorToRef(s string, fallback uintptr) uintptr {
  86. s = strings.TrimSpace(s)
  87. if len(s) == 7 && s[0] == '#' {
  88. var r, g, b uint32
  89. if _, err := fmt.Sscanf(s, "#%02x%02x%02x", &r, &g, &b); err == nil {
  90. return uintptr(r | g<<8 | b<<16)
  91. }
  92. }
  93. if len(s) == 4 && s[0] == '#' {
  94. var r, g, b uint32
  95. if _, err := fmt.Sscanf(s, "#%1x%1x%1x", &r, &g, &b); err == nil {
  96. return uintptr(r*17 | g*17<<8 | b*17<<16)
  97. }
  98. }
  99. return fallback
  100. }
  101. // ------------------------------------------------------------------------
  102. // Windows 结构体与函数指针声明(用 syscall 直接调用,避免引入额外依赖)
  103. // ------------------------------------------------------------------------
  104. type point struct {
  105. X int32
  106. Y int32
  107. }
  108. // rect Windows RECT 结构(GetWindowRect / FillRect 输出)
  109. type rect struct {
  110. Left int32
  111. Top int32
  112. Right int32
  113. Bottom int32
  114. }
  115. type msg struct {
  116. HWND uintptr
  117. Message uint32
  118. WParam uintptr
  119. LParam uintptr
  120. Time uint32
  121. Pt point
  122. LPrivate uint32
  123. }
  124. type wndclassex struct {
  125. CbSize uint32
  126. Style uint32
  127. LpfnWndProc uintptr
  128. CbClsExtra int32
  129. CbWndExtra int32
  130. HInstance uintptr
  131. HIcon uintptr
  132. HCursor uintptr
  133. HbrBackground uintptr
  134. LpszMenuName *uint16
  135. LpszClassName *uint16
  136. HIconSm uintptr
  137. }
  138. // paintstruct PAINTSTRUCT(BeginPaint / EndPaint 输出)
  139. type paintstruct struct {
  140. Hdc uintptr
  141. FErase int32
  142. RcPaint rect
  143. FRestore int32
  144. FIncUpdate int32
  145. RGBReserved [32]byte
  146. }
  147. // size SIZE 结构(GetTextExtentPoint32W 输出:文字像素尺寸)
  148. type size struct {
  149. Cx int32
  150. Cy int32
  151. }
  152. // 延迟加载的 user32.dll / gdi32.dll / kernel32.dll 函数
  153. var (
  154. user32 = syscall.NewLazyDLL("user32.dll")
  155. gdi32 = syscall.NewLazyDLL("gdi32.dll")
  156. kernel32 = syscall.NewLazyDLL("kernel32.dll")
  157. procGetMessage = user32.NewProc("GetMessageW")
  158. procTranslateMessage = user32.NewProc("TranslateMessage")
  159. procDispatchMessage = user32.NewProc("DispatchMessageW")
  160. procRegisterClassEx = user32.NewProc("RegisterClassExW")
  161. procCreateWindowEx = user32.NewProc("CreateWindowExW")
  162. procDefWindowProc = user32.NewProc("DefWindowProcW")
  163. procDestroyWindow = user32.NewProc("DestroyWindow")
  164. procSetTimer = user32.NewProc("SetTimer")
  165. procKillTimer = user32.NewProc("KillTimer")
  166. procShowWindow = user32.NewProc("ShowWindow")
  167. procSetWindowPos = user32.NewProc("SetWindowPos")
  168. procGetCursorPos = user32.NewProc("GetCursorPos")
  169. procPostMessage = user32.NewProc("PostMessageW")
  170. procSetLayeredWindowAttr = user32.NewProc("SetLayeredWindowAttributes")
  171. procBeginPaint = user32.NewProc("BeginPaint")
  172. procEndPaint = user32.NewProc("EndPaint")
  173. procInvalidateRect = user32.NewProc("InvalidateRect")
  174. procFillRect = user32.NewProc("FillRect") // 注意:FillRect 在 user32.dll,不在 gdi32.dll
  175. procDrawTextW = user32.NewProc("DrawTextW")
  176. procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush")
  177. procDeleteObject = gdi32.NewProc("DeleteObject")
  178. procSetBkMode = gdi32.NewProc("SetBkMode")
  179. procSetTextColor = gdi32.NewProc("SetTextColor")
  180. procGetStockObject = gdi32.NewProc("GetStockObject")
  181. procSelectObject = gdi32.NewProc("SelectObject")
  182. procCreateFontW = gdi32.NewProc("CreateFontW")
  183. procTextOutW = gdi32.NewProc("TextOutW") // 单行文字输出(超长滚动用)
  184. procGetTextExtentPoint32W = gdi32.NewProc("GetTextExtentPoint32W") // 测量文字宽度(判断是否超长)
  185. procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC") // 双缓冲:创建内存 DC
  186. procCreateCompatibleBitmap = gdi32.NewProc("CreateCompatibleBitmap") // 双缓冲:创建兼容位图
  187. procBitBlt = gdi32.NewProc("BitBlt") // 双缓冲:整块拷贝上屏
  188. procDeleteDC = gdi32.NewProc("DeleteDC") // 双缓冲:删除内存 DC
  189. procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW")
  190. )
  191. // ------------------------------------------------------------------------
  192. // 鼠标提醒运行时状态
  193. // ------------------------------------------------------------------------
  194. // mouseAlertState 鼠标提醒的运行时状态(单实例)
  195. type mouseAlertState struct {
  196. mu sync.Mutex
  197. started bool // 工作线程是否已启动(持久化,仅启动一次)
  198. overlayWnd uintptr
  199. active bool
  200. text string
  201. flashOn bool
  202. lastX, lastY int32
  203. stopTime time.Time
  204. wndProc uintptr
  205. classOK bool
  206. bgBrush uintptr
  207. scrollX int32 // 超长文字水平滚动偏移(px),定时器推进
  208. textWidth int32 // 当前文字像素宽度(>可用宽度时滚动)
  209. textGap int32 // 头尾相联间隔(两个空格宽度 px),滚动周期 = 文字宽 + 间隔
  210. // 运行时配置(triggerMouseAlert 时从设置加载)
  211. cfgWidth int32 // 气泡窗口宽度(px)
  212. cfgHeight int32 // 气泡窗口高度(px,按字号自适应)
  213. cfgFontSize int32 // 字号(px)
  214. cfgSpeed int // 超长文字滚动速度(px/s)
  215. cfgDuration time.Duration // 显示时长
  216. cfgTextColor uintptr // 文字颜色(COLORREF)
  217. cfgBgColor uintptr // 气泡背景色(COLORKEY 抠透明)
  218. cfgOutlineColor uintptr // 文字描边颜色(COLORREF),形成反差
  219. cfgOutlineWidth int32 // 文字描边宽度(px),0 关闭
  220. }
  221. var mouseAlert = &mouseAlertState{}
  222. // triggerMouseAlert 触发鼠标提醒:鼠标旁浮出半透明气泡提示,跟随鼠标闪烁。
  223. // 文字为空时用默认提示。重复触发会刷新提醒计时并立即显示。
  224. //
  225. // 定位策略:窗口线程的定时器主动用 GetCursorPos 读取当前鼠标位置并跟随,
  226. // 不依赖任何全局钩子回调(钩子回调在系统线程执行,曾导致进程退出)。
  227. func triggerMouseAlert(text string) {
  228. if text == "" {
  229. text = "有新消息提醒"
  230. }
  231. // 从设置加载鼠标提醒配置(字号/宽度/速度/颜色/时长)
  232. cfg := loadSettings().MouseAlert
  233. var pt point
  234. curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
  235. hasCursor := curRet != 0
  236. // 智能延长显示时长:文字超长(需滚动)时,追加滚动一圈所需时间,保证能看完
  237. cfgWidth := int32(cfg.Width)
  238. if cfgWidth <= 0 {
  239. cfgWidth = overlayWidth
  240. }
  241. cfgFontSize := int32(cfg.FontSize)
  242. if cfgFontSize <= 0 {
  243. cfgFontSize = DefaultMouseAlertFontSize
  244. }
  245. estW := estimateTextWidth(text, cfgFontSize)
  246. baseDur := cfgDurationOrDefault(cfg.Duration)
  247. totalDur := baseDur
  248. if estW > cfgWidth-24 && cfg.Speed > 0 {
  249. // 超出可用宽度的部分需滚动,所需时间 = 超出像素 / 速度
  250. extra := estW - (cfgWidth - 24)
  251. need := time.Duration(float64(extra) / float64(cfg.Speed) * float64(time.Second))
  252. totalDur = baseDur + need
  253. }
  254. mouseAlert.mu.Lock()
  255. mouseAlert.text = text
  256. mouseAlert.active = true
  257. mouseAlert.flashOn = true
  258. mouseAlert.stopTime = time.Now().Add(totalDur)
  259. mouseAlert.scrollX = 0 // 新消息从头开始滚动
  260. mouseAlert.textWidth = 0 // 强制重新测量
  261. // 缓存本次提醒的显示配置
  262. mouseAlert.cfgWidth = cfgWidth
  263. mouseAlert.cfgFontSize = cfgFontSize
  264. mouseAlert.cfgSpeed = cfg.Speed
  265. mouseAlert.cfgDuration = totalDur
  266. mouseAlert.cfgTextColor = hexColorToRef(cfg.Color, 0x00FFFFFF) // 默认白字
  267. mouseAlert.cfgBgColor = bgColorRef
  268. mouseAlert.cfgOutlineColor = hexColorToRef(cfg.OutlineColor, 0x00000000) // 默认黑边
  269. mouseAlert.cfgOutlineWidth = int32(cfg.OutlineWidth)
  270. // 头尾相联间隔 = 两个空格宽度(约 0.3*字号 每个),提前缓存供滚动周期使用
  271. mouseAlert.textGap = int32(float32(cfgFontSize) * 0.3 * 2)
  272. if mouseAlert.textGap < 4 {
  273. mouseAlert.textGap = 4
  274. }
  275. // 气泡高度按字号自适应:字号 + 上下留白 + 内边距
  276. mouseAlert.cfgHeight = mouseAlert.cfgFontSize + 34
  277. if hasCursor {
  278. mouseAlert.lastX, mouseAlert.lastY = pt.X, pt.Y
  279. }
  280. started := mouseAlert.started
  281. mouseAlert.started = true
  282. overlay := mouseAlert.overlayWnd
  283. mouseAlert.mu.Unlock()
  284. // 首次触发:启动常驻工作线程(幂等),建窗后由线程主动定位并重绘
  285. if !started {
  286. go mouseAlertLoop()
  287. return
  288. }
  289. // 已启动:唤醒窗口线程立即刷新(窗口线程负责 SetWindowPos/绘制)
  290. if overlay != 0 {
  291. procPostMessage.Call(overlay, wmRefresh, 0, 0)
  292. }
  293. }
  294. // stopMouseAlert 立即停止鼠标提醒(隐藏气泡;线程保持常驻)
  295. func stopMouseAlert() {
  296. mouseAlert.mu.Lock()
  297. mouseAlert.active = false
  298. overlay := mouseAlert.overlayWnd
  299. mouseAlert.mu.Unlock()
  300. if overlay != 0 {
  301. procShowWindow.Call(overlay, 0) // SW_HIDE
  302. }
  303. }
  304. // mouseAlertLoop 常驻工作线程:注册窗口类、创建半透明置顶气泡窗口、
  305. // 启动跟随/闪烁定时器,进入消息循环。
  306. func mouseAlertLoop() {
  307. // 关键:Go 的 goroutine 会在 OS 线程间迁移。窗口消息循环(CreateWindowEx +
  308. // GetMessage + DispatchMessage)必须固定在同一个 OS 线程上,否则 WM_TIMER /
  309. // WM_PAINT 等消息投递到创建窗口的线程,而 GetMessage 在另一线程永远收不到,
  310. // 导致窗口出现后卡死(不跟随鼠标、不重绘、无文字)。LockOSThread 锁定本
  311. // goroutine 到当前 OS 线程,保证消息循环与窗口同线程。
  312. runtime.LockOSThread()
  313. hInst, _, _ := procGetModuleHandleW.Call(0)
  314. mouseAlert.mu.Lock()
  315. className, _ := syscall.UTF16PtrFromString("TengTuMouseAlertBubble")
  316. wc := wndclassex{
  317. CbSize: uint32(unsafe.Sizeof(wndclassex{})),
  318. LpfnWndProc: mouseAlert.wndProcAddr(),
  319. HInstance: hInst,
  320. LpszClassName: className,
  321. HCursor: 0,
  322. }
  323. atom, _, _ := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc)))
  324. if atom != 0 {
  325. mouseAlert.classOK = true
  326. } else {
  327. fmt.Fprintf(os.Stderr, "[mousealert] RegisterClassEx failed (classOK=%v)\n", mouseAlert.classOK)
  328. }
  329. mouseAlert.mu.Unlock()
  330. // 创建半透明置顶气泡窗口(不抢焦点、不占任务栏)
  331. // dwStyle 必须含 WS_POPUP(0x80000000):无边框无标题的弹出窗口,
  332. // 否则 CreateWindowEx 可能失败或窗口不可见。
  333. style := uintptr(wndExLayered | wndExTool | wndExTopMost | wndExNoActivate)
  334. mouseAlert.mu.Lock()
  335. w, h := mouseAlert.cfgWidth, mouseAlert.cfgHeight
  336. if w <= 0 {
  337. w = overlayWidth // 首次触发配置尚未缓存时用默认
  338. }
  339. if h <= 0 {
  340. h = overlayHeight
  341. }
  342. mouseAlert.mu.Unlock()
  343. overlay, _, _ := procCreateWindowEx.Call(
  344. style,
  345. uintptr(unsafe.Pointer(className)),
  346. 0,
  347. uintptr(0x80000000) /* WS_POPUP */, 0, 0, uintptr(w), uintptr(h), 0,
  348. 0, 0, hInst, 0,
  349. )
  350. if overlay == 0 {
  351. fmt.Fprintf(os.Stderr, "[mousealert] CreateWindowEx failed\n")
  352. }
  353. mouseAlert.mu.Lock()
  354. mouseAlert.overlayWnd = overlay
  355. if overlay != 0 {
  356. // 背景透明(LWA_COLORKEY):把背景色 bgColorRef 的像素抠成透明,只留白色
  357. // 文字。WM_PAINT 用普通 GDI 绘制气泡(背景+文字)。
  358. // 注意:不要用 UpdateLayeredWindow——它会接管分层窗口内容,导致
  359. // WM_PAINT 完全失效(这正是当前「完全不显示」的根因)。
  360. bg := mouseAlert.cfgBgColor
  361. if bg == 0 {
  362. bg = bgColorRef
  363. }
  364. brush, _, _ := procCreateSolidBrush.Call(bg)
  365. mouseAlert.bgBrush = brush
  366. // LWA_COLORKEY:crKey=背景色,bAlpha=0(忽略),dwFlags=LWA_COLORKEY
  367. procSetLayeredWindowAttr.Call(overlay, bg, 0, uintptr(lwaColorKey))
  368. }
  369. mouseAlert.mu.Unlock()
  370. // 启动跟随/闪烁定时器(窗口线程内轮询鼠标位置)
  371. if overlay != 0 {
  372. procSetTimer.Call(overlay, timerFollow, followIntervalMs, 0)
  373. // 建窗后立即用当前鼠标位置定位并重绘一次,保证首次触发即可见
  374. var pt point
  375. curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
  376. if curRet != 0 {
  377. mouseAlert.mu.Lock()
  378. if mouseAlert.active {
  379. mouseAlert.lastX, mouseAlert.lastY = pt.X, pt.Y
  380. mouseAlert.mu.Unlock()
  381. mouseAlert.positionAndDraw(overlay, pt.X, pt.Y)
  382. } else {
  383. mouseAlert.mu.Unlock()
  384. }
  385. }
  386. }
  387. // 消息循环(常驻,直到线程退出)
  388. var m msg
  389. for {
  390. ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
  391. if ret == 0 {
  392. break // WM_QUIT
  393. }
  394. procTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
  395. procDispatchMessage.Call(uintptr(unsafe.Pointer(&m)))
  396. }
  397. // 清理
  398. if overlay != 0 {
  399. procKillTimer.Call(overlay, timerFollow)
  400. procDestroyWindow.Call(overlay)
  401. }
  402. mouseAlert.mu.Lock()
  403. if mouseAlert.bgBrush != 0 {
  404. procDeleteObject.Call(mouseAlert.bgBrush)
  405. mouseAlert.bgBrush = 0
  406. }
  407. mouseAlert.overlayWnd = 0
  408. mouseAlert.mu.Unlock()
  409. }
  410. // wndProcAddr 返回覆盖层窗口过程地址(保持 Go 函数引用,防 GC)
  411. func (s *mouseAlertState) wndProcAddr() uintptr {
  412. if s.wndProc != 0 {
  413. return s.wndProc
  414. }
  415. s.wndProc = syscall.NewCallback(s.wndProcCallback)
  416. return s.wndProc
  417. }
  418. // wndProcCallback 气泡窗口过程:处理跟随/闪烁定时器、自定义刷新、绘制与销毁。
  419. // 此回调由 DispatchMessageW 在消息循环线程同步调用,是 Go runtime 支持的安全路径。
  420. func (s *mouseAlertState) wndProcCallback(hwnd uintptr, msg uint32, wParam uintptr, lParam uintptr) uintptr {
  421. switch msg {
  422. case wmRefresh, wmTimer:
  423. if msg == wmRefresh || uintptr(wParam) == timerFollow {
  424. // 主动用 GetCursorPos 读取当前鼠标位置(窗口线程,安全)
  425. var pt point
  426. curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
  427. s.mu.Lock()
  428. if !s.active {
  429. s.mu.Unlock()
  430. procShowWindow.Call(hwnd, 0) // SW_HIDE
  431. return 0
  432. }
  433. if curRet != 0 {
  434. s.lastX, s.lastY = pt.X, pt.Y
  435. }
  436. // 到期判断
  437. if !s.stopTime.IsZero() && time.Now().After(s.stopTime) {
  438. s.active = false
  439. s.mu.Unlock()
  440. procShowWindow.Call(hwnd, 0) // SW_HIDE
  441. return 0
  442. }
  443. s.flashOn = !s.flashOn
  444. // 超长文字滚动:文字宽度超过可用区时,按配置速度推进偏移,循环滚动
  445. w := s.cfgWidth
  446. if w <= 0 {
  447. w = overlayWidth
  448. }
  449. if s.textWidth > w-24 {
  450. step := int32(s.cfgSpeed * 60 / 1000) // 每 60ms 帧推进(px)
  451. if step < 1 {
  452. step = 1
  453. }
  454. s.scrollX += step
  455. // 头尾相联无缝循环:滚动一个完整周期(文字宽 + 间隔)后归零,
  456. // 下一份正好接上,形成连续跑马灯。
  457. cycle := s.textWidth + s.textGap
  458. if cycle <= 0 {
  459. cycle = s.textWidth + 40
  460. }
  461. if s.scrollX > cycle {
  462. s.scrollX = 0
  463. }
  464. } else {
  465. s.scrollX = 0
  466. }
  467. x, y := s.lastX, s.lastY
  468. s.mu.Unlock()
  469. s.positionAndDraw(hwnd, x, y)
  470. return 0
  471. }
  472. return 0
  473. case wmPaint:
  474. // WM_PAINT 用普通 GDI 绘制气泡(背景 + 文字)。分层窗口已设 LWA_COLORKEY
  475. // 抠透明,此处只画内容,不调用 UpdateLayeredWindow(会接管窗口)。
  476. // 双缓冲:先在内存 DC(兼容位图)上合成背景+文字,再一次性 BitBlt 上屏,
  477. // 避免多遍描边 + 正文逐笔直接画到屏幕导致滚动时闪烁。
  478. var ps paintstruct
  479. hdc, _, _ := procBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
  480. if hdc != 0 {
  481. s.mu.Lock()
  482. fw, fh := s.cfgWidth, s.cfgHeight
  483. s.mu.Unlock()
  484. if fw <= 0 {
  485. fw = overlayWidth
  486. }
  487. if fh <= 0 {
  488. fh = overlayHeight
  489. }
  490. // 创建内存 DC + 兼容位图作为双缓冲画布
  491. memDC, _, _ := procCreateCompatibleDC.Call(hdc)
  492. memBmp, _, _ := procCreateCompatibleBitmap.Call(hdc, uintptr(fw), uintptr(fh))
  493. oldBmp, _, _ := procSelectObject.Call(memDC, memBmp)
  494. if memDC != 0 && memBmp != 0 {
  495. // 背景:填满整个客户区(LWA_COLORKEY 把该色抠成透明,只留文字)
  496. var full rect
  497. full.Left = 0
  498. full.Top = 0
  499. full.Right = fw
  500. full.Bottom = fh
  501. if s.bgBrush != 0 {
  502. procFillRect.Call(memDC, uintptr(unsafe.Pointer(&full)), s.bgBrush)
  503. }
  504. // 文字:在内存 DC 上绘制(TRANSPARENT 背景模式,不覆盖气泡背景)
  505. s.paintText(memDC)
  506. // 一次性把合成结果拷贝上屏,避免逐笔绘制的闪烁
  507. procBitBlt.Call(hdc, 0, 0, uintptr(fw), uintptr(fh), memDC, 0, 0, 0x00CC0020 /* SRCCOPY */)
  508. }
  509. // 清理双缓冲对象
  510. if memDC != 0 {
  511. procSelectObject.Call(memDC, oldBmp)
  512. if memBmp != 0 {
  513. procDeleteObject.Call(memBmp)
  514. }
  515. procDeleteDC.Call(memDC)
  516. }
  517. procEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
  518. }
  519. return 0
  520. case wmDestroy, wmNcDestroy:
  521. procKillTimer.Call(hwnd, timerFollow)
  522. return 0
  523. }
  524. ret, _, _ := procDefWindowProc.Call(hwnd, uintptr(msg), wParam, lParam)
  525. return ret
  526. }
  527. // positionAndDraw 把气泡窗口定位到鼠标旁,设置整体 alpha,并触发 WM_PAINT 重绘。
  528. // 窗口线程内调用。绘制本身由 WM_PAINT(普通 GDI)完成。
  529. // 不闪烁:固定 alpha=235(此前 60ms 翻转 235↔150 导致闪烁严重,已去掉)。
  530. func (s *mouseAlertState) positionAndDraw(overlay uintptr, x, y int32) {
  531. if overlay == 0 {
  532. return
  533. }
  534. s.mu.Lock()
  535. w, h := s.cfgWidth, s.cfgHeight
  536. if w <= 0 {
  537. w = overlayWidth
  538. }
  539. if h <= 0 {
  540. h = overlayHeight
  541. }
  542. s.mu.Unlock()
  543. // 气泡左上角位于鼠标右下偏移,避免遮挡光标
  544. left := x + 20
  545. top := y + 24
  546. // 背景透明(LWA_COLORKEY)只在创建窗口时设置一次,避免每帧重复设置导致闪烁。
  547. // 定位 + 显示(不激活)
  548. procSetWindowPos.Call(overlay, hwndTopMost,
  549. uintptr(int32(left)), uintptr(int32(top)),
  550. uintptr(w), uintptr(h), swpNoActivate|swpShowWindow)
  551. // 触发重绘(WM_PAINT 绘制背景 + 文字)
  552. procInvalidateRect.Call(overlay, 0, 0)
  553. }
  554. // paintText 在指定 HDC 上绘制白色提示文字(WM_PAINT 内调用,窗口线程)。
  555. // 用 DrawTextW + 系统字体 + TRANSPARENT 背景模式:只画字形、不填充背景,
  556. // 这样不会覆盖 FillRect 画的气泡背景(此前 OPAQUE 模式导致文字区被填成黑色
  557. // 背景而不可见)。
  558. func (s *mouseAlertState) paintText(hdc uintptr) {
  559. s.mu.Lock()
  560. text := s.text
  561. scrollX := s.scrollX
  562. fontSize := s.cfgFontSize
  563. textColor := s.cfgTextColor
  564. outlineColor := s.cfgOutlineColor
  565. outlineWidth := s.cfgOutlineWidth
  566. width := s.cfgWidth
  567. s.mu.Unlock()
  568. if text == "" || hdc == 0 {
  569. return
  570. }
  571. if fontSize <= 0 {
  572. fontSize = DefaultMouseAlertFontSize // 默认 20px
  573. }
  574. if textColor == 0 {
  575. textColor = 0x00FFFFFF // 默认白字
  576. }
  577. if outlineColor == 0 {
  578. outlineColor = 0x00000000 // 默认黑边
  579. }
  580. if width <= 0 {
  581. width = overlayWidth
  582. }
  583. // TRANSPARENT:只画字形,不填背景(保留气泡背景)
  584. procSetBkMode.Call(hdc, 1)
  585. procSetTextColor.Call(hdc, textColor)
  586. // 大字体:CreateFontW 按配置字号创建清晰字体
  587. // 参数:h=-fontSize(px), w=0, esc=0, orient=0, weight=FW_NORMAL(400), italic=0,
  588. // underline=0, strikeout=0, charset=DEFAULT_CHARSET(1), outprec=0, clipprec=0,
  589. // quality=ANTIALIASED(4), pitch=0, face=微软雅黑
  590. facePtr, _ := syscall.UTF16PtrFromString("Microsoft YaHei")
  591. var fontH int32 = -fontSize // 负值=像素高度(字号 px),用变量避免常量溢出
  592. hFont, _, _ := procCreateFontW.Call(
  593. uintptr(fontH), 0, 0, 0, 400, 0, 0, 0,
  594. 1 /* DEFAULT_CHARSET */, 0, 0, 4 /* ANTIALIASED */, 0,
  595. uintptr(unsafe.Pointer(facePtr)),
  596. )
  597. if hFont == 0 {
  598. // 回退系统字体
  599. hFont, _, _ = procGetStockObject.Call(17) // DEFAULT_GUI_FONT
  600. }
  601. if hFont != 0 {
  602. procSelectObject.Call(hdc, hFont)
  603. }
  604. // 测量文字像素宽度(选中字体下),判断是否需要滚动
  605. textPtr, _ := syscall.UTF16PtrFromString(text)
  606. var ext size
  607. procGetTextExtentPoint32W.Call(hdc, uintptr(unsafe.Pointer(textPtr)),
  608. uintptr(len([]rune(text))), uintptr(unsafe.Pointer(&ext)))
  609. textW := ext.Cx
  610. availW := width - 24 // 左右各 12px 边距
  611. s.mu.Lock()
  612. s.textWidth = textW
  613. s.mu.Unlock()
  614. // 文字垂直居中:气泡高度 = 字号 + 34,上下留白各约 (34-字号)/2 + 基线偏移
  615. baseY := (fontSize + 10) / 2 // 字形基线 y 坐标(垂直居中)
  616. // 单行绘制(TextOutW)+ 超长时按 scrollX 偏移。文字从左边距开始画,
  617. // 滚动时整体向左平移(x - scrollX),实现文字从右向左循环滚动。
  618. // 文字超长时先画两遍(偏移 和 偏移+周期)实现头尾相联无缝循环。
  619. //
  620. // 描边反差:为避免文字在任意背景(亮/暗)上看不清,先用描边色把文字
  621. // 向 8 个方向各偏移 outlineWidth 各画一遍形成外描边(stroke),最后用
  622. // 正文色在中心画一遍(fill),构成带色边的文字,形成颜色反差。
  623. textLen := uintptr(len([]rune(text)))
  624. leftX := int32(12) - scrollX
  625. if textW > availW {
  626. // 头尾相联:第二份从「文字头 + 文字宽 + 间隔」开始画,间隔为两个空格
  627. s.mu.Lock()
  628. gap := s.textGap
  629. s.mu.Unlock()
  630. if gap < 4 {
  631. gap = 4
  632. }
  633. // 第一遍:当前偏移处(文字头)
  634. s.drawOutlinedText(hdc, textPtr, textLen, leftX, baseY, outlineColor, outlineWidth, textColor)
  635. // 第二遍:文字头 + 文字宽 + 间隔,实现头尾相联无缝循环
  636. s.drawOutlinedText(hdc, textPtr, textLen, leftX+textW+gap, baseY, outlineColor, outlineWidth, textColor)
  637. } else {
  638. // 未超长:左对齐单行
  639. s.drawOutlinedText(hdc, textPtr, textLen, 12, baseY, outlineColor, outlineWidth, textColor)
  640. }
  641. }
  642. // drawOutlinedText 绘制带描边的文字:先用 outlineColor 在 8 方向各偏移
  643. // outlineWidth 描一圈形成外轮廓,再在中心用 textColor 画正文字形。
  644. // outlineWidth<=0 时不描边,直接画正文(等同旧行为)。
  645. func (s *mouseAlertState) drawOutlinedText(hdc uintptr, textPtr *uint16, textLen uintptr, x, y int32, outlineColor uintptr, outlineWidth int32, textColor uintptr) {
  646. if textLen == 0 {
  647. return
  648. }
  649. draw := func(dx, dy int32, color uintptr) {
  650. procSetTextColor.Call(hdc, color)
  651. procTextOutW.Call(hdc, uintptr(x+dx), uintptr(y+dy), uintptr(unsafe.Pointer(textPtr)), textLen)
  652. }
  653. if outlineWidth > 0 {
  654. // 8 方向外描边(每像素偏移),形成环绕轮廓,避免不同背景下文字难辨认
  655. for dy := -outlineWidth; dy <= outlineWidth; dy++ {
  656. for dx := -outlineWidth; dx <= outlineWidth; dx++ {
  657. if dx == 0 && dy == 0 {
  658. continue
  659. }
  660. draw(dx, dy, outlineColor)
  661. }
  662. }
  663. }
  664. // 中心正文字形
  665. draw(0, 0, textColor)
  666. }
  667. // renderBubbleToDib 已移除:回退到 WM_PAINT 普通 GDI 绘制方案(见 wmPaint/paintText)。
  668. // 分层窗口用 LWA_ALPHA 整体半透明,不再用 DIB + UpdateLayeredWindow。