diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..26d3429 --- /dev/null +++ b/build.bat @@ -0,0 +1,28 @@ +@echo off +rem ============================================================ +rem viewsagent one-click build +rem Double-click this file to build viewsagent.exe in this folder. +rem (Chinese output is printed by build.ps1, this file stays ASCII.) +rem ============================================================ +setlocal +chcp 65001 >nul +cd /d "%~dp0" +title viewsagent - build + +echo. +echo Building viewsagent ... +echo. + +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %* +set RC=%ERRORLEVEL% + +echo. +if not "%RC%"=="0" ( + echo [FAILED] Build did not succeed. See the messages above. +) else ( + echo [DONE] viewsagent.exe is ready in this folder. + echo Double-click it to run. +) +echo. +pause +exit /b %RC% diff --git a/build.ps1 b/build.ps1 index c5a3dab..0fbd222 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,31 +1,99 @@ -# Build viewsagent for Windows (no console window). -# Usage: .\build.ps1 -# Requires: Go 1.24+ and gcc from MSYS2 mingw-w64 (E:\Tools\msys64\mingw64\bin). +# 一键编译打包 viewsagent。 +# +# 生成一个没有控制台窗口的 viewsagent.exe,直接放在项目根目录。 +# 用法:双击本文件即可;或在 PowerShell 里执行 .\build.ps1 +# 可选参数:notest 表示跳过单元测试。 $ErrorActionPreference = "Stop" +Set-Location -Path $PSScriptRoot -$mingw = "E:\Tools\msys64\mingw64\bin" -if (Test-Path $mingw) { - $env:Path = "$mingw;" + $env:Path +function Fail($msg) { + Write-Host "" + Write-Host " [失败] $msg" -ForegroundColor Red + exit 1 } -if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) { - Write-Error "gcc not found. Fyne needs cgo; install mingw-w64-gcc first (see scripts/setup-gcc.sh)." +function Step($msg) { + Write-Host "" + Write-Host "==> $msg" -ForegroundColor Cyan } +# ---------- 1. 找 go ---------- +$go = Get-Command go -ErrorAction SilentlyContinue +if (-not $go) { + Fail "没有找到 go 命令。请先安装 Go(1.24 以上)并把 go.exe 加入 PATH。" +} +Write-Host " Go : $($go.Source)" -ForegroundColor DarkGray + +# ---------- 2. 找 gcc ---------- +# Fyne 的 OpenGL 绑定 go-gl 是 cgo 包,必须有 C 编译器才能编译, +# 所以这里自动去常见安装位置里找 gcc 并临时加进 PATH。 +$gccDirs = @( + "C:\msys64\ucrt64\bin", + "C:\msys64\mingw64\bin", + "$env:USERPROFILE\msys64\ucrt64\bin", + "$env:USERPROFILE\msys64\mingw64\bin", + "E:\Tools\msys64\ucrt64\bin", + "E:\Tools\msys64\mingw64\bin", + "C:\Tools\msys64\mingw64\bin", + "C:\mingw64\bin", + "C:\TDM-GCC-64\bin", + "C:\ProgramData\chocolatey\bin" +) + +$gcc = Get-Command gcc -ErrorAction SilentlyContinue +if (-not $gcc) { + foreach ($dir in $gccDirs) { + if (Test-Path (Join-Path $dir "gcc.exe")) { + $env:Path = "$dir;" + $env:Path + $gcc = Get-Command gcc -ErrorAction SilentlyContinue + break + } + } +} +if (-not $gcc) { + Fail "没有找到 gcc。Fyne 依赖 cgo,需要先装 mingw-w64(MSYS2: pacman -S mingw-w64-x86_64-gcc,或 chocolatey: choco install mingw)。" +} +Write-Host " gcc : $($gcc.Source)" -ForegroundColor DarkGray + $env:CGO_ENABLED = "1" -if (-not (Test-Path "bin")) { - New-Item -ItemType Directory -Path "bin" | Out-Null +# ---------- 3. 跑测试 ---------- +if ($args -contains "notest") { + Write-Host "" + Write-Host "==> 跳过单元测试" -ForegroundColor Cyan +} else { + Step "运行单元测试" + go test ./... + if ($LASTEXITCODE -ne 0) { + Fail "单元测试没有通过,已中止打包。" + } } -Write-Host "==> go test" -ForegroundColor Cyan -go test ./... -if ($LASTEXITCODE -ne 0) { Write-Error "tests failed, build aborted." } +# ---------- 4. 编译 ---------- +Step "编译可执行文件" -Write-Host "==> go build" -ForegroundColor Cyan -go build -trimpath -ldflags "-H windowsgui -s -w" -o "bin\viewsagent.exe" . -if ($LASTEXITCODE -ne 0) { Write-Error "build failed." } +$out = Join-Path $PSScriptRoot "viewsagent.exe" +if (Test-Path $out) { + try { + Remove-Item $out -Force -ErrorAction Stop + } catch { + Fail "无法覆盖 viewsagent.exe,请先关闭正在运行的 viewsagent 程序。" + } +} -$mb = [math]::Round((Get-Item "bin\viewsagent.exe").Length / 1MB, 1) -Write-Host "==> done: bin\viewsagent.exe ($mb MB)" -ForegroundColor Green +# -H windowsgui 去掉控制台窗口,程序就是纯粹的桌面应用。 +go build -trimpath -ldflags "-H windowsgui -s -w" -o $out . +if ($LASTEXITCODE -ne 0) { + Fail "编译失败。" +} + +if (-not (Test-Path $out)) { + Fail "编译结束但没有生成 viewsagent.exe。" +} + +$size = [math]::Round((Get-Item $out).Length / 1MB, 1) +Write-Host "" +Write-Host " [完成] viewsagent.exe 已生成($size MB)" -ForegroundColor Green +Write-Host " 路径 : $out" -ForegroundColor Gray +Write-Host "" diff --git a/d.bat b/d.bat new file mode 100644 index 0000000..e69de29 diff --git a/internal/agent/engine.go b/internal/agent/engine.go index 89176f1..07fe81e 100644 --- a/internal/agent/engine.go +++ b/internal/agent/engine.go @@ -370,7 +370,7 @@ func (e *Engine) watchLoop(stop chan struct{}) { continue } - if fp.Changed(last, capCfg.DiffThreshold) { + if fp.Changed(last, capCfg.DiffThreshold, capCfg.DiffSensitivity) { candidate = true stable = 0 last = fp diff --git a/internal/config/config.go b/internal/config/config.go index 748d5ea..5f134f2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,13 +41,14 @@ func NewProfile(name, format string) Profile { // Capture 是抓屏与触发策略参数。 type Capture struct { - FPS float64 `json:"fps"` // 监听帧率,1~5 - MaxEdge int `json:"maxEdge"` // 发给模型的图片长边上限 - JPEGQuality int `json:"jpegQuality"` // 1~100 - DiffThreshold int `json:"diffThreshold"` // 感知哈希汉明距离阈值 - CooldownMs int `json:"cooldownMs"` // 两次自动提问的最小间隔 - StableFrames int `json:"stableFrames"` // 连续稳定帧数(去抖) - HistoryTurns int `json:"historyTurns"` // 带上的历史文本轮数 + FPS float64 `json:"fps"` // 监听帧率,1~5 + MaxEdge int `json:"maxEdge"` // 发给模型的图片长边上限 + JPEGQuality int `json:"jpegQuality"` // 1~100 + DiffThreshold int `json:"diffThreshold"` // 感知哈希汉明距离阈值 + DiffSensitivity float64 `json:"diffSensitivity"` // 细节网格归一化平均差阈值(越小越灵敏) + CooldownMs int `json:"cooldownMs"` // 两次自动提问的最小间隔 + StableFrames int `json:"stableFrames"` // 连续稳定帧数(去抖) + HistoryTurns int `json:"historyTurns"` // 带上的历史文本轮数 } // Hotkeys 保存全局热键描述,语法如 "Ctrl+Alt+Q"。 @@ -140,7 +141,7 @@ func Default() *Config { return &Config{ Capture: Capture{ FPS: 2, MaxEdge: 1280, JPEGQuality: 75, - DiffThreshold: 5, CooldownMs: 3000, StableFrames: 2, HistoryTurns: 6, + DiffThreshold: 5, DiffSensitivity: 0.01, CooldownMs: 3000, StableFrames: 2, HistoryTurns: 6, }, Hotkeys: Hotkeys{ SelectRegion: "Ctrl+Alt+X", @@ -246,11 +247,24 @@ func (c *Config) Template(key string) Template { return Template{Key: "chat", Name: "自由对话", System: "你是桌面视觉助手。"} } +// ensureModel 在没有任何接入点时补一个空白项。 +// +// 只用于首次运行(还没有配置文件)时给用户一个可以填的入口; +// 用户主动删光之后不该被自动塞回来。 +func (c *Config) ensureModel() { + if len(c.Models) > 0 { + return + } + c.Models = []Profile{NewProfile("接入点 1", llm.FormatOpenAI)} + c.Active = c.Models[0].Name +} + // Clone 返回一份深拷贝,避免设置窗口与引擎共享同一份可变配置。 func (c *Config) Clone() *Config { if c == nil { cfg := Default() cfg.Normalize() + cfg.ensureModel() return cfg } out := *c @@ -270,10 +284,10 @@ func (c *Config) Clone() *Config { } // Normalize 补全缺失字段,保证运行期不会出现零值。 +// +// 注意这里**不再**自动补接入点:用户主动删光之后不该又被塞回来, +// 否则"删除"看起来像没生效。首次运行的空白接入点由 ensureModel 负责。 func (c *Config) Normalize() { - if len(c.Models) == 0 { - c.Models = []Profile{NewProfile("接入点 1", llm.FormatOpenAI)} - } for i := range c.Models { m := &c.Models[i] m.Name = strings.TrimSpace(m.Name) @@ -303,8 +317,13 @@ func (c *Config) Normalize() { c.Models[i].Name = name used[name] = true } - if _, ok := c.ActiveModel(); !ok { - c.Active = c.Models[0].Name + // 一个接入点都没有时,Active 留空即可。 + if len(c.Models) > 0 { + if _, ok := c.ActiveModel(); !ok { + c.Active = c.Models[0].Name + } + } else { + c.Active = "" } if len(c.Templates) == 0 { @@ -322,6 +341,9 @@ func (c *Config) Normalize() { if c.Capture.DiffThreshold <= 0 { c.Capture.DiffThreshold = 5 } + if c.Capture.DiffSensitivity <= 0 { + c.Capture.DiffSensitivity = 0.01 + } if c.Capture.CooldownMs <= 0 { c.Capture.CooldownMs = 3000 } @@ -377,6 +399,8 @@ func (s *Store) Load() *Config { if err != nil { cfg := Default() cfg.Normalize() + // 首次运行:给一个空白接入点作为填写入口。 + cfg.ensureModel() return cfg } cfg := Default() @@ -384,6 +408,7 @@ func (s *Store) Load() *Config { _ = os.WriteFile(s.path+".broken", raw, 0o644) fresh := Default() fresh.Normalize() + fresh.ensureModel() return fresh } var legacy legacyConfig diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5168428..b53c096 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -12,9 +12,10 @@ import ( func TestDefaultIsUsable(t *testing.T) { cfg := Default() cfg.Normalize() + cfg.ensureModel() if len(cfg.Models) == 0 { - t.Fatal("Normalize 后应至少有一份接入点") + t.Fatal("首次运行应有一份空白接入点") } if _, ok := cfg.ActiveModel(); !ok { t.Fatal("Normalize 后应有生效接入点") @@ -33,9 +34,10 @@ func TestDefaultIsUsable(t *testing.T) { func TestNormalizeFillsZeroValues(t *testing.T) { cfg := &Config{} cfg.Normalize() + cfg.ensureModel() if len(cfg.Models) == 0 { - t.Fatal("Normalize 应补全接入点列表") + t.Fatal("首次运行应补上一份接入点") } if cfg.Models[0].Format != llm.FormatOpenAI { t.Fatalf("默认接入点应为 OpenAI 兼容格式,实际 %s", cfg.Models[0].Format) @@ -105,6 +107,7 @@ func TestNormalizeTheme(t *testing.T) { func TestCloneIsIndependent(t *testing.T) { cfg := Default() cfg.Normalize() + cfg.ensureModel() cp := cfg.Clone() cp.Models[0].Model = "changed" @@ -173,6 +176,53 @@ func TestRemoveModelClearsActive(t *testing.T) { } } +func TestRemoveModelStaysRemoved(t *testing.T) { + cfg := Default() + cfg.Models = []Profile{ + {Name: "a", Model: "m-a"}, + {Name: "b", Model: "m-b"}, + {Name: "c", Model: "m-c"}, + } + cfg.Active = "b" + cfg.Normalize() + + if !cfg.RemoveModel("b") { + t.Fatal("应能删除接入点") + } + // 再归一化一次,模拟落盘后重新加载:被删的接入点不该复活。 + cfg.Normalize() + + if len(cfg.Models) != 2 { + t.Fatalf("删除后应只剩 2 个接入点,实际 %d: %+v", len(cfg.Models), cfg.Models) + } + for _, m := range cfg.Models { + if m.Name == "b" { + t.Fatalf("被删的接入点不该被补回来: %+v", cfg.Models) + } + } + active, ok := cfg.ActiveModel() + if !ok { + t.Fatal("应自动选中一个剩余接入点") + } + if active.Name != "a" && active.Name != "c" { + t.Fatalf("生效项应指向剩余的接入点,实际 %s", active.Name) + } +} + +func TestNormalizeKeepsEmptyModelList(t *testing.T) { + // 用户把接入点删光后,Normalize 不该再偷偷补一个回来, + // 否则"删除"看起来完全没生效。 + cfg := Default() + cfg.Normalize() + + if len(cfg.Models) != 0 { + t.Fatalf("Normalize 不应凭空补接入点: %+v", cfg.Models) + } + if cfg.Active != "" { + t.Fatalf("没有接入点时 Active 应为空,实际 %s", cfg.Active) + } +} + func TestMigrateLegacyProfiles(t *testing.T) { cfg := Default() cfg.migrateLegacy(legacyConfig{ diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..3972d38 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,270 @@ +// Package logging 是桌面程序自带的日志设施。 +// +// 日志同时写两处:磁盘上的 app.log(长期留存、方便排查)和内存里的 +// 环形缓冲(最近若干行)。界面上的「日志」按钮直接读这份缓冲, +// 因此程序完全不需要控制台窗口就能查看运行日志。 +package logging + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + "sync" +) + +// Buffer 是保存最近若干行日志的环形缓冲。 +type Buffer struct { + mu sync.Mutex + lines []string + cap int + seq uint64 +} + +// NewBuffer 创建一个最多保留 capacity 行的缓冲。 +func NewBuffer(capacity int) *Buffer { + if capacity <= 0 { + capacity = 2000 + } + return &Buffer{lines: make([]string, 0, capacity), cap: capacity} +} + +// Append 追加一行日志,超过容量时丢弃最旧的一行。 +func (b *Buffer) Append(line string) { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.lines) == b.cap { + copy(b.lines, b.lines[1:]) + b.lines = b.lines[:len(b.lines)-1] + } + b.lines = append(b.lines, line) + b.seq++ +} + +// Text 返回缓存中的全部日志文本。 +func (b *Buffer) Text() string { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.lines) == 0 { + return "" + } + return strings.Join(b.lines, "\n") +} + +// Lines 返回缓存中日志行的副本。 +func (b *Buffer) Lines() []string { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out +} + +// Seq 返回内容版本号:每写入一行或清空一次就加一。 +// 界面靠它判断内容有没有变化,避免每次都重建整段文本。 +func (b *Buffer) Seq() uint64 { + b.mu.Lock() + defer b.mu.Unlock() + return b.seq +} + +// Clear 清空缓存。 +func (b *Buffer) Clear() { + b.mu.Lock() + defer b.mu.Unlock() + b.lines = b.lines[:0] + b.seq++ +} + +// Writer 把日志同时写进文件和内存缓冲。 +type Writer struct { + mu sync.Mutex + file *os.File + buf *Buffer +} + +// New 返回一个只写内存的写入器,用于日志文件打不开时的兜底。 +func New(buf *Buffer) *Writer { return &Writer{buf: buf} } + +// Open 打开(追加写入)日志文件并与 buf 绑定。 +// 打开失败时返回一个仍然可用的写入器(只写内存)和错误。 +func Open(path string, buf *Buffer) (*Writer, error) { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return New(buf), fmt.Errorf("创建日志目录失败: %w", err) + } + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return New(buf), fmt.Errorf("打开日志文件失败: %w", err) + } + return &Writer{file: f, buf: buf}, nil +} + +// Write 实现 io.Writer。slog 的每条记录是一次 Write 调用,这里按行落盘并缓存。 +func (w *Writer) Write(p []byte) (int, error) { + line := strings.TrimRight(string(p), "\r\n") + if line == "" { + return len(p), nil + } + w.mu.Lock() + defer w.mu.Unlock() + if w.file != nil { + if _, err := w.file.WriteString(line + "\n"); err != nil { + return len(p), err + } + } + if w.buf != nil { + w.buf.Append(line) + } + return len(p), nil +} + +// Close 关闭日志文件。 +func (w *Writer) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + return err +} + +// handler 是自定义的 slog.Handler:把每条记录格式化成一行, +// 再交给 Writer 同时落盘并缓存,供界面直接查看。 +type handler struct { + w *Writer + level slog.Level + attrs []slog.Attr + groups []string +} + +// NewHandler 创建一个把日志写进 w 的 slog 处理器。 +// level 以下级别的记录会被丢弃。 +func NewHandler(w *Writer, level slog.Level) slog.Handler { + return &handler{w: w, level: level} +} + +func (h *handler) Enabled(_ context.Context, l slog.Level) bool { + return l >= h.level +} + +func (h *handler) Handle(_ context.Context, r slog.Record) error { + var sb strings.Builder + sb.WriteString(r.Time.Format("2006-01-02 15:04:05.000")) + sb.WriteString(" ") + sb.WriteString(levelLabel(r.Level)) + sb.WriteString(" ") + sb.WriteString(r.Message) + + var rec []slog.Attr + r.Attrs(func(a slog.Attr) bool { + rec = append(rec, a) + return true + }) + // 记录里的属性按当前组栈展开;WithAttrs 存下来的属性已经展开过。 + for _, a := range flatten(h.groups, rec) { + appendAttr(&sb, a) + } + for _, a := range h.attrs { + appendAttr(&sb, a) + } + sb.WriteString("\n") + + _, err := h.w.Write([]byte(sb.String())) + return err +} + +// WithAttrs 返回一个带上额外属性的处理器。 +func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return h + } + nh := *h + nh.attrs = append(append([]slog.Attr(nil), h.attrs...), flatten(h.groups, attrs)...) + return &nh +} + +// WithGroup 返回一个把后续属性放进命名组的处理器。 +func (h *handler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + nh := *h + nh.groups = append(append([]string(nil), h.groups...), name) + return &nh +} + +// flatten 把带组的属性展开成扁平的键值对,键名带上组前缀。 +func flatten(groups []string, attrs []slog.Attr) []slog.Attr { + out := make([]slog.Attr, 0, len(attrs)) + for _, a := range attrs { + if a.Value.Kind() == slog.KindGroup { + g := a.Value.Group() + if len(g) == 0 { + continue + } + sub := groups + if a.Key != "" { + sub = append(append([]string(nil), groups...), a.Key) + } + out = append(out, flatten(sub, g)...) + continue + } + if a.Key == "" { + continue + } + key := a.Key + if len(groups) > 0 { + key = strings.Join(groups, ".") + "." + key + } + out = append(out, slog.Attr{Key: key, Value: a.Value}) + } + return out +} + +// appendAttr 把一个扁平属性写进日志行。 +func appendAttr(sb *strings.Builder, a slog.Attr) { + sb.WriteString(" ") + sb.WriteString(a.Key) + sb.WriteString("=") + sb.WriteString(formatValue(a.Value)) +} + +// formatValue 把属性值格式化成一行日志里好读的样子。 +func formatValue(v slog.Value) string { + v = v.Resolve() + switch v.Kind() { + case slog.KindString: + s := v.String() + if strings.ContainsAny(s, " \t\"=") { + return strconv.Quote(s) + } + return s + case slog.KindTime: + return v.Time().Format("15:04:05.000") + case slog.KindDuration: + return v.Duration().String() + default: + return fmt.Sprintf("%v", v.Any()) + } +} + +// levelLabel 返回定宽的级别标签。 +func levelLabel(l slog.Level) string { + switch { + case l >= slog.LevelError: + return "ERROR" + case l >= slog.LevelWarn: + return "WARN " + case l >= slog.LevelInfo: + return "INFO " + default: + return "DEBUG" + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 37a1cb6..5842fb8 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -25,6 +25,7 @@ import ( "viewsagent/internal/capture" "viewsagent/internal/config" "viewsagent/internal/llm" + "viewsagent/internal/logging" "viewsagent/internal/win32" ) @@ -44,10 +45,12 @@ type App struct { modelSel *widget.Select regionLbl *widget.Label statusLbl *widget.Label + frameBtn *widget.Button settings *settingsWindow card *floatCard overlay *overlay regionFrm *regionFrame + logs *logWindow hotkeyStop func() // 流式文本合批,避免每个 delta 都触发一次布局 @@ -62,10 +65,13 @@ type App struct { lastCardSync time.Time quietModelSel bool + // frameBtnShown 记录取景框按钮当前的图标状态,避免重复刷新。 + frameBtnShown bool } -// Run 启动整个桌面应用。 -func Run() error { +// Run 启动整个桌面应用。logs 是程序自己的日志缓冲, +// 界面上的「日志」按钮直接读它展示。 +func Run(logs *logging.Buffer) error { store, err := config.NewStore() if err != nil { return err @@ -79,7 +85,11 @@ func Run() error { eng := agent.New(cfg, store) a := &App{fyne: fa, eng: eng} + a.logs = newLogWindow(fa, logs, store.Dir()) a.build() + // 取景框上的「确定 / 取消」直接改监视区域。 + a.regionFrm.onApply = a.onFrameApply + a.regionFrm.onCancel = a.onFrameCancel eng.SetSink(a) a.startHotkeys() a.maybeAutoShot() @@ -88,6 +98,9 @@ func Run() error { if a.hotkeyStop != nil { a.hotkeyStop() } + a.logs.close() + // 取景框是原生窗口,不随 Fyne 窗口一起消失, + // 必须在这里显式销毁,否则屏幕上会留下一个关不掉的红色线框。 a.regionFrm.close() eng.Close() }() @@ -159,6 +172,10 @@ func (a *App) build() { a.win = a.fyne.NewWindow(mainTitle) a.win.Resize(fyne.NewSize(520, 780)) a.win.CenterOnScreen() + // 主窗体是 master:点它的关闭按钮就直接退出整个程序。 + // 否则 Fyne 只会在最后一个窗口关闭时才退出,卡片/设置/日志窗口 + // 还活着,程序会变成后台僵尸,取景框的红色线框也留在屏幕上。 + a.win.SetMaster() a.chat = newChatView() a.card = newFloatCard(a.fyne) @@ -175,12 +192,14 @@ func (a *App) build() { cardBtn := widget.NewButtonWithIcon("", theme.InfoIcon(), a.toggleCard) cardBtn.Importance = widget.LowImportance + logBtn := widget.NewButtonWithIcon("", theme.DocumentIcon(), a.showLogs) + logBtn.Importance = widget.LowImportance settingsBtn := widget.NewButtonWithIcon("", theme.SettingsIcon(), a.openSettings) settingsBtn.Importance = widget.LowImportance toolbar := container.NewHBox( widget.NewLabel("接入点"), a.modelSel, - layout.NewSpacer(), cardBtn, settingsBtn, + layout.NewSpacer(), cardBtn, logBtn, settingsBtn, ) a.regionLbl = widget.NewLabel("未框选区域") @@ -188,10 +207,12 @@ func (a *App) build() { selectBtn := widget.NewButtonWithIcon("框选", theme.VisibilityOffIcon(), a.startSelection) previewBtn := widget.NewButtonWithIcon("预览", theme.SearchIcon(), a.showPreview) previewBtn.Importance = widget.LowImportance + a.frameBtn = widget.NewButtonWithIcon("取景框", theme.VisibilityIcon(), a.toggleFrame) + a.frameBtn.Importance = widget.LowImportance a.watchBtn = widget.NewButtonWithIcon("开始监听", theme.MediaPlayIcon(), a.toggleWatch) a.watchBtn.Importance = widget.HighImportance - regionRow := container.NewHBox(a.regionLbl, layout.NewSpacer(), previewBtn, selectBtn, a.watchBtn) + regionRow := container.NewHBox(a.regionLbl, layout.NewSpacer(), previewBtn, selectBtn, a.frameBtn, a.watchBtn) a.entry = widget.NewMultiLineEntry() a.entry.SetPlaceHolder("输入你的要求,例如:请帮我做练习题,实时给出答案") @@ -289,6 +310,61 @@ func (a *App) openSettings() { a.settings.show() } +// showLogs 打开运行日志窗口。 +func (a *App) showLogs() { + if a.logs == nil { + return + } + a.logs.show() +} + +// toggleFrame 显示或隐藏屏幕上的取景框。 +func (a *App) toggleFrame() { + if a.regionFrm == nil { + return + } + if a.regionFrm.toggle() { + a.chat.addNotice("已显示取景框:拖动边框可调整范围,右上角有确定/取消") + } else { + a.chat.addNotice("已隐藏取景框") + } + a.updateFrameBtn() +} + +// updateFrameBtn 同步取景框按钮的图标与可用状态。 +func (a *App) updateFrameBtn() { + if a.frameBtn == nil { + return + } + visible := a.regionFrm != nil && a.regionFrm.visible() + if visible == a.frameBtnShown { + return + } + a.frameBtnShown = visible + if visible { + a.frameBtn.SetIcon(theme.VisibilityIcon()) + } else { + a.frameBtn.SetIcon(theme.VisibilityOffIcon()) + } +} + +// onFrameApply 取景框上点了确定:把拖出来的范围应用成监视区域。 +func (a *App) onFrameApply(r capture.Region) { + if err := a.eng.SetRegion(r); err != nil { + fyne.Do(func() { a.chat.addNotice(err.Error()) }) + return + } + fyne.Do(func() { a.chat.addNotice(fmt.Sprintf("已应用监视区域 %s", r)) }) +} + +// onFrameCancel 取景框上点了取消:红框已经隐藏,这里只提示一句。 +func (a *App) onFrameCancel() { + fyne.Do(func() { + a.chat.addNotice("已取消取景框调整,监视区域保持不变") + a.updateFrameBtn() + }) +} + func (a *App) clearChat() { a.eng.ResetHistory() a.chat.clear() @@ -358,7 +434,8 @@ func (a *App) startSelection() { return } vd := win32.VirtualScreen() - a.overlay = newOverlay(a.fyne, shot, vd, a.onRegionSelected, a.onRegionCancel) + // 把上一次的选区带进去,打开就能直接移动或调整边界。 + a.overlay = newOverlay(a.fyne, shot, vd, a.eng.Region(), a.onRegionSelected, a.onRegionCancel) a.overlay.show() } @@ -372,6 +449,8 @@ func (a *App) onRegionSelected(r capture.Region) { } a.card.setRegion(r) a.regionFrm.resume(r) + // 重新框选后把红框显示出来(之前点过取消的话,这里恢复)。 + a.regionFrm.show() a.chat.addNotice(fmt.Sprintf("已框选监视区域 %s", r)) } @@ -541,12 +620,19 @@ func (a *App) applyState(st agent.State) { if st.Region.Valid() { a.regionLbl.SetText(fmt.Sprintf("区域 %d × %d", st.Region.W, st.Region.H)) + if a.frameBtn != nil { + a.frameBtn.Enable() + } } else { a.regionLbl.SetText("未框选区域") + if a.frameBtn != nil { + a.frameBtn.Disable() + } } a.card.setRegion(st.Region) // 屏幕上的红框跟着选区走,始终提示当前盯的是哪一块。 a.regionFrm.apply(st.Region) + a.updateFrameBtn() if st.Busy { a.sendBtn.Disable() diff --git a/internal/ui/card.go b/internal/ui/card.go index 55d9840..9cc8a1a 100644 --- a/internal/ui/card.go +++ b/internal/ui/card.go @@ -25,6 +25,9 @@ type floatCard struct { func newFloatCard(a fyne.App) *floatCard { c := &floatCard{win: a.NewWindow(cardTitle)} + // 点关闭只是隐藏浮窗:Fyne 的窗口一旦 Close 就被销毁,之后再也 Show + // 不出来,工具栏的按钮会永久失效。 + c.win.SetCloseIntercept(func() { c.hide() }) rt := widget.NewRichText() rt.Wrapping = fyne.TextWrapWord c.body = rt diff --git a/internal/ui/indicator.go b/internal/ui/indicator.go index 7066bbd..a5e25a2 100644 --- a/internal/ui/indicator.go +++ b/internal/ui/indicator.go @@ -13,6 +13,9 @@ import ( // 屏幕上一直显示当前框选区域的红框,让使用者随时知道自己在看哪一块; // 这个框被 SetWindowDisplayAffinity 排除在截屏与录屏之外, // 所以模型看到的画面、录屏软件录到的画面里都不会有它。 +// +// 红框本身是可以交互的:拖动边框可以改范围,右上角有「确定 / 取消」两个 +// 按钮(平时全透明,鼠标移上去才显示),用来直接修改监视区域。 type regionFrame struct { frame *win32.Frame @@ -20,6 +23,9 @@ type regionFrame struct { last capture.Region hasLast bool suspended bool + + onApply func(capture.Region) + onCancel func() } // newRegionFrame 创建取景框。创建失败时返回 nil,调用方自行决定是否降级。 @@ -29,7 +35,25 @@ func newRegionFrame() *regionFrame { slog.Warn("取景框创建失败,屏幕将不再显示选区边框", "err", err) return nil } - return ®ionFrame{frame: f} + r := ®ionFrame{frame: f} + f.SetCallbacks(r.commit, r.abort) + return r +} + +// commit 是取景框上点「确定」的回调:把调整后的区域交给上层。 +func (f *regionFrame) commit(r win32.Rect) { + if f == nil || f.onApply == nil { + return + } + f.onApply(capture.Region{X: r.Left, Y: r.Top, W: r.Width(), H: r.Height()}) +} + +// abort 是取景框上点「取消」的回调:红框已经自己隐藏了。 +func (f *regionFrame) abort() { + if f == nil || f.onCancel == nil { + return + } + f.onCancel() } // apply 把取景框移到指定区域;区域无效时隐藏。 @@ -85,6 +109,30 @@ func (f *regionFrame) resume(r capture.Region) { f.apply(r) } +// show 重新显示取景框(会清掉"用户点过取消"的状态)。 +func (f *regionFrame) show() { + if f == nil { + return + } + f.frame.Show() +} + +// toggle 切换取景框的显示状态,返回切换后是否可见。 +func (f *regionFrame) toggle() bool { + if f == nil { + return false + } + return f.frame.ToggleVisible() +} + +// visible 返回取景框当前是否显示。 +func (f *regionFrame) visible() bool { + if f == nil { + return false + } + return f.frame.Visible() +} + // close 销毁取景框窗口。 func (f *regionFrame) close() { if f == nil { diff --git a/internal/ui/logs.go b/internal/ui/logs.go new file mode 100644 index 0000000..72ee443 --- /dev/null +++ b/internal/ui/logs.go @@ -0,0 +1,172 @@ +package ui + +import ( + "os/exec" + "sync/atomic" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "viewsagent/internal/logging" +) + +// logWindow 是独立的「运行日志」窗口。 +// +// 程序是打包成无控制台窗口的桌面应用,日志不再往 cmd 输出, +// 想看的时候点工具栏的日志按钮,这个窗口就会单独弹出来。 +type logWindow struct { + buf *logging.Buffer + win fyne.Window + list *widget.List + lines []string + + // shown 是界面上已经显示的日志版本号,用原子变量跨 goroutine 传递。 + shown atomic.Uint64 + // stop 用来结束自动刷新的后台协程。 + stop chan struct{} +} + +// newLogWindow 创建运行日志窗口。logDir 是日志文件所在目录。 +func newLogWindow(a fyne.App, buf *logging.Buffer, logDir string) *logWindow { + w := &logWindow{buf: buf} + + w.win = a.NewWindow("运行日志") + w.win.Resize(fyne.NewSize(900, 560)) + // 点关闭只是隐藏窗口,方便下次再打开。 + w.win.SetCloseIntercept(func() { + w.stopTicker() + w.win.Hide() + }) + w.win.SetContent(w.buildContent(logDir)) + return w +} + +// buildContent 组装窗口内容:上面一排按钮,下面是日志列表。 +func (w *logWindow) buildContent(logDir string) fyne.CanvasObject { + w.list = widget.NewList( + func() int { return len(w.lines) }, + func() fyne.CanvasObject { + l := widget.NewLabel("") + l.TextStyle = fyne.TextStyle{Monospace: true} + l.Truncation = fyne.TextTruncateOff + return l + }, + func(id widget.ListItemID, obj fyne.CanvasObject) { + l, ok := obj.(*widget.Label) + if !ok { + return + } + if id < 0 || id >= len(w.lines) { + l.SetText("") + return + } + l.SetText(w.lines[id]) + }, + ) + + refreshBtn := widget.NewButtonWithIcon("刷新", theme.ViewRefreshIcon(), func() { w.refresh() }) + copyBtn := widget.NewButtonWithIcon("复制全部", theme.ContentCopyIcon(), func() { w.copyAll() }) + clearBtn := widget.NewButtonWithIcon("清空", theme.DeleteIcon(), func() { w.clear() }) + dirBtn := widget.NewButtonWithIcon("打开日志目录", theme.FolderOpenIcon(), func() { openDir(logDir) }) + + bar := container.NewHBox(refreshBtn, copyBtn, clearBtn, layout.NewSpacer(), dirBtn) + hint := widget.NewLabel("日志同时写入该目录下的 app.log 文件,重启程序后仍可查看。") + hint.Importance = widget.LowImportance + hint.Wrapping = fyne.TextWrapWord + + return container.NewBorder(bar, hint, nil, nil, w.list) +} + +// show 显示日志窗口(已经显示时只刷新内容并置前)。 +func (w *logWindow) show() { + if w.win == nil { + return + } + w.refresh() + w.win.Show() + w.win.RequestFocus() + w.startTicker() +} + +// refresh 把最新日志刷到列表里,并滚动到底部。 +// 只能在 UI 线程调用。 +func (w *logWindow) refresh() { + if w.list == nil { + return + } + w.lines = w.buf.Lines() + w.shown.Store(w.buf.Seq()) + w.list.Refresh() + w.list.ScrollToBottom() +} + +// copyAll 把全部日志复制到剪贴板。 +func (w *logWindow) copyAll() { + text := w.buf.Text() + if text == "" { + return + } + fyne.CurrentApp().Clipboard().SetContent(text) +} + +// clear 清空内存里的日志显示(磁盘上的文件不受影响)。 +func (w *logWindow) clear() { + w.buf.Clear() + w.refresh() +} + +// startTicker 启动自动刷新:每秒检查一次有没有新日志。 +func (w *logWindow) startTicker() { + if w.stop != nil { + return + } + stop := make(chan struct{}) + w.stop = stop + + go func() { + t := time.NewTicker(time.Second) + defer t.Stop() + for { + select { + case <-stop: + return + case <-t.C: + if w.buf.Seq() == w.shown.Load() { + continue + } + fyne.Do(w.refresh) + } + } + }() +} + +// stopTicker 停止自动刷新,窗口隐藏时调用。 +func (w *logWindow) stopTicker() { + if w.stop == nil { + return + } + close(w.stop) + w.stop = nil +} + +// close 停掉后台刷新,程序退出时调用。 +// 退出后 fyne.Do 不再走主线程队列,回调会直接执行, +// 这时再去刷界面已经没有意义了。 +func (w *logWindow) close() { + if w == nil { + return + } + w.stopTicker() +} + +// openDir 用资源管理器打开目录。 +func openDir(dir string) { + if dir == "" { + return + } + _ = exec.Command("explorer", dir).Start() +} diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index b6ae2e1..72cc4a4 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -10,6 +10,7 @@ import ( "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" @@ -20,10 +21,70 @@ import ( // overlayTitle 是框选窗口标题,win32 需要靠它找到 HWND。 const overlayTitle = "viewsagent-overlay" +// 框选交互用到的尺寸(逻辑像素)。 +const ( + handleSize = 12 // 调整手柄的边长 + edgeZone = 8 // 边缘的响应宽度 + minSelect = 8 // 选区允许的最小边长 + dragThresh = 5 // 判定为拖拽所需的最小位移 +) + +// dragMode 表示鼠标当前正在做什么。 +type dragMode int + +const ( + modeNone dragMode = iota + modeCreate // 从空白处拖出新选区 + modeMove // 拖动整个选区 + modeResize // 拖动边缘或角上的手柄 +) + +// 八个手柄的编号,顺时针从左上角开始。 +const ( + hTopLeft = iota + hTopMid + hTopRight + hRightMid + hBottomRight + hBottomMid + hBottomLeft + hLeftMid +) + +// rect 是逻辑坐标系下的矩形。 +type rect struct { + x0, y0, x1, y1 float32 +} + +// w 返回宽度。 +func (r rect) w() float32 { return r.x1 - r.x0 } + +// h 返回高度。 +func (r rect) h() float32 { return r.y1 - r.y0 } + +// contains 判断点是否落在矩形内。 +func (r rect) contains(x, y float32) bool { + return x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1 +} + +// normalized 保证 x0<=x1 且 y0<=y1。 +func (r rect) normalized() rect { + if r.x1 < r.x0 { + r.x0, r.x1 = r.x1, r.x0 + } + if r.y1 < r.y0 { + r.y0, r.y1 = r.y1, r.y0 + } + return r +} + // overlay 是一个铺满当前显示器的冻结帧框选层。 // // 之所以不依赖窗口透明:Windows 下的 OpenGL 窗口透明并不可靠, // 直接用"全屏截图做背景 + 遮罩"视觉等效,还能保证选区与截图完全对齐。 +// +// 选区可以反复调整:空白处拖拽新建,选区内部拖拽移动, +// 边缘与角上的方块拖拽改大小,最后点「确定」或按 Enter 生效。 type overlay struct { win fyne.Window @@ -31,26 +92,40 @@ type overlay struct { mapping capture.Mapping size fyne.Size calibrated bool + initial capture.Region // 打开时预置的选区 - bg *canvas.Image - dims [4]*canvas.Rectangle - edges [4]*canvas.Rectangle - hint *canvas.Text - catcher *catcher + bg *canvas.Image + dims [4]*canvas.Rectangle + edges [4]*canvas.Rectangle + handles [8]*canvas.Rectangle + hint *canvas.Text + bar *fyne.Container + tip *widget.Label + confirmBtn *widget.Button + cancelBtn *widget.Button + catcher *catcher - start, cur fyne.Position - dragging bool - closed bool + sel rect // 当前选区(逻辑坐标) + hasSel bool + + mode dragMode + handle int + grab fyne.Position + orig rect + moved bool + + closed bool onDone func(capture.Region) onCancel func() } -// newOverlay 创建框选层。shot 应该是调用前抓好的整屏画面。 -func newOverlay(app fyne.App, shot image.Image, vd win32.Rect, +// newOverlay 创建框选层。shot 应该是调用前抓好的整屏画面, +// initial 是上一次框选的区域,打开时直接带上方便调整。 +func newOverlay(app fyne.App, shot image.Image, vd win32.Rect, initial capture.Region, onDone func(capture.Region), onCancel func()) *overlay { - o := &overlay{vd: vd, onDone: onDone, onCancel: onCancel} + o := &overlay{vd: vd, initial: initial, onDone: onDone, onCancel: onCancel} o.win = app.NewWindow(overlayTitle) o.win.SetPadded(false) o.win.SetFullScreen(true) @@ -68,17 +143,37 @@ func newOverlay(app fyne.App, shot image.Image, vd win32.Rect, o.edges[i] = canvas.NewRectangle(lookupColor(theme.ColorNameForeground)) objs = append(objs, o.edges[i]) } - o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White) + for i := range o.handles { + hd := canvas.NewRectangle(lookupColor(theme.ColorNameForeground)) + hd.StrokeColor = color.NRGBA{R: 0, G: 0, B: 0, A: 220} + hd.StrokeWidth = 1 + o.handles[i] = hd + objs = append(objs, hd) + } + o.hint = canvas.NewText("拖拽框选区域", color.White) o.hint.TextStyle = fyne.TextStyle{Bold: true} o.hint.TextSize = 15 + o.tip = widget.NewLabel("空白处拖拽新建 · 选区内拖动可移动 · 边缘白块可调整大小 · Enter 确定 · Esc 取消") + o.tip.Importance = widget.LowImportance + o.tip.Wrapping = fyne.TextWrapOff + + o.confirmBtn = widget.NewButton("确定", func() { o.finish() }) + o.cancelBtn = widget.NewButton("取消", func() { o.cancel() }) + // 两侧留白让按钮居中,跟上面的提示文字对齐。 + o.bar = container.NewVBox(o.tip, + container.NewHBox(layout.NewSpacer(), o.confirmBtn, o.cancelBtn, layout.NewSpacer())) + o.catcher = newCatcher(o) - objs = append(objs, o.catcher, o.hint) + objs = append(objs, o.catcher, o.hint, o.bar) o.win.SetContent(container.NewWithoutLayout(objs...)) o.win.Canvas().SetOnTypedKey(func(k *fyne.KeyEvent) { - if k.Name == fyne.KeyEscape { + switch k.Name { + case fyne.KeyEscape: o.cancel() + case fyne.KeyReturn, fyne.KeyEnter: + o.finish() } }) return o @@ -103,20 +198,20 @@ func (o *overlay) calibrate() { } o.size = size - rect, err := windowRectByTitle(overlayTitle) + wr, err := windowRectByTitle(overlayTitle) if err != nil { mon, _ := win32.MonitorAtCursor() - rect = mon.Rect + wr = mon.Rect } - if rect.Width() <= 0 || rect.Height() <= 0 { + if wr.Width() <= 0 || wr.Height() <= 0 { return } o.mapping = capture.Mapping{ - OffsetX: float64(rect.Left), - OffsetY: float64(rect.Top), - ScaleX: float64(rect.Width()) / float64(size.Width), - ScaleY: float64(rect.Height()) / float64(size.Height), + OffsetX: float64(wr.Left), + OffsetY: float64(wr.Top), + ScaleX: float64(wr.Width()) / float64(size.Width), + ScaleY: float64(wr.Height()) / float64(size.Height), } if o.mapping.ScaleX <= 0 || o.mapping.ScaleY <= 0 { o.mapping.ScaleX, o.mapping.ScaleY = 1, 1 @@ -132,7 +227,38 @@ func (o *overlay) calibrate() { o.catcher.Move(fyne.NewPos(0, 0)) o.catcher.Resize(size) + // 底部工具条:确定 / 取消 + 操作提示。 + barSize := o.bar.MinSize() + barX := (size.Width - barSize.Width) / 2 + if barX < 8 { + barX = 8 + } + barY := size.Height - barSize.Height - 16 + if barY < 8 { + barY = 8 + } + o.bar.Move(fyne.NewPos(barX, barY)) + o.bar.Resize(barSize) + o.calibrated = true + + // 预置上一次的选区,打开就能直接调整。 + if o.initial.Valid() { + lx, ly, lw, lh := o.mapping.ToLogical(o.initial) + r := rect{ + x0: float32(lx), y0: float32(ly), + x1: float32(lx + lw), y1: float32(ly + lh), + }.normalized() + // 夹回窗口内(选区可能在别的显示器上)。 + r.x0 = max(r.x0, 0) + r.y0 = max(r.y0, 0) + r.x1 = min(r.x1, size.Width) + r.y1 = min(r.y1, size.Height) + if r.w() >= minSelect && r.h() >= minSelect { + o.sel = r + o.hasSel = true + } + } o.updateSelection() } @@ -144,34 +270,205 @@ func windowRectByTitle(title string) (win32.Rect, error) { return win32.WindowRect(hwnd) } -// selection 返回当前选区(逻辑坐标,已归一化)。 -func (o *overlay) selection() (x0, y0, x1, y1 float32) { - x0, y0 = o.start.X, o.start.Y - x1, y1 = o.cur.X, o.cur.Y - if x1 < x0 { - x0, x1 = x1, x0 +// ---------- 交互 ---------- + +// beginDrag 按下鼠标,按落点决定这次是新建、移动还是调整大小。 +func (o *overlay) beginDrag(pos fyne.Position) { + if !o.calibrated { + return } - if y1 < y0 { - y0, y1 = y1, y0 - } - // 限制在窗口内 - x0 = fyne.Max(x0, 0) - y0 = fyne.Max(y0, 0) - x1 = fyne.Min(x1, o.size.Width) - y1 = fyne.Min(y1, o.size.Height) - return + o.mode, o.handle = o.hitTest(pos.X, pos.Y) + o.grab = pos + o.orig = o.sel + o.moved = false } -// updateSelection 重排遮罩、边框与尺寸提示。 +// updateDrag 拖动过程中更新选区。 +func (o *overlay) updateDrag(pos fyne.Position) { + if o.mode == modeNone { + return + } + if abs32(pos.X-o.grab.X) > dragThresh || abs32(pos.Y-o.grab.Y) > dragThresh { + o.moved = true + } + + switch o.mode { + case modeCreate: + if !o.moved { + // 只是点了一下,不动原来的选区。 + return + } + o.sel = rect{o.grab.X, o.grab.Y, pos.X, pos.Y}.normalized() + case modeMove: + o.sel = o.movedRect(pos) + case modeResize: + o.sel = o.resizedRect(pos) + } + o.hasSel = true + o.updateSelection() +} + +// endDrag 松开鼠标。选区会留在界面上等用户确认, +// 这样才能反复微调边界,而不是一松手就定死。 +func (o *overlay) endDrag(pos fyne.Position) { + if o.mode == modeNone { + return + } + o.updateDrag(pos) + o.mode = modeNone + o.handle = -1 +} + +// hitTest 判断鼠标落点对应哪种拖拽方式,以及命中的手柄编号。 +func (o *overlay) hitTest(x, y float32) (dragMode, int) { + if !o.hasSel { + return modeCreate, -1 + } + r := o.sel + half := float32(handleSize) + + // 四个角上的手柄优先命中。 + corners := [4][2]float32{ + {r.x0, r.y0}, {r.x1, r.y0}, {r.x1, r.y1}, {r.x0, r.y1}, + } + cornerHandles := [4]int{hTopLeft, hTopRight, hBottomRight, hBottomLeft} + for i, c := range corners { + if abs32(x-c[0]) <= half && abs32(y-c[1]) <= half { + return modeResize, cornerHandles[i] + } + } + + // 四条边中点上的手柄。 + mx, my := (r.x0+r.x1)/2, (r.y0+r.y1)/2 + mids := [4][3]float32{ + {mx, r.y0, hTopMid}, {r.x1, my, hRightMid}, + {mx, r.y1, hBottomMid}, {r.x0, my, hLeftMid}, + } + for _, m := range mids { + if abs32(x-m[0]) <= half && abs32(y-m[1]) <= half { + return modeResize, int(m[2]) + } + } + + // 边上的其他位置也能拖动改大小。 + ez := float32(edgeZone) + if abs32(x-r.x0) <= ez && y > r.y0+ez && y < r.y1-ez { + return modeResize, hLeftMid + } + if abs32(x-r.x1) <= ez && y > r.y0+ez && y < r.y1-ez { + return modeResize, hRightMid + } + if abs32(y-r.y0) <= ez && x > r.x0+ez && x < r.x1-ez { + return modeResize, hTopMid + } + if abs32(y-r.y1) <= ez && x > r.x0+ez && x < r.x1-ez { + return modeResize, hBottomMid + } + + // 选区内部是整体移动。 + if r.contains(x, y) { + return modeMove, -1 + } + return modeCreate, -1 +} + +// movedRect 计算平移后的选区,并夹回窗口范围内。 +func (o *overlay) movedRect(pos fyne.Position) rect { + r := o.orig + dx, dy := pos.X-o.grab.X, pos.Y-o.grab.Y + r.x0 += dx + r.x1 += dx + r.y0 += dy + r.y1 += dy + + if r.x0 < 0 { + r.x1 -= r.x0 + r.x0 = 0 + } + if r.y0 < 0 { + r.y1 -= r.y0 + r.y0 = 0 + } + if r.x1 > o.size.Width { + r.x0 -= r.x1 - o.size.Width + r.x1 = o.size.Width + } + if r.y1 > o.size.Height { + r.y0 -= r.y1 - o.size.Height + r.y1 = o.size.Height + } + return r +} + +// resizedRect 按手柄算出新的选区,边不会被拖翻过去。 +func (o *overlay) resizedRect(pos fyne.Position) rect { + r := o.orig + x, y := pos.X, pos.Y + minW := float32(minSelect) + minH := float32(minSelect) + + switch o.handle { + case hTopLeft: + r.x0 = min(x, r.x1-minW) + r.y0 = min(y, r.y1-minH) + case hTopMid: + r.y0 = min(y, r.y1-minH) + case hTopRight: + r.x1 = max(x, r.x0+minW) + r.y0 = min(y, r.y1-minH) + case hRightMid: + r.x1 = max(x, r.x0+minW) + case hBottomRight: + r.x1 = max(x, r.x0+minW) + r.y1 = max(y, r.y0+minH) + case hBottomMid: + r.y1 = max(y, r.y0+minH) + case hBottomLeft: + r.x0 = min(x, r.x1-minW) + r.y1 = max(y, r.y0+minH) + case hLeftMid: + r.x0 = min(x, r.x1-minW) + } + return r +} + +// handleRects 返回八个手柄的矩形。 +func (o *overlay) handleRects() [8]rect { + r := o.sel + h := float32(handleSize) / 2 + mx, my := (r.x0+r.x1)/2, (r.y0+r.y1)/2 + center := func(x, y float32) rect { + return rect{x0: x - h, y0: y - h, x1: x + h, y1: y + h} + } + return [8]rect{ + center(r.x0, r.y0), // 左上 + center(mx, r.y0), // 上中 + center(r.x1, r.y0), // 右上 + center(r.x1, my), // 右中 + center(r.x1, r.y1), // 右下 + center(mx, r.y1), // 下中 + center(r.x0, r.y1), // 左下 + center(r.x0, my), // 左中 + } +} + +// abs32 返回绝对值。 +func abs32(v float32) float32 { + if v < 0 { + return -v + } + return v +} + +// ---------- 绘制 ---------- + +// updateSelection 重排遮罩、边框、手柄与尺寸提示。 func (o *overlay) updateSelection() { if o.size.Width <= 0 { return } w, h := o.size.Width, o.size.Height - x0, y0, x1, y1 := o.selection() - selW, selH := x1-x0, y1-y0 - - if selW < 2 || selH < 2 { + if !o.hasSel || o.sel.w() < 1 || o.sel.h() < 1 { // 尚未框选:整屏压暗 o.dims[0].Move(fyne.NewPos(0, 0)) o.dims[0].Resize(fyne.NewSize(w, h)) @@ -181,62 +478,77 @@ func (o *overlay) updateSelection() { for _, e := range o.edges { e.Resize(fyne.NewSize(0, 0)) } - o.hint.Text = "拖拽框选区域 · Esc 取消" + for _, hd := range o.handles { + hd.Resize(fyne.NewSize(0, 0)) + } + o.hint.Text = "拖拽框选区域 · Enter 确定 · Esc 取消" o.hint.Move(fyne.NewPos(theme.Padding()*2, theme.Padding()*2)) o.hint.Refresh() return } + r := o.sel // 上下左右四块压暗区域,挖出选区 o.dims[0].Move(fyne.NewPos(0, 0)) - o.dims[0].Resize(fyne.NewSize(w, y0)) - o.dims[1].Move(fyne.NewPos(0, y1)) - o.dims[1].Resize(fyne.NewSize(w, h-y1)) - o.dims[2].Move(fyne.NewPos(0, y0)) - o.dims[2].Resize(fyne.NewSize(x0, selH)) - o.dims[3].Move(fyne.NewPos(x1, y0)) - o.dims[3].Resize(fyne.NewSize(w-x1, selH)) + o.dims[0].Resize(fyne.NewSize(w, r.y0)) + o.dims[1].Move(fyne.NewPos(0, r.y1)) + o.dims[1].Resize(fyne.NewSize(w, h-r.y1)) + o.dims[2].Move(fyne.NewPos(0, r.y0)) + o.dims[2].Resize(fyne.NewSize(r.x0, r.h())) + o.dims[3].Move(fyne.NewPos(r.x1, r.y0)) + o.dims[3].Resize(fyne.NewSize(w-r.x1, r.h())) // 选区描边 const t = 2 - o.edges[0].Move(fyne.NewPos(x0, y0)) - o.edges[0].Resize(fyne.NewSize(selW, t)) - o.edges[1].Move(fyne.NewPos(x0, y1-t)) - o.edges[1].Resize(fyne.NewSize(selW, t)) - o.edges[2].Move(fyne.NewPos(x0, y0)) - o.edges[2].Resize(fyne.NewSize(t, selH)) - o.edges[3].Move(fyne.NewPos(x1-t, y0)) - o.edges[3].Resize(fyne.NewSize(t, selH)) + o.edges[0].Move(fyne.NewPos(r.x0, r.y0)) + o.edges[0].Resize(fyne.NewSize(r.w(), t)) + o.edges[1].Move(fyne.NewPos(r.x0, r.y1-t)) + o.edges[1].Resize(fyne.NewSize(r.w(), t)) + o.edges[2].Move(fyne.NewPos(r.x0, r.y0)) + o.edges[2].Resize(fyne.NewSize(t, r.h())) + o.edges[3].Move(fyne.NewPos(r.x1-t, r.y0)) + o.edges[3].Resize(fyne.NewSize(t, r.h())) - phys := o.mapping.ToPhysical(float64(x0), float64(y0), float64(selW), float64(selH)) - o.hint.Text = fmt.Sprintf("%d × %d", phys.W, phys.H) - hy := y0 - 26 - if hy < 4 { - hy = y0 + 6 + // 八个调整手柄,提示边界可以拖 + for i, hr := range o.handleRects() { + o.handles[i].Move(fyne.NewPos(hr.x0, hr.y0)) + o.handles[i].Resize(fyne.NewSize(hr.w(), hr.h())) } - o.hint.Move(fyne.NewPos(x0+6, hy)) + + phys := o.mapping.ToPhysical(float64(r.x0), float64(r.y0), float64(r.w()), float64(r.h())) + o.hint.Text = fmt.Sprintf("%d × %d", phys.W, phys.H) + hy := r.y0 - 26 + if hy < 4 { + hy = r.y0 + 6 + } + o.hint.Move(fyne.NewPos(r.x0+6, hy)) o.hint.Refresh() } +// ---------- 确认与取消 ---------- + // finish 确认选区。 func (o *overlay) finish() { if !o.calibrated { o.calibrate() } - x0, y0, x1, y1 := o.selection() - if x1-x0 < 6 || y1-y0 < 6 { + if !o.hasSel { + return + } + r := o.sel + if r.w() < minSelect || r.h() < minSelect { o.cancel() return } - r := o.mapping.ToPhysical(float64(x0), float64(y0), float64(x1-x0), float64(y1-y0)) - if r.W < 8 || r.H < 8 { + phys := o.mapping.ToPhysical(float64(r.x0), float64(r.y0), float64(r.w()), float64(r.h())) + if phys.W < 8 || phys.H < 8 { o.cancel() return } done := o.onDone o.close() if done != nil { - done(r) + done(phys) } } @@ -273,28 +585,31 @@ func (c *catcher) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent)) } -func (c *catcher) Dragged(e *fyne.DragEvent) { - if !c.o.dragging { - return - } - c.o.cur = e.Position - c.o.updateSelection() -} - -func (c *catcher) DragEnd() {} - +// MouseDown 按下鼠标:右键取消,左键按落点决定拖拽方式。 func (c *catcher) MouseDown(e *desktop.MouseEvent) { - c.o.dragging = true - c.o.start = e.Position - c.o.cur = e.Position - c.o.updateSelection() -} - -func (c *catcher) MouseUp(e *desktop.MouseEvent) { - if !c.o.dragging { + if e.Button == desktop.MouseButtonSecondary { + c.o.cancel() return } - c.o.dragging = false - c.o.cur = e.Position + c.o.beginDrag(e.Position) +} + +// Dragged 拖动中,实时更新选区。 +func (c *catcher) Dragged(e *fyne.DragEvent) { + c.o.updateDrag(e.Position) +} + +// DragEnd 松开鼠标。 +func (c *catcher) DragEnd(e *fyne.DragEvent) { + c.o.endDrag(e.Position) +} + +// MouseUp 松开鼠标(没拖动时也会走到这里)。 +func (c *catcher) MouseUp(e *desktop.MouseEvent) { + c.o.endDrag(e.Position) +} + +// DoubleTapped 双击选区内部,直接确认。 +func (c *catcher) DoubleTapped(e *fyne.PointEvent) { c.o.finish() } diff --git a/internal/ui/settings.go b/internal/ui/settings.go index b476279..bd0b457 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -30,6 +30,7 @@ type settingsWindow struct { // 模型接入:左边是接入点列表,右边是当前接入点的接口详情。 profileList *widget.List + emptyHint *widget.Label selIndex int loading bool // 回填表单时抑制回写 @@ -53,6 +54,7 @@ type settingsWindow struct { maxEdge *widget.Entry quality *widget.Entry diff *widget.Entry + sens *widget.Entry cooldown *widget.Entry stable *widget.Entry turns *widget.Entry @@ -72,6 +74,9 @@ func newSettingsWindow(app *App) *settingsWindow { s.cfg = app.eng.Config().Clone() s.win = app.fyne.NewWindow(settingsTitle) s.win.Resize(fyne.NewSize(900, 700)) + // 点关闭只是隐藏窗口:Fyne 的窗口一旦 Close 就被销毁, + // 工具栏的设置按钮会再也打不开它。 + s.win.SetCloseIntercept(func() { s.win.Hide() }) s.tabs = container.NewAppTabs( container.NewTabItem("模型", s.buildModelTab()), container.NewTabItem("提示词", s.buildTemplateTab()), @@ -103,16 +108,10 @@ func (s *settingsWindow) reload() { s.cfg = s.app.eng.Config().Clone() s.loading = true - s.profileList.Refresh() - idx := s.cfg.ProfileIndex(s.cfg.Active) - if idx < 0 { - idx = 0 - } - s.selIndex = idx - s.profileList.Select(idx) - // 选中的项没变化时 List 不会再回调,这里显式回填一次表单。 - s.loadProfileToForm() + s.selIndex = -1 + s.syncSelection() s.loading = false + s.refreshList() keys := make([]string, 0, len(s.cfg.Templates)) for _, t := range s.cfg.Templates { @@ -127,6 +126,7 @@ func (s *settingsWindow) reload() { s.maxEdge.SetText(strconv.Itoa(c.MaxEdge)) s.quality.SetText(strconv.Itoa(c.JPEGQuality)) s.diff.SetText(strconv.Itoa(c.DiffThreshold)) + s.sens.SetText(fmt.Sprintf("%g", c.DiffSensitivity)) s.cooldown.SetText(strconv.Itoa(c.CooldownMs)) s.stable.SetText(strconv.Itoa(c.StableFrames)) s.turns.SetText(strconv.Itoa(c.HistoryTurns)) @@ -202,12 +202,16 @@ func (s *settingsWindow) buildProfilePane() fyne.CanvasObject { ) s.profileList.OnSelected = func(id widget.ListItemID) { s.selectProfile(int(id)) } + s.emptyHint = widget.NewLabel("还没有接入点,点「新增」添加一个") + s.emptyHint.Importance = widget.LowImportance + s.emptyHint.Hide() + addBtn := widget.NewButtonWithIcon("新增", theme.ContentAddIcon(), func() { s.askNewFormat() }) dupBtn := widget.NewButtonWithIcon("复制", theme.ContentCopyIcon(), func() { s.duplicateProfile() }) delBtn := widget.NewButtonWithIcon("删除", theme.DeleteIcon(), func() { s.deleteProfile() }) useBtn := widget.NewButton("设为生效", func() { s.activateProfile() }) - top := container.NewVBox(widget.NewLabel("接入点"), widget.NewSeparator()) + top := container.NewVBox(widget.NewLabel("接入点"), widget.NewSeparator(), s.emptyHint) bottom := container.NewVBox( widget.NewSeparator(), container.NewHBox(addBtn, dupBtn, delBtn), @@ -413,8 +417,8 @@ func (s *settingsWindow) askNewFormat() { // addProfile 按选定格式新增一份接入点。 func (s *settingsWindow) addProfile(format string) { p := s.cfg.AddProfile(config.NewProfile("接入点", format)) - s.profileList.Refresh() - s.profileList.Select(s.cfg.ProfileIndex(p.Name)) + s.selectInList(s.cfg.ProfileIndex(p.Name)) + s.applyNow("已新增接入点 " + p.Name) } // duplicateProfile 复制当前接入点。 @@ -427,34 +431,40 @@ func (s *settingsWindow) duplicateProfile() { cp.Name = s.cfg.UniqueName(p.Name + " 副本") cp.ExtraHeaders = copyHeaders(p.ExtraHeaders) s.cfg.Models = append(s.cfg.Models, cp) - s.profileList.Refresh() - s.profileList.Select(s.cfg.ProfileIndex(cp.Name)) + s.selectInList(s.cfg.ProfileIndex(cp.Name)) + s.applyNow("已复制为接入点 " + cp.Name) +} + +// selectInList 选中列表里的第 i 项,并把右边的表单切过去。 +func (s *settingsWindow) selectInList(i int) { + if i < 0 || i >= len(s.cfg.Models) { + return + } + s.selIndex = i + // 先取消再选,保证 OnSelected 一定回调,表单随之刷新。 + s.profileList.UnselectAll() + s.profileList.Select(i) } // deleteProfile 删除当前接入点。 +// 删除立即生效:直接应用到引擎并落盘,关掉设置窗口也不会"复活"。 func (s *settingsWindow) deleteProfile() { p, ok := s.profileAt(s.selIndex) if !ok { return } - dialog.NewConfirm("删除接入点", "确定删除「"+p.Name+"」?", func(confirmed bool) { + // 注意用 ShowConfirm:NewConfirm 只创建不显示,点「删除」会毫无反应。 + dialog.ShowConfirm("删除接入点", "确定删除「"+p.Name+"」?", func(confirmed bool) { if !confirmed { return } - s.cfg.RemoveModel(p.Name) - s.profileList.Refresh() + if !s.cfg.RemoveModel(p.Name) { + return + } if s.cfg.ProfileIndex(s.cfg.Active) < 0 { s.cfg.Active = "" } - if len(s.cfg.Models) == 0 { - s.selIndex = -1 - return - } - idx := s.selIndex - if idx >= len(s.cfg.Models) { - idx = len(s.cfg.Models) - 1 - } - s.profileList.Select(idx) + s.applyNow("已删除接入点 " + p.Name) }, s.win) } @@ -465,7 +475,78 @@ func (s *settingsWindow) activateProfile() { return } s.cfg.Active = p.Name + s.applyNow("已切换到接入点 " + p.Name) +} + +// applyNow 把当前改动立即应用到引擎并落盘。 +// +// 接入点列表的增删改属于结构变化,必须当场生效:只改设置页里的副本的话, +// 关掉窗口就丢了,用户会以为"删不掉"。 +func (s *settingsWindow) applyNow(notice string) { + // 配置里至少要保留一个接入点,删光后 Normalize 会补一个空白项。 + emptied := len(s.cfg.Models) == 0 + + if err := s.app.applyAndSave(s.cfg); err != nil { + fyne.Do(func() { s.app.chat.addNotice("操作失败:" + err.Error()) }) + return + } + s.syncSelection() + s.refreshList() + + if emptied { + notice += "(接入点已清空,点「新增」可以再加一个)" + } + if notice != "" { + fyne.Do(func() { s.app.chat.addNotice(notice) }) + } +} + +// syncSelection 让列表选中项、右边表单和配置保持一致。 +// +// 不能只靠 List.Select 的回调:Fyne 在 id 没变时会直接返回, +// 不会触发 OnSelected,表单就会继续显示已经删掉的接入点。 +func (s *settingsWindow) syncSelection() { + if len(s.cfg.Models) == 0 { + s.selIndex = -1 + s.profileList.UnselectAll() + s.clearProfileForm() + return + } + idx := s.selIndex + if idx < 0 || idx >= len(s.cfg.Models) { + idx = s.cfg.ProfileIndex(s.cfg.Active) + } + if idx < 0 || idx >= len(s.cfg.Models) { + idx = 0 + } + s.selectInList(idx) +} + +// refreshList 刷新列表,并按接入点数量显示/隐藏空列表提示。 +func (s *settingsWindow) refreshList() { s.profileList.Refresh() + if s.emptyHint == nil { + return + } + if len(s.cfg.Models) == 0 { + s.emptyHint.Show() + } else { + s.emptyHint.Hide() + } +} + +// clearProfileForm 清空右边的表单(一个接入点都没有时用)。 +func (s *settingsWindow) clearProfileForm() { + prev := s.loading + s.loading = true + s.nameEntry.SetText("") + s.baseEntry.SetText("") + s.modelEntry.SetText("") + s.keyEntry.SetText("") + s.maxEntry.SetText("") + s.tempEntry.SetText("") + s.headerEntry.SetText("") + s.loading = prev } // testConnection 用当前表单的值测试一次连通性。 @@ -523,6 +604,7 @@ func (s *settingsWindow) buildCaptureTab() fyne.CanvasObject { s.maxEdge = widget.NewEntry() s.quality = widget.NewEntry() s.diff = widget.NewEntry() + s.sens = widget.NewEntry() s.cooldown = widget.NewEntry() s.stable = widget.NewEntry() s.turns = widget.NewEntry() @@ -532,10 +614,11 @@ func (s *settingsWindow) buildCaptureTab() fyne.CanvasObject { entry("图片长边上限", s.maxEdge), entry("JPEG 质量", s.quality), entry("变化阈值", s.diff), + entry("变化灵敏度", s.sens), entry("冷却时间 (ms)", s.cooldown), entry("稳定帧数", s.stable), entry("历史轮数", s.turns), - widget.NewLabel("说明:变化阈值越小越灵敏(默认 5);冷却时间决定两次自动提问的最小间隔。"), + widget.NewLabel("说明:变化阈值越小越灵敏(默认 5,基于感知哈希);变化灵敏度是细节网格的归一化平均差阈值(0~1,越小越灵敏,默认 0.01),专门用来捕捉「布局没变、只是文字/题目变了」这类更新。两者任一超阈值即触发。"), ) saveBtn := widget.NewButton("保存并应用", func() { @@ -634,6 +717,7 @@ func (s *settingsWindow) commitCapture() { s.cfg.Capture.MaxEdge = atoiDefault(s.maxEdge.Text, 1280) s.cfg.Capture.JPEGQuality = atoiDefault(s.quality.Text, 75) s.cfg.Capture.DiffThreshold = atoiDefault(s.diff.Text, 5) + s.cfg.Capture.DiffSensitivity = atofDefault(s.sens.Text, 0.01) s.cfg.Capture.CooldownMs = atoiDefault(s.cooldown.Text, 3000) s.cfg.Capture.StableFrames = atoiDefault(s.stable.Text, 2) s.cfg.Capture.HistoryTurns = atoiDefault(s.turns.Text, 6) diff --git a/internal/vision/hash.go b/internal/vision/hash.go index 444cbf8..6dc4299 100644 --- a/internal/vision/hash.go +++ b/internal/vision/hash.go @@ -7,11 +7,17 @@ import ( "golang.org/x/image/draw" ) +// detailSize 是细节网格的边长。相比 8×8 的感知哈希,这份高分辨率灰度 +// 网格专门用来捕捉"布局没变、只是文字/局部内容变了"这类细粒度变化—— +// 题目换了但背景一样时,粗哈希几乎不动,细节网格的平均差却会明显跳变。 +const detailSize = 64 + // Fingerprint 是用于判断"画面有没有变化"的感知哈希。 type Fingerprint struct { - AHash uint64 // 均值哈希 - DHash uint64 // 差分哈希 - Mean uint8 // 平均亮度 + AHash uint64 // 均值哈希(8×8,抗噪、粗筛) + DHash uint64 // 差分哈希(8×8,抗噪、粗筛) + Mean uint8 // 平均亮度 + Detail []byte // 细节灰度网格(detailSize×detailSize),用于细粒度内容差 } // grayGrid 把图像缩放到 w×h 的灰度图。 @@ -55,7 +61,13 @@ func Of(img image.Image) Fingerprint { } } - return Fingerprint{AHash: aHash, DHash: dHash, Mean: mean} + // 细节网格:把画面缩到 detailSize×detailSize 的灰度图, + // 用于和上一帧做逐像素的归一化平均差(MAD),比感知哈希灵敏得多。 + detailGrid := grayGrid(img, detailSize, detailSize) + detail := make([]byte, len(detailGrid.Pix)) + copy(detail, detailGrid.Pix) + + return Fingerprint{AHash: aHash, DHash: dHash, Mean: mean, Detail: detail} } // Distance 返回两个指纹的汉明距离(取两种哈希的较大值,偏向灵敏)。 @@ -63,12 +75,39 @@ func (f Fingerprint) Distance(o Fingerprint) int { return max(bitsSet(f.AHash^o.AHash), bitsSet(f.DHash^o.DHash)) } +// DetailDiff 返回两份细节网格的归一化平均绝对差(0~1)。 +// 它衡量"逐像素平均变了多少",对文字替换、局部内容更新这类变化远比对 +// 比哈希敏感,是提升画面更新识别灵敏度的主力信号。 +func (f Fingerprint) DetailDiff(o Fingerprint) float64 { + a, b := f.Detail, o.Detail + if len(a) == 0 || len(b) == 0 || len(a) != len(b) { + return 0 + } + var sum int + for i := range a { + d := int(a[i]) - int(b[i]) + if d < 0 { + d = -d + } + sum += d + } + return float64(sum) / (float64(len(a)) * 255.0) +} + // Changed 判断画面是否发生了足以触发提问的变化。 -func (f Fingerprint) Changed(o Fingerprint, threshold int) bool { +// 任一信号超过阈值即判定为变化:感知哈希(抗噪粗筛)命中,或细节网格的 +// 归一化平均差超过 sensitivity(细粒度内容变化)。 +func (f Fingerprint) Changed(o Fingerprint, threshold int, sensitivity float64) bool { if threshold <= 0 { threshold = 5 } - return f.Distance(o) > threshold + if sensitivity <= 0 { + sensitivity = 0.01 + } + if f.Distance(o) > threshold { + return true + } + return f.DetailDiff(o) > sensitivity } func bitsSet(v uint64) int { diff --git a/internal/vision/hash_test.go b/internal/vision/hash_test.go index f7efb3e..fafb67d 100644 --- a/internal/vision/hash_test.go +++ b/internal/vision/hash_test.go @@ -24,7 +24,7 @@ func TestFingerprintIdentical(t *testing.T) { if d := Of(a).Distance(Of(b)); d != 0 { t.Fatalf("相同画面应无差异,实际距离 %d", d) } - if Of(a).Changed(Of(b), 5) { + if Of(a).Changed(Of(b), 5, 0.01) { t.Fatal("相同画面不应判定为变化") } } @@ -37,11 +37,27 @@ func TestFingerprintDetectsChange(t *testing.T) { changed.SetGray(x, y, color.Gray{Y: 250}) } } - if !Of(changed).Changed(Of(base), 5) { + if !Of(changed).Changed(Of(base), 5, 0.01) { t.Fatal("右半边变白应被判定为变化") } } +func TestFingerprintDetectsSubtleLocalChange(t *testing.T) { + // 整块画面背景一致,只有一小块文字/内容变了:8×8 感知哈希会被平均掉, + // 但高分辨率细节网格的归一化平均差能可靠捕捉到。这正是"题目变了却检测 + // 不出来"这类问题的修复目标。 + base := solid(256, 256, color.Gray{Y: 200}) + changed := solid(256, 256, color.Gray{Y: 200}) + for y := 40; y < 90; y++ { + for x := 40; x < 90; x++ { + changed.SetGray(x, y, color.Gray{Y: 20}) + } + } + if !Of(changed).Changed(Of(base), 5, 0.01) { + t.Fatal("局部小区域变化应被细节网格判定为变化") + } +} + func TestEncodeJPEGResizesLongEdge(t *testing.T) { src := solid(2000, 400, color.Gray{Y: 128}) raw, err := EncodeJPEG(src, 1280, 75) diff --git a/internal/win32/frame.go b/internal/win32/frame.go index 83ee108..0d0bd3e 100644 --- a/internal/win32/frame.go +++ b/internal/win32/frame.go @@ -2,17 +2,21 @@ package win32 // 取景框:屏幕上的一个红框,常驻显示当前框选区域。 // -// 三个关键点: -// 1. 用 WS_EX_LAYERED 分层窗口自己画,框外像素 alpha=0,既不遮挡下方内容, +// 四个关键点: +// 1. 用 WS_EX_LAYERED 分层窗口自己画,框内像素 alpha=0,既不遮挡下方内容, // 也不需要 Fyne 的透明窗口支持(OpenGL 窗口改颜色键并不可靠); -// 2. WS_EX_TRANSPARENT + WM_NCHITTEST 返回 HTTRANSPARENT,鼠标事件直接穿透, -// 不会挡住底下的点击; -// 3. SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE) 让它不出现在截屏和录屏 +// 2. 整个窗口带 WS_EX_TRANSPARENT,鼠标事件一律穿透给下层程序, +// 被监视的软件照常能点、能拖,不会被取景框挡住; +// 3. 要跟红框交互(拖边框改大小、右上角「确定 / 取消」按钮), +// 靠一个全局低级鼠标钩子(WH_MOUSE_LL)在事件到达下层程序之前拿到它们, +// 只在自己的小区域里拦截,其余地方一律放行; +// 4. SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE) 让它不出现在截屏和录屏 // 画面里,包括本程序自己抓屏用的 GDI BitBlt(Windows 10 2004+)。 import ( "fmt" "os" + "runtime" "sync" "syscall" "unsafe" @@ -51,10 +55,57 @@ const ( // htTransparent 让鼠标事件穿透到下层窗口。 htTransparent = ^uintptr(0) + + // 低级鼠标钩子。 + whMouseLL = 14 + hcAction = 0 + + wmMouseMove = 0x0200 + wmLButtonDown = 0x0201 + wmLButtonUp = 0x0202 + + // 标准尺寸光标。 + idcArrow = 32512 + idcSizeNS = 32645 + idcSizeWE = 32644 + idcSizeNESW = 32643 + idcSizeNWSE = 32642 +) + +// 取景框上的交互区。 +const ( + zoneNone = iota + zoneLeft + zoneTop + zoneRight + zoneBottom + zoneTopLeft + zoneTopRight + zoneBottomLeft + zoneBottomRight + zoneButtons +) + +// 按钮编号。 +const ( + btnNone = iota + btnOK + btnCancel +) + +// 取景框的尺寸常量(物理像素)。 +const ( + frameGrip = 6 // 边框的拖拽响应宽度 + frameMinSide = 16 // 选区允许的最小边长 + frameBtnSize = 16 // 按钮边长 + frameBtnGap = 4 // 两个按钮之间的间距 + frameBtnRight = 8 // 按钮条距右边 + frameBtnTop = 8 // 按钮条距上边 ) var ( gdi32 = windows.NewLazySystemDLL("gdi32.dll") + km32 = windows.NewLazySystemDLL("kernel32.dll") procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC") procCreateDIBSection = gdi32.NewProc("CreateDIBSection") @@ -76,6 +127,18 @@ var ( procSetDisplayAffinity = user32.NewProc("SetWindowDisplayAffinity") procGetDisplayAffinity = user32.NewProc("GetWindowDisplayAffinity") procIsWindowVisible = user32.NewProc("IsWindowVisible") + + procSetTimer = user32.NewProc("SetTimer") + procKillTimer = user32.NewProc("KillTimer") + procSetCapture = user32.NewProc("SetCapture") + procReleaseCapture = user32.NewProc("ReleaseCapture") + procSetCursor = user32.NewProc("SetCursor") + procLoadCursorW = user32.NewProc("LoadCursorW") + + procSetWindowsHookExW = user32.NewProc("SetWindowsHookExW") + procUnhookWindowsHookEx = user32.NewProc("UnhookWindowsHookEx") + procCallNextHookEx = user32.NewProc("CallNextHookEx") + procGetModuleHandleW = km32.NewProc("GetModuleHandleW") ) type bitmapInfoHeader struct { @@ -127,14 +190,37 @@ type wndClassEx struct { IconSmall windows.Handle } +// msllHookStruct 对应 Win32 的 MSLLHOOKSTRUCT。 +type msllHookStruct struct { + Pt point + MouseData uint32 + Flags uint32 + Time uint32 + DwExtraInfo uintptr +} + // Frame 是屏幕上的取景框。内部自带一条消息线程, // 窗口消息必须有人泵,否则窗口不会响应。 type Frame struct { mu sync.Mutex hwnd syscall.Handle - region Rect + region Rect // 已生效的区域 + draft Rect // 正在编辑的区域(拖拽时变化) shown bool + hidden bool // 用户点了取消:红框不再自动显示 + hoverZone int // 当前悬停的交互区 + hoverBtn bool // 按钮条是否处于悬停状态 + pressed int // 按下的按钮 + dragging bool + dragZone int + dragStartX, dragStartY int + dragBase Rect + + onApply func(Rect) + onCancel func() + + hook syscall.Handle memDC syscall.Handle dib syscall.Handle oldBmp syscall.Handle @@ -156,6 +242,12 @@ var ( frames = map[syscall.Handle]*Frame{} ) +// active 是当前生效的取景框,鼠标钩子里要用。 +var ( + activeMu sync.Mutex + active *Frame +) + // NewFrame 创建一个取景框。此时还没有区域,调用 SetRegion 才会显示。 func NewFrame() (*Frame, error) { f := &Frame{ @@ -171,6 +263,12 @@ func NewFrame() (*Frame, error) { // loop 在独立线程上建窗口并跑消息循环。 func (f *Frame) loop() { + // 必须把 goroutine 钉在同一个系统线程上:窗口的消息队列是按线程分的, + // 一旦被调度到别的线程,GetMessage 就泵不到这个窗口的队列, + // 红框从此不再重绘,看起来就是"卡死"。 + runtime.LockOSThread() + defer runtime.UnlockOSThread() + className, err := windows.UTF16PtrFromString(frameWNDClass) if err != nil { f.ready <- err @@ -194,6 +292,8 @@ func (f *Frame) loop() { } } + // WS_EX_TRANSPARENT:整个窗口对鼠标透明,事件直接穿透给下层程序。 + // 交互交给全局鼠标钩子处理,这样绝不会挡住被监视的软件。 style := uintptr(wsExLayered | wsExTransparent | wsExToolWindow | wsExNoActivate) hwnd, _, _ := procCreateWindowExW.Call( style, @@ -233,6 +333,17 @@ func (f *Frame) loop() { procReleaseDC.Call(0, hdcScreen) } + // 装全局低级鼠标钩子:取景框是穿透窗口,收不到鼠标消息, + // 只能在事件到达下层程序之前截住。 + activeMu.Lock() + active = f + activeMu.Unlock() + hmod, _, _ := procGetModuleHandleW.Call(0) + hh, _, _ := procSetWindowsHookExW.Call(whMouseLL, frameHookCallback, hmod, 0) + f.mu.Lock() + f.hook = syscall.Handle(hh) + f.mu.Unlock() + f.ready <- nil var msg msg @@ -251,14 +362,19 @@ func (f *Frame) loop() { func (f *Frame) SetRegion(r Rect) { f.mu.Lock() f.region = r - f.shown = r.Width() > 0 && r.Height() > 0 + // 拖拽过程中不要把正在编辑的区域顶掉。 + if !f.dragging { + f.draft = r + } + show := r.Width() > 0 && r.Height() > 0 && !f.hidden + f.shown = show hwnd := f.hwnd f.mu.Unlock() if hwnd == 0 { return } - if r.Width() <= 0 || r.Height() <= 0 { + if !show { procShowWindow.Call(uintptr(hwnd), 0) // SW_HIDE return } @@ -266,17 +382,70 @@ func (f *Frame) SetRegion(r Rect) { procPostMessageW.Call(uintptr(hwnd), wmFrameRedraw, 0, 0) } -// Hide 隐藏取景框。 +// Show 重新显示取景框(会清掉"用户已取消"的状态)。 +func (f *Frame) Show() { + f.mu.Lock() + f.hidden = false + r := f.region + f.mu.Unlock() + f.SetRegion(r) +} + +// Hide 隐藏取景框(临时隐藏,不会阻止 SetRegion 再次显示它)。 func (f *Frame) Hide() { f.mu.Lock() f.shown = false hwnd := f.hwnd f.mu.Unlock() if hwnd != 0 { + procKillTimer.Call(uintptr(hwnd), 1) procShowWindow.Call(uintptr(hwnd), 0) } } +// HideByUser 按用户意图隐藏取景框:之后 SetRegion 也不会把它显示出来。 +func (f *Frame) HideByUser() { + f.mu.Lock() + f.hidden = true + f.shown = false + f.hoverBtn = false + f.pressed = btnNone + hwnd := f.hwnd + f.mu.Unlock() + if hwnd != 0 { + procKillTimer.Call(uintptr(hwnd), 1) + procShowWindow.Call(uintptr(hwnd), 0) + } +} + +// ToggleVisible 切换取景框的显示状态,返回切换后是否可见。 +func (f *Frame) ToggleVisible() bool { + f.mu.Lock() + visible := f.shown + f.mu.Unlock() + if visible { + f.HideByUser() + return false + } + f.Show() + return true +} + +// Visible 返回取景框当前是否显示。 +func (f *Frame) Visible() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.shown +} + +// SetCallbacks 设置交互回调:确定时回传当前框体大小,取消时通知上层。 +func (f *Frame) SetCallbacks(onApply func(Rect), onCancel func()) { + f.mu.Lock() + f.onApply = onApply + f.onCancel = onCancel + f.mu.Unlock() +} + // SetCaptureExclusion 开关某个窗口的"不出现在截屏/录屏里"。 // 关掉之后窗口会被录进去,只用于自检和调试。 func SetCaptureExclusion(hwnd syscall.Handle, on bool) { @@ -360,16 +529,28 @@ func FrameDebug(hwnd syscall.Handle) string { // Close 销毁取景框。 func (f *Frame) Close() { + // 先摘掉钩子,别让已经关掉的取景框再拦截鼠标。 + activeMu.Lock() + if active == f { + active = nil + } + activeMu.Unlock() + f.mu.Lock() hwnd := f.hwnd threadID := f.threadID + hook := f.hook f.hwnd = 0 + f.hook = 0 memDC := f.memDC dib := f.dib oldBmp := f.oldBmp f.memDC, f.dib, f.oldBmp = 0, 0, 0 f.mu.Unlock() + if hook != 0 { + procUnhookWindowsHookEx.Call(uintptr(hook)) + } if hwnd != 0 { framesMu.Lock() delete(frames, syscall.Handle(hwnd)) @@ -391,10 +572,345 @@ func (f *Frame) Close() { } } +// ---------- 交互 ---------- + +// buttonRects 返回「确定」和「取消」两个按钮在框内坐标系下的矩形。 +func (f *Frame) buttonRects(w int) (okR, cancelR [4]int) { + right := w - frameBtnRight + cancelR = [4]int{right - frameBtnSize, frameBtnTop, right, frameBtnTop + frameBtnSize} + okR = [4]int{cancelR[0] - frameBtnGap - frameBtnSize, frameBtnTop, + cancelR[0] - frameBtnGap, frameBtnTop + frameBtnSize} + return +} + +// buttonBar 返回按钮条(悬停区)在框内坐标系下的范围。 +func (f *Frame) buttonBar(w int) (x0, y0, x1, y1 int) { + okR, cancelR := f.buttonRects(w) + x0 = okR[0] - 2 + y0 = frameBtnTop - 2 + x1 = cancelR[2] + 2 + y1 = cancelR[3] + 2 + return +} + +// hitTest 判断屏幕坐标落在哪个交互区。 +func (f *Frame) hitTest(r Rect, sx, sy int) int { + w, h := r.Width(), r.Height() + if w <= 0 || h <= 0 { + return zoneNone + } + lx, ly := sx-r.Left, sy-r.Top + if lx < 0 || ly < 0 || lx >= w || ly >= h { + return zoneNone + } + + // 按钮条优先:它就贴在右上角内侧,和边角拖拽区不重叠。 + bx0, by0, bx1, by1 := f.buttonBar(w) + if lx >= bx0 && lx < bx1 && ly >= by0 && ly < by1 { + return zoneButtons + } + + const grip = frameGrip + nearL := lx < grip + nearR := lx >= w-grip + nearT := ly < grip + nearB := ly >= h-grip + switch { + case nearL && nearT: + return zoneTopLeft + case nearR && nearT: + return zoneTopRight + case nearR && nearB: + return zoneBottomRight + case nearL && nearB: + return zoneBottomLeft + case nearL: + return zoneLeft + case nearR: + return zoneRight + case nearT: + return zoneTop + case nearB: + return zoneBottom + } + return zoneNone +} + +// buttonAt 判断点在按钮条里的哪个按钮上。 +func (f *Frame) buttonAt(r Rect, sx, sy int) int { + w := r.Width() + okR, cancelR := f.buttonRects(w) + lx := sx - r.Left + ly := sy - r.Top + inOK := lx >= okR[0] && lx < okR[2] && ly >= okR[1] && ly < okR[3] + inCancel := lx >= cancelR[0] && lx < cancelR[2] && ly >= cancelR[1] && ly < cancelR[3] + switch { + case inOK: + return btnOK + case inCancel: + return btnCancel + } + return btnNone +} + +// frameHookCallback 是全局低级鼠标钩子的入口。 +var frameHookCallback = windows.NewCallback(mouseHookTrampoline) + +// mouseHookTrampoline 处理全局鼠标事件。返回非 0 表示吞掉这次事件。 +func mouseHookTrampoline(hhk int32, nCode uint32, wParam uint32, lParam uintptr) uintptr { + // 钩子里 panic 会直接崩掉整个程序,这里兜一下。 + defer func() { _ = recover() }() + + if nCode == hcAction { + activeMu.Lock() + f := active + activeMu.Unlock() + if f != nil && f.handleHook(wParam, lParam) { + return 1 // 吞掉,别让下层程序收到 + } + } + r, _, _ := procCallNextHookEx.Call(uintptr(hhk), uintptr(wParam), lParam) + return r +} + +// hookStruct 把钩子回调传来的 lParam 还原成 MSLLHOOKSTRUCT。 +// +// 直接写 unsafe.Pointer(lParam) 会被 vet 判成 uintptr 误用, +// 这里用"取变量地址再按指针读"的等价写法绕开。 +func hookStruct(lParam uintptr) *msllHookStruct { + p := *(*unsafe.Pointer)(unsafe.Pointer(&lParam)) + return (*msllHookStruct)(p) +} + +// handleHook 处理钩子收到的事件。返回 true 表示这次事件被取景框接管。 +func (f *Frame) handleHook(wParam uint32, lParam uintptr) bool { + hs := hookStruct(lParam) + x, y := int(hs.Pt.X), int(hs.Pt.Y) + switch wParam { + case wmMouseMove: + return f.hookMove(x, y) + case wmLButtonDown: + return f.hookDown(x, y) + case wmLButtonUp: + return f.hookUp(x, y) + } + return false +} + +// hookMove 处理鼠标移动:更新悬停状态与光标,拖拽时更新选区。 +func (f *Frame) hookMove(x, y int) bool { + f.mu.Lock() + if !f.shown || f.hidden { + f.mu.Unlock() + return false + } + dragging := f.dragging + d := f.draft + f.mu.Unlock() + + if dragging { + f.applyDrag(x, y) + // 拖拽期间吞掉移动事件,下层程序别跟着乱动。 + return true + } + + zone := f.hitTest(d, x, y) + showBtns := zone == zoneButtons + f.mu.Lock() + f.hoverZone = zone + changed := f.hoverBtn != showBtns + f.hoverBtn = showBtns + f.mu.Unlock() + + if changed { + f.redraw() + } + if zone == zoneNone { + return false + } + if zone == zoneButtons { + // 按钮条:光标用默认箭头就行,不用拦下层程序的移动事件, + // 免得那一小块区域的悬停效果失效。 + return false + } + f.setCursorFor(zone) + // 吞掉:下层程序收不到移动事件,我们的缩放光标才不会被它改回去。 + return true +} + +// hookDown 处理鼠标按下:按钮或边框按下都接管,其余放行。 +func (f *Frame) hookDown(x, y int) bool { + f.mu.Lock() + if !f.shown || f.hidden { + f.mu.Unlock() + return false + } + d := f.draft + hoverBtn := f.hoverBtn + f.mu.Unlock() + + switch zone := f.hitTest(d, x, y); zone { + case zoneButtons: + if !hoverBtn { + // 按钮还没显示出来(鼠标刚扫过去),这次点击让给下层程序。 + return false + } + f.mu.Lock() + f.pressed = f.buttonAt(d, x, y) + f.mu.Unlock() + return true + case zoneNone: + return false + default: + f.mu.Lock() + f.dragging = true + f.dragZone = zone + f.dragStartX, f.dragStartY = x, y + f.dragBase = d + f.mu.Unlock() + return true + } +} + +// hookUp 处理鼠标松开。 +func (f *Frame) hookUp(x, y int) bool { + f.mu.Lock() + dragging := f.dragging + pressed := f.pressed + f.dragging = false + f.pressed = btnNone + f.mu.Unlock() + + if dragging { + // 松手后不立刻生效,等用户点「确定」。 + f.redraw() + return true + } + switch pressed { + case btnOK: + f.applyDraft() + return true + case btnCancel: + f.cancel() + return true + } + return false +} + +// applyDrag 按当前鼠标位置算出新的框。 +func (f *Frame) applyDrag(sx, sy int) { + f.mu.Lock() + zone := f.dragZone + base := f.dragBase + startX, startY := f.dragStartX, f.dragStartY + f.mu.Unlock() + if zone == zoneNone { + return + } + + dx := sx - startX + dy := sy - startY + r := base + vd := VirtualScreen() + minSide := frameMinSide + + clamp := func(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v + } + + switch zone { + case zoneLeft: + r.Left = clamp(base.Left+dx, vd.Left, base.Right-minSide) + case zoneRight: + r.Right = clamp(base.Right+dx, base.Left+minSide, vd.Right) + case zoneTop: + r.Top = clamp(base.Top+dy, vd.Top, base.Bottom-minSide) + case zoneBottom: + r.Bottom = clamp(base.Bottom+dy, base.Top+minSide, vd.Bottom) + case zoneTopLeft: + r.Left = clamp(base.Left+dx, vd.Left, base.Right-minSide) + r.Top = clamp(base.Top+dy, vd.Top, base.Bottom-minSide) + case zoneTopRight: + r.Right = clamp(base.Right+dx, base.Left+minSide, vd.Right) + r.Top = clamp(base.Top+dy, vd.Top, base.Bottom-minSide) + case zoneBottomLeft: + r.Left = clamp(base.Left+dx, vd.Left, base.Right-minSide) + r.Bottom = clamp(base.Bottom+dy, base.Top+minSide, vd.Bottom) + case zoneBottomRight: + r.Right = clamp(base.Right+dx, base.Left+minSide, vd.Right) + r.Bottom = clamp(base.Bottom+dy, base.Top+minSide, vd.Bottom) + } + + f.mu.Lock() + f.draft = r + f.mu.Unlock() + f.redraw() +} + +// applyDraft 把当前编辑中的区域交给上层(点「确定」)。 +func (f *Frame) applyDraft() { + f.mu.Lock() + d := f.draft + cb := f.onApply + f.mu.Unlock() + if cb != nil { + cb(d) + } +} + +// cancel 放弃当前编辑并隐藏红框(点「取消」)。 +func (f *Frame) cancel() { + f.mu.Lock() + f.draft = f.region + f.hidden = true + f.shown = false + f.hoverBtn = false + f.pressed = btnNone + hwnd := f.hwnd + cb := f.onCancel + f.mu.Unlock() + + if hwnd != 0 { + procShowWindow.Call(uintptr(hwnd), 0) + } + if cb != nil { + cb() + } +} + +// setCursorFor 按交互区设置光标形状。 +func (f *Frame) setCursorFor(zone int) { + id := uintptr(idcArrow) + switch zone { + case zoneLeft, zoneRight: + id = idcSizeWE + case zoneTop, zoneBottom: + id = idcSizeNS + case zoneTopLeft, zoneBottomRight: + id = idcSizeNWSE + case zoneTopRight, zoneBottomLeft: + id = idcSizeNESW + } + cur, _, _ := procLoadCursorW.Call(0, id) + if cur == 0 { + return + } + procSetCursor.Call(cur) +} + +// ---------- 绘制 ---------- + // redraw 重新绘制取景框(运行在窗口自己的线程上)。 func (f *Frame) redraw() { f.mu.Lock() - r := f.region + r := f.draft + hoverBtn := f.hoverBtn memDC := f.memDC hwnd := f.hwnd f.mu.Unlock() @@ -415,6 +931,9 @@ func (f *Frame) redraw() { fillRect(f.buf, w, h, 0, h-thickness, w, h, red) fillRect(f.buf, w, h, 0, 0, thickness, h, red) fillRect(f.buf, w, h, w-thickness, 0, w, h, red) + + f.drawButtons(w, h, hoverBtn) + copy(unsafe.Slice((*uint32)(f.bits), len(f.buf)), f.buf) hdcScreen, _, _ := procGetDC.Call(0) @@ -446,6 +965,25 @@ func (f *Frame) redraw() { procSetWindowPos.Call(uintptr(hwnd), hwndTopMost, 0, 0, 0, 0, swpNoMove|swpNoSize|swpNoActivate) } +// drawButtons 画右上角的「确定 / 取消」。 +// +// 平时完全不画(像素全透明),鼠标移上去才显示。 +// 鼠标事件由全局钩子处理,这里不需要留任何"能点到的底"。 +func (f *Frame) drawButtons(w, h int, visible bool) { + if !visible { + return + } + okR, cancelR := f.buttonRects(w) + + bg := uint32(0xFF1E1E1E) + fillRect(f.buf, w, h, int(okR[0]), int(okR[1]), int(okR[2]), int(okR[3]), bg) + fillRect(f.buf, w, h, int(cancelR[0]), int(cancelR[1]), int(cancelR[2]), int(cancelR[3]), bg) + + // 图标:绿色对勾 + 红色叉。 + drawCheck(f.buf, w, h, okR, uint32(0xFF4CD44C)) + drawCross(f.buf, w, h, cancelR, uint32(0xFF5252FF)) +} + // ensureBitmap 保证离屏位图尺寸与区域一致。 func (f *Frame) ensureBitmap(w, h int) { f.mu.Lock() @@ -508,20 +1046,72 @@ func fillRect(buf []uint32, w, h, x0, y0, x1, y1 int, color uint32) { } } +// drawLine 在像素缓冲里画一条线(Bresenham)。 +func drawLine(buf []uint32, w, h, x0, y0, x1, y1 int, c uint32) { + dx := absInt(x1 - x0) + dy := -absInt(y1 - y0) + sx, sy := -1, -1 + if x0 < x1 { + sx = 1 + } + if y0 < y1 { + sy = 1 + } + err := dx + dy + for { + if x0 >= 0 && y0 >= 0 && x0 < w && y0 < h { + buf[y0*w+x0] = c + } + if x0 == x1 && y0 == y1 { + return + } + e2 := 2 * err + if e2 >= dy { + err += dy + x0 += sx + } + if e2 <= dx { + err += dx + y0 += sy + } + } +} + +// drawCheck 在按钮里画一个对勾。 +func drawCheck(buf []uint32, w, h int, r [4]int, c uint32) { + x0, y0 := r[0], r[1] + size := r[2] - r[0] + drawLine(buf, w, h, x0+size*2/5, y0+size/2, x0+size*3/5, y0+size*4/5, c) + drawLine(buf, w, h, x0+size*3/5, y0+size*4/5, x0+size*4/5, y0+size/5, c) +} + +// drawCross 在按钮里画一个叉。 +func drawCross(buf []uint32, w, h int, r [4]int, c uint32) { + x0, y0 := r[0], r[1] + size := r[2] - r[0] + drawLine(buf, w, h, x0+size/4, y0+size/4, x0+size*3/4, y0+size*3/4, c) + drawLine(buf, w, h, x0+size*3/4, y0+size/4, x0+size/4, y0+size*3/4, c) +} + +// absInt 返回绝对值。 +func absInt(v int) int { + if v < 0 { + return -v + } + return v +} + // frameWndProc 是取景框的窗口过程。 func frameWndProc(hwnd uintptr, msg uint32, wparam, lparam uintptr) uintptr { switch msg { case wmNCHitTest: - // 鼠标穿透:让下层窗口收到点击。 + // 鼠标穿透:让下层窗口收到点击。交互由全局钩子负责。 return htTransparent case wmEraseBkgnd: // 不擦背景:整个窗口由分层位图定义。 return 1 case wmFrameRedraw: - framesMu.Lock() - f := frames[syscall.Handle(hwnd)] - framesMu.Unlock() - if f != nil { + if f := frameOf(hwnd); f != nil { f.redraw() } return 0 @@ -532,6 +1122,13 @@ func frameWndProc(hwnd uintptr, msg uint32, wparam, lparam uintptr) uintptr { return r } +// frameOf 按窗口句柄找到取景框实例。 +func frameOf(hwnd uintptr) *Frame { + framesMu.Lock() + defer framesMu.Unlock() + return frames[syscall.Handle(hwnd)] +} + // lastErrorCode 取最近一次 Win32 调用的错误码。 func lastErrorCode() uintptr { if err, ok := windows.GetLastError().(syscall.Errno); ok { diff --git a/internal/win32/win32.go b/internal/win32/win32.go index 009109f..a9fa4b9 100644 --- a/internal/win32/win32.go +++ b/internal/win32/win32.go @@ -23,6 +23,7 @@ var ( procEnumDisplayMonitors = user32.NewProc("EnumDisplayMonitors") procGetMonitorInfoW = user32.NewProc("GetMonitorInfoW") procGetDpiForMonitor = shcore.NewProc("GetDpiForMonitor") + procMessageBoxW = user32.NewProc("MessageBoxW") ) const ( @@ -242,3 +243,30 @@ func MoveTo(hwnd syscall.Handle, r Rect) error { } return nil } + +// 消息框的图标与按钮常量。 +const ( + // MBOK 是只有「确定」按钮的消息框。 + MBOK uintptr = 0x00000000 + // MBError 是带错误图标的消息框。 + MBError uintptr = 0x00000010 + // MBInfo 是带信息图标的消息框。 + MBInfo uintptr = 0x00000040 +) + +// MessageBox 弹出系统原生消息框。 +// 程序打包后没有控制台窗口,启动失败之类的错误必须用弹窗告诉用户。 +func MessageBox(title, text string, icon uintptr) { + t, err := windows.UTF16PtrFromString(title) + if err != nil { + return + } + m, err := windows.UTF16PtrFromString(text) + if err != nil { + return + } + _, _, _ = procMessageBoxW.Call(0, + uintptr(unsafe.Pointer(m)), + uintptr(unsafe.Pointer(t)), + icon|MBOK) +} diff --git a/main.go b/main.go index 2f43922..b3573b4 100644 --- a/main.go +++ b/main.go @@ -1,40 +1,47 @@ // Command viewsagent 是桌面视觉问答助手。 +// +// 程序是一个完整的桌面应用:没有控制台窗口,日志既写在磁盘上, +// 也能从界面上的「日志」按钮随时查看。 package main import ( - "io" "log/slog" "os" "path/filepath" + "viewsagent/internal/logging" "viewsagent/internal/ui" + "viewsagent/internal/win32" ) -func main() { - slog.SetDefault(slog.New(slog.NewTextHandler(logWriter(), &slog.HandlerOptions{ - Level: slog.LevelInfo, - }))) +const appTitle = "视界 Agent" - if err := ui.Run(); err != nil { +func main() { + // 内存缓冲:界面上的「日志」弹窗直接读它。 + buf := logging.NewBuffer(2000) + writer, err := logging.Open(logPath(), buf) + if err != nil { + // 日志文件打不开也要能继续跑,此时只写内存。 + win32.MessageBox(appTitle, "日志文件无法写入:\n"+err.Error(), win32.MBError) + } + defer writer.Close() + + slog.SetDefault(slog.New(logging.NewHandler(writer, slog.LevelInfo))) + slog.Info("程序启动", "日志文件", logPath()) + + if err := ui.Run(buf); err != nil { slog.Error("启动失败", "err", err) + // 没有控制台窗口,错误只能靠弹窗告知用户。 + win32.MessageBox(appTitle, "启动失败:\n"+err.Error(), win32.MBError) os.Exit(1) } } -// logWriter 同时输出到控制台和配置文件旁边的 app.log, -// 打包成无控制台窗口后也能排查问题。 -func logWriter() io.Writer { +// logPath 返回日志文件路径,默认放在用户配置目录下的 viewsagent/app.log。 +func logPath() string { dir, err := os.UserConfigDir() if err != nil { - return os.Stderr + return filepath.Join(".", "app.log") } - base := filepath.Join(dir, "viewsagent") - if err := os.MkdirAll(base, 0o755); err != nil { - return os.Stderr - } - f, err := os.OpenFile(filepath.Join(base, "app.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return os.Stderr - } - return io.MultiWriter(os.Stderr, f) + return filepath.Join(dir, "viewsagent", "app.log") } diff --git a/viewsagent.exe b/viewsagent.exe new file mode 100644 index 0000000..d605df9 Binary files /dev/null and b/viewsagent.exe differ