feat(screenshot): support 4-corner resizing while holding Ctrl (#3259)

* feat(screenshot): support 4-corner resizing while holding Ctrl

- Hit-test selection corners when Ctrl is held to allow resizing.
- Set opposite corner as fixed anchor and update dynamic cursor shape
  (NwseResize/NeswResize).
- Draw corner handle indicators when Ctrl is held or during active resize.
- Add 'Ctrl resize/move' to overlay HUD and provide complete glyph
  coverage for HUD labels.
- Add comprehensive unit tests covering hit testing, inverted dragging,
  incremental handle damage, and glyph coverage.

* fix(screenshot): invalidate background cache on handle and shift modifier changes

- Track backgroundHandles and backgroundShift in RenderSlot.cacheValid.
- Rebuild background slot and HUD when handle or shift modifier states
  change with an existing selection, preventing incremental re-dimming
  from punching holes in the HUD.
- Fix trailing newline in region_resize_test.go for golangci-lint.

* fix(screenshot): gate redraw and handle dimming on render modifier changes

- Only trigger redrawSurface when Shift or Ctrl modifier states actually
  change, preventing redundant redraws when tapping Alt, Super, or Locks.
- Only re-dim and damage corner handle bounding boxes in overlayDelta and
  overlayDamage when handles move or their visibility changes, preventing
  unnecessary re-dimming of stationary handles over the HUD.
- Add TestOverlayDeltaIdenticalOverlayNoHandleDimming test.

* fix(screenshot): measure hud text advance accurately to prevent pill overflow

- Use (1+len(item.desc))*(charW+1) when calculating totalW in drawHUD,
  matching the full 9px advance used when drawing space and description.
- Add TestHUDDimensionsContainText to verify HUD pill dimensions enclose
  all text items with correct padding across all variant combinations.

* fix(screenshot): compare handle rect sets in overlay delta and damage

- Use slices.Equal to compare prev and cur handleRects in overlayDelta
  and overlayDamage instead of interior equality.
- Properly re-dim and damage handles when selection corners cross monitor
  boundaries where clamped interior is unchanged but top/bottom/left/right
  flags change.
- Add TestOverlayDeltaHandlesClampedMonitorEdge test.

* test(screenshot): verify drawHUD output buffer pixels stay within pill bounds

- Extract hudDimensions helper on RegionSelector used by drawHUD.
- Update TestHUDDimensionsContainText to drive drawHUD directly into a
  zeroed buffer and assert no lit pixel falls outside the computed
  HUD pill boundary across all configuration variants.
This commit is contained in:
Huỳnh Thiện Lộc
2026-09-02 09:23:37 -04:00
committed by GitHub
parent 4d6d7e0d5f
commit 95ee917e37
4 changed files with 650 additions and 38 deletions
+49 -4
View File
@@ -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
+120 -9
View File
@@ -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
}
+174 -25
View File
@@ -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,
@@ -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)
}
}