mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-21 18:54:01 -04:00
feat(screenshot): snap to active popouts & modals in region mode (#3618)
* feat(screenshot): snap to active popouts and modals in region mode Allow region screenshot selector to detect and snap to visible shell popouts and modals on hover and click or keyboard confirmation. - PopoutManager.qml: add getActiveSurfaces() collecting visible DankPopout and DankModal global bounding boxes - DMSShellIPC.qml: add screenshot.getSurfaces IPC endpoint - core: fetch snap targets from shell IPC during region screenshot init - selector: highlight snap targets on hover with surface name and dimensions - selector: click or Enter/Space on hovered target locks selection to surface bounds - selector: allow hover snap over pre-selection (SelectionState.fromPreSelect) - selector: reset selection on degenerate (1x1) click release for quick re-selection - unit tests for display name formatting, hover detection, and render bounds * fix(screenshot): correct snap off-by-one and drag-away cancellation - region_input: fix drag-away snap cancellation by setting hasSelection=true and clearing preSelect so freeform dragging renders and is not discarded - region_input: add snapToTarget helper subtracting 1 device pixel (devPxX/Y) from currentX/currentY to prevent inclusive coordinate off-by-one (420x580 -> 420x580) - region_render: compute hover bounds with w = round(width * scaleX) and x2 = x1 + w - 1 to eliminate +1 device pixel in render bounds - region_snap_test: assert exact 420x580 bounds for both hover and snapped states * style(screenshot): format region_snap_test.go with golangci-lint-fmt
This commit is contained in:
@@ -9,11 +9,11 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/qsipc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -246,9 +246,29 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
}
|
||||
config.Quality = ssQuality
|
||||
|
||||
if mode == screenshot.ModeRegion || mode == screenshot.ModeScroll {
|
||||
config.SnapTargets = fetchShellSnapTargets()
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func fetchShellSnapTargets() []screenshot.SnapTarget {
|
||||
pid, ok := shellApp.SessionPID()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
res, isVoid, err := qsipc.Call(qsipc.SocketPathForPID(pid), "screenshot", "getSurfaces", nil)
|
||||
if err != nil || isVoid || res == "" {
|
||||
return nil
|
||||
}
|
||||
var targets []screenshot.SnapTarget
|
||||
if err := json.Unmarshal([]byte(res), &targets); err != nil {
|
||||
return nil
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select.
|
||||
// Best-effort and not awaited: qs takes ~20ms to come up, longer than the selector needs to show.
|
||||
func setPopoutScreenshotMode(begin bool) {
|
||||
@@ -256,18 +276,18 @@ func setPopoutScreenshotMode(begin bool) {
|
||||
if begin {
|
||||
fn = "begin"
|
||||
}
|
||||
cmdArgs := []string{"ipc"}
|
||||
if pid, ok := shellApp.SessionPID(); ok {
|
||||
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
|
||||
} else {
|
||||
if err := shellApp.ResolveConfig(nil, nil); err != nil {
|
||||
return
|
||||
}
|
||||
if qsHasAnyDisplay() {
|
||||
cmdArgs = append(cmdArgs, "--any-display")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
|
||||
_, _, _ = qsipc.Call(qsipc.SocketPathForPID(pid), "screenshot", fn, nil)
|
||||
return
|
||||
}
|
||||
cmdArgs := []string{"ipc"}
|
||||
if err := shellApp.ResolveConfig(nil, nil); err != nil {
|
||||
return
|
||||
}
|
||||
if qsHasAnyDisplay() {
|
||||
cmdArgs = append(cmdArgs, "--any-display")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
|
||||
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
|
||||
_ = exec.Command("qs", cmdArgs...).Start()
|
||||
}
|
||||
|
||||
@@ -32,9 +32,10 @@ const (
|
||||
)
|
||||
|
||||
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
|
||||
surface *OutputSurface // Surface where selection was made
|
||||
hasSelection bool // There's a selection to display (pre-loaded or user-drawn)
|
||||
fromPreSelect bool // hasSelection was set by applyPreSelection, not by user interaction
|
||||
dragging bool // User is actively drawing a new selection
|
||||
surface *OutputSurface // Surface where selection was made
|
||||
// Global logical coordinates. Keeping these independent of the active
|
||||
// surface lets a drag continue across output boundaries.
|
||||
anchorX float64
|
||||
@@ -161,6 +162,12 @@ type RegionSelector struct {
|
||||
phase selectorPhase
|
||||
scroll *scrollSession
|
||||
|
||||
snapTargets []SnapTarget
|
||||
hoveredTarget *SnapTarget
|
||||
clickedTarget *SnapTarget
|
||||
dragStartX float64
|
||||
dragStartY float64
|
||||
|
||||
running bool
|
||||
cancelled bool
|
||||
result Region
|
||||
@@ -175,6 +182,7 @@ func NewRegionSelector(s *Screenshoter) *RegionSelector {
|
||||
outputs: make(map[uint32]*WaylandOutput),
|
||||
preCapture: make(map[*WaylandOutput]*PreCapture),
|
||||
showCapturedCursor: s.config.Cursor == CursorOn,
|
||||
snapTargets: s.config.SnapTargets,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -983,6 +991,7 @@ func (r *RegionSelector) applyPreSelection(os *OutputSurface) {
|
||||
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height)*scaleY - scaleY
|
||||
|
||||
r.selection.hasSelection = true
|
||||
r.selection.fromPreSelect = true
|
||||
r.selection.dragging = false
|
||||
r.selection.surface = os
|
||||
r.selection.anchorX = float64(os.output.x) + x1
|
||||
|
||||
@@ -53,6 +53,8 @@ func (r *RegionSelector) setupPointerHandlers() {
|
||||
r.pointerY = e.SurfaceY
|
||||
if r.selection.dragging {
|
||||
r.updateSelectionCurrent(r.activeSurface, r.pointerX, r.pointerY)
|
||||
} else {
|
||||
r.updateHoverTarget(r.pointerX, r.pointerY)
|
||||
}
|
||||
r.refreshCursor()
|
||||
})
|
||||
@@ -74,9 +76,26 @@ func (r *RegionSelector) setupPointerHandlers() {
|
||||
if r.ctrlHeld && r.selection.hasSelection {
|
||||
r.refreshCursor()
|
||||
}
|
||||
r.updateHoverTarget(e.SurfaceX, e.SurfaceY)
|
||||
return
|
||||
}
|
||||
|
||||
if r.clickedTarget != nil && r.activeSurface.output != nil {
|
||||
pointerX := e.SurfaceX + float64(r.activeSurface.output.x)
|
||||
pointerY := e.SurfaceY + float64(r.activeSurface.output.y)
|
||||
if math.Hypot(pointerX-r.dragStartX, pointerY-r.dragStartY) > 5 {
|
||||
r.clickedTarget = nil
|
||||
r.hoveredTarget = nil
|
||||
r.preSelect = Region{}
|
||||
r.selection.hasSelection = true
|
||||
r.selection.anchorX = r.dragStartX
|
||||
r.selection.anchorY = r.dragStartY
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.updateSelectionCurrent(r.activeSurface, e.SurfaceX, e.SurfaceY)
|
||||
})
|
||||
|
||||
@@ -105,6 +124,8 @@ func (r *RegionSelector) setupPointerHandlers() {
|
||||
case 1: // pressed
|
||||
pointerX := r.pointerX + float64(r.activeSurface.output.x)
|
||||
pointerY := r.pointerY + float64(r.activeSurface.output.y)
|
||||
r.dragStartX = pointerX
|
||||
r.dragStartY = pointerY
|
||||
if r.ctrlHeld && r.selection.hasSelection {
|
||||
if handle := r.resizeHandleAt(pointerX, pointerY); handle != handleNone {
|
||||
if r.beginSelectionResize(handle, pointerX, pointerY) {
|
||||
@@ -119,10 +140,23 @@ func (r *RegionSelector) setupPointerHandlers() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!r.selection.hasSelection || r.selection.fromPreSelect) && r.hoveredTarget != nil {
|
||||
r.clickedTarget = r.hoveredTarget
|
||||
r.selection.dragging = true
|
||||
r.selection.fromPreSelect = false
|
||||
r.selection.surface = r.activeSurface
|
||||
r.selection.anchorX = pointerX
|
||||
r.selection.anchorY = pointerY
|
||||
r.selection.currentX = pointerX
|
||||
r.selection.currentY = pointerY
|
||||
break
|
||||
}
|
||||
|
||||
r.preSelect = Region{}
|
||||
r.movingSelection = false
|
||||
r.resizingHandle = handleNone
|
||||
r.selection.hasSelection = true
|
||||
r.selection.fromPreSelect = false
|
||||
r.selection.dragging = true
|
||||
r.selection.surface = r.activeSurface
|
||||
r.selection.anchorX = pointerX
|
||||
@@ -134,10 +168,41 @@ func (r *RegionSelector) setupPointerHandlers() {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
case 0: // released
|
||||
if r.clickedTarget != nil {
|
||||
t := r.clickedTarget
|
||||
r.clickedTarget = nil
|
||||
r.hoveredTarget = nil
|
||||
r.snapToTarget(t)
|
||||
r.refreshCursor()
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
if r.screenshoter != nil && r.screenshoter.config.NoConfirm {
|
||||
r.finishSelection()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
r.selection.dragging = false
|
||||
r.movingSelection = false
|
||||
r.resizingHandle = handleNone
|
||||
r.refreshCursor()
|
||||
|
||||
// A degenerate (1×1 or smaller) selection resets state so the
|
||||
// user can re-draw without relaunching the command.
|
||||
if r.selection.hasSelection {
|
||||
if _, _, _, w, h := r.selectionDeviceRect(); w <= 1 && h <= 1 {
|
||||
r.preSelect = Region{}
|
||||
r.selection = SelectionState{}
|
||||
r.hoveredTarget = nil
|
||||
r.clickedTarget = nil
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
@@ -152,6 +217,57 @@ func (r *RegionSelector) setupPointerHandlers() {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *RegionSelector) updateHoverTarget(surfaceX, surfaceY float64) {
|
||||
// Block hover snap only when the user has actively drawn/committed a selection.
|
||||
// A preloaded selection (fromPreSelect) does not block hover snapping.
|
||||
if (r.selection.hasSelection && !r.selection.fromPreSelect) || r.activeSurface == nil || r.activeSurface.output == nil {
|
||||
if r.hoveredTarget != nil {
|
||||
r.hoveredTarget = nil
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
gx := surfaceX + float64(r.activeSurface.output.x)
|
||||
gy := surfaceY + float64(r.activeSurface.output.y)
|
||||
|
||||
var hit *SnapTarget
|
||||
for i := len(r.snapTargets) - 1; i >= 0; i-- {
|
||||
t := &r.snapTargets[i]
|
||||
if gx >= t.X && gx < t.X+t.Width && gy >= t.Y && gy < t.Y+t.Height {
|
||||
hit = t
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hit != r.hoveredTarget {
|
||||
r.hoveredTarget = hit
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RegionSelector) snapToTarget(t *SnapTarget) {
|
||||
r.preSelect = Region{}
|
||||
os := r.activeSurface
|
||||
devPxX, devPxY := 1.0, 1.0
|
||||
if os != nil && os.screenBuf != nil && os.logicalW > 0 && os.logicalH > 0 {
|
||||
devPxX = float64(os.logicalW) / float64(os.screenBuf.Width)
|
||||
devPxY = float64(os.logicalH) / float64(os.screenBuf.Height)
|
||||
}
|
||||
r.selection = SelectionState{
|
||||
hasSelection: true,
|
||||
surface: os,
|
||||
anchorX: t.X,
|
||||
anchorY: t.Y,
|
||||
currentX: t.X + t.Width - devPxX,
|
||||
currentY: t.Y + t.Height - devPxY,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RegionSelector) setupTouchHandlers() {
|
||||
r.touch.SetDownHandler(func(e client.TouchDownEvent) {
|
||||
r.handleTouchDown(e.Surface, e.Id, e.X, e.Y)
|
||||
@@ -626,7 +742,12 @@ func (r *RegionSelector) handleKey(sym string, state uint32) {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
case "Return", "space", "KP_Enter":
|
||||
if r.selection.hasSelection {
|
||||
if r.hoveredTarget != nil && r.activeSurface != nil && (!r.selection.hasSelection || r.selection.fromPreSelect) {
|
||||
t := r.hoveredTarget
|
||||
r.hoveredTarget = nil
|
||||
r.snapToTarget(t)
|
||||
r.finishSelection()
|
||||
} else if r.selection.hasSelection {
|
||||
r.finishSelection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,13 @@ var fontGlyphs = map[rune][fontCharH]uint8{
|
||||
'/': {0x00, 0x02, 0x06, 0x0C, 0x18, 0x18, 0x30, 0x60, 0x40, 0x00, 0x00, 0x00},
|
||||
'[': {0x3C, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3C, 0x00, 0x00},
|
||||
']': {0x3C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x3C, 0x00, 0x00},
|
||||
'b': {0x00, 0x60, 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x7C, 0x00, 0x00},
|
||||
'f': {0x00, 0x0E, 0x18, 0x18, 0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, 0x00},
|
||||
'j': {0x00, 0x0C, 0x00, 0x1C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x4C, 0x38, 0x00},
|
||||
'k': {0x00, 0x60, 0x60, 0x60, 0x66, 0x6C, 0x78, 0x6C, 0x66, 0x66, 0x00, 0x00},
|
||||
'q': {0x00, 0x00, 0x00, 0x3E, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x00, 0x00},
|
||||
'y': {0x00, 0x00, 0x00, 0x66, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x7C, 0x00, 0x00},
|
||||
'-': {0x00, 0x00, 0x00, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||
}
|
||||
|
||||
type OverlayStyle struct {
|
||||
@@ -299,6 +306,53 @@ func (r *RegionSelector) selectionRenderBounds(os *OutputSurface) (selectionRend
|
||||
return selectionRenderBounds{}, false
|
||||
}
|
||||
|
||||
if (!r.selection.hasSelection || r.selection.fromPreSelect) && r.hoveredTarget != nil {
|
||||
t := r.hoveredTarget
|
||||
srcBuf := r.getSourceBuffer(os)
|
||||
if srcBuf == nil {
|
||||
return selectionRenderBounds{}, false
|
||||
}
|
||||
outputMinX := float64(os.output.x)
|
||||
outputMinY := float64(os.output.y)
|
||||
outputMaxX := outputMinX + float64(os.logicalW)
|
||||
outputMaxY := outputMinY + float64(os.logicalH)
|
||||
|
||||
minX := t.X
|
||||
minY := t.Y
|
||||
maxX := t.X + t.Width
|
||||
maxY := t.Y + t.Height
|
||||
|
||||
if math.Max(minX, outputMinX) >= math.Min(maxX, outputMaxX) ||
|
||||
math.Max(minY, outputMinY) >= math.Min(maxY, outputMaxY) {
|
||||
return selectionRenderBounds{}, false
|
||||
}
|
||||
|
||||
scaleX, scaleY := 1.0, 1.0
|
||||
if os.logicalW > 0 && os.logicalH > 0 {
|
||||
scaleX = float64(srcBuf.Width) / float64(os.logicalW)
|
||||
scaleY = float64(srcBuf.Height) / float64(os.logicalH)
|
||||
}
|
||||
x1 := clamp(int(math.Floor((minX-outputMinX)*scaleX)), 0, srcBuf.Width-1)
|
||||
y1 := clamp(int(math.Floor((minY-outputMinY)*scaleY)), 0, srcBuf.Height-1)
|
||||
w := int(math.Round(t.Width * scaleX))
|
||||
h := int(math.Round(t.Height * scaleY))
|
||||
x2 := clamp(x1+w-1, 0, srcBuf.Width-1)
|
||||
y2 := clamp(y1+h-1, 0, srcBuf.Height-1)
|
||||
w = x2 - x1 + 1
|
||||
h = y2 - y1 + 1
|
||||
|
||||
labelText := fmt.Sprintf("[%s] %dx%d", t.DisplayName(), w, h)
|
||||
|
||||
return selectionRenderBounds{
|
||||
x: x1, y: y1, w: w, h: h, labelText: labelText,
|
||||
top: minY >= outputMinY && minY < outputMaxY,
|
||||
bottom: maxY > outputMinY && maxY <= outputMaxY,
|
||||
left: minX >= outputMinX && minX < outputMaxX,
|
||||
right: maxX > outputMinX && maxX <= outputMaxX,
|
||||
scaleX: scaleX,
|
||||
}, true
|
||||
}
|
||||
|
||||
ext, ok := r.selectionExtent()
|
||||
if !ok || !ext.intersects(os) {
|
||||
return selectionRenderBounds{}, false
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSnapTargetDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
target SnapTarget
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
target: SnapTarget{Name: "dms:control-center", Type: "popout"},
|
||||
expected: "control center",
|
||||
},
|
||||
{
|
||||
target: SnapTarget{Name: "dms:clipboard-popout", Type: "popout"},
|
||||
expected: "clipboard",
|
||||
},
|
||||
{
|
||||
target: SnapTarget{Name: "dms:wifi-password", Type: "modal"},
|
||||
expected: "wifi password",
|
||||
},
|
||||
{
|
||||
target: SnapTarget{Name: "dms:polkit-auth-surface", Type: "modal"},
|
||||
expected: "polkit auth",
|
||||
},
|
||||
{
|
||||
target: SnapTarget{Name: "", Type: "popout"},
|
||||
expected: "popout",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := tt.target.DisplayName()
|
||||
if got != tt.expected {
|
||||
t.Errorf("DisplayName() for %+v = %q, want %q", tt.target, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTarget(t *testing.T) {
|
||||
mockSurface := &OutputSurface{
|
||||
logicalW: 1920,
|
||||
logicalH: 1080,
|
||||
output: &WaylandOutput{
|
||||
name: "eDP-1",
|
||||
x: 0,
|
||||
y: 0,
|
||||
fractionalScale: 1.0,
|
||||
},
|
||||
screenBuf: &ShmBuffer{
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
Stride: 1920 * 4,
|
||||
},
|
||||
}
|
||||
|
||||
targets := []SnapTarget{
|
||||
{
|
||||
Name: "dms:control-center",
|
||||
Type: "popout",
|
||||
X: 1400,
|
||||
Y: 48,
|
||||
Width: 420,
|
||||
Height: 580,
|
||||
},
|
||||
{
|
||||
Name: "dms:clipboard-popout",
|
||||
Type: "popout",
|
||||
X: 500,
|
||||
Y: 100,
|
||||
Width: 350,
|
||||
Height: 400,
|
||||
},
|
||||
}
|
||||
|
||||
r := &RegionSelector{
|
||||
activeSurface: mockSurface,
|
||||
surfaces: []*OutputSurface{mockSurface},
|
||||
snapTargets: targets,
|
||||
}
|
||||
|
||||
// 1. Outside any target
|
||||
r.updateHoverTarget(100, 100)
|
||||
if r.hoveredTarget != nil {
|
||||
t.Fatalf("expected nil hoveredTarget, got %+v", r.hoveredTarget)
|
||||
}
|
||||
|
||||
// 2. Inside control center
|
||||
r.updateHoverTarget(1500, 100)
|
||||
if r.hoveredTarget == nil || r.hoveredTarget.Name != "dms:control-center" {
|
||||
t.Fatalf("expected control-center target, got %+v", r.hoveredTarget)
|
||||
}
|
||||
|
||||
// 3. Move inside clipboard
|
||||
r.updateHoverTarget(600, 200)
|
||||
if r.hoveredTarget == nil || r.hoveredTarget.Name != "dms:clipboard-popout" {
|
||||
t.Fatalf("expected clipboard target, got %+v", r.hoveredTarget)
|
||||
}
|
||||
|
||||
// 4. Move outside
|
||||
r.updateHoverTarget(0, 0)
|
||||
if r.hoveredTarget != nil {
|
||||
t.Fatalf("expected nil hoveredTarget when outside, got %+v", r.hoveredTarget)
|
||||
}
|
||||
|
||||
// 5. When user has actively drawn a selection, hoverTarget must not change
|
||||
r.selection.hasSelection = true
|
||||
r.selection.fromPreSelect = false
|
||||
r.updateHoverTarget(1500, 100)
|
||||
if r.hoveredTarget != nil {
|
||||
t.Fatalf("expected nil hoveredTarget when user selection is active, got %+v", r.hoveredTarget)
|
||||
}
|
||||
|
||||
// 6. When selection is a preselection (fromPreSelect=true), hover snap must still work
|
||||
r.selection.hasSelection = true
|
||||
r.selection.fromPreSelect = true
|
||||
r.updateHoverTarget(1500, 100)
|
||||
if r.hoveredTarget == nil || r.hoveredTarget.Name != "dms:control-center" {
|
||||
t.Fatalf("expected control-center hover over preselection, got %+v", r.hoveredTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionRenderBoundsHoveredTarget(t *testing.T) {
|
||||
mockSurface := &OutputSurface{
|
||||
logicalW: 1920,
|
||||
logicalH: 1080,
|
||||
output: &WaylandOutput{
|
||||
name: "eDP-1",
|
||||
x: 0,
|
||||
y: 0,
|
||||
fractionalScale: 1.0,
|
||||
},
|
||||
screenBuf: &ShmBuffer{
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
Stride: 1920 * 4,
|
||||
},
|
||||
}
|
||||
|
||||
target := &SnapTarget{
|
||||
Name: "dms:control-center",
|
||||
Type: "popout",
|
||||
X: 1400,
|
||||
Y: 48,
|
||||
Width: 420,
|
||||
Height: 580,
|
||||
}
|
||||
|
||||
r := &RegionSelector{
|
||||
activeSurface: mockSurface,
|
||||
surfaces: []*OutputSurface{mockSurface},
|
||||
hoveredTarget: target,
|
||||
}
|
||||
|
||||
bounds, ok := r.selectionRenderBounds(mockSurface)
|
||||
if !ok {
|
||||
t.Fatalf("expected selectionRenderBounds to return ok for hovered target")
|
||||
}
|
||||
|
||||
if bounds.x != 1400 || bounds.y != 48 || bounds.w != 420 || bounds.h != 580 {
|
||||
t.Errorf("unexpected bounds: %+v", bounds)
|
||||
}
|
||||
|
||||
if bounds.labelText != "[control center] 420x580" {
|
||||
t.Errorf("unexpected label text: %q", bounds.labelText)
|
||||
}
|
||||
|
||||
// With a preselection active, hoveredTarget must still be rendered.
|
||||
r.selection.hasSelection = true
|
||||
r.selection.fromPreSelect = true
|
||||
bounds, ok = r.selectionRenderBounds(mockSurface)
|
||||
if !ok {
|
||||
t.Fatalf("expected selectionRenderBounds to return ok over preselection")
|
||||
}
|
||||
if bounds.x != 1400 || bounds.y != 48 || bounds.w != 420 || bounds.h != 580 {
|
||||
t.Errorf("unexpected bounds over preselection: %+v", bounds)
|
||||
}
|
||||
|
||||
// With a user-drawn selection, hoveredTarget must NOT be rendered.
|
||||
r.selection.fromPreSelect = false
|
||||
_, ok = r.selectionRenderBounds(mockSurface)
|
||||
if ok {
|
||||
t.Fatalf("expected selectionRenderBounds to return !ok when user selection is active")
|
||||
}
|
||||
|
||||
// Snapping to target must produce exact target dimensions without off-by-one.
|
||||
r.hoveredTarget = nil
|
||||
r.snapToTarget(target)
|
||||
if !r.selection.hasSelection || r.selection.fromPreSelect {
|
||||
t.Fatalf("expected committed selection from snapToTarget")
|
||||
}
|
||||
snappedBounds, ok := r.selectionRenderBounds(mockSurface)
|
||||
if !ok {
|
||||
t.Fatalf("expected selectionRenderBounds to return ok for snapped target")
|
||||
}
|
||||
if snappedBounds.x != 1400 || snappedBounds.y != 48 || snappedBounds.w != 420 || snappedBounds.h != 580 {
|
||||
t.Errorf("unexpected snapped bounds: %+v (want 1400, 48, 420, 580)", snappedBounds)
|
||||
}
|
||||
if snappedBounds.labelText != "420x580" {
|
||||
t.Errorf("unexpected snapped label text: %q (want 420x580)", snappedBounds.labelText)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package screenshot
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Mode int
|
||||
|
||||
@@ -55,6 +58,30 @@ type Output struct {
|
||||
Transform int32
|
||||
}
|
||||
|
||||
type SnapTarget struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}
|
||||
|
||||
func (s SnapTarget) DisplayName() string {
|
||||
name := strings.TrimPrefix(s.Name, "dms:")
|
||||
for _, suffix := range []string{"-popout", "-modal", "-surface"} {
|
||||
name = strings.TrimSuffix(name, suffix)
|
||||
}
|
||||
name = strings.ReplaceAll(name, "-", " ")
|
||||
if name == "" {
|
||||
if s.Type != "" {
|
||||
return s.Type
|
||||
}
|
||||
return "surface"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Seat string
|
||||
Mode Mode
|
||||
@@ -74,6 +101,7 @@ type Config struct {
|
||||
AllowMultiple bool
|
||||
IntervalMs int
|
||||
HUD string
|
||||
SnapTargets []SnapTarget
|
||||
// SelectorHook runs as the interactive selector starts (true) and ends (false).
|
||||
SelectorHook func(begin bool)
|
||||
}
|
||||
|
||||
@@ -337,4 +337,73 @@ Singleton {
|
||||
function requestHoverPopout(popout, tabIndex, triggerSource) {
|
||||
_requestPopout(popout, tabIndex, triggerSource, true);
|
||||
}
|
||||
|
||||
function getActiveSurfaces() {
|
||||
const results = [];
|
||||
|
||||
for (const screenName in currentPopoutsByScreen) {
|
||||
const popout = currentPopoutsByScreen[screenName];
|
||||
if (!popout || !popout.shouldBeVisible)
|
||||
continue;
|
||||
|
||||
const host = popout.impl?.item ?? popout;
|
||||
if (!host || !host.shouldBeVisible || host.isClosing)
|
||||
continue;
|
||||
|
||||
const screen = host.surfaceScreen ?? popout.screen ?? null;
|
||||
const sx = screen?.x ?? 0;
|
||||
const sy = screen?.y ?? 0;
|
||||
const px = host.renderedAlignedX;
|
||||
const py = host.renderedAlignedY;
|
||||
const pw = host.renderedAlignedWidth ?? popout.popupWidth ?? 0;
|
||||
const ph = host.renderedAlignedHeight ?? popout.popupHeight ?? 0;
|
||||
|
||||
// Skip if origin is not yet laid out (undefined/null means not positioned).
|
||||
if (px == null || py == null || pw <= 0 || ph <= 0)
|
||||
continue;
|
||||
|
||||
results.push({
|
||||
"name": popout.layerNamespace || "popout",
|
||||
"type": "popout",
|
||||
"x": Math.round(sx + px),
|
||||
"y": Math.round(sy + py),
|
||||
"width": Math.round(pw),
|
||||
"height": Math.round(ph)
|
||||
});
|
||||
}
|
||||
|
||||
const modals = ModalManager.currentModalsByScreen || {};
|
||||
for (const screenName in modals) {
|
||||
const modal = modals[screenName];
|
||||
if (!modal || !modal.shouldBeVisible)
|
||||
continue;
|
||||
|
||||
const host = modal.impl?.item ?? modal;
|
||||
if (!host || !host.shouldBeVisible || host.isClosing)
|
||||
continue;
|
||||
|
||||
const screen = host.effectiveScreen ?? modal.targetScreen ?? null;
|
||||
const sx = screen?.x ?? 0;
|
||||
const sy = screen?.y ?? 0;
|
||||
const mx = host.alignedX;
|
||||
const my = host.alignedY;
|
||||
const mw = host.alignedWidth ?? modal.modalWidth ?? 0;
|
||||
const mh = host.alignedHeight ?? modal.modalHeight ?? 0;
|
||||
|
||||
// Skip if origin is not yet laid out.
|
||||
if (mx == null || my == null || mw <= 0 || mh <= 0)
|
||||
continue;
|
||||
|
||||
results.push({
|
||||
"name": modal.layerNamespace || "modal",
|
||||
"type": "modal",
|
||||
"x": Math.round(sx + mx),
|
||||
"y": Math.round(sy + my),
|
||||
"width": Math.round(mw),
|
||||
"height": Math.round(mh)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +325,10 @@ Item {
|
||||
return "SCREENSHOT_MODE_OFF";
|
||||
}
|
||||
|
||||
function getSurfaces(): string {
|
||||
return JSON.stringify(PopoutManager.getActiveSurfaces());
|
||||
}
|
||||
|
||||
target: "screenshot"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user