更新代码
This commit is contained in:
1 parent
eda7e8bc96
commit
6ff632e0b0
19 files changed
+2033
-197
No files matched your search
+91
-5
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user