Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 58 additions & 14 deletions internal/cocoa/cocoa_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,13 @@ type Window struct {
// See Options.Passive.
passive bool

root toolkit.Widget
dmg damageRenderer
root toolkit.Widget
dmg damageRenderer
// pending is the damage presentRects asked AppKit to redraw, handed to
// -drawRect: so it converts only those rows. Empty means "whole buffer",
// which is what an AppKit-initiated draw — an expose, a resize, the first
// display — needs, since its invalid region is none of our business.
pending []toolkit.Rect
buttonHeld bool
dnd *dnd.Controller

Expand Down Expand Up @@ -322,30 +327,59 @@ func viewDrawRect(self objc.ID, _ objc.SEL) {
}
w.mu.Lock()
buf, bw, bh := w.buf, w.w, w.h
pending := w.pending
w.pending = nil
w.mu.Unlock()
if len(buf) == 0 || bw == 0 || bh == 0 {
return
}
rep := newBitmapRep(buf, bw, bh)
if rep == 0 {
return
}
bounds := objc.Send[nsRect](self, selBounds)
// The full drawInRect: form with respectFlipped:YES honours the flipped
// view so the buffer's row 0 lands at the top of the window; fromRect zero =
// whole image; fraction 1.0; hints nil. The op is Copy in the ordinary
// opaque path; in translucent mode it is SourceOver so the framebuffer's
// transparent holes (punched over material regions) reveal the effect views
// composited behind this view.
// The op is Copy in the ordinary opaque path; in translucent mode it is
// SourceOver so the framebuffer's transparent holes (punched over material
// regions) reveal the effect views composited behind this view.
op := nsCompositingCopy
if w.translucent {
op = nsCompositingSourceOver
}
objc.Send[objc.ID](rep, selDrawInRectFull, bounds, nsRect{}, uint(op), 1.0, true, objc.ID(0))
rep.Send(selRelease)
bands := DrawBands(pending, bh)
if len(bands) == 0 {
bands = []Band{{Y: 0, H: bh}} // AppKit asked; it gets everything
}
for _, b := range bands {
w.drawBand(buf, bw, bh, b, bounds, op)
}
runtime.KeepAlive(buf)
}

// drawBand converts and blits one run of framebuffer rows.
//
// A run of rows is contiguous in the buffer, so it can be wrapped as a bitmap of
// its own — and that is the saving. AppKit converts the rep it is handed to a
// CGImage in order to draw it, over every row the rep spans, whatever the clip
// says: a rep over the whole buffer converts the whole buffer even when one line
// of text changed. Sampling a window presenting at 60 Hz put
// -[NSBitmapImageRep CGImage] at the top of the draw path, which is why
// reporting damage at all had saved nothing measurable (26.2% of a core without,
// 26.8% with).
func (w *Window) drawBand(buf []byte, bw, bh int, b Band, bounds nsRect, op int) {
if b.H <= 0 || b.Y < 0 || b.Y+b.H > bh {
return
}
stride := bw * 4
rep := newBitmapRep(buf[b.Y*stride:], bw, b.H)
if rep == 0 {
return
}
defer rep.Send(selRelease)
x, y, dw, dh := BandDest(b, bh, bounds.Origin.X, bounds.Origin.Y,
bounds.Size.W, bounds.Size.H)
dst := nsRect{Origin: nsPoint{X: x, Y: y}, Size: nsSize{W: dw, H: dh}}
// respectFlipped:YES honours the flipped view so the band's row 0 lands at
// the top of its destination; fromRect zero = the whole (banded) image;
// fraction 1.0; hints nil.
objc.Send[objc.ID](rep, selDrawInRectFull, dst, nsRect{}, uint(op), 1.0, true, objc.ID(0))
}

