From 99e2368b1c2c3693b35d304be2e06119dae2dea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Thi=E1=BB=87n=20L=E1=BB=99c?= Date: Thu, 3 Sep 2026 08:28:29 +0700 Subject: [PATCH] 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. --- core/internal/screenshot/region.go | 9 +- core/internal/screenshot/region_input.go | 7 +- core/internal/screenshot/region_render.go | 92 ++++++ core/internal/screenshot/scroll.go | 126 +++++++- .../screenshot/scroll_preview_test.go | 276 ++++++++++++++++++ 5 files changed, 506 insertions(+), 4 deletions(-) create mode 100644 core/internal/screenshot/scroll_preview_test.go diff --git a/core/internal/screenshot/region.go b/core/internal/screenshot/region.go index c5b613d2c..57f905b68 100644 --- a/core/internal/screenshot/region.go +++ b/core/internal/screenshot/region.go @@ -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) diff --git a/core/internal/screenshot/region_input.go b/core/internal/screenshot/region_input.go index e11b1712f..6298adf03 100644 --- a/core/internal/screenshot/region_input.go +++ b/core/internal/screenshot/region_input.go @@ -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 diff --git a/core/internal/screenshot/region_render.go b/core/internal/screenshot/region_render.go index e9b30b6db..e8a10d1fd 100644 --- a/core/internal/screenshot/region_render.go +++ b/core/internal/screenshot/region_render.go @@ -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) { diff --git a/core/internal/screenshot/scroll.go b/core/internal/screenshot/scroll.go index 7354a9667..2b70fc830 100644 --- a/core/internal/screenshot/scroll.go +++ b/core/internal/screenshot/scroll.go @@ -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) } } diff --git a/core/internal/screenshot/scroll_preview_test.go b/core/internal/screenshot/scroll_preview_test.go new file mode 100644 index 000000000..738fb214a --- /dev/null +++ b/core/internal/screenshot/scroll_preview_test.go @@ -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]) + } +}