perf(screenshot): frame-paced, incremental region selector

related #3056
This commit is contained in:
bbedward
2026-08-20 17:33:35 -04:00
parent 486febd4ce
commit c90b0cddab
7 changed files with 419 additions and 114 deletions
+10 -11
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
@@ -225,7 +226,8 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
return config
}
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. Best-effort.
// 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) {
fn := "end"
if begin {
@@ -244,7 +246,7 @@ func setPopoutScreenshotMode(begin bool) {
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
}
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
_ = exec.Command("qs", cmdArgs...).Run()
_ = exec.Command("qs", cmdArgs...).Start()
}
func writeScreenshotJSON(meta screenshotMetadata) {
@@ -277,16 +279,13 @@ func runScreenshot(config screenshot.Config) {
os.Exit(1)
}
// Region select needs the keyboard; drop popout grabs for its duration.
result, err := func() (*screenshot.CaptureResult, error) {
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
if interactive {
setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false)
}
return screenshot.New(config).Run()
}()
// Short-lived process over a few tens of MB: let the heap grow instead of paying GC cycles mid-capture.
debug.SetGCPercent(-1)
debug.SetMemoryLimit(1 << 30)
// Region select needs the keyboard; drop popout grabs for its duration.
config.SelectorHook = setPopoutScreenshotMode
result, err := screenshot.New(config).Run()
if err != nil {
exitScreenshotError("", err)
}
+58 -8
View File
@@ -34,7 +34,7 @@ type RenderSlot struct {
backgroundDragging bool
backgroundCursor bool
backgroundPhase selectorPhase
dirty *dirtyRect
overlay *overlay
}
func (s *RenderSlot) cacheValid(src *ShmBuffer, dragging, cursor bool, phase selectorPhase) bool {
@@ -61,6 +61,10 @@ type OutputSurface struct {
// Triple-buffered render slots
slots [3]*RenderSlot
slotsReady bool
shown *overlay
framePending bool
redrawQueued bool
}
type PreCapture struct {
@@ -86,6 +90,8 @@ type RegionSelector struct {
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
viewporter *wp_viewporter.WpViewporter
compositorVersion uint32
shortcutsInhibitMgr *keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitManagerV1
shortcutsInhibitor *keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitorV1
@@ -261,6 +267,7 @@ func (r *RegionSelector) handleGlobal(e client.RegistryGlobalEvent) {
comp := client.NewCompositor(r.ctx)
if err := r.registry.Bind(e.Name, e.Interface, e.Version, comp); err == nil {
r.compositor = comp
r.compositorVersion = e.Version
}
case client.ShmInterfaceName:
@@ -732,6 +739,9 @@ func (r *RegionSelector) initRenderBuffer(os *OutputSurface) {
slotRef := slot
wlBuf.SetReleaseHandler(func(e client.BufferReleaseEvent) {
slotRef.busy = false
if os.redrawQueued && !os.framePending {
r.renderSurface(os)
}
})
os.slots[i] = slot
@@ -783,7 +793,16 @@ func (r *RegionSelector) getSourceBuffer(os *OutputSurface) *ShmBuffer {
return os.screenBuf
}
// redrawSurface coalesces redraws to one per compositor frame.
func (r *RegionSelector) redrawSurface(os *OutputSurface) {
os.redrawQueued = true
if os.framePending {
return
}
r.renderSurface(os)
}
func (r *RegionSelector) renderSurface(os *OutputSurface) {
srcBuf := r.getSourceBuffer(os)
if srcBuf == nil || !os.slotsReady {
return
@@ -793,15 +812,20 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
if slot == nil {
return
}
os.redrawQueued = false
fullDamage := true
var damage []dirtyRect
switch r.phase {
case phaseScroll:
r.drawScrollOverlay(os, slot.shm)
slot.backgroundInitialized = false
slot.backgroundSource = nil
slot.dirty = nil
slot.overlay, os.shown = nil, nil
default:
if !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase) {
cur := r.overlayFor(os, slot.shm)
switch {
case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase):
slot.shm.CopyFrom(srcBuf)
r.dimBackground(slot.shm)
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat)
@@ -810,11 +834,16 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
slot.backgroundDragging = r.selection.dragging
slot.backgroundCursor = r.showCapturedCursor
slot.backgroundPhase = r.phase
slot.dirty = nil
} else if slot.dirty != nil {
r.restoreSourceRect(os, slot.shm, *slot.dirty)
slot.overlay = nil
case r.compositorVersion >= 4:
fullDamage = false
damage = overlayDamage(os.shown, cur)
if len(damage) == 0 {
return
}
}
slot.dirty = r.drawOverlay(os, slot.shm)
r.drawOverlay(os, slot.shm, slot.overlay, cur)
slot.overlay, os.shown = cur, cur
}
if os.viewport != nil {
@@ -830,7 +859,28 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
}
_ = os.wlSurface.Attach(slot.wlBuf, 0, 0)
_ = os.wlSurface.Damage(0, 0, int32(os.logicalW), int32(os.logicalH))
switch {
case fullDamage:
_ = os.wlSurface.Damage(0, 0, int32(os.logicalW), int32(os.logicalH))
default:
for _, d := range damage {
d = d.clampTo(slot.shm.Width, slot.shm.Height)
if d.empty() {
continue
}
_ = os.wlSurface.DamageBuffer(int32(d.x1), int32(d.y1), int32(d.x2-d.x1), int32(d.y2-d.y1))
}
}
if cb, err := os.wlSurface.Frame(); err == nil {
os.framePending = true
cb.SetDoneHandler(func(client.CallbackDoneEvent) {
_ = cb.Destroy()
os.framePending = false
if os.redrawQueued {
r.renderSurface(os)
}
})
}
_ = os.wlSurface.Commit()
// Mark this slot as busy until compositor releases it
+2 -2
View File
@@ -84,8 +84,8 @@ func (r *RegionSelector) setupPointerHandlers() {
r.selection.currentX = curX
r.selection.currentY = curY
for _, os := range r.surfaces {
r.redrawSurface(os)
if r.selection.surface != nil {
r.redrawSurface(r.selection.surface)
}
})
+168 -88
View File
@@ -58,6 +58,7 @@ type selectionRenderBounds struct {
h int
}
// dirtyRect is half-open in buffer pixels.
type dirtyRect struct {
x1 int
y1 int
@@ -65,6 +66,10 @@ type dirtyRect struct {
y2 int
}
func (d dirtyRect) empty() bool {
return d.x2 <= d.x1 || d.y2 <= d.y1
}
func (d dirtyRect) union(o dirtyRect) dirtyRect {
return dirtyRect{
x1: min(d.x1, o.x1),
@@ -74,27 +79,127 @@ func (d dirtyRect) union(o dirtyRect) dirtyRect {
}
}
func (r *RegionSelector) dimBackground(renderBuf *ShmBuffer) {
data := renderBuf.Data()
r.dimRegion(data, renderBuf.Stride, renderBuf.Width, renderBuf.Height, 0, 0, renderBuf.Width, renderBuf.Height)
func (d dirtyRect) intersect(o dirtyRect) dirtyRect {
return dirtyRect{
x1: max(d.x1, o.x1),
y1: max(d.y1, o.y1),
x2: min(d.x2, o.x2),
y2: min(d.y2, o.y2),
}
}
func (r *RegionSelector) dimRegion(data []byte, stride, bufW, bufH, x1, y1, x2, y2 int) {
x1 = clamp(x1, 0, bufW)
y1 = clamp(y1, 0, bufH)
x2 = clamp(x2, 0, bufW)
y2 = clamp(y2, 0, bufH)
for y := y1; y < y2; y++ {
off := y * stride
for x := x1; x < x2; x++ {
i := off + x*4
if i+3 >= len(data) {
continue
}
data[i+0] = uint8(int(data[i+0]) * 3 / 5)
data[i+1] = uint8(int(data[i+1]) * 3 / 5)
data[i+2] = uint8(int(data[i+2]) * 3 / 5)
data[i+3] = 255
func (d dirtyRect) grow(n int) dirtyRect {
return dirtyRect{x1: d.x1 - n, y1: d.y1 - n, x2: d.x2 + n, y2: d.y2 + n}
}
func (d dirtyRect) clampTo(w, h int) dirtyRect {
return d.intersect(dirtyRect{0, 0, w, h})
}
// minus returns d with o cut out, as up to four strips.
func (d dirtyRect) minus(o dirtyRect) []dirtyRect {
if d.empty() {
return nil
}
i := d.intersect(o)
if i.empty() {
return []dirtyRect{d}
}
var out []dirtyRect
for _, s := range []dirtyRect{
{d.x1, d.y1, d.x2, i.y1},
{d.x1, i.y2, d.x2, d.y2},
{d.x1, i.y1, i.x1, i.y2},
{i.x2, i.y1, d.x2, i.y2},
} {
if !s.empty() {
out = append(out, s)
}
}
return out
}
// overlay is what a frame draws on top of the dimmed background: the bright
// selection interior with its border ring, and the dimensions label.
type overlay struct {
interior dirtyRect
label dirtyRect
text string
}
func (o *overlay) full() dirtyRect {
return o.interior.grow(borderThickness - 1)
}
func (o *overlay) inner() dirtyRect {
return o.interior.grow(-1)
}
func (o *overlay) ring() []dirtyRect {
return o.full().minus(o.inner())
}
// overlayDelta returns the areas to re-dim and to re-brighten when prev is replaced by cur; nil means no overlay.
func overlayDelta(prev, cur *overlay) (dim, bright []dirtyRect) {
var curInterior, prevInner dirtyRect
if cur != nil {
curInterior = cur.interior
}
if prev != nil {
dim = append(prev.full().minus(curInterior), prev.label.minus(curInterior)...)
prevInner = prev.inner()
}
if cur != nil {
bright = cur.interior.minus(prevInner)
}
return dim, bright
}
var dimLUT = func() (t [256]uint8) {
for i := range t {
t[i] = uint8(i * 3 / 5)
}
return t
}()
func dimRow(p []byte) {
for i := 0; i+3 < len(p); i += 4 {
p[i], p[i+1], p[i+2], p[i+3] = dimLUT[p[i]], dimLUT[p[i+1]], dimLUT[p[i+2]], 255
}
}
func opaqueRow(p []byte) {
for i := 3; i < len(p); i += 4 {
p[i] = 255
}
}
func (r *RegionSelector) dimBackground(renderBuf *ShmBuffer) {
data := renderBuf.Data()
for y := range renderBuf.Height {
dimRow(data[y*renderBuf.Stride:][:renderBuf.Width*4])
}
}
// paintRect copies area from the capture into dst, dimmed or bright.
func (r *RegionSelector) paintRect(os *OutputSurface, dst *ShmBuffer, area dirtyRect, dim bool) {
src := r.getSourceBuffer(os)
if src == nil {
return
}
area = area.clampTo(min(src.Width, dst.Width), min(src.Height, dst.Height))
if area.empty() {
return
}
rowLen := (area.x2 - area.x1) * 4
for y := area.y1; y < area.y2; y++ {
s := src.Data()[y*src.Stride+area.x1*4:][:rowLen]
d := dst.Data()[y*dst.Stride+area.x1*4:][:rowLen]
copy(d, s)
if dim {
dimRow(d)
} else {
opaqueRow(d)
}
}
}
@@ -140,72 +245,46 @@ func (r *RegionSelector) selectionRenderBounds(os *OutputSurface) (selectionRend
return selectionRenderBounds{x: x1, y: y1, w: w, h: h}, true
}
func (r *RegionSelector) restoreSourceRect(os *OutputSurface, renderBuf *ShmBuffer, d dirtyRect) {
srcBuf := r.getSourceBuffer(os)
if srcBuf == nil {
return
}
maxW := min(srcBuf.Width, renderBuf.Width)
maxH := min(srcBuf.Height, renderBuf.Height)
x1 := clamp(d.x1, 0, maxW)
y1 := clamp(d.y1, 0, maxH)
x2 := clamp(d.x2, 0, maxW)
y2 := clamp(d.y2, 0, maxH)
if x2 <= x1 || y2 <= y1 {
return
}
width := (x2 - x1) * 4
for y := y1; y < y2; y++ {
srcStart := y*srcBuf.Stride + x1*4
dstStart := y*renderBuf.Stride + x1*4
if srcStart+width > len(srcBuf.Data()) || dstStart+width > len(renderBuf.Data()) {
continue
}
copy(renderBuf.Data()[dstStart:dstStart+width], srcBuf.Data()[srcStart:srcStart+width])
}
r.dimRegion(renderBuf.Data(), renderBuf.Stride, renderBuf.Width, renderBuf.Height, x1, y1, x2, y2)
}
func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) *dirtyRect {
func (r *RegionSelector) overlayFor(os *OutputSurface, buf *ShmBuffer) *overlay {
bounds, ok := r.selectionRenderBounds(os)
if !ok {
return nil
}
label, text := labelRect(bounds, buf.Width, buf.Height)
return &overlay{
interior: dirtyRect{bounds.x, bounds.y, bounds.x + bounds.w, bounds.y + bounds.h},
label: label,
text: text,
}
}
data := renderBuf.Data()
stride := renderBuf.Stride
w, h := renderBuf.Width, renderBuf.Height
format := os.screenFormat
srcBuf := r.getSourceBuffer(os)
srcData := srcBuf.Data()
for y := bounds.y; y < bounds.y+bounds.h; y++ {
rowOff := y * stride
for x := bounds.x; x < bounds.x+bounds.w; x++ {
si := y*srcBuf.Stride + x*4
di := rowOff + x*4
if si+3 >= len(srcData) || di+3 >= len(data) {
continue
}
data[di+0] = srcData[si+0]
data[di+1] = srcData[si+1]
data[di+2] = srcData[si+2]
data[di+3] = 255
}
// drawOverlay brings a buffer holding prev up to cur, touching only what changed.
func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer, prev, cur *overlay) {
dim, bright := overlayDelta(prev, cur)
for _, d := range dim {
r.paintRect(os, renderBuf, d, true)
}
for _, b := range bright {
r.paintRect(os, renderBuf, b, false)
}
if cur == nil {
return
}
r.drawBorder(data, stride, w, h, bounds.x, bounds.y, bounds.w, bounds.h, format)
labelRect := r.drawDimensions(data, stride, w, h, bounds.x, bounds.y, bounds.w, bounds.h, format)
data, stride, w, h := renderBuf.Data(), renderBuf.Stride, renderBuf.Width, renderBuf.Height
in := cur.interior
r.drawBorder(data, stride, w, h, in.x1, in.y1, in.x2-in.x1, in.y2-in.y1, os.screenFormat)
r.drawLabel(data, stride, w, h, cur, os.screenFormat)
}
dirty := dirtyRect{
x1: bounds.x - borderThickness,
y1: bounds.y - borderThickness,
x2: bounds.x + bounds.w + borderThickness,
y2: bounds.y + bounds.h + borderThickness,
}.union(labelRect)
return &dirty
// overlayDamage lists the buffer areas that differ between a frame showing prev and one showing cur.
func overlayDamage(prev, cur *overlay) []dirtyRect {
dim, bright := overlayDelta(prev, cur)
damage := append(dim, bright...)
if cur == nil {
return damage
}
return append(append(damage, cur.ring()...), cur.label)
}
func (r *RegionSelector) drawScrollOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
@@ -384,24 +463,25 @@ func (r *RegionSelector) drawVLine(data []byte, stride, bufW, bufH, x, y, length
}
}
func (r *RegionSelector) drawDimensions(data []byte, stride, bufW, bufH, x, y, w, h int, format uint32) dirtyRect {
text := fmt.Sprintf("%dx%d", w, h)
func labelRect(b selectionRenderBounds, bufW, bufH int) (dirtyRect, string) {
text := fmt.Sprintf("%dx%d", b.w, b.h)
const charW, charH = 8, 12
textW := len(text) * (charW + 1)
textH := charH
tx := x + (w-textW)/2
ty := y + h + 8
if ty+textH > bufH {
ty = y - textH - 8
tx := b.x + (b.w-textW)/2
ty := b.y + b.h + 8
if ty+charH > bufH {
ty = b.y - charH - 8
}
tx = clamp(tx, 0, bufW-textW)
return dirtyRect{x1: tx - 4, y1: ty - 2, x2: tx + textW + 4, y2: ty + charH + 2}, text
}
r.fillRect(data, stride, bufW, bufH, tx-4, ty-2, textW+8, textH+4, 0, 0, 0, 200, format)
r.drawText(data, stride, bufW, bufH, tx, ty, text, 255, 255, 255, format)
return dirtyRect{x1: tx - 4, y1: ty - 2, x2: tx + textW + 4, y2: ty + textH + 2}
func (r *RegionSelector) drawLabel(data []byte, stride, bufW, bufH int, o *overlay, format uint32) {
l := o.label
r.fillRect(data, stride, bufW, bufH, l.x1, l.y1, l.x2-l.x1, l.y2-l.y1, 0, 0, 0, 200, format)
r.drawText(data, stride, bufW, bufH, l.x1+4, l.y1+2, o.text, 255, 255, 255, format)
}
func (r *RegionSelector) fillRect(data []byte, stride, bufW, bufH, x, y, w, h int, cr, cg, cb, ca uint8, format uint32) {
@@ -0,0 +1,167 @@
package screenshot
import (
"bytes"
"math/rand"
"testing"
)
func rectArea(r dirtyRect) int {
if r.empty() {
return 0
}
return (r.x2 - r.x1) * (r.y2 - r.y1)
}
func TestDirtyRectMinus(t *testing.T) {
a := dirtyRect{0, 0, 10, 10}
cases := []struct {
name string
b dirtyRect
parts int
area int
}{
{"disjoint", dirtyRect{20, 20, 30, 30}, 1, 100},
{"covered", dirtyRect{-1, -1, 11, 11}, 0, 0},
{"center", dirtyRect{3, 3, 6, 6}, 4, 91},
{"corner", dirtyRect{5, 5, 20, 20}, 2, 75},
{"zero", dirtyRect{}, 1, 100},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
parts := a.minus(tc.b)
if len(parts) != tc.parts {
t.Fatalf("parts = %d, want %d", len(parts), tc.parts)
}
area := 0
for i, p := range parts {
area += rectArea(p)
if !p.intersect(a).equal(p) || !p.intersect(tc.b).empty() {
t.Errorf("part %v outside a or inside b", p)
}
for _, q := range parts[i+1:] {
if !p.intersect(q).empty() {
t.Errorf("parts %v and %v overlap", p, q)
}
}
}
if area != tc.area {
t.Errorf("area = %d, want %d", area, tc.area)
}
})
}
}
func (d dirtyRect) equal(o dirtyRect) bool { return d == o }
func newOverlayFixture(t testing.TB, w, h int) (*RegionSelector, *OutputSurface) {
t.Helper()
src, err := CreateShmBuffer(w, h, w*4+16)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { src.Close() })
rng := rand.New(rand.NewSource(7))
rng.Read(src.Data())
os := &OutputSurface{screenBuf: src, screenFormat: uint32(FormatXRGB8888), logicalW: w, logicalH: h}
r := &RegionSelector{showCapturedCursor: true}
r.selection.surface = os
return r, os
}
func newFrameBuffer(t testing.TB, r *RegionSelector, os *OutputSurface) *ShmBuffer {
t.Helper()
buf, err := CreateShmBuffer(os.screenBuf.Width, os.screenBuf.Height, os.screenBuf.Stride)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { buf.Close() })
buf.CopyFrom(os.screenBuf)
r.dimBackground(buf)
return buf
}
func (r *RegionSelector) selectRect(x1, y1, x2, y2 float64) {
r.selection.hasSelection = true
r.selection.anchorX, r.selection.anchorY = x1, y1
r.selection.currentX, r.selection.currentY = x2, y2
}
func inRects(x, y int, rects []dirtyRect) bool {
for _, d := range rects {
if x >= d.x1 && x < d.x2 && y >= d.y1 && y < d.y2 {
return true
}
}
return false
}
func TestDrawOverlayIncremental(t *testing.T) {
const w, h = 160, 120
r, os := newOverlayFixture(t, w, h)
incr := newFrameBuffer(t, r, os)
shown := newFrameBuffer(t, r, os)
rng := rand.New(rand.NewSource(3))
var prev *overlay
for i := range 300 {
var cur *overlay
switch rng.Intn(8) {
case 0:
r.selection.hasSelection = false
default:
r.selectRect(rng.Float64()*w, rng.Float64()*h, rng.Float64()*w, rng.Float64()*h)
cur = r.overlayFor(os, incr)
}
damage := overlayDamage(prev, cur)
r.drawOverlay(os, incr, prev, cur)
full := newFrameBuffer(t, r, os)
r.drawOverlay(os, full, nil, cur)
if !bytes.Equal(incr.Data(), full.Data()) {
t.Fatalf("step %d: incremental frame differs from full render (%v -> %v)", i, prev, cur)
}
for y := range h {
for x := range w {
off := y*full.Stride + x*4
if bytes.Equal(full.Data()[off:off+4], shown.Data()[off:off+4]) {
continue
}
if !inRects(x, y, damage) {
t.Fatalf("step %d: pixel %d,%d changed outside damage %v", i, x, y, damage)
}
}
}
copy(shown.Data(), full.Data())
prev = cur
}
}
func BenchmarkDrawOverlayStep(b *testing.B) {
r, os := newOverlayFixture(b, 2560, 1440)
buf := newFrameBuffer(b, r, os)
r.selectRect(10, 10, 2000, 1400)
prev := r.overlayFor(os, buf)
r.drawOverlay(os, buf, nil, prev)
b.ResetTimer()
for i := range b.N {
r.selectRect(10, 10, float64(2000+i%8), float64(1400-i%8))
cur := r.overlayFor(os, buf)
r.drawOverlay(os, buf, prev, cur)
prev = cur
}
}
func BenchmarkDrawOverlayFull(b *testing.B) {
r, os := newOverlayFixture(b, 2560, 1440)
buf := newFrameBuffer(b, r, os)
b.ResetTimer()
for i := range b.N {
r.selectRect(10, 10, float64(2000+i%8), float64(1400-i%8))
cur := r.overlayFor(os, buf)
r.paintRect(os, buf, dirtyRect{0, 0, buf.Width, buf.Height}, true)
r.drawOverlay(os, buf, nil, cur)
}
}
+12 -5
View File
@@ -131,6 +131,11 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
}
}
if s.config.SelectorHook != nil {
s.config.SelectorHook(true)
defer s.config.SelectorHook(false)
}
selector := NewRegionSelector(s)
result, cancelled, err := selector.Run()
if err != nil {
@@ -579,11 +584,13 @@ func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*Ca
outX, outY := output.x, output.y
scale := float64(output.scale)
if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok {
outX, outY = hx, hy
}
if s := GetHyprlandMonitorScale(output.name); s > 0 {
scale = s
if DetectCompositor() == CompositorHyprland {
if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok {
outX, outY = hx, hy
}
if s := GetHyprlandMonitorScale(output.name); s > 0 {
scale = s
}
}
if scale <= 0 {
scale = 1.0
+2
View File
@@ -64,6 +64,8 @@ type Config struct {
Notify bool
Stdout bool
IntervalMs int
// SelectorHook runs as the interactive selector starts (true) and ends (false).
SelectorHook func(begin bool)
}
func DefaultConfig() Config {