From 34e0ed2e90e8dcf95226dddb28fd6b9fb5776d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Thi=E1=BB=87n=20L=E1=BB=99c?= Date: Wed, 30 Sep 2026 04:06:19 +0700 Subject: [PATCH] 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 --- core/cmd/dms/commands_screenshot.go | 42 +++- core/internal/screenshot/region.go | 15 +- core/internal/screenshot/region_input.go | 123 ++++++++++- core/internal/screenshot/region_render.go | 54 +++++ core/internal/screenshot/region_snap_test.go | 204 +++++++++++++++++++ core/internal/screenshot/types.go | 30 ++- quickshell/Common/PopoutManager.qml | 69 +++++++ quickshell/DMSShellIPC.qml | 4 + 8 files changed, 525 insertions(+), 16 deletions(-) create mode 100644 core/internal/screenshot/region_snap_test.go diff --git a/core/cmd/dms/commands_screenshot.go b/core/cmd/dms/commands_screenshot.go index fa9e28606..5133ad5ae 100644 --- a/core/cmd/dms/commands_screenshot.go +++ b/core/cmd/dms/commands_screenshot.go @@ -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() } diff --git a/core/internal/screenshot/region.go b/core/internal/screenshot/region.go index 3b2e1ddb2..50fc5e3eb 100644 --- a/core/internal/screenshot/region.go +++ b/core/internal/screenshot/region.go @@ -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 diff --git a/core/internal/screenshot/region_input.go b/core/internal/screenshot/region_input.go index 4aec305b2..1d77ca88e 100644 --- a/core/internal/screenshot/region_input.go +++ b/core/internal/screenshot/region_input.go @@ -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() } } diff --git a/core/internal/screenshot/region_render.go b/core/internal/screenshot/region_render.go index f549c663a..eae1385f8 100644 --- a/core/internal/screenshot/region_render.go +++ b/core/internal/screenshot/region_render.go @@ -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 diff --git a/core/internal/screenshot/region_snap_test.go b/core/internal/screenshot/region_snap_test.go new file mode 100644 index 000000000..ce657b1ce --- /dev/null +++ b/core/internal/screenshot/region_snap_test.go @@ -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) + } +} diff --git a/core/internal/screenshot/types.go b/core/internal/screenshot/types.go index 7b7a632e8..0adea73e6 100644 --- a/core/internal/screenshot/types.go +++ b/core/internal/screenshot/types.go @@ -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) } diff --git a/quickshell/Common/PopoutManager.qml b/quickshell/Common/PopoutManager.qml index a328117e6..1b63194e2 100644 --- a/quickshell/Common/PopoutManager.qml +++ b/quickshell/Common/PopoutManager.qml @@ -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; + } } diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index e3fa11091..0d815dda8 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -325,6 +325,10 @@ Item { return "SCREENSHOT_MODE_OFF"; } + function getSurfaces(): string { + return JSON.stringify(PopoutManager.getActiveSurfaces()); + } + target: "screenshot" }