// newBitmapRep wraps an RGBA buffer in an NSBitmapImageRep that references (does
// not copy) the bytes. buf must outlive the rep's use (the caller keeps it
// alive). The returned rep is owned by the caller (alloc/init) and released
Expand Down Expand Up @@ -1039,6 +1073,9 @@ func (w *Window) presentFull() {
if w.view == 0 {
return
}
w.mu.Lock()
w.pending = nil
w.mu.Unlock()
w.view.Send(selSetNeedsDisplay, true)
w.view.Send(selDisplayIfNeeded)
}
Expand All @@ -1049,6 +1086,13 @@ func (w *Window) presentRects(rects []toolkit.Rect) {
if w.view == 0 || len(rects) == 0 {
return
}
// Handed to -drawRect: through the window rather than read from AppKit's
// dirty region, because these are OUR rectangles and the callback's NSRect
// rides in the float registers undeclared. It is consumed there, so a draw
// AppKit starts for its own reasons finds nothing and redraws everything.
w.mu.Lock()
w.pending = rects
w.mu.Unlock()
for _, r := range rects {
x, y, rw, rh := DirtyRect(r, w.scale)
w.view.Send(selSetNeedsDisplayRect, nsRect{Origin: nsPoint{X: x, Y: y}, Size: nsSize{W: rw, H: rh}})
Expand Down
88 changes: 87 additions & 1 deletion internal/cocoa/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
// everything here.
package cocoa

import "github.com/go-widgets/toolkit"
import (
"slices"

"github.com/go-widgets/toolkit"
)

// NSEventModifierFlags bits used by the backend. AppKit reports device-
// independent modifier state in the high bits of the flags mask.
Expand Down Expand Up @@ -355,3 +359,85 @@ func unitToByte(v float64) uint8 {
return uint8(v*255 + 0.5)
}
}

// Band is a contiguous run of framebuffer ROWS to convert and blit: Y is the
// first row, H the count. A band is always full width.
type Band struct{ Y, H int }

// DrawBands reduces damage rectangles to the row runs a draw has to touch,
// merged, ordered and clamped to a buffer bufH rows tall. Rectangles that fall
// wholly outside contribute nothing; an empty result means there is nothing to
// draw.
//
// Rows, not rectangles, because a run of rows is CONTIGUOUS in the framebuffer
// and can therefore be wrapped as a bitmap of its own. That is the whole point:
// -drawRect: builds an NSBitmapImageRep over the buffer and AppKit converts it
// to a CGImage to draw it, and that conversion covers every row the rep spans
// whatever the clip says. Sampling a window presenting at 60 Hz put
// -[NSBitmapImageRep CGImage] at the top of the draw path, under
// -[NSImageRep drawInRect:fromRect:...] under -[NSView displayIfNeeded]: the
// invalid region limited what reached the screen, not what was converted, so
// reporting damage saved nothing at all (measured: 26.2% of a core without
// damage reporting, 26.8% with). A rep spanning only the changed rows makes the
// conversion proportional to the change.
//
// The x span is deliberately dropped. Narrowing columns would need a
// non-contiguous sub-image, which is a copy — and the rows are where the cost
// is: a changed line of text spans a handful of rows out of a thousand.
func DrawBands(rects []toolkit.Rect, bufH int) []Band {
if bufH <= 0 {
return nil
}
var spans []Band
for _, r := range rects {
y0, y1 := r.Y, r.Y+r.H
if y0 < 0 {
y0 = 0
}
if y1 > bufH {
y1 = bufH
}
if y1 <= y0 {
continue
}
spans = append(spans, Band{Y: y0, H: y1 - y0})
}
if len(spans) == 0 {
return nil
}
slices.SortFunc(spans, func(a, b Band) int { return a.Y - b.Y })
out := []Band{spans[0]}
for _, s := range spans[1:] {
last := &out[len(out)-1]
// Touching counts as overlapping: two bands that meet edge to edge are
// one run of rows, and splitting them would convert the seam twice.
if s.Y <= last.Y+last.H {
if end := s.Y + s.H; end > last.Y+last.H {
last.H = end - last.Y
}
continue
}
out = append(out, s)
}
return out
}

// BandDest is where a band of framebuffer rows lands in the flipped view, given
// the view's bounds in points and the buffer's height in device pixels.
//
// Full width, because DrawBands drops the x span; the y arithmetic is the whole
// of it, and it is the part that can be wrong. The scale is taken from the
// buffer and the bounds rather than from the window's stored scale, so a band
// lands exactly where the whole-buffer draw would have put those same rows --
// including on the frame after a backing-scale change, when the two disagree
// for one draw.
//
// A bounds with no height cannot say where anything goes: the band is returned
// at the origin with no height, which draws nothing.
func BandDest(b Band, bufH int, ox, oy, ow, oh float64) (x, y, w, h float64) {
if bufH <= 0 || oh <= 0 {
return ox, oy, ow, 0
}
perRow := oh / float64(bufH)
return ox, oy + float64(b.Y)*perRow, ow, float64(b.H) * perRow
}
132 changes: 132 additions & 0 deletions internal/cocoa/mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,135 @@ func TestUnitToByte(t *testing.T) {
}
}
}

