Skip to content

feat(screenshot): drag region with ctrl - #3157

Merged
bbedward merged 4 commits into
AvengeMedia:masterfrom
hthienloc:feat/screenshot-region-drag
Aug 24, 2026
Merged

feat(screenshot): drag region with ctrl#3157
bbedward merged 4 commits into
AvengeMedia:masterfrom
hthienloc:feat/screenshot-region-drag

Conversation

@hthienloc

Copy link
Copy Markdown
Member

Add Ctrl + left-drag support for moving the current screenshot region.

The selection keeps its size and cursor offset while being moved, and is clamped to the available output bounds.

@hthienloc
hthienloc force-pushed the feat/screenshot-region-drag branch from 697dbf5 to 05053c5 Compare August 23, 2026 18:04
@hthienloc

Copy link
Copy Markdown
Member Author

/claude review

Comment on lines +235 to +236
return math.Max(minX, math.Min(maxX-width, x)),
math.Max(minY, math.Min(maxY-height, y))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clamping to the exclusive output edge overshoots by one device pixel, because selection coords here are inclusive.

Moving the region flush right/bottom sets maxX exactly to output.x + logicalW, so selectionExtent() computes x2 = floor(logicalW*scaleX) + 1 = screenBuf.Width + 1. Then ext.within(ext.surface) is false, so even on a single output finishSelection() takes the selectionSpansOutputs() branch: the capture is 1px wider than the screen with a transparent column (nothing blits it), the size label reads e.g. 1921x..., and result.Output is cleared so "last region" can no longer replay it.

Clamp so the inclusive far edge stays inside the output, e.g. cap at maxX - width - 1/scaleX (one device pixel, same convention applyPreSelection uses for x2) instead of maxX - width.

@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude review

One off-by-one in the move clamp; the rest of the ctrl-drag flow looks sound.

  • Clamp uses the exclusive output edge while selection coords are inclusive, so a region moved flush to the right/bottom edge captures 1px past the screen and falls into the cross-output path, core/internal/screenshot/region_input.go:235
    Checked: press/release state handling, movingSelection resets, multi-output offsets and clamp bounds, cursor shape path, interaction with preSelect/shift and the capture/render paths. Model: claude-opus-5.

@hthienloc
hthienloc force-pushed the feat/screenshot-region-drag branch from 05053c5 to 591e658 Compare August 23, 2026 18:13
@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

r.selection.anchorX += deltaX
r.selection.currentX += deltaX
r.selection.anchorY += deltaY
r.selection.currentY += deltaY

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the region leaves r.selection.surface pointing at the output it was created on, and the clamp uses the union of all outputs, so on multi-monitor the region can end up entirely on a different output while selection.surface still names the old one.

Trace with A x=0,1920x1080 and B x=1920,1920x1080, selection on A at 100..299: ctrl-drag to global 2000..2199 (allowed, clamp cap is 3840-width-epsilon). selectionExtent() still uses A, so x2=2200 > 1920 and within(A) is false → finishSelection() takes the selectionSpansOutputs() branch even though the region sits wholly inside B. Consequences:

  • r.result.Output is set to "", so "last region" can no longer replay the capture;
  • capturedRegion.Output reports A's name for pixels that came from B;
  • if the outputs have different scales the composite is built on A's grid, so a 200x200 logical region on a 2x B is captured at 200x200 instead of 400x400 (half resolution).

After applying the delta, re-home the selection: pick the surface that now contains the moved rect (fall back to the current one if none does) and assign it to r.selection.surface before returning, so a fully-contained move goes back through the single-output path.

@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude review

The earlier off-by-one is fixed by the epsilon = 1/scale clamp; one multi-monitor issue remains.

  • Moved region can leave selection.surface, forcing the cross-output path (clears result.Output, misreports the output name, downsamples on mixed-DPI), core/internal/screenshot/region_input.go:204
    Checked: press/release state and movingSelection resets, break scoping in the nested switch, offset/delta math preserving region size, clamp bounds and the new epsilon vs selectionExtent()/within() (verified it now lands exactly on screenBuf.Width at integer and fractional scale), pointer enter/motion during a move, preSelect/applyPreSelection interaction, cursor shape path, scroll-phase guard, and the capture/render paths. Model: claude-opus-5.

@hthienloc

Copy link
Copy Markdown
Member Author

/claude review

epsilonY = float64(surface.logicalH) / float64(surface.screenBuf.Height)
}
}
return math.Max(minX, math.Min(maxX-width-epsilonX, x)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clamp uses the bounding box of all outputs, which on any non-uniform layout contains dead space no output covers, so a region can be dragged off-screen into it.

Two 1080p/1440p monitors side by side (A x=0,1920x1080, B x=1920,2560x1440): union maxY is 1440, so a 500px-tall region on A grabbed near its top can be dragged to maxY = 1440-eps — its bottom ~360 logical px sit below A's bottom edge over nothing. rehomeSelectionSurface() finds no containing output (A fails on maxY, B on minX) so the surface stays A, within(A) is false, and finishSelection() takes the selectionSpansOutputs() branch: only A intersects, so the bottom rows of the composite are never blitted and stay at composite.Clear() (transparent), while result.Output is cleared. During the drag it also looks fine — selectionRenderBounds() clamps to srcBuf.Height-1, so the drawn box stops at A's edge and the user never sees the part that's hanging off.

Clamping against the rect of the output under the pointer (r.activeSurface, falling back to the union when nil) instead of the union keeps the region on real screen area and still lets it follow the pointer across a boundary.

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude review

The rehome fix resolves the previous multi-monitor finding; one clamp issue remains.

  • Move clamp uses the union bounding box of all outputs, so on non-uniform layouts a region can be dragged into dead space and captures transparent rows via the cross-output path, core/internal/screenshot/region_input.go:276
    Checked: press/release state and movingSelection resets, break scoping in the nested switch, offset/delta math preserving size, rehomeSelectionSurface() epsilon vs selectionExtent()/within() (consistent at integer and fractional scale, incl. the mixed-DPI cross-output case), pointer enter/motion during a move, preSelect/applyPreSelection interaction, cursor shape path, scroll-phase guard, and the render/capture paths. Model: claude-opus-5.

@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

epsilonY = float64(boundsSurface.logicalH) / float64(boundsSurface.screenBuf.Height)
}
}
return math.Max(minX, math.Min(maxX-width-epsilonX, x)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clamp range inverts when the region is larger than the bounds output, so an oversized axis snaps to the output origin and stops following the pointer.

With a cross-output selection (press on A, drag into B — allowed, finishSelectionAcrossOutputs() handles it), say width = 3000 and bounds A x=0,1920 wide: maxX-width-epsilonX = -1081, so math.Max(minX, math.Min(-1081, x)) always returns 0. On the first motion of a ctrl-drag the region jumps left to A's origin and then refuses to move horizontally for the rest of the drag; the same happens vertically for a region taller than the active output (e.g. a 1200px-tall region while the pointer's output is 1080p), and there the overhang can land in dead space again — no output intersects those rows, so the composite keeps the transparent Clear() rows and result.Output is emptied.

Guard each axis before clamping, e.g. only apply the clamp when maxX-width-epsilonX >= minX (falling back to the union bounds, or leaving that axis alone) instead of clamping into an empty range.

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude review

The active-output clamp fixes the dead-space case for normal-sized regions; one inverted-range edge remains.

  • Clamp range inverts when the region is bigger than the bounds output, so cross-output/oversized regions snap to the output origin and stop following the cursor, core/internal/screenshot/region_input.go:287
    Checked: press/release state and movingSelection resets, break scoping in the nested switch, offset/delta math preserving size, active-surface bounds vs rehomeSelectionSurface() containment (epsilons agree, so a fitting region always rehomes to the clamp output), epsilon vs selectionExtent()/within() at integer and fractional scale, implicit-grab vs enter-during-drag paths, preSelect/applyPreSelection gating, cursor shape path, scroll-phase guard, and the render/capture paths. Model: claude-opus-5.

@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude review

No issues found. The union fallback resolves the previous inverted-clamp finding.
Checked: clampMoveAxis fallback/swap ranges (verified a full-output-sized region does not trigger the fallback since width == logicalW - epsilon exactly, and that the post-fallback swap can only fire when the region exceeds the union, leaving a non-empty range so the drag never freezes), active-surface vs union bounds per axis, epsilon consistency between clampMovedSelection, rehomeSelectionSurface and selectionExtent()/within() at integer and fractional scale (flush right/bottom lands exactly on screenBuf.Width/Height), press/release state and movingSelection resets, break scoping in the nested switch, offset/delta math preserving region size, implicit-grab vs enter-during-drag paths, preSelect/applyPreSelection gating, cursor shape path, scroll-phase guard, and the render/capture paths. Model: claude-opus-5.

@bbedward
bbedward merged commit 0bee6a2 into AvengeMedia:master Aug 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants