mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-21 10:44:01 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user