func TestDrawBands(t *testing.T) {
cases := []struct {
name string
in []toolkit.Rect
bufH int
want []Band
}{{
name: "nothing to draw",
in: nil, bufH: 100, want: nil,
}, {
name: "a buffer with no rows",
in: []toolkit.Rect{{Y: 0, H: 10}}, bufH: 0, want: nil,
}, {
// The case the whole change exists for: a line of text changed, and the
// conversion should cover those rows rather than the window.
name: "one small rectangle",
in: []toolkit.Rect{{X: 40, Y: 120, W: 200, H: 14}}, bufH: 1000,
want: []Band{{Y: 120, H: 14}},
}, {
name: "two apart stay apart",
in: []toolkit.Rect{{Y: 10, H: 5}, {Y: 100, H: 5}}, bufH: 1000,
want: []Band{{Y: 10, H: 5}, {Y: 100, H: 5}},
}, {
name: "out of order",
in: []toolkit.Rect{{Y: 100, H: 5}, {Y: 10, H: 5}}, bufH: 1000,
want: []Band{{Y: 10, H: 5}, {Y: 100, H: 5}},
}, {
name: "overlapping merge",
in: []toolkit.Rect{{Y: 10, H: 20}, {Y: 20, H: 20}}, bufH: 1000,
want: []Band{{Y: 10, H: 30}},
}, {
// Edge to edge is one run: converting the seam twice costs more than
// the row it saves.
name: "touching merge",
in: []toolkit.Rect{{Y: 10, H: 10}, {Y: 20, H: 10}}, bufH: 1000,
want: []Band{{Y: 10, H: 20}},
}, {
name: "one swallowed by another",
in: []toolkit.Rect{{Y: 10, H: 100}, {Y: 20, H: 5}}, bufH: 1000,
want: []Band{{Y: 10, H: 100}},
}, {
name: "clamped to the buffer",
in: []toolkit.Rect{{Y: -20, H: 30}, {Y: 990, H: 40}}, bufH: 1000,
want: []Band{{Y: 0, H: 10}, {Y: 990, H: 10}},
}, {
name: "wholly outside contributes nothing",
in: []toolkit.Rect{{Y: -50, H: 10}, {Y: 2000, H: 10}, {Y: 5, H: 0}}, bufH: 1000,
want: nil,
}}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := DrawBands(c.in, c.bufH)
if len(got) != len(c.want) {
t.Fatalf("DrawBands = %v, want %v", got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("DrawBands = %v, want %v", got, c.want)
}
}
})
}
}

// Whatever the rectangles, every row one of them names must end up inside a
// band: a row left out is a row the screen keeps stale, because the
// framebuffer persists between frames.
func TestDrawBandsCoversEveryRowItWasGiven(t *testing.T) {
const bufH = 200
rects := []toolkit.Rect{
{Y: 190, H: 30}, {Y: 3, H: 1}, {Y: 3, H: 40}, {Y: -5, H: 7}, {Y: 120, H: 0},
}
bands := DrawBands(rects, bufH)
for _, r := range rects {
for y := max(r.Y, 0); y < min(r.Y+r.H, bufH); y++ {
in := false
for _, b := range bands {
if y >= b.Y && y < b.Y+b.H {
in = true
break
}
}
if !in {
t.Fatalf("row %d was asked for and lies outside every band %v", y, bands)
}
}
}
}

func TestBandDest(t *testing.T) {
// A retina window: 1600 buffer rows over 800 points, so two pixels a point.
// The band at rows 200..214 lands at points 100..107.
if x, y, w, h := BandDest(Band{Y: 200, H: 14}, 1600, 0, 0, 400, 800); x != 0 || y != 100 || w != 400 || h != 7 {
t.Fatalf("BandDest retina = (%v,%v,%v,%v), want (0,100,400,7)", x, y, w, h)
}
// Scale 1: rows are points.
if _, y, _, h := BandDest(Band{Y: 30, H: 10}, 600, 0, 0, 400, 600); y != 30 || h != 10 {
t.Fatalf("BandDest scale1 = (y%v,h%v), want (y30,h10)", y, h)
}
// The bounds origin is carried, not assumed to be zero.
if x, y, _, _ := BandDest(Band{Y: 0, H: 10}, 600, 12, 34, 400, 600); x != 12 || y != 34 {
t.Fatalf("BandDest origin = (%v,%v), want (12,34)", x, y)
}
// The whole buffer as one band covers the whole bounds, which is what the
// AppKit-initiated draw falls back to: it must be pixel-identical to the
// draw this replaces.
if x, y, w, h := BandDest(Band{Y: 0, H: 1600}, 1600, 0, 0, 400, 800); x != 0 || y != 0 || w != 400 || h != 800 {
t.Fatalf("BandDest whole = (%v,%v,%v,%v), want the full bounds", x, y, w, h)
}
// Nothing to scale by: no height, so nothing is drawn.
if _, _, _, h := BandDest(Band{Y: 0, H: 10}, 0, 0, 0, 400, 800); h != 0 {
t.Fatalf("BandDest with no rows h = %v, want 0", h)
}
if _, _, _, h := BandDest(Band{Y: 0, H: 10}, 600, 0, 0, 400, 0); h != 0 {
t.Fatalf("BandDest with no bounds h = %v, want 0", h)
}
}

// The bands of one frame must tile the bounds exactly as the whole-buffer draw
// would: a gap between two adjacent bands is a line of stale pixels across the
// window.
func TestBandDestsOfAdjacentBandsMeetExactly(t *testing.T) {
const bufH, oh = 1600, 800.0
a := Band{Y: 100, H: 20}
b := Band{Y: 120, H: 30}
_, ay, _, ah := BandDest(a, bufH, 0, 0, 400, oh)
_, by, _, _ := BandDest(b, bufH, 0, 0, 400, oh)
if ay+ah != by {
t.Fatalf("band ends at %v and the next starts at %v; the seam is stale", ay+ah, by)
}
}