Files
deskview/internal/ui/overlay.go
T
2026-10-28 17:39:33 +08:00

616 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"fmt"
"image"
"image/color"
"time"
"fyne.io/fyne/v2"
"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"
"viewsagent/internal/capture"
"viewsagent/internal/win32"
)
// 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
vd win32.Rect
mapping capture.Mapping
size fyne.Size
calibrated bool
initial capture.Region // 打开时预置的选区
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
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 应该是调用前抓好的整屏画面,
// 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, initial: initial, onDone: onDone, onCancel: onCancel}
o.win = app.NewWindow(overlayTitle)
o.win.SetPadded(false)
o.win.SetFullScreen(true)
o.bg = canvas.NewImageFromImage(shot)
o.bg.FillMode = canvas.ImageFillStretch
objs := []fyne.CanvasObject{o.bg}
for i := range o.dims {
o.dims[i] = canvas.NewRectangle(color.NRGBA{R: 0, G: 0, B: 0, A: 130})
objs = append(objs, o.dims[i])
}
for i := range o.edges {
// 描边跟着主题走:黑白主题下是纯白/纯黑,不会残留彩色。
o.edges[i] = canvas.NewRectangle(lookupColor(theme.ColorNameForeground))
objs = append(objs, o.edges[i])
}
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, o.bar)
o.win.SetContent(container.NewWithoutLayout(objs...))
o.win.Canvas().SetOnTypedKey(func(k *fyne.KeyEvent) {
switch k.Name {
case fyne.KeyEscape:
o.cancel()
case fyne.KeyReturn, fyne.KeyEnter:
o.finish()
}
})
return o
}
// show 显示框选层并做一次坐标校准。
func (o *overlay) show() {
o.win.Show()
// 窗口尺寸在 Show 之后才确定,稍等一拍再校准。
go func() {
time.Sleep(90 * time.Millisecond)
fyne.Do(o.calibrate)
}()
}
// calibrate 反推"逻辑坐标 → 物理像素"的比例与原点。
// 宁可依赖实际窗口尺寸,也不假设系统缩放,这样 125%/150% 屏都不会偏。
func (o *overlay) calibrate() {
size := o.win.Canvas().Size()
if size.Width < 10 || size.Height < 10 {
return
}
o.size = size
wr, err := windowRectByTitle(overlayTitle)
if err != nil {
mon, _ := win32.MonitorAtCursor()
wr = mon.Rect
}
if wr.Width() <= 0 || wr.Height() <= 0 {
return
}
o.mapping = capture.Mapping{
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
}
// 把整张虚拟桌面按当前窗口的实际映射铺到画布上。
bx, by, bw, bh := o.mapping.ToLogical(capture.Region{
X: o.vd.Left, Y: o.vd.Top, W: o.vd.Width(), H: o.vd.Height(),
})
o.bg.Move(fyne.NewPos(float32(bx), float32(by)))
o.bg.Resize(fyne.NewSize(float32(bw), float32(bh)))
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()
}
func windowRectByTitle(title string) (win32.Rect, error) {
hwnd, err := win32.FindWindowByTitle(title)
if err != nil {
return win32.Rect{}, err
}
return win32.WindowRect(hwnd)
}
// ---------- 交互 ----------
// beginDrag 按下鼠标,按落点决定这次是新建、移动还是调整大小。
func (o *overlay) beginDrag(pos fyne.Position) {
if !o.calibrated {
return
}
o.mode, o.handle = o.hitTest(pos.X, pos.Y)
o.grab = pos
o.orig = o.sel
o.moved = false
}
// 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
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))
for i := 1; i < 4; i++ {
o.dims[i].Resize(fyne.NewSize(0, 0))
}
for _, e := range o.edges {
e.Resize(fyne.NewSize(0, 0))
}
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, 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(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()))
// 八个调整手柄,提示边界可以拖
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()))
}
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()
}
if !o.hasSel {
return
}
r := o.sel
if r.w() < minSelect || r.h() < minSelect {
o.cancel()
return
}
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(phys)
}
}
// cancel 取消框选。
func (o *overlay) cancel() {
cancel := o.onCancel
o.close()
if cancel != nil {
cancel()
}
}
func (o *overlay) close() {
if o.closed {
return
}
o.closed = true
o.win.Close()
}
// catcher 是铺满窗口的事件接收层,负责鼠标拖拽。
type catcher struct {
widget.BaseWidget
o *overlay
}
func newCatcher(o *overlay) *catcher {
c := &catcher{o: o}
c.ExtendBaseWidget(c)
return c
}
func (c *catcher) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent))
}
// MouseDown 按下鼠标:右键取消,左键按落点决定拖拽方式。
func (c *catcher) MouseDown(e *desktop.MouseEvent) {
if e.Button == desktop.MouseButtonSecondary {
c.o.cancel()
return
}
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()
}