301 lines
7.1 KiB
Go
301 lines
7.1 KiB
Go
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/theme"
|
||
"fyne.io/fyne/v2/widget"
|
||
|
||
"viewsagent/internal/capture"
|
||
"viewsagent/internal/win32"
|
||
)
|
||
|
||
// overlayTitle 是框选窗口标题,win32 需要靠它找到 HWND。
|
||
const overlayTitle = "viewsagent-overlay"
|
||
|
||
// overlay 是一个铺满当前显示器的冻结帧框选层。
|
||
//
|
||
// 之所以不依赖窗口透明:Windows 下的 OpenGL 窗口透明并不可靠,
|
||
// 直接用"全屏截图做背景 + 遮罩"视觉等效,还能保证选区与截图完全对齐。
|
||
type overlay struct {
|
||
win fyne.Window
|
||
|
||
vd win32.Rect
|
||
mapping capture.Mapping
|
||
size fyne.Size
|
||
calibrated bool
|
||
|
||
bg *canvas.Image
|
||
dims [4]*canvas.Rectangle
|
||
edges [4]*canvas.Rectangle
|
||
hint *canvas.Text
|
||
catcher *catcher
|
||
|
||
start, cur fyne.Position
|
||
dragging bool
|
||
closed bool
|
||
|
||
onDone func(capture.Region)
|
||
onCancel func()
|
||
}
|
||
|
||
// newOverlay 创建框选层。shot 应该是调用前抓好的整屏画面。
|
||
func newOverlay(app fyne.App, shot image.Image, vd win32.Rect,
|
||
onDone func(capture.Region), onCancel func()) *overlay {
|
||
|
||
o := &overlay{vd: vd, 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])
|
||
}
|
||
o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White)
|
||
o.hint.TextStyle = fyne.TextStyle{Bold: true}
|
||
o.hint.TextSize = 15
|
||
|
||
o.catcher = newCatcher(o)
|
||
objs = append(objs, o.catcher, o.hint)
|
||
|
||
o.win.SetContent(container.NewWithoutLayout(objs...))
|
||
o.win.Canvas().SetOnTypedKey(func(k *fyne.KeyEvent) {
|
||
if k.Name == fyne.KeyEscape {
|
||
o.cancel()
|
||
}
|
||
})
|
||
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
|
||
|
||
rect, err := windowRectByTitle(overlayTitle)
|
||
if err != nil {
|
||
mon, _ := win32.MonitorAtCursor()
|
||
rect = mon.Rect
|
||
}
|
||
if rect.Width() <= 0 || rect.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),
|
||
}
|
||
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)
|
||
|
||
o.calibrated = 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)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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 {
|
||
// 尚未框选:整屏压暗
|
||
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))
|
||
}
|
||
o.hint.Text = "拖拽框选区域 · Esc 取消"
|
||
o.hint.Move(fyne.NewPos(theme.Padding()*2, theme.Padding()*2))
|
||
o.hint.Refresh()
|
||
return
|
||
}
|
||
|
||
// 上下左右四块压暗区域,挖出选区
|
||
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))
|
||
|
||
// 选区描边
|
||
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))
|
||
|
||
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
|
||
}
|
||
o.hint.Move(fyne.NewPos(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 {
|
||
o.cancel()
|
||
return
|
||
}
|
||
r := o.mapping.ToPhysical(float64(x0), float64(y0), float64(x1-x0), float64(y1-y0))
|
||
if r.W < 8 || r.H < 8 {
|
||
o.cancel()
|
||
return
|
||
}
|
||
done := o.onDone
|
||
o.close()
|
||
if done != nil {
|
||
done(r)
|
||
}
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
|
||
func (c *catcher) Dragged(e *fyne.DragEvent) {
|
||
if !c.o.dragging {
|
||
return
|
||
}
|
||
c.o.cur = e.Position
|
||
c.o.updateSelection()
|
||
}
|
||
|
||
func (c *catcher) DragEnd() {}
|
||
|
||
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 {
|
||
return
|
||
}
|
||
c.o.dragging = false
|
||
c.o.cur = e.Position
|
||
c.o.finish()
|
||
}
|