//go:build windows // ============================================================================ // 系统级鼠标提醒(Windows 原生实现)—— 可靠方案 v2 // // 需求:平时鼠标是正常样式;有消息来(通过 API 触发)时,鼠标旁边浮出 // 半透明气泡提示文字,跟随鼠标移动、闪烁,可覆盖任意窗口(系统级), // 默认 8 秒自动消退。 // // v1 的失败原因:全局低层鼠标钩子 WH_MOUSE_LL 的回调由系统在非 Go 线程 // 上调用,经 syscall.NewCallback 进入 Go runtime 时会导致进程静默退出; // 且 DIB 自绘 + UpdateLayeredWindow 尺寸不匹配时绘制失效。v2 全部移除: // // 1. 不装任何钩子(去掉唯一危险源),位置跟随改为窗口线程定时器轮询 // GetCursorPos(GetMessage 消息循环所在线程,安全)。 // 2. 用 SetLayeredWindowAttributes(LWA_COLORKEY) 把背景色抠成透明(背景透明, // 只留白色文字),WM_PAINT 用普通 GDI(FillRect + TextOutW)绘制气泡, // 不再用 DIB/UpdateLayeredWindow。 // 3. 窗口过程 WndProc 的 syscall.NewCallback 由 DispatchMessageW 在同线程 // 同步调用,Go runtime 完全支持(安全)。 // 4. 超长文字:TextOutW 单行绘制 + 定时器推进 scrollX 偏移,实现水平循环滚动。 // // 说明:本文件仅在 Windows 编译。其他平台见 mousealert_other.go 空实现。 // ============================================================================ package main import ( "fmt" "os" "runtime" "strings" "sync" "syscall" "time" "unsafe" ) // Windows API 常量 const ( wmTimer = 0x0113 wmDestroy = 0x0002 wmNcDestroy = 0x0082 wmPaint = 0x000F wmRefresh = 0x8001 // 自定义:立即刷新(trigger 唤醒窗口线程) wndExLayered = 0x00080000 // WS_EX_LAYERED 分层窗口(支持透明) wndExTool = 0x00000080 // WS_EX_TOOLWINDOW 不占任务栏 wndExTopMost = 0x00000008 // WS_EX_TOPMOST 置顶 wndExNoActivate = 0x08000000 // WS_EX_NOACTIVATE 不抢焦点 swpNoActivate = 0x0010 // SWP_NOACTIVATE swpShowWindow = 0x0040 // SWP_SHOWWINDOW lwaAlpha = 0x2 // LWA_ALPHA:SetLayeredWindowAttributes 设置整体 alpha lwaColorKey = 0x1 // LWA_COLORKEY:把指定颜色像素抠成透明(背景透明) timerFollow = 1 // 位置跟随 + 闪烁定时器 ID overlayWidth = 200 overlayHeight = 54 alertDuration = 8 * time.Second followIntervalMs = 60 // 跟随/闪烁轮询间隔 60ms ) // hwndTopMost HWND_TOPMOST = (HWND)-1 const hwndTopMost uintptr = ^uintptr(0) // 气泡背景色(深蓝黑):COLORREF = 0x00BBGGRR(LWA_COLORKEY 抠成透明,只留文字) const bgColorRef = 0x001E1E3C // cfgDurationOrDefault 返回配置的显示时长(秒),非法时用默认 8 秒 func cfgDurationOrDefault(sec int) time.Duration { if sec < 2 || sec > 60 { sec = 8 } return time.Duration(sec) * time.Second } // estimateTextWidth 估算文字像素宽度(微软雅黑:全角/中文≈字号,半角/ASCII≈0.55*字号)。 // 用于在触发时预判文字是否超长,从而智能延长显示时长。 func estimateTextWidth(text string, fontSize int32) int32 { if text == "" || fontSize <= 0 { return 0 } var w float32 for _, r := range text { if r < 0x2E80 { // 半角 / ASCII / 拉丁 w += 0.55 * float32(fontSize) } else { // 全角 / 中日韩 w += float32(fontSize) } } return int32(w) } // hexColorToRef 把 #RRGGBB 或 #RGB 颜色字符串转 COLORREF(0x00BBGGRR)。 // 解析失败时回退 fallback。 func hexColorToRef(s string, fallback uintptr) uintptr { s = strings.TrimSpace(s) if len(s) == 7 && s[0] == '#' { var r, g, b uint32 if _, err := fmt.Sscanf(s, "#%02x%02x%02x", &r, &g, &b); err == nil { return uintptr(r | g<<8 | b<<16) } } if len(s) == 4 && s[0] == '#' { var r, g, b uint32 if _, err := fmt.Sscanf(s, "#%1x%1x%1x", &r, &g, &b); err == nil { return uintptr(r*17 | g*17<<8 | b*17<<16) } } return fallback } // ------------------------------------------------------------------------ // Windows 结构体与函数指针声明(用 syscall 直接调用,避免引入额外依赖) // ------------------------------------------------------------------------ type point struct { X int32 Y int32 } // rect Windows RECT 结构(GetWindowRect / FillRect 输出) type rect struct { Left int32 Top int32 Right int32 Bottom int32 } type msg struct { HWND uintptr Message uint32 WParam uintptr LParam uintptr Time uint32 Pt point LPrivate uint32 } type wndclassex struct { CbSize uint32 Style uint32 LpfnWndProc uintptr CbClsExtra int32 CbWndExtra int32 HInstance uintptr HIcon uintptr HCursor uintptr HbrBackground uintptr LpszMenuName *uint16 LpszClassName *uint16 HIconSm uintptr } // paintstruct PAINTSTRUCT(BeginPaint / EndPaint 输出) type paintstruct struct { Hdc uintptr FErase int32 RcPaint rect FRestore int32 FIncUpdate int32 RGBReserved [32]byte } // size SIZE 结构(GetTextExtentPoint32W 输出:文字像素尺寸) type size struct { Cx int32 Cy int32 } // 延迟加载的 user32.dll / gdi32.dll / kernel32.dll 函数 var ( user32 = syscall.NewLazyDLL("user32.dll") gdi32 = syscall.NewLazyDLL("gdi32.dll") kernel32 = syscall.NewLazyDLL("kernel32.dll") procGetMessage = user32.NewProc("GetMessageW") procTranslateMessage = user32.NewProc("TranslateMessage") procDispatchMessage = user32.NewProc("DispatchMessageW") procRegisterClassEx = user32.NewProc("RegisterClassExW") procCreateWindowEx = user32.NewProc("CreateWindowExW") procDefWindowProc = user32.NewProc("DefWindowProcW") procDestroyWindow = user32.NewProc("DestroyWindow") procSetTimer = user32.NewProc("SetTimer") procKillTimer = user32.NewProc("KillTimer") procShowWindow = user32.NewProc("ShowWindow") procSetWindowPos = user32.NewProc("SetWindowPos") procGetCursorPos = user32.NewProc("GetCursorPos") procPostMessage = user32.NewProc("PostMessageW") procSetLayeredWindowAttr = user32.NewProc("SetLayeredWindowAttributes") procBeginPaint = user32.NewProc("BeginPaint") procEndPaint = user32.NewProc("EndPaint") procInvalidateRect = user32.NewProc("InvalidateRect") procFillRect = user32.NewProc("FillRect") // 注意:FillRect 在 user32.dll,不在 gdi32.dll procDrawTextW = user32.NewProc("DrawTextW") procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush") procDeleteObject = gdi32.NewProc("DeleteObject") procSetBkMode = gdi32.NewProc("SetBkMode") procSetTextColor = gdi32.NewProc("SetTextColor") procGetStockObject = gdi32.NewProc("GetStockObject") procSelectObject = gdi32.NewProc("SelectObject") procCreateFontW = gdi32.NewProc("CreateFontW") procTextOutW = gdi32.NewProc("TextOutW") // 单行文字输出(超长滚动用) procGetTextExtentPoint32W = gdi32.NewProc("GetTextExtentPoint32W") // 测量文字宽度(判断是否超长) procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC") // 双缓冲:创建内存 DC procCreateCompatibleBitmap = gdi32.NewProc("CreateCompatibleBitmap") // 双缓冲:创建兼容位图 procBitBlt = gdi32.NewProc("BitBlt") // 双缓冲:整块拷贝上屏 procDeleteDC = gdi32.NewProc("DeleteDC") // 双缓冲:删除内存 DC procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") ) // ------------------------------------------------------------------------ // 鼠标提醒运行时状态 // ------------------------------------------------------------------------ // mouseAlertState 鼠标提醒的运行时状态(单实例) type mouseAlertState struct { mu sync.Mutex started bool // 工作线程是否已启动(持久化,仅启动一次) overlayWnd uintptr active bool text string flashOn bool lastX, lastY int32 stopTime time.Time wndProc uintptr classOK bool bgBrush uintptr scrollX int32 // 超长文字水平滚动偏移(px),定时器推进 textWidth int32 // 当前文字像素宽度(>可用宽度时滚动) textGap int32 // 头尾相联间隔(两个空格宽度 px),滚动周期 = 文字宽 + 间隔 // 运行时配置(triggerMouseAlert 时从设置加载) cfgWidth int32 // 气泡窗口宽度(px) cfgHeight int32 // 气泡窗口高度(px,按字号自适应) cfgFontSize int32 // 字号(px) cfgSpeed int // 超长文字滚动速度(px/s) cfgDuration time.Duration // 显示时长 cfgTextColor uintptr // 文字颜色(COLORREF) cfgBgColor uintptr // 气泡背景色(COLORKEY 抠透明) cfgOutlineColor uintptr // 文字描边颜色(COLORREF),形成反差 cfgOutlineWidth int32 // 文字描边宽度(px),0 关闭 } var mouseAlert = &mouseAlertState{} // triggerMouseAlert 触发鼠标提醒:鼠标旁浮出半透明气泡提示,跟随鼠标闪烁。 // 文字为空时用默认提示。重复触发会刷新提醒计时并立即显示。 // // 定位策略:窗口线程的定时器主动用 GetCursorPos 读取当前鼠标位置并跟随, // 不依赖任何全局钩子回调(钩子回调在系统线程执行,曾导致进程退出)。 func triggerMouseAlert(text string) { if text == "" { text = "有新消息提醒" } // 从设置加载鼠标提醒配置(字号/宽度/速度/颜色/时长) cfg := loadSettings().MouseAlert var pt point curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt))) hasCursor := curRet != 0 // 智能延长显示时长:文字超长(需滚动)时,追加滚动一圈所需时间,保证能看完 cfgWidth := int32(cfg.Width) if cfgWidth <= 0 { cfgWidth = overlayWidth } cfgFontSize := int32(cfg.FontSize) if cfgFontSize <= 0 { cfgFontSize = DefaultMouseAlertFontSize } estW := estimateTextWidth(text, cfgFontSize) baseDur := cfgDurationOrDefault(cfg.Duration) totalDur := baseDur if estW > cfgWidth-24 && cfg.Speed > 0 { // 超出可用宽度的部分需滚动,所需时间 = 超出像素 / 速度 extra := estW - (cfgWidth - 24) need := time.Duration(float64(extra) / float64(cfg.Speed) * float64(time.Second)) totalDur = baseDur + need } mouseAlert.mu.Lock() mouseAlert.text = text mouseAlert.active = true mouseAlert.flashOn = true mouseAlert.stopTime = time.Now().Add(totalDur) mouseAlert.scrollX = 0 // 新消息从头开始滚动 mouseAlert.textWidth = 0 // 强制重新测量 // 缓存本次提醒的显示配置 mouseAlert.cfgWidth = cfgWidth mouseAlert.cfgFontSize = cfgFontSize mouseAlert.cfgSpeed = cfg.Speed mouseAlert.cfgDuration = totalDur mouseAlert.cfgTextColor = hexColorToRef(cfg.Color, 0x00FFFFFF) // 默认白字 mouseAlert.cfgBgColor = bgColorRef mouseAlert.cfgOutlineColor = hexColorToRef(cfg.OutlineColor, 0x00000000) // 默认黑边 mouseAlert.cfgOutlineWidth = int32(cfg.OutlineWidth) // 头尾相联间隔 = 两个空格宽度(约 0.3*字号 每个),提前缓存供滚动周期使用 mouseAlert.textGap = int32(float32(cfgFontSize) * 0.3 * 2) if mouseAlert.textGap < 4 { mouseAlert.textGap = 4 } // 气泡高度按字号自适应:字号 + 上下留白 + 内边距 mouseAlert.cfgHeight = mouseAlert.cfgFontSize + 34 if hasCursor { mouseAlert.lastX, mouseAlert.lastY = pt.X, pt.Y } started := mouseAlert.started mouseAlert.started = true overlay := mouseAlert.overlayWnd mouseAlert.mu.Unlock() // 首次触发:启动常驻工作线程(幂等),建窗后由线程主动定位并重绘 if !started { go mouseAlertLoop() return } // 已启动:唤醒窗口线程立即刷新(窗口线程负责 SetWindowPos/绘制) if overlay != 0 { procPostMessage.Call(overlay, wmRefresh, 0, 0) } } // stopMouseAlert 立即停止鼠标提醒(隐藏气泡;线程保持常驻) func stopMouseAlert() { mouseAlert.mu.Lock() mouseAlert.active = false overlay := mouseAlert.overlayWnd mouseAlert.mu.Unlock() if overlay != 0 { procShowWindow.Call(overlay, 0) // SW_HIDE } } // mouseAlertLoop 常驻工作线程:注册窗口类、创建半透明置顶气泡窗口、 // 启动跟随/闪烁定时器,进入消息循环。 func mouseAlertLoop() { // 关键:Go 的 goroutine 会在 OS 线程间迁移。窗口消息循环(CreateWindowEx + // GetMessage + DispatchMessage)必须固定在同一个 OS 线程上,否则 WM_TIMER / // WM_PAINT 等消息投递到创建窗口的线程,而 GetMessage 在另一线程永远收不到, // 导致窗口出现后卡死(不跟随鼠标、不重绘、无文字)。LockOSThread 锁定本 // goroutine 到当前 OS 线程,保证消息循环与窗口同线程。 runtime.LockOSThread() hInst, _, _ := procGetModuleHandleW.Call(0) mouseAlert.mu.Lock() className, _ := syscall.UTF16PtrFromString("TengTuMouseAlertBubble") wc := wndclassex{ CbSize: uint32(unsafe.Sizeof(wndclassex{})), LpfnWndProc: mouseAlert.wndProcAddr(), HInstance: hInst, LpszClassName: className, HCursor: 0, } atom, _, _ := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc))) if atom != 0 { mouseAlert.classOK = true } else { fmt.Fprintf(os.Stderr, "[mousealert] RegisterClassEx failed (classOK=%v)\n", mouseAlert.classOK) } mouseAlert.mu.Unlock() // 创建半透明置顶气泡窗口(不抢焦点、不占任务栏) // dwStyle 必须含 WS_POPUP(0x80000000):无边框无标题的弹出窗口, // 否则 CreateWindowEx 可能失败或窗口不可见。 style := uintptr(wndExLayered | wndExTool | wndExTopMost | wndExNoActivate) mouseAlert.mu.Lock() w, h := mouseAlert.cfgWidth, mouseAlert.cfgHeight if w <= 0 { w = overlayWidth // 首次触发配置尚未缓存时用默认 } if h <= 0 { h = overlayHeight } mouseAlert.mu.Unlock() overlay, _, _ := procCreateWindowEx.Call( style, uintptr(unsafe.Pointer(className)), 0, uintptr(0x80000000) /* WS_POPUP */, 0, 0, uintptr(w), uintptr(h), 0, 0, 0, hInst, 0, ) if overlay == 0 { fmt.Fprintf(os.Stderr, "[mousealert] CreateWindowEx failed\n") } mouseAlert.mu.Lock() mouseAlert.overlayWnd = overlay if overlay != 0 { // 背景透明(LWA_COLORKEY):把背景色 bgColorRef 的像素抠成透明,只留白色 // 文字。WM_PAINT 用普通 GDI 绘制气泡(背景+文字)。 // 注意:不要用 UpdateLayeredWindow——它会接管分层窗口内容,导致 // WM_PAINT 完全失效(这正是当前「完全不显示」的根因)。 bg := mouseAlert.cfgBgColor if bg == 0 { bg = bgColorRef } brush, _, _ := procCreateSolidBrush.Call(bg) mouseAlert.bgBrush = brush // LWA_COLORKEY:crKey=背景色,bAlpha=0(忽略),dwFlags=LWA_COLORKEY procSetLayeredWindowAttr.Call(overlay, bg, 0, uintptr(lwaColorKey)) } mouseAlert.mu.Unlock() // 启动跟随/闪烁定时器(窗口线程内轮询鼠标位置) if overlay != 0 { procSetTimer.Call(overlay, timerFollow, followIntervalMs, 0) // 建窗后立即用当前鼠标位置定位并重绘一次,保证首次触发即可见 var pt point curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt))) if curRet != 0 { mouseAlert.mu.Lock() if mouseAlert.active { mouseAlert.lastX, mouseAlert.lastY = pt.X, pt.Y mouseAlert.mu.Unlock() mouseAlert.positionAndDraw(overlay, pt.X, pt.Y) } else { mouseAlert.mu.Unlock() } } } // 消息循环(常驻,直到线程退出) var m msg for { ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) if ret == 0 { break // WM_QUIT } procTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) procDispatchMessage.Call(uintptr(unsafe.Pointer(&m))) } // 清理 if overlay != 0 { procKillTimer.Call(overlay, timerFollow) procDestroyWindow.Call(overlay) } mouseAlert.mu.Lock() if mouseAlert.bgBrush != 0 { procDeleteObject.Call(mouseAlert.bgBrush) mouseAlert.bgBrush = 0 } mouseAlert.overlayWnd = 0 mouseAlert.mu.Unlock() } // wndProcAddr 返回覆盖层窗口过程地址(保持 Go 函数引用,防 GC) func (s *mouseAlertState) wndProcAddr() uintptr { if s.wndProc != 0 { return s.wndProc } s.wndProc = syscall.NewCallback(s.wndProcCallback) return s.wndProc } // wndProcCallback 气泡窗口过程:处理跟随/闪烁定时器、自定义刷新、绘制与销毁。 // 此回调由 DispatchMessageW 在消息循环线程同步调用,是 Go runtime 支持的安全路径。 func (s *mouseAlertState) wndProcCallback(hwnd uintptr, msg uint32, wParam uintptr, lParam uintptr) uintptr { switch msg { case wmRefresh, wmTimer: if msg == wmRefresh || uintptr(wParam) == timerFollow { // 主动用 GetCursorPos 读取当前鼠标位置(窗口线程,安全) var pt point curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt))) s.mu.Lock() if !s.active { s.mu.Unlock() procShowWindow.Call(hwnd, 0) // SW_HIDE return 0 } if curRet != 0 { s.lastX, s.lastY = pt.X, pt.Y } // 到期判断 if !s.stopTime.IsZero() && time.Now().After(s.stopTime) { s.active = false s.mu.Unlock() procShowWindow.Call(hwnd, 0) // SW_HIDE return 0 } s.flashOn = !s.flashOn // 超长文字滚动:文字宽度超过可用区时,按配置速度推进偏移,循环滚动 w := s.cfgWidth if w <= 0 { w = overlayWidth } if s.textWidth > w-24 { step := int32(s.cfgSpeed * 60 / 1000) // 每 60ms 帧推进(px) if step < 1 { step = 1 } s.scrollX += step // 头尾相联无缝循环:滚动一个完整周期(文字宽 + 间隔)后归零, // 下一份正好接上,形成连续跑马灯。 cycle := s.textWidth + s.textGap if cycle <= 0 { cycle = s.textWidth + 40 } if s.scrollX > cycle { s.scrollX = 0 } } else { s.scrollX = 0 } x, y := s.lastX, s.lastY s.mu.Unlock() s.positionAndDraw(hwnd, x, y) return 0 } return 0 case wmPaint: // WM_PAINT 用普通 GDI 绘制气泡(背景 + 文字)。分层窗口已设 LWA_COLORKEY // 抠透明,此处只画内容,不调用 UpdateLayeredWindow(会接管窗口)。 // 双缓冲:先在内存 DC(兼容位图)上合成背景+文字,再一次性 BitBlt 上屏, // 避免多遍描边 + 正文逐笔直接画到屏幕导致滚动时闪烁。 var ps paintstruct hdc, _, _ := procBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) if hdc != 0 { s.mu.Lock() fw, fh := s.cfgWidth, s.cfgHeight s.mu.Unlock() if fw <= 0 { fw = overlayWidth } if fh <= 0 { fh = overlayHeight } // 创建内存 DC + 兼容位图作为双缓冲画布 memDC, _, _ := procCreateCompatibleDC.Call(hdc) memBmp, _, _ := procCreateCompatibleBitmap.Call(hdc, uintptr(fw), uintptr(fh)) oldBmp, _, _ := procSelectObject.Call(memDC, memBmp) if memDC != 0 && memBmp != 0 { // 背景:填满整个客户区(LWA_COLORKEY 把该色抠成透明,只留文字) var full rect full.Left = 0 full.Top = 0 full.Right = fw full.Bottom = fh if s.bgBrush != 0 { procFillRect.Call(memDC, uintptr(unsafe.Pointer(&full)), s.bgBrush) } // 文字:在内存 DC 上绘制(TRANSPARENT 背景模式,不覆盖气泡背景) s.paintText(memDC) // 一次性把合成结果拷贝上屏,避免逐笔绘制的闪烁 procBitBlt.Call(hdc, 0, 0, uintptr(fw), uintptr(fh), memDC, 0, 0, 0x00CC0020 /* SRCCOPY */) } // 清理双缓冲对象 if memDC != 0 { procSelectObject.Call(memDC, oldBmp) if memBmp != 0 { procDeleteObject.Call(memBmp) } procDeleteDC.Call(memDC) } procEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) } return 0 case wmDestroy, wmNcDestroy: procKillTimer.Call(hwnd, timerFollow) return 0 } ret, _, _ := procDefWindowProc.Call(hwnd, uintptr(msg), wParam, lParam) return ret } // positionAndDraw 把气泡窗口定位到鼠标旁,设置整体 alpha,并触发 WM_PAINT 重绘。 // 窗口线程内调用。绘制本身由 WM_PAINT(普通 GDI)完成。 // 不闪烁:固定 alpha=235(此前 60ms 翻转 235↔150 导致闪烁严重,已去掉)。 func (s *mouseAlertState) positionAndDraw(overlay uintptr, x, y int32) { if overlay == 0 { return } s.mu.Lock() w, h := s.cfgWidth, s.cfgHeight if w <= 0 { w = overlayWidth } if h <= 0 { h = overlayHeight } s.mu.Unlock() // 气泡左上角位于鼠标右下偏移,避免遮挡光标 left := x + 20 top := y + 24 // 背景透明(LWA_COLORKEY)只在创建窗口时设置一次,避免每帧重复设置导致闪烁。 // 定位 + 显示(不激活) procSetWindowPos.Call(overlay, hwndTopMost, uintptr(int32(left)), uintptr(int32(top)), uintptr(w), uintptr(h), swpNoActivate|swpShowWindow) // 触发重绘(WM_PAINT 绘制背景 + 文字) procInvalidateRect.Call(overlay, 0, 0) } // paintText 在指定 HDC 上绘制白色提示文字(WM_PAINT 内调用,窗口线程)。 // 用 DrawTextW + 系统字体 + TRANSPARENT 背景模式:只画字形、不填充背景, // 这样不会覆盖 FillRect 画的气泡背景(此前 OPAQUE 模式导致文字区被填成黑色 // 背景而不可见)。 func (s *mouseAlertState) paintText(hdc uintptr) { s.mu.Lock() text := s.text scrollX := s.scrollX fontSize := s.cfgFontSize textColor := s.cfgTextColor outlineColor := s.cfgOutlineColor outlineWidth := s.cfgOutlineWidth width := s.cfgWidth s.mu.Unlock() if text == "" || hdc == 0 { return } if fontSize <= 0 { fontSize = DefaultMouseAlertFontSize // 默认 20px } if textColor == 0 { textColor = 0x00FFFFFF // 默认白字 } if outlineColor == 0 { outlineColor = 0x00000000 // 默认黑边 } if width <= 0 { width = overlayWidth } // TRANSPARENT:只画字形,不填背景(保留气泡背景) procSetBkMode.Call(hdc, 1) procSetTextColor.Call(hdc, textColor) // 大字体:CreateFontW 按配置字号创建清晰字体 // 参数:h=-fontSize(px), w=0, esc=0, orient=0, weight=FW_NORMAL(400), italic=0, // underline=0, strikeout=0, charset=DEFAULT_CHARSET(1), outprec=0, clipprec=0, // quality=ANTIALIASED(4), pitch=0, face=微软雅黑 facePtr, _ := syscall.UTF16PtrFromString("Microsoft YaHei") var fontH int32 = -fontSize // 负值=像素高度(字号 px),用变量避免常量溢出 hFont, _, _ := procCreateFontW.Call( uintptr(fontH), 0, 0, 0, 400, 0, 0, 0, 1 /* DEFAULT_CHARSET */, 0, 0, 4 /* ANTIALIASED */, 0, uintptr(unsafe.Pointer(facePtr)), ) if hFont == 0 { // 回退系统字体 hFont, _, _ = procGetStockObject.Call(17) // DEFAULT_GUI_FONT } if hFont != 0 { procSelectObject.Call(hdc, hFont) } // 测量文字像素宽度(选中字体下),判断是否需要滚动 textPtr, _ := syscall.UTF16PtrFromString(text) var ext size procGetTextExtentPoint32W.Call(hdc, uintptr(unsafe.Pointer(textPtr)), uintptr(len([]rune(text))), uintptr(unsafe.Pointer(&ext))) textW := ext.Cx availW := width - 24 // 左右各 12px 边距 s.mu.Lock() s.textWidth = textW s.mu.Unlock() // 文字垂直居中:气泡高度 = 字号 + 34,上下留白各约 (34-字号)/2 + 基线偏移 baseY := (fontSize + 10) / 2 // 字形基线 y 坐标(垂直居中) // 单行绘制(TextOutW)+ 超长时按 scrollX 偏移。文字从左边距开始画, // 滚动时整体向左平移(x - scrollX),实现文字从右向左循环滚动。 // 文字超长时先画两遍(偏移 和 偏移+周期)实现头尾相联无缝循环。 // // 描边反差:为避免文字在任意背景(亮/暗)上看不清,先用描边色把文字 // 向 8 个方向各偏移 outlineWidth 各画一遍形成外描边(stroke),最后用 // 正文色在中心画一遍(fill),构成带色边的文字,形成颜色反差。 textLen := uintptr(len([]rune(text))) leftX := int32(12) - scrollX if textW > availW { // 头尾相联:第二份从「文字头 + 文字宽 + 间隔」开始画,间隔为两个空格 s.mu.Lock() gap := s.textGap s.mu.Unlock() if gap < 4 { gap = 4 } // 第一遍:当前偏移处(文字头) s.drawOutlinedText(hdc, textPtr, textLen, leftX, baseY, outlineColor, outlineWidth, textColor) // 第二遍:文字头 + 文字宽 + 间隔,实现头尾相联无缝循环 s.drawOutlinedText(hdc, textPtr, textLen, leftX+textW+gap, baseY, outlineColor, outlineWidth, textColor) } else { // 未超长:左对齐单行 s.drawOutlinedText(hdc, textPtr, textLen, 12, baseY, outlineColor, outlineWidth, textColor) } } // drawOutlinedText 绘制带描边的文字:先用 outlineColor 在 8 方向各偏移 // outlineWidth 描一圈形成外轮廓,再在中心用 textColor 画正文字形。 // outlineWidth<=0 时不描边,直接画正文(等同旧行为)。 func (s *mouseAlertState) drawOutlinedText(hdc uintptr, textPtr *uint16, textLen uintptr, x, y int32, outlineColor uintptr, outlineWidth int32, textColor uintptr) { if textLen == 0 { return } draw := func(dx, dy int32, color uintptr) { procSetTextColor.Call(hdc, color) procTextOutW.Call(hdc, uintptr(x+dx), uintptr(y+dy), uintptr(unsafe.Pointer(textPtr)), textLen) } if outlineWidth > 0 { // 8 方向外描边(每像素偏移),形成环绕轮廓,避免不同背景下文字难辨认 for dy := -outlineWidth; dy <= outlineWidth; dy++ { for dx := -outlineWidth; dx <= outlineWidth; dx++ { if dx == 0 && dy == 0 { continue } draw(dx, dy, outlineColor) } } } // 中心正文字形 draw(0, 0, textColor) } // renderBubbleToDib 已移除:回退到 WM_PAINT 普通 GDI 绘制方案(见 wmPaint/paintText)。 // 分层窗口用 LWA_ALPHA 整体半透明,不再用 DIB + UpdateLayeredWindow。