更新代码

This commit is contained in:
hero920103 committed 2026-10-28 17:39:33 +08:00
1 parent eda7e8bc96
commit 6ff632e0b0
19 files changed
+2033 -197

No files matched your search

+1 -1
View File
@@ -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
+38 -13
View File
@@ -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
+52 -2
View File
@@ -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{
+270
View File
@@ -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"
}
}
+91 -5
View File
@@ -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()
+3
View File
@@ -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
+49 -1
View File
@@ -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 &regionFrame{frame: f}
r := &regionFrame{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 {
+172
View File
@@ -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()
}
+403 -88
View File
@@ -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()
}
+111 -27
View File
@@ -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)
+45 -6
View File
@@ -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 {
+18 -2
View File
@@ -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)
+612 -15
View File
@@ -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 {
+28
View File
@@ -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)
}