Files
Huỳnh Thiện Lộc 99e2368b1c 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.
2026-09-02 21:28:29 -04:00

277 lines
6.8 KiB
Go

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])
}
}