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() }