| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731 |
- //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。
|