diff --git a/core/internal/screenshot/region.go b/core/internal/screenshot/region.go index fa27c7835..c5b613d2c 100644 --- a/core/internal/screenshot/region.go +++ b/core/internal/screenshot/region.go @@ -14,6 +14,21 @@ import ( "github.com/AvengeMedia/dankgo/wayland/client" ) +type resizeHandle int + +const ( + handleNone resizeHandle = iota + handleTopLeft + handleTopRight + handleBottomLeft + handleBottomRight +) + +const ( + resizeHandleRadius = 12 + resizeHitRadius = resizeHandleRadius + 4 +) + type SelectionState struct { hasSelection bool // There's a selection to display (pre-loaded or user-drawn) dragging bool // User is actively drawing a new selection @@ -36,15 +51,19 @@ type RenderSlot struct { backgroundDragging bool backgroundCursor bool backgroundPhase selectorPhase + backgroundHandles bool + backgroundShift bool overlay *overlay } -func (s *RenderSlot) cacheValid(src *ShmBuffer, dragging, cursor bool, phase selectorPhase) bool { +func (s *RenderSlot) cacheValid(src *ShmBuffer, dragging, cursor bool, phase selectorPhase, handles, shift bool) bool { return s.backgroundInitialized && s.backgroundSource == src && s.backgroundDragging == dragging && s.backgroundCursor == cursor && - s.backgroundPhase == phase + s.backgroundPhase == phase && + s.backgroundHandles == handles && + s.backgroundShift == shift } type OutputSurface struct { @@ -124,6 +143,7 @@ type RegionSelector struct { movingSelection bool moveOffsetX float64 moveOffsetY float64 + resizingHandle resizeHandle phase selectorPhase scroll *scrollSession @@ -624,13 +644,34 @@ func (r *RegionSelector) refreshCursor() { r.setNativeCursor(r.cursorSerial) } +func cursorShapeForHandle(handle resizeHandle) uint32 { + switch handle { + case handleTopLeft, handleBottomRight: + return uint32(wp_cursor_shape.WpCursorShapeDeviceV1ShapeNwseResize) + case handleTopRight, handleBottomLeft: + return uint32(wp_cursor_shape.WpCursorShapeDeviceV1ShapeNeswResize) + default: + return uint32(wp_cursor_shape.WpCursorShapeDeviceV1ShapeGrab) + } +} + func (r *RegionSelector) setNativeCursor(serial uint32) { if r.cursorShape == nil || r.pointer == nil || serial == 0 { return } shape := uint32(wp_cursor_shape.WpCursorShapeDeviceV1ShapeCrosshair) - if r.movingSelection && r.selection.dragging { + if r.resizingHandle != handleNone { + shape = cursorShapeForHandle(r.resizingHandle) + } else if r.movingSelection && r.selection.dragging { shape = uint32(wp_cursor_shape.WpCursorShapeDeviceV1ShapeGrabbing) + } else if r.ctrlHeld && r.selection.hasSelection { + if r.activeSurface != nil && r.activeSurface.output != nil { + pointerGlobalX := r.pointerX + float64(r.activeSurface.output.x) + pointerGlobalY := r.pointerY + float64(r.activeSurface.output.y) + shape = cursorShapeForHandle(r.resizeHandleAt(pointerGlobalX, pointerGlobalY)) + } else { + shape = uint32(wp_cursor_shape.WpCursorShapeDeviceV1ShapeGrab) + } } else if r.ctrlHeld { shape = uint32(wp_cursor_shape.WpCursorShapeDeviceV1ShapeGrab) } @@ -864,8 +905,10 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) { slot.overlay, os.shown = nil, nil default: cur := r.overlayFor(os, slot.shm) + handles := (r.resizingHandle != handleNone || r.ctrlHeld) && r.selection.hasSelection && r.phase != phaseScroll + shift := r.shiftHeld && r.selection.hasSelection switch { - case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase): + case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase, handles, shift): slot.shm.CopyFrom(srcBuf) r.dimBackground(slot.shm) r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat) @@ -874,6 +917,8 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) { slot.backgroundDragging = r.selection.dragging slot.backgroundCursor = r.showCapturedCursor slot.backgroundPhase = r.phase + slot.backgroundHandles = handles + slot.backgroundShift = shift slot.overlay = nil case r.compositorVersion >= 4: fullDamage = false diff --git a/core/internal/screenshot/region_input.go b/core/internal/screenshot/region_input.go index e00bbcc14..e11b1712f 100644 --- a/core/internal/screenshot/region_input.go +++ b/core/internal/screenshot/region_input.go @@ -59,6 +59,9 @@ func (r *RegionSelector) setupPointerHandlers() { r.pointerY = e.SurfaceY if !r.selection.dragging { + if r.ctrlHeld && r.selection.hasSelection { + r.refreshCursor() + } return } @@ -90,14 +93,23 @@ func (r *RegionSelector) setupPointerHandlers() { case 1: // pressed pointerX := r.pointerX + float64(r.activeSurface.output.x) pointerY := r.pointerY + float64(r.activeSurface.output.y) - if r.ctrlHeld && r.beginSelectionMove(pointerX, pointerY) { - r.selection.dragging = true - r.refreshCursor() - break + if r.ctrlHeld && r.selection.hasSelection { + if handle := r.resizeHandleAt(pointerX, pointerY); handle != handleNone { + if r.beginSelectionResize(handle, pointerX, pointerY) { + r.refreshCursor() + break + } + } + if r.beginSelectionMove(pointerX, pointerY) { + r.selection.dragging = true + r.refreshCursor() + break + } } r.preSelect = Region{} r.movingSelection = false + r.resizingHandle = handleNone r.selection.hasSelection = true r.selection.dragging = true r.selection.surface = r.activeSurface @@ -112,6 +124,7 @@ func (r *RegionSelector) setupPointerHandlers() { case 0: // released r.selection.dragging = false r.movingSelection = false + r.resizingHandle = handleNone r.refreshCursor() for _, os := range r.surfaces { r.redrawSurface(os) @@ -191,6 +204,87 @@ func surfaceClampBounds(os *OutputSurface) (minX, minY, maxX, maxY float64, ok b return minX, minY, maxX, maxY, true } +func (r *RegionSelector) resizeHandleAt(pointerX, pointerY float64) resizeHandle { + if !r.selection.hasSelection { + return handleNone + } + + minX := math.Min(r.selection.anchorX, r.selection.currentX) + maxX := math.Max(r.selection.anchorX, r.selection.currentX) + minY := math.Min(r.selection.anchorY, r.selection.currentY) + maxY := math.Max(r.selection.anchorY, r.selection.currentY) + + const maxDistSq = resizeHitRadius * resizeHitRadius + + distSq := func(cx, cy float64) float64 { + dx := pointerX - cx + dy := pointerY - cy + return dx*dx + dy*dy + } + + corners := []struct { + handle resizeHandle + cx, cy float64 + }{ + {handleTopLeft, minX, minY}, + {handleTopRight, maxX, minY}, + {handleBottomLeft, minX, maxY}, + {handleBottomRight, maxX, maxY}, + } + + bestHandle := handleNone + bestDist := float64(maxDistSq + 1) + + for _, c := range corners { + d := distSq(c.cx, c.cy) + if d <= maxDistSq && d < bestDist { + bestDist = d + bestHandle = c.handle + } + } + + return bestHandle +} + +func (r *RegionSelector) beginSelectionResize(handle resizeHandle, pointerX, pointerY float64) bool { + if !r.selection.hasSelection || handle == handleNone { + return false + } + + minX := math.Min(r.selection.anchorX, r.selection.currentX) + maxX := math.Max(r.selection.anchorX, r.selection.currentX) + minY := math.Min(r.selection.anchorY, r.selection.currentY) + maxY := math.Max(r.selection.anchorY, r.selection.currentY) + + switch handle { + case handleTopLeft: + r.selection.anchorX = maxX + r.selection.anchorY = maxY + r.selection.currentX = minX + r.selection.currentY = minY + case handleTopRight: + r.selection.anchorX = minX + r.selection.anchorY = maxY + r.selection.currentX = maxX + r.selection.currentY = minY + case handleBottomLeft: + r.selection.anchorX = maxX + r.selection.anchorY = minY + r.selection.currentX = minX + r.selection.currentY = maxY + case handleBottomRight: + r.selection.anchorX = minX + r.selection.anchorY = minY + r.selection.currentX = maxX + r.selection.currentY = maxY + } + + r.resizingHandle = handle + r.movingSelection = false + r.selection.dragging = true + return true +} + func (r *RegionSelector) beginSelectionMove(pointerX, pointerY float64) bool { if !r.selection.hasSelection { return false @@ -318,17 +412,34 @@ func surfaceEpsilon(surface *OutputSurface) (float64, float64) { func (r *RegionSelector) setupKeyboardHandlers() { r.keyboard.SetModifiersHandler(func(e client.KeyboardModifiersEvent) { - r.shiftHeld = e.ModsDepressed&1 != 0 - r.ctrlHeld = e.ModsDepressed&4 != 0 - r.altHeld = e.ModsDepressed&8 != 0 + shift := e.ModsDepressed&1 != 0 + ctrl := e.ModsDepressed&4 != 0 + alt := e.ModsDepressed&8 != 0 + changed := shift != r.shiftHeld || ctrl != r.ctrlHeld + r.shiftHeld = shift + r.ctrlHeld = ctrl + r.altHeld = alt r.refreshCursor() + if changed && r.selection.hasSelection { + for _, os := range r.surfaces { + r.redrawSurface(os) + } + } }) r.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) { switch e.Key { case 29, 97: // Ctrl left/right - r.ctrlHeld = e.State != 0 - r.refreshCursor() + ctrl := e.State != 0 + if ctrl != r.ctrlHeld { + r.ctrlHeld = ctrl + r.refreshCursor() + if r.selection.hasSelection { + for _, os := range r.surfaces { + r.redrawSurface(os) + } + } + } case 56, 100: // Alt left/right r.altHeld = e.State != 0 } diff --git a/core/internal/screenshot/region_render.go b/core/internal/screenshot/region_render.go index b35f24fdb..e9b30b6db 100644 --- a/core/internal/screenshot/region_render.go +++ b/core/internal/screenshot/region_render.go @@ -3,6 +3,7 @@ package screenshot import ( "fmt" "math" + "slices" ) var fontGlyphs = map[rune][12]uint8{ @@ -17,15 +18,20 @@ var fontGlyphs = map[rune][12]uint8{ '8': {0x3C, 0x66, 0x66, 0x66, 0x3C, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00}, '9': {0x3C, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x06, 0x0C, 0x38, 0x00, 0x00}, 'x': {0x00, 0x00, 0x00, 0x66, 0x66, 0x3C, 0x18, 0x3C, 0x66, 0x66, 0x00, 0x00}, + 'C': {0x3C, 0x66, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x66, 0x3C, 0x00, 0x00}, + 'D': {0x7C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x7C, 0x00, 0x00}, 'E': {0x7E, 0x60, 0x60, 0x60, 0x7C, 0x60, 0x60, 0x60, 0x60, 0x7E, 0x00, 0x00}, 'P': {0x7C, 0x66, 0x66, 0x66, 0x7C, 0x60, 0x60, 0x60, 0x60, 0x60, 0x00, 0x00}, + 'R': {0x7C, 0x66, 0x66, 0x66, 0x7C, 0x6C, 0x66, 0x66, 0x66, 0x66, 0x00, 0x00}, 'S': {0x3C, 0x66, 0x60, 0x60, 0x3C, 0x06, 0x06, 0x06, 0x66, 0x3C, 0x00, 0x00}, 'a': {0x00, 0x00, 0x00, 0x3C, 0x06, 0x3E, 0x66, 0x66, 0x66, 0x3E, 0x00, 0x00}, 'c': {0x00, 0x00, 0x00, 0x3C, 0x66, 0x60, 0x60, 0x60, 0x66, 0x3C, 0x00, 0x00}, 'd': {0x00, 0x00, 0x06, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x66, 0x3E, 0x00, 0x00}, 'e': {0x00, 0x00, 0x00, 0x3C, 0x66, 0x66, 0x7E, 0x60, 0x60, 0x3C, 0x00, 0x00}, + 'g': {0x00, 0x00, 0x00, 0x3E, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x7C, 0x00, 0x00}, 'h': {0x00, 0x60, 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x00, 0x00}, 'i': {0x00, 0x18, 0x00, 0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00, 0x00}, + 'm': {0x00, 0x00, 0x00, 0x76, 0x6B, 0x6B, 0x6B, 0x6B, 0x6B, 0x6B, 0x00, 0x00}, 'n': {0x00, 0x00, 0x00, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x00, 0x00}, 'o': {0x00, 0x00, 0x00, 0x3C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00}, 'p': {0x00, 0x00, 0x00, 0x7C, 0x66, 0x66, 0x66, 0x7C, 0x60, 0x60, 0x00, 0x00}, @@ -33,8 +39,11 @@ var fontGlyphs = map[rune][12]uint8{ 's': {0x00, 0x00, 0x00, 0x3E, 0x60, 0x60, 0x3C, 0x06, 0x06, 0x7C, 0x00, 0x00}, 't': {0x00, 0x18, 0x18, 0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x0E, 0x00, 0x00}, 'u': {0x00, 0x00, 0x00, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3E, 0x00, 0x00}, + 'v': {0x00, 0x00, 0x00, 0x66, 0x66, 0x66, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x00}, 'w': {0x00, 0x00, 0x00, 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00, 0x00}, + 'z': {0x00, 0x00, 0x00, 0x7E, 0x0C, 0x18, 0x30, 0x60, 0x60, 0x7E, 0x00, 0x00}, 'l': {0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00, 0x00}, + '+': {0x00, 0x00, 0x00, 0x18, 0x18, 0x7E, 0x18, 0x18, 0x00, 0x00, 0x00, 0x00}, ' ': {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, ':': {0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00}, '/': {0x00, 0x02, 0x06, 0x0C, 0x18, 0x18, 0x30, 0x60, 0x40, 0x00, 0x00, 0x00}, @@ -59,6 +68,7 @@ type selectionRenderBounds struct { labelText string top, bottom bool left, right bool + scaleX float64 } // dirtyRect is half-open in buffer pixels. @@ -116,13 +126,15 @@ func (d dirtyRect) minus(o dirtyRect) []dirtyRect { // overlay is what a frame draws on top of the dimmed background: the bright // selection interior with its border ring, and the dimensions label. type overlay struct { - interior dirtyRect - label dirtyRect - text string - top bool - bottom bool - left bool - right bool + interior dirtyRect + label dirtyRect + text string + top bool + bottom bool + left bool + right bool + showHandles bool + scaleX float64 } func (o *overlay) full() dirtyRect { @@ -137,14 +149,51 @@ func (o *overlay) ring() []dirtyRect { return o.full().minus(o.inner()) } +func handleRadius(scale float64) int { + radius := int(math.Round(float64(resizeHandleRadius) * scale)) + if radius < 6 { + return 6 + } + return radius +} + +func (o *overlay) handleRects() []dirtyRect { + if !o.showHandles { + return nil + } + radius := handleRadius(o.scaleX) + var rects []dirtyRect + in := o.interior + if o.top && o.left { + rects = append(rects, dirtyRect{in.x1 - radius, in.y1 - radius, in.x1 + radius + 1, in.y1 + radius + 1}) + } + if o.top && o.right { + rects = append(rects, dirtyRect{in.x2 - 1 - radius, in.y1 - radius, in.x2 + radius, in.y1 + radius + 1}) + } + if o.bottom && o.left { + rects = append(rects, dirtyRect{in.x1 - radius, in.y2 - 1 - radius, in.x1 + radius + 1, in.y2 + radius}) + } + if o.bottom && o.right { + rects = append(rects, dirtyRect{in.x2 - 1 - radius, in.y2 - 1 - radius, in.x2 + radius, in.y2 + radius}) + } + return rects +} + // overlayDelta returns the areas to re-dim and to re-brighten when prev is replaced by cur; nil means no overlay. func overlayDelta(prev, cur *overlay) (dim, bright []dirtyRect) { var curInterior, prevInner dirtyRect + var curHandles []dirtyRect if cur != nil { curInterior = cur.interior + curHandles = cur.handleRects() } if prev != nil { dim = append(prev.full().minus(curInterior), prev.label.minus(curInterior)...) + if prev.showHandles && !slices.Equal(prev.handleRects(), curHandles) { + for _, h := range prev.handleRects() { + dim = append(dim, h.minus(curInterior)...) + } + } prevInner = prev.inner() } if cur != nil { @@ -252,6 +301,7 @@ func (r *RegionSelector) selectionRenderBounds(os *OutputSurface) (selectionRend bottom: selectionMaxY > outputMinY && selectionMaxY <= outputMaxY, left: selectionMinX >= outputMinX && selectionMinX < outputMaxX, right: selectionMaxX > outputMinX && selectionMaxX <= outputMaxX, + scaleX: scaleX, }, true } @@ -265,13 +315,15 @@ func (r *RegionSelector) overlayFor(os *OutputSurface, buf *ShmBuffer) *overlay label, _ = labelRect(bounds, buf.Width, buf.Height) } return &overlay{ - interior: dirtyRect{bounds.x, bounds.y, bounds.x + bounds.w, bounds.y + bounds.h}, - label: label, - text: bounds.labelText, - top: bounds.top, - bottom: bounds.bottom, - left: bounds.left, - right: bounds.right, + interior: dirtyRect{bounds.x, bounds.y, bounds.x + bounds.w, bounds.y + bounds.h}, + label: label, + text: bounds.labelText, + top: bounds.top, + bottom: bounds.bottom, + left: bounds.left, + right: bounds.right, + showHandles: (r.resizingHandle != handleNone || r.ctrlHeld) && r.selection.hasSelection && r.phase != phaseScroll, + scaleX: bounds.scaleX, } } @@ -291,6 +343,9 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer, pr data, stride, w, h := renderBuf.Data(), renderBuf.Stride, renderBuf.Width, renderBuf.Height in := cur.interior r.drawSelectionBorder(data, stride, w, h, in, cur, os.screenFormat) + if cur.showHandles { + r.drawCornerHandles(data, stride, w, h, in, cur, os.screenFormat) + } if cur.text != "" { r.drawLabel(data, stride, w, h, cur, os.screenFormat) } @@ -313,10 +368,82 @@ func (r *RegionSelector) drawSelectionBorder(data []byte, stride, bufW, bufH int } } +func (r *RegionSelector) drawCornerHandles(data []byte, stride, bufW, bufH int, in dirtyRect, o *overlay, format uint32) { + radius := handleRadius(o.scaleX) + if o.top && o.left { + r.drawCornerHandle(data, stride, bufW, bufH, in.x1, in.y1, radius, handleTopLeft) + } + if o.top && o.right { + r.drawCornerHandle(data, stride, bufW, bufH, in.x2-1, in.y1, radius, handleTopRight) + } + if o.bottom && o.left { + r.drawCornerHandle(data, stride, bufW, bufH, in.x1, in.y2-1, radius, handleBottomLeft) + } + if o.bottom && o.right { + r.drawCornerHandle(data, stride, bufW, bufH, in.x2-1, in.y2-1, radius, handleBottomRight) + } +} + +func (r *RegionSelector) drawCornerHandle(data []byte, stride, bufW, bufH, cx, cy, radius int, handle resizeHandle) { + radSq := radius * radius + for dy := -radius; dy <= radius; dy++ { + py := cy + dy + if py < 0 || py >= bufH { + continue + } + rowOff := py * stride + for dx := -radius; dx <= radius; dx++ { + px := cx + dx + if px < 0 || px >= bufW { + continue + } + if dx*dx+dy*dy > radSq { + continue + } + switch handle { + case handleTopLeft: + if dx >= 0 && dy >= 0 { + continue + } + case handleTopRight: + if dx <= 0 && dy >= 0 { + continue + } + case handleBottomLeft: + if dx >= 0 && dy <= 0 { + continue + } + case handleBottomRight: + if dx <= 0 && dy <= 0 { + continue + } + } + off := rowOff + px*4 + if off+3 < len(data) { + data[off] = 255 + data[off+1] = 255 + data[off+2] = 255 + data[off+3] = 255 + } + } + } +} + // overlayDamage lists the buffer areas that differ between a frame showing prev and one showing cur. func overlayDamage(prev, cur *overlay) []dirtyRect { dim, bright := overlayDelta(prev, cur) damage := append(dim, bright...) + var prevHandles, curHandles []dirtyRect + if prev != nil { + prevHandles = prev.handleRects() + } + if cur != nil { + curHandles = cur.handleRects() + } + if !slices.Equal(prevHandles, curHandles) { + damage = append(damage, prevHandles...) + damage = append(damage, curHandles...) + } if cur == nil { return damage } @@ -398,12 +525,7 @@ func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, form style.TextR, style.TextG, style.TextB, format) } -func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) { - if r.selection.dragging { - return - } - - style := LoadOverlayStyle() +func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH int) { const charW, charH, padding, itemSpacing = 8, 12, 12, 24 cursorLabel := "hide" @@ -417,26 +539,53 @@ func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uin items := []struct{ key, desc string }{ {captureKey, "capture"}, + {"Ctrl", "resize/move"}, {"P", cursorLabel + " cursor"}, {"Esc", "cancel"}, } totalW := 0 for i, item := range items { - totalW += len(item.key)*(charW+1) + 4 + len(item.desc)*(charW+1) + totalW += len(item.key)*(charW+1) + (1+len(item.desc))*(charW+1) if i < len(items)-1 { totalW += itemSpacing } } - hudW := totalW + padding*2 - hudH := charH + padding*2 - hudX := (bufW - hudW) / 2 - hudY := bufH - hudH - 20 + hudW = totalW + padding*2 + hudH = charH + padding*2 + hudX = (bufW - hudW) / 2 + hudY = bufH - hudH - 20 + return hudX, hudY, hudW, hudH +} +func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) { + if r.selection.dragging { + return + } + + hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH) + style := LoadOverlayStyle() r.fillRect(data, stride, bufW, bufH, hudX, hudY, hudW, hudH, style.BackgroundR, style.BackgroundG, style.BackgroundB, style.BackgroundA, format) + const charW, padding, itemSpacing = 8, 12, 24 + cursorLabel := "hide" + if !r.showCapturedCursor { + cursorLabel = "show" + } + captureKey := "Space/Enter" + if r.screenshoter != nil && r.screenshoter.config.NoConfirm { + captureKey = "Drag+Release" + } + + items := []struct{ key, desc string }{ + {captureKey, "capture"}, + {"Ctrl", "resize/move"}, + {"P", cursorLabel + " cursor"}, + {"Esc", "cancel"}, + } + tx, ty := hudX+padding, hudY+padding for i, item := range items { r.drawText(data, stride, bufW, bufH, tx, ty, item.key, diff --git a/core/internal/screenshot/region_resize_test.go b/core/internal/screenshot/region_resize_test.go new file mode 100644 index 000000000..d3ff1f22e --- /dev/null +++ b/core/internal/screenshot/region_resize_test.go @@ -0,0 +1,307 @@ +package screenshot + +import ( + "bytes" + "math" + "math/rand" + "testing" +) + +func TestResizeHandleAt(t *testing.T) { + r := &RegionSelector{} + r.selectRect(100, 200, 400, 350) + + cases := []struct { + name string + px, py float64 + expected resizeHandle + }{ + {"exact TopLeft", 100, 200, handleTopLeft}, + {"near TopLeft", 108, 206, handleTopLeft}, + {"exact TopRight", 400, 200, handleTopRight}, + {"near TopRight", 392, 196, handleTopRight}, + {"exact BottomLeft", 100, 350, handleBottomLeft}, + {"near BottomLeft", 105, 345, handleBottomLeft}, + {"exact BottomRight", 400, 350, handleBottomRight}, + {"near BottomRight", 406, 354, handleBottomRight}, + {"center", 250, 275, handleNone}, + {"outside", 50, 50, handleNone}, + {"just outside TopLeft", 100 - resizeHitRadius - 2, 200, handleNone}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := r.resizeHandleAt(tc.px, tc.py) + if got != tc.expected { + t.Fatalf("resizeHandleAt(%.1f, %.1f) = %v, want %v", tc.px, tc.py, got, tc.expected) + } + }) + } + + // When no selection exists + r.selection.hasSelection = false + if got := r.resizeHandleAt(100, 200); got != handleNone { + t.Fatalf("expected handleNone when no selection, got %v", got) + } +} + +func TestBeginSelectionResize(t *testing.T) { + tests := []struct { + handle resizeHandle + wantAnchorX float64 + wantAnchorY float64 + wantCurX float64 + wantCurY float64 + }{ + {handleTopLeft, 400, 350, 100, 200}, + {handleTopRight, 100, 350, 400, 200}, + {handleBottomLeft, 400, 200, 100, 350}, + {handleBottomRight, 100, 200, 400, 350}, + } + + for _, tc := range tests { + r := &RegionSelector{} + r.selectRect(100, 200, 400, 350) + ok := r.beginSelectionResize(tc.handle, 150, 220) + if !ok { + t.Fatalf("beginSelectionResize(%v) returned false", tc.handle) + } + if r.resizingHandle != tc.handle { + t.Errorf("resizingHandle = %v, want %v", r.resizingHandle, tc.handle) + } + if !r.selection.dragging || r.movingSelection { + t.Errorf("dragging = %v, moving = %v", r.selection.dragging, r.movingSelection) + } + if r.selection.anchorX != tc.wantAnchorX || r.selection.anchorY != tc.wantAnchorY { + t.Errorf("anchor = (%.1f, %.1f), want (%.1f, %.1f)", r.selection.anchorX, r.selection.anchorY, tc.wantAnchorX, tc.wantAnchorY) + } + if r.selection.currentX != tc.wantCurX || r.selection.currentY != tc.wantCurY { + t.Errorf("current = (%.1f, %.1f), want (%.1f, %.1f)", r.selection.currentX, r.selection.currentY, tc.wantCurX, tc.wantCurY) + } + } +} + +func TestDrawOverlayIncrementalWithHandles(t *testing.T) { + const w, h = 160, 120 + r, os := newOverlayFixture(t, w, h) + incr := newFrameBuffer(t, r, os) + shown := newFrameBuffer(t, r, os) + rng := rand.New(rand.NewSource(42)) + + var prev *overlay + for i := range 300 { + var cur *overlay + r.ctrlHeld = rng.Intn(2) == 1 + switch rng.Intn(8) { + case 0: + r.selection.hasSelection = false + default: + r.selectRect(rng.Float64()*w, rng.Float64()*h, rng.Float64()*w, rng.Float64()*h) + cur = r.overlayFor(os, incr) + } + damage := overlayDamage(prev, cur) + r.drawOverlay(os, incr, prev, cur) + + full := newFrameBuffer(t, r, os) + r.drawOverlay(os, full, nil, cur) + if !bytes.Equal(incr.Data(), full.Data()) { + t.Fatalf("step %d (ctrl=%v): incremental frame differs from full render", i, r.ctrlHeld) + } + + for y := range h { + for x := range w { + off := y*full.Stride + x*4 + if bytes.Equal(full.Data()[off:off+4], shown.Data()[off:off+4]) { + continue + } + if !inRects(x, y, damage) { + t.Fatalf("step %d (ctrl=%v): pixel %d,%d changed outside damage %v", i, r.ctrlHeld, x, y, damage) + } + } + } + copy(shown.Data(), full.Data()) + prev = cur + } +} + +func TestResizeInvertedDrag(t *testing.T) { + r := &RegionSelector{} + r.selectRect(100, 100, 200, 200) + + // Begin resizing TopLeft + r.beginSelectionResize(handleTopLeft, 100, 100) + if r.selection.anchorX != 200 || r.selection.anchorY != 200 { + t.Fatalf("anchor = (%f, %f), want (200, 200)", r.selection.anchorX, r.selection.anchorY) + } + + // Drag past opposite corner (anchor) to (300, 300) + os := &OutputSurface{ + output: &WaylandOutput{x: 0, y: 0}, + logicalW: 1920, + logicalH: 1080, + } + r.selection.surface = os + r.updateSelectionCurrent(os, 300, 300) + + minX := math.Min(r.selection.anchorX, r.selection.currentX) + maxX := math.Max(r.selection.anchorX, r.selection.currentX) + minY := math.Min(r.selection.anchorY, r.selection.currentY) + maxY := math.Max(r.selection.anchorY, r.selection.currentY) + + if minX != 200 || maxX != 300 || minY != 200 || maxY != 300 { + t.Errorf("inverted bounds = (%f, %f, %f, %f), want (200, 200, 300, 300)", minX, minY, maxX, maxY) + } +} + +func TestResizeTinySelectionHitTest(t *testing.T) { + r := &RegionSelector{} + // 4x4 tiny selection + r.selectRect(100, 100, 104, 104) + + // Closer to TopLeft (101, 101) + if got := r.resizeHandleAt(101, 101); got != handleTopLeft { + t.Errorf("got %v, want %v", got, handleTopLeft) + } + + // Closer to BottomRight (103, 103) + if got := r.resizeHandleAt(103, 103); got != handleBottomRight { + t.Errorf("got %v, want %v", got, handleBottomRight) + } +} + +func TestHUDGlyphsCoverage(t *testing.T) { + texts := []string{ + "Space/Enter", + "capture", + "Drag+Release", + "Ctrl", + "resize/move", + "P", + "show cursor", + "hide cursor", + "Esc", + "cancel", + } + + for _, text := range texts { + for _, ch := range text { + if ch == ' ' { + continue + } + if _, ok := fontGlyphs[ch]; !ok { + t.Errorf("fontGlyphs missing rune '%c' (%d) used in HUD text %q", ch, ch, text) + } + } + } +} + +func TestOverlayDeltaIdenticalOverlayNoHandleDimming(t *testing.T) { + o1 := &overlay{ + interior: dirtyRect{10, 10, 100, 100}, + top: true, + bottom: true, + left: true, + right: true, + showHandles: true, + scaleX: 1.0, + } + o2 := &overlay{ + interior: dirtyRect{10, 10, 100, 100}, + top: true, + bottom: true, + left: true, + right: true, + showHandles: true, + scaleX: 1.0, + } + + dim, _ := overlayDelta(o1, o2) + for _, d := range dim { + for _, h := range o1.handleRects() { + if d == h { + t.Errorf("handle rect %v should not be re-dimmed when overlay is identical", h) + } + } + } +} + +func TestHUDDimensionsContainText(t *testing.T) { + const bufW, bufH = 1920, 1080 + stride := bufW * 4 + + for _, noConfirm := range []bool{false, true} { + for _, showCursor := range []bool{false, true} { + r := &RegionSelector{ + showCapturedCursor: showCursor, + screenshoter: &Screenshoter{ + config: Config{NoConfirm: noConfirm}, + }, + } + + hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH) + data := make([]byte, bufH*stride) + r.drawHUD(data, stride, bufW, bufH, uint32(FormatARGB8888)) + + // Assert no lit pixel falls outside the pill boundaries + for y := range bufH { + for x := range bufW { + off := y*stride + x*4 + if data[off] != 0 || data[off+1] != 0 || data[off+2] != 0 || data[off+3] != 0 { + if x < hudX || x >= hudX+hudW || y < hudY || y >= hudY+hudH { + t.Fatalf("noConfirm=%v showCursor=%v: pixel (%d, %d) is non-zero outside HUD pill [%d..%d, %d..%d]", + noConfirm, showCursor, x, y, hudX, hudX+hudW, hudY, hudY+hudH) + } + } + } + } + } + } +} + +func TestOverlayDeltaHandlesClampedMonitorEdge(t *testing.T) { + prev := &overlay{ + interior: dirtyRect{100, 100, 1920, 1080}, + top: true, + bottom: true, + left: true, + right: true, + showHandles: true, + scaleX: 1.0, + } + cur := &overlay{ + interior: dirtyRect{100, 100, 1920, 1080}, + top: true, + bottom: true, + left: true, + right: false, + showHandles: true, + scaleX: 1.0, + } + + dim, _ := overlayDelta(prev, cur) + rightTopHandle := prev.handleRects()[1] // TopRight + for _, piece := range rightTopHandle.minus(cur.interior) { + found := false + for _, d := range dim { + if d == piece { + found = true + break + } + } + if !found { + t.Errorf("expected piece %v of right-top handle in dim rects, got %v", piece, dim) + } + } + + damage := overlayDamage(prev, cur) + foundDamage := false + for _, d := range damage { + if d == rightTopHandle { + foundDamage = true + break + } + } + if !foundDamage { + t.Errorf("expected right-top handle %v in damage rects when crossing monitor edge, got %v", rightTopHandle, damage) + } +}