feat(screenshot): add live preview panel for scroll capture (#3269)

* feat(screenshot): add live preview panel for scroll capture

- Port scrolling live preview from dms-quick-capture backend to native screenshot.
- Calculate dynamic preview panel geometry placed beside the capture hole on the side with most available space.
- Render downscaled live stitched canvas with dark translucent background and white border in drawScrollOverlay.
- Include preview panel in Wayland input regions and allow completing capture by clicking the preview panel.
- Update cursor shape to pointer hand when hovering over preview or control buttons.
- Add unit tests for layout, hit testing, and canvas rendering.

* fix(screenshot): bound scroll preview aspect ratio to recent canvas tail

- Derive preview aspect ratio from a bounded tail window of the canvas (maxPreviewRows = frameW * maxHeight / minImageW).
- Prevent preview panel width from collapsing below minImageW and disappearing when stitched rows exceed ~4.5x width.
- Render the recent window rows in drawScrollPreview to maintain a stable panel size and display live incoming content.
- Add unit test for long scrolling capture preview bounds.
This commit is contained in:
Huỳnh Thiện Lộc
2026-09-02 21:28:29 -04:00
committed by GitHub
parent c6c14576eb
commit 99e2368b1c
5 changed files with 506 additions and 4 deletions
+8 -1
View File
@@ -660,7 +660,14 @@ func (r *RegionSelector) setNativeCursor(serial uint32) {
return
}
shape := uint32(wp_cursor_shape.WpCursorShapeDeviceV1ShapeCrosshair)
if r.resizingHandle != handleNone {
if r.phase == phaseScroll {
switch r.scrollBarHit(r.pointerX, r.pointerY) {
case "done", "cancel", "preview":
shape = uint32(wp_cursor_shape.WpCursorShapeDeviceV1ShapePointer)
default:
shape = uint32(wp_cursor_shape.WpCursorShapeDeviceV1ShapeDefault)
}
} else if r.resizingHandle != handleNone {
shape = cursorShapeForHandle(r.resizingHandle)
} else if r.movingSelection && r.selection.dragging {
shape = uint32(wp_cursor_shape.WpCursorShapeDeviceV1ShapeGrabbing)
+6 -1
View File
@@ -58,6 +58,11 @@ func (r *RegionSelector) setupPointerHandlers() {
r.pointerX = e.SurfaceX
r.pointerY = e.SurfaceY
if r.phase == phaseScroll {
r.refreshCursor()
return
}
if !r.selection.dragging {
if r.ctrlHeld && r.selection.hasSelection {
r.refreshCursor()
@@ -78,7 +83,7 @@ func (r *RegionSelector) setupPointerHandlers() {
return
}
switch r.scrollBarHit(r.pointerX, r.pointerY) {
case "done":
case "done", "preview":
r.finishScroll()
case "cancel":
r.cancelled = true
+92
View File
@@ -496,6 +496,98 @@ func (r *RegionSelector) drawScrollOverlay(os *OutputSurface, renderBuf *ShmBuff
r.drawBorder(data, stride, w, h, holeX-1, holeY-1, holeW+2, holeH+2, os.screenFormat)
r.drawScrollBar(data, stride, w, h, os.screenFormat)
r.drawScrollPreview(data, stride, w, h, os)
}
func (r *RegionSelector) drawScrollPreview(data []byte, stride, bufW, bufH int, os *OutputSurface) {
s := r.scroll
if s == nil || s.st == nil || s.st.rows() == 0 || !s.hasPreview {
return
}
canvas := s.st.canvas
sourceW := s.frameW
sourceRows := s.st.rows()
if sourceW <= 0 || sourceRows <= 0 || len(canvas) < sourceW*sourceRows*4 {
return
}
scale := 1.0
if os.logicalW > 0 {
scale = float64(bufW) / float64(os.logicalW)
}
if scale <= 0 {
scale = 1.0
}
padding := int(float64(scrollPreviewPaddingLogical) * scale)
format := os.screenFormat
// 1. Draw translucent dark panel background
r.fillRect(data, stride, bufW, bufH, s.previewX, s.previewY, s.previewW, s.previewH, 16, 16, 16, 230, format)
// 2. Draw downscaled stitched canvas
imageX := s.previewX + padding
imageY := s.previewY + padding
imageW := s.previewW - padding*2
imageH := s.previewH - padding*2
if imageW <= 0 || imageH <= 0 {
return
}
needSwap := formatIsBGR(uint32(s.format)) != formatIsBGR(format)
startRow := s.previewStartRow
previewRows := s.previewRows
if previewRows <= 0 || startRow < 0 || startRow+previewRows > sourceRows {
startRow = 0
previewRows = sourceRows
}
for y := range imageH {
srcY := startRow + y*previewRows/imageH
if srcY >= sourceRows {
srcY = sourceRows - 1
}
dstY := imageY + y
if dstY < 0 || dstY >= bufH {
continue
}
dstRowOff := dstY * stride
srcRowOff := srcY * sourceW * 4
for x := range imageW {
srcX := x * sourceW / imageW
dstX := imageX + x
if dstX < 0 || dstX >= bufW {
continue
}
srcIdx := srcRowOff + srcX*4
dstIdx := dstRowOff + dstX*4
if srcIdx+3 >= len(canvas) || dstIdx+3 >= len(data) {
continue
}
if needSwap {
data[dstIdx+0] = canvas[srcIdx+2]
data[dstIdx+1] = canvas[srcIdx+1]
data[dstIdx+2] = canvas[srcIdx+0]
} else {
data[dstIdx+0] = canvas[srcIdx+0]
data[dstIdx+1] = canvas[srcIdx+1]
data[dstIdx+2] = canvas[srcIdx+2]
}
data[dstIdx+3] = 255
}
}
// 3. Draw border around preview panel
borderW := max(int(scale+0.5), 1)
for i := range borderW {
r.drawHLine(data, stride, bufW, bufH, s.previewX-i, s.previewY-i, s.previewW+2*i, format)
r.drawHLine(data, stride, bufW, bufH, s.previewX-i, s.previewY+s.previewH+i-1, s.previewW+2*i, format)
r.drawVLine(data, stride, bufW, bufH, s.previewX-i, s.previewY-i, s.previewH+2*i, format)
r.drawVLine(data, stride, bufW, bufH, s.previewX+s.previewW+i-1, s.previewY-i, s.previewH+2*i, format)
}
}
func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, format uint32) {
+124 -2
View File
@@ -25,6 +25,12 @@ const (
const (
scrollMaxFailures = 5
scrollSeamTicks = 4
scrollPreviewMarginLogical = 16
scrollPreviewPaddingLogical = 6
scrollPreviewMinImageWLogical = 80
scrollPreviewMaxImageWLogical = 240
scrollPreviewMaxImageHLogical = 360
)
type scrollSession struct {
@@ -61,6 +67,11 @@ type scrollSession struct {
cancelW int
btnH int
// preview panel geometry and window in overlay buffer pixels
previewX, previewY, previewW, previewH int
previewStartRow, previewRows int
hasPreview bool
sigCh chan os.Signal
keysBound bool
@@ -195,9 +206,12 @@ func (r *RegionSelector) layoutScrollBar(os *OutputSurface) {
borderX1, borderY1 := s.holeX-3, s.holeY-3
borderX2, borderY2 := s.holeX+s.holeW+3, s.holeY+s.holeH+3
overlaps := s.barX < borderX2 && s.barX+s.barW > borderX1 &&
overlapsHole := s.barX < borderX2 && s.barX+s.barW > borderX1 &&
s.barY < borderY2 && s.barY+s.barH > borderY1
if overlaps {
overlapsPreview := s.hasPreview &&
s.barX < s.previewX+s.previewW && s.barX+s.barW > s.previewX &&
s.barY < s.previewY+s.previewH && s.barY+s.barH > s.previewY
if overlapsHole || overlapsPreview {
s.barY = 24
}
@@ -207,7 +221,108 @@ func (r *RegionSelector) layoutScrollBar(os *OutputSurface) {
s.cancelY = s.doneY
}
func (r *RegionSelector) scrollPreviewPanel(os *OutputSurface) (x, y, w, h int, startRow, previewRows int, ok bool) {
s := r.scroll
if s == nil || s.st == nil || s.frameW <= 0 || s.st.rows() <= 0 || os == nil || os.screenBuf == nil {
return 0, 0, 0, 0, 0, 0, false
}
bufW, bufH := os.screenBuf.Width, os.screenBuf.Height
scale := 1.0
if os.logicalW > 0 {
scale = float64(bufW) / float64(os.logicalW)
}
if scale <= 0 {
scale = 1.0
}
margin := int(float64(scrollPreviewMarginLogical) * scale)
padding := int(float64(scrollPreviewPaddingLogical) * scale)
minImageW := int(float64(scrollPreviewMinImageWLogical) * scale)
maxImageW := int(float64(scrollPreviewMaxImageWLogical) * scale)
maxImageH := int(float64(scrollPreviewMaxImageHLogical) * scale)
selX := s.holeX
selY := s.holeY
selW := s.holeW
selH := s.holeH
leftGap := selX
rightGap := bufW - (selX + selW)
if leftGap < 0 {
leftGap = 0
}
if rightGap < 0 {
rightGap = 0
}
onLeft := leftGap >= rightGap
sideGap := rightGap
if onLeft {
sideGap = leftGap
}
maxHeight := min(maxImageH, bufH-margin*2)
availableWidth := sideGap - (margin*2 + padding*2)
if maxHeight <= 0 || availableWidth < minImageW {
return 0, 0, 0, 0, 0, 0, false
}
totalRows := s.st.rows()
maxPreviewRows := int(float64(s.frameW) * float64(maxHeight) / float64(minImageW))
startRow = 0
previewRows = totalRows
if maxPreviewRows > 0 && totalRows > maxPreviewRows {
startRow = totalRows - maxPreviewRows
previewRows = maxPreviewRows
}
aspect := float64(s.frameW) / float64(previewRows)
imageWidth := int(float64(maxHeight)*aspect + 0.5)
imageWidth = min(imageWidth, maxImageW)
imageWidth = min(imageWidth, availableWidth)
if imageWidth < minImageW {
return 0, 0, 0, 0, 0, 0, false
}
imageHeight := max(int(float64(imageWidth)/aspect+0.5), 1)
panelWidth := imageWidth + padding*2
panelHeight := imageHeight + padding*2
var panelX int
if onLeft {
panelX = selX - margin - panelWidth
} else {
panelX = selX + selW + margin
}
panelY := selY + (selH-panelHeight)/2
panelY = clamp(panelY, margin, bufH-margin-panelHeight)
return panelX, panelY, panelWidth, panelHeight, startRow, previewRows, true
}
func (r *RegionSelector) updateScrollPreviewLayout(os *OutputSurface) {
s := r.scroll
if s == nil || os == nil {
return
}
px, py, pw, ph, startRow, previewRows, ok := r.scrollPreviewPanel(os)
changed := s.hasPreview != ok || s.previewX != px || s.previewY != py || s.previewW != pw || s.previewH != ph
s.previewX, s.previewY, s.previewW, s.previewH = px, py, pw, ph
s.previewStartRow, s.previewRows = startRow, previewRows
s.hasPreview = ok
if changed {
r.layoutScrollBar(os)
r.setInputPassthrough(os, true)
}
}
func (r *RegionSelector) setInputPassthrough(os *OutputSurface, withBar bool) {
if r.compositor == nil || os == nil || os.wlSurface == nil {
return
}
reg, err := r.compositor.CreateRegion()
if err != nil {
return
@@ -218,6 +333,10 @@ func (r *RegionSelector) setInputPassthrough(os *OutputSurface, withBar bool) {
scaleY := float64(os.logicalH) / float64(os.screenBuf.Height)
_ = reg.Add(int32(float64(s.barX)*scaleX), int32(float64(s.barY)*scaleY),
int32(float64(s.barW)*scaleX)+1, int32(float64(s.barH)*scaleY)+1)
if s.hasPreview {
_ = reg.Add(int32(float64(s.previewX)*scaleX), int32(float64(s.previewY)*scaleY),
int32(float64(s.previewW)*scaleX)+1, int32(float64(s.previewH)*scaleY)+1)
}
}
_ = os.wlSurface.SetInputRegion(reg)
_ = reg.Destroy()
@@ -304,6 +423,8 @@ func (r *RegionSelector) scrollBarHit(x, y float64) string {
return "done"
case bx >= s.cancelX && bx < s.cancelX+s.cancelW && by >= s.cancelY && by < s.cancelY+s.btnH:
return "cancel"
case s.hasPreview && bx >= s.previewX && bx < s.previewX+s.previewW && by >= s.previewY && by < s.previewY+s.previewH:
return "preview"
default:
return ""
}
@@ -468,6 +589,7 @@ func (r *RegionSelector) handleScrollFrame() {
}
s.kept++
if r.selection.surface != nil {
r.updateScrollPreviewLayout(r.selection.surface)
r.redrawSurface(r.selection.surface)
}
}
@@ -0,0 +1,276 @@
package screenshot
import (
"testing"
)
func createMockScrollSurface(bufW, bufH, logicalW, logicalH int) *OutputSurface {
return &OutputSurface{
logicalW: logicalW,
logicalH: logicalH,
output: &WaylandOutput{
fractionalScale: float64(bufW) / float64(logicalW),
},
screenBuf: &ShmBuffer{
Width: bufW,
Height: bufH,
Stride: bufW * 4,
},
screenFormat: uint32(FormatXRGB8888),
}
}
func TestScrollPreviewPanelLayoutRight(t *testing.T) {
os := createMockScrollSurface(1920, 1080, 1920, 1080)
st := newStitcher(600 * 4)
st.canvas = make([]byte, 600*4*1200)
st.cols = make([]rowCols, 1200)
r := &RegionSelector{
scroll: &scrollSession{
holeX: 100,
holeY: 100,
holeW: 600,
holeH: 800,
frameW: 600,
st: st,
},
}
x, y, w, h, startRow, previewRows, ok := r.scrollPreviewPanel(os)
if !ok {
t.Fatalf("expected scrollPreviewPanel to succeed, got ok=false")
}
if startRow != 0 || previewRows != 1200 {
t.Errorf("expected full canvas window (0, 1200), got (%d, %d)", startRow, previewRows)
}
// Selection is at x=100..700. Right gap is 1920 - 700 = 1220; left gap is 100.
// Panel must be placed to the right of the hole.
if x <= r.scroll.holeX+r.scroll.holeW {
t.Errorf("panel x=%d should be to the right of hole end %d", x, r.scroll.holeX+r.scroll.holeW)
}
if x+w > 1920 {
t.Errorf("panel right edge %d exceeds screen width 1920", x+w)
}
if y < 0 || y+h > 1080 {
t.Errorf("panel y=%d h=%d out of screen bounds [0, 1080]", y, h)
}
if y < 0 || y+h > 1080 || w <= 0 || h <= 0 {
t.Errorf("invalid dimensions w=%d h=%d", w, h)
}
}
func TestScrollPreviewPanelLayoutLeft(t *testing.T) {
os := createMockScrollSurface(1920, 1080, 1920, 1080)
st := newStitcher(600 * 4)
st.canvas = make([]byte, 600*4*1200)
st.cols = make([]rowCols, 1200)
r := &RegionSelector{
scroll: &scrollSession{
holeX: 1220,
holeY: 100,
holeW: 600,
holeH: 800,
frameW: 600,
st: st,
},
}
x, y, w, h, _, _, ok := r.scrollPreviewPanel(os)
if !ok {
t.Fatalf("expected scrollPreviewPanel to succeed, got ok=false")
}
// Selection is at x=1220..1820. Left gap is 1220; right gap is 100.
// Panel must be placed to the left of the hole.
if x+w >= r.scroll.holeX {
t.Errorf("panel right edge %d should be to the left of hole start %d", x+w, r.scroll.holeX)
}
if x < 0 {
t.Errorf("panel left edge %d is negative", x)
}
if y < 0 || y+h > 1080 {
t.Errorf("panel y=%d h=%d out of screen bounds [0, 1080]", y, h)
}
}
func TestScrollPreviewPanelInsufficientSpace(t *testing.T) {
os := createMockScrollSurface(1920, 1080, 1920, 1080)
st := newStitcher(1880 * 4)
st.canvas = make([]byte, 1880*4*2000)
st.cols = make([]rowCols, 2000)
r := &RegionSelector{
scroll: &scrollSession{
holeX: 20,
holeY: 20,
holeW: 1880,
holeH: 1040,
frameW: 1880,
st: st,
},
}
_, _, _, _, _, _, ok := r.scrollPreviewPanel(os)
if ok {
t.Fatalf("expected scrollPreviewPanel to return false when side gaps are too small")
}
}
func TestScrollPreviewPanelBoundedTailLongCapture(t *testing.T) {
os := createMockScrollSurface(1920, 1080, 1920, 1080)
st := newStitcher(600 * 4)
totalRows := 10000
st.canvas = make([]byte, 600*4*totalRows)
st.cols = make([]rowCols, totalRows)
r := &RegionSelector{
scroll: &scrollSession{
holeX: 100,
holeY: 100,
holeW: 600,
holeH: 800,
frameW: 600,
st: st,
},
}
x, y, w, h, startRow, previewRows, ok := r.scrollPreviewPanel(os)
if !ok {
t.Fatalf("expected scrollPreviewPanel to succeed on long capture, got ok=false")
}
if startRow <= 0 {
t.Errorf("expected startRow > 0 for 10000-row capture, got %d", startRow)
}
if startRow+previewRows != totalRows {
t.Errorf("expected startRow (%d) + previewRows (%d) == %d", startRow, previewRows, totalRows)
}
if y < 0 || y+h > 1080 || w <= 0 || h <= 0 {
t.Errorf("invalid dimensions w=%d, h=%d", w, h)
}
if x <= r.scroll.holeX+r.scroll.holeW {
t.Errorf("panel x=%d should be to the right of hole end", x)
}
}
func TestScrollBarHitPreview(t *testing.T) {
os := createMockScrollSurface(1920, 1080, 1920, 1080)
r := &RegionSelector{
selection: SelectionState{
surface: os,
},
scroll: &scrollSession{
doneX: 100,
doneY: 1000,
doneW: 60,
btnH: 24,
cancelX: 180,
cancelY: 1000,
cancelW: 70,
previewX: 800,
previewY: 200,
previewW: 200,
previewH: 300,
hasPreview: true,
},
}
tests := []struct {
name string
x, y float64
want string
}{
{"done hit", 120, 1010, "done"},
{"cancel hit", 200, 1010, "cancel"},
{"preview hit", 900, 350, "preview"},
{"miss", 500, 500, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := r.scrollBarHit(tc.x, tc.y)
if got != tc.want {
t.Errorf("scrollBarHit(%.1f, %.1f) = %q, want %q", tc.x, tc.y, got, tc.want)
}
})
}
}
func TestDrawScrollPreviewRendering(t *testing.T) {
bufW, bufH := 1920, 1080
renderBuf, err := CreateShmBuffer(bufW, bufH, bufW*4)
if err != nil {
t.Fatalf("CreateShmBuffer: %v", err)
}
defer renderBuf.Close()
os := &OutputSurface{
logicalW: bufW,
logicalH: bufH,
screenBuf: renderBuf,
screenFormat: uint32(FormatXRGB8888),
output: &WaylandOutput{fractionalScale: 1.0},
}
sourceW, sourceRows := 400, 800
st := newStitcher(sourceW * 4)
st.canvas = make([]byte, sourceW*4*sourceRows)
st.cols = make([]rowCols, sourceRows)
for y := range sourceRows {
for x := range sourceW {
idx := (y*sourceW + x) * 4
st.canvas[idx+0] = 50 // B
st.canvas[idx+1] = 150 // G
st.canvas[idx+2] = 200 // R
st.canvas[idx+3] = 255 // A
}
}
r := &RegionSelector{
selection: SelectionState{
surface: os,
},
scroll: &scrollSession{
holeX: 100,
holeY: 100,
holeW: 500,
holeH: 600,
frameW: sourceW,
format: PixelFormat(FormatXRGB8888),
st: st,
},
}
r.updateScrollPreviewLayout(os)
if !r.scroll.hasPreview {
t.Fatalf("expected hasPreview=true")
}
data := renderBuf.Data()
r.drawScrollPreview(data, renderBuf.Stride, bufW, bufH, os)
// Sample center of preview image
padding := int(float64(scrollPreviewPaddingLogical))
centerX := r.scroll.previewX + padding + (r.scroll.previewW-padding*2)/2
centerY := r.scroll.previewY + padding + (r.scroll.previewH-padding*2)/2
pixelIdx := centerY*renderBuf.Stride + centerX*4
b := data[pixelIdx+0]
g := data[pixelIdx+1]
red := data[pixelIdx+2]
a := data[pixelIdx+3]
if red != 200 || g != 150 || b != 50 || a != 255 {
t.Errorf("center pixel = (%d, %d, %d, %d), want (200, 150, 50, 255)", red, g, b, a)
}
// Sample top-left border of preview panel
borderIdx := r.scroll.previewY*renderBuf.Stride + r.scroll.previewX*4
if data[borderIdx+0] != 255 || data[borderIdx+1] != 255 || data[borderIdx+2] != 255 {
t.Errorf("border pixel = (%d, %d, %d), want white (255, 255, 255)",
data[borderIdx+2], data[borderIdx+1], data[borderIdx+0])
}
}