detached 7e46072

perf(render): reduce frame construction and idle wait allocations

commit
7e46072588c8dce90eaa8bc11f89a5564f182b04
author
brice · · 2026-09-30 04:11 UTC
parent
1488e4a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +1007 -86
M internal/layout/layout.go +150 -18
@@ -178,11 +178,29 @@ func Layout(root *Node, options Options) (Output, error) {
178178 return Output{}, err
179179 }
180180 out := Output{Tree: tree}
181- ctx.paint(root, tree, &out.Display)
181+ out.Display = ctx.display(root, tree)
182182 return out, nil
183183 }
184184
185-type context struct{ options Options }
185+// display paints the placed tree into a list sized once from the tree.
186+func (c context) display(root *Node, tree *Result) []Command {
187+ n := countCommands(root, tree)
188+ if n.commands == 0 {
189+ return nil
190+ }
191+ out := make([]Command, 0, n.commands)
192+ c.shadows, c.runs, c.glyphs = newSlab[css.Shadow](n.shadows), newSlab[Run](n.runs), newSlab[Glyph](n.glyphs)
193+ c.paint(root, tree, &out)
194+ return out
195+}
196+
197+type context struct {
198+ options Options
199+ // Painter-owned display storage, sized by countCommands; nil is valid.
200+ shadows *slab[css.Shadow]
201+ runs *slab[Run]
202+ glyphs *slab[Glyph]
203+}
186204
187205 func style(n *Node) css.Style {
188206 if n.Style != nil {
@@ -743,56 +761,157 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
743761 return nil
744762 }
745763
764+// slab hands out detached values or sub-slices from one exactly sized block,
765+// so a display list costs a few allocations rather than one per shadow, run or
766+// text command. Blocks are never resized or reused, so pointers and slices
767+// stay valid for the life of the returned Output. Sub-slices have their
768+// capacity clipped, so a caller's append cannot overwrite a neighbour. A
769+// miscount, or a nil slab, only falls back to individual allocations.
770+type slab[T any] struct{ block []T }
771+
772+func newSlab[T any](n int) *slab[T] {
773+ if n <= 0 {
774+ return nil
775+ }
776+ return &slab[T]{block: make([]T, 0, n)}
777+}
778+
779+// one stores v in the slab and returns its address.
780+func (a *slab[T]) one(v T) *T {
781+ if a == nil || len(a.block) == cap(a.block) {
782+ p := new(T) // copy, so v itself never escapes
783+ *p = v
784+ return p
785+ }
786+ a.block = append(a.block, v)
787+ return &a.block[len(a.block)-1]
788+}
789+
790+// take returns an empty slice with capacity exactly n (nil for n == 0).
791+func (a *slab[T]) take(n int) []T {
792+ if n <= 0 {
793+ return nil
794+ }
795+ if a == nil || cap(a.block)-len(a.block) < n {
796+ return make([]T, 0, n)
797+ }
798+ i := len(a.block)
799+ a.block = a.block[:i+n]
800+ return a.block[i : i : i+n]
801+}
802+
803+// radiiOf resolves border-radius percentages against basis.
804+func radiiOf(s *css.Style, basis float64) [4]float64 {
805+ return [4]float64{positive(s.BorderRadius.TopLeft, basis), positive(s.BorderRadius.TopRight, basis), positive(s.BorderRadius.BottomRight, basis), positive(s.BorderRadius.BottomLeft, basis)}
806+}
807+
808+func hasOutline(s *css.Style, r *Result) bool {
809+ return s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0
810+}
811+
812+// countCommands returns how many commands and shadow copies paint emits for
813+// the subtree, so Layout can size the display list once. It mirrors paint's
814+// conditions; if it ever drifts, paint still appends correctly and only regrows.
815+func countCommands(n *Node, r *Result) (c counts) {
816+ if r == nil {
817+ return c
818+ }
819+ s := style(n)
820+ total := len(s.BoxShadow)
821+ c.shadows = len(s.BoxShadow)
822+ if s.BackgroundColor.A > 0 {
823+ total++
824+ }
825+ if r.BorderWidths != (Edges{}) || radiiOf(&s, r.Border.W) != ([4]float64{}) {
826+ total++
827+ }
828+ if hasOutline(&s, r) {
829+ total++
830+ }
831+ if r.Clipped {
832+ total += 2
833+ }
834+ if n.Kind == Image || n.Kind == Text {
835+ total++
836+ }
837+ if n.Kind == Text {
838+ for i := range r.Lines {
839+ c.runs += len(r.Lines[i].Runs)
840+ for j := range r.Lines[i].Runs {
841+ c.glyphs += len(r.Lines[i].Runs[j].Glyphs)
842+ }
843+ }
844+ }
845+ j := 0
846+ for _, child := range n.Children {
847+ if child == nil || style(child).Display == css.KeywordNone {
848+ continue
849+ }
850+ if j < len(r.Children) {
851+ cc := countCommands(child, r.Children[j])
852+ total += cc.commands
853+ c.shadows += cc.shadows
854+ c.runs += cc.runs
855+ c.glyphs += cc.glyphs
856+ j++
857+ }
858+ }
859+ c.commands += total
860+ return c
861+}
862+
863+type counts struct{ commands, shadows, runs, glyphs int }
864+
746865 func (c context) paint(n *Node, r *Result, out *[]Command) {
747866 if r == nil {
748867 return
749868 }
750869 s := style(n)
751870 base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))}
871+ borderRadii := radiiOf(&s, r.Border.W)
752872 for i := range s.BoxShadow {
753- sh := s.BoxShadow[i]
754- if sh.Inset {
873+ if s.BoxShadow[i].Inset {
755874 continue
756875 }
757876 cmd := base
758877 cmd.Op = "shadow"
759- cmd.Shadow = &sh
760- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)}
878+ cmd.Shadow = c.shadows.one(s.BoxShadow[i])
879+ cmd.Radii = borderRadii
761880 *out = append(*out, cmd)
762881 }
763882 if s.BackgroundColor.A > 0 {
764883 cmd := base
765884 cmd.Op = "rect"
766885 cmd.Color = s.BackgroundColor
767- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)}
886+ cmd.Radii = borderRadii
768887 *out = append(*out, cmd)
769888 }
770889 cmd := base
771890 cmd.Op = "border"
772891 cmd.Widths = r.BorderWidths
773892 cmd.Colors = s.BorderColor
774- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)}
893+ cmd.Radii = borderRadii
775894 if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) {
776895 cmd.Color = s.BorderColor.Top
777896 *out = append(*out, cmd)
778897 }
779898 for i := range s.BoxShadow {
780- sh := s.BoxShadow[i]
781- if !sh.Inset {
899+ if !s.BoxShadow[i].Inset {
782900 continue
783901 }
784902 cmd := base
785903 cmd.Op = "shadow"
786- cmd.Shadow = &sh
787- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)}
904+ cmd.Shadow = c.shadows.one(s.BoxShadow[i])
905+ cmd.Radii = borderRadii
788906 *out = append(*out, cmd)
789907 }
790- if s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 {
908+ if hasOutline(&s, r) {
791909 cmd = base
792910 cmd.Op = "outline"
793911 cmd.Color = s.OutlineColor
794- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)}
795- cmd.Widths = Edges{Top: positive(s.OutlineWidth, r.Border.W), Right: positive(s.OutlineWidth, r.Border.W), Bottom: positive(s.OutlineWidth, r.Border.W), Left: positive(s.OutlineWidth, r.Border.W)}
912+ cmd.Radii = borderRadii
913+ width := positive(s.OutlineWidth, r.Border.W)
914+ cmd.Widths = Edges{Top: width, Right: width, Bottom: width, Left: width}
796915 offset := coordValue(s.OutlineOffset, r.Border.W)
797916 cmd.Rect = Rect{X: coord(r.Border.X - offset), Y: coord(r.Border.Y - offset), W: safe(r.Border.W + 2*offset), H: safe(r.Border.H + 2*offset)}
798917 *out = append(*out, cmd)
@@ -808,7 +927,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
808927 cmd.Op = "image"
809928 cmd.Rect = r.Content
810929 cmd.Image = n.Image
811- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Content.W), positive(s.BorderRadius.TopRight, r.Content.W), positive(s.BorderRadius.BottomRight, r.Content.W), positive(s.BorderRadius.BottomLeft, r.Content.W)}
930+ cmd.Radii = radiiOf(&s, r.Content.W)
812931 *out = append(*out, cmd)
813932 }
814933 if n.Kind == Text {
@@ -817,6 +936,15 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
817936 cmd.Rect = r.Content
818937 cmd.Text = n.Content
819938 cmd.Color = s.Color
939+ runs, glyphs := 0, 0
940+ for i := range r.Lines {
941+ runs += len(r.Lines[i].Runs)
942+ for j := range r.Lines[i].Runs {
943+ glyphs += len(r.Lines[i].Runs[j].Glyphs)
944+ }
945+ }
946+ cmd.Runs = c.runs.take(runs)
947+ backing := c.glyphs.take(glyphs)
820948 for _, line := range r.Lines {
821949 offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width)
822950 for _, run := range line.Runs {
@@ -827,8 +955,12 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
827955 if run.Face != nil {
828956 rr.FaceID = run.Face.ID
829957 }
830- for _, g := range run.Glyphs {
831- rr.Glyphs = append(rr.Glyphs, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster})
958+ if len(run.Glyphs) > 0 { // keep nil for glyph-less runs
959+ first := len(backing)
960+ for _, g := range run.Glyphs {
961+ backing = append(backing, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster})
962+ }
963+ rr.Glyphs = backing[first:len(backing):len(backing)]
832964 }
833965 cmd.Runs = append(cmd.Runs, rr)
834966 }
@@ -178,11 +178,29 @@ func Layout(root *Node, options Options) (Output, error) {
178 return Output{}, err178 return Output{}, err
179 }179 }
180 out := Output{Tree: tree}180 out := Output{Tree: tree}
181- ctx.paint(root, tree, &out.Display)181+ out.Display = ctx.display(root, tree)
182 return out, nil182 return out, nil
183 }183 }
184 184
185-type context struct{ options Options }185+// display paints the placed tree into a list sized once from the tree.
186+func (c context) display(root *Node, tree *Result) []Command {
187+ n := countCommands(root, tree)
188+ if n.commands == 0 {
189+ return nil
190+ }
191+ out := make([]Command, 0, n.commands)
192+ c.shadows, c.runs, c.glyphs = newSlab[css.Shadow](n.shadows), newSlab[Run](n.runs), newSlab[Glyph](n.glyphs)
193+ c.paint(root, tree, &out)
194+ return out
195+}
196+
197+type context struct {
198+ options Options
199+ // Painter-owned display storage, sized by countCommands; nil is valid.
200+ shadows *slab[css.Shadow]
201+ runs *slab[Run]
202+ glyphs *slab[Glyph]
203+}
186 204
187 func style(n *Node) css.Style {205 func style(n *Node) css.Style {
188 if n.Style != nil {206 if n.Style != nil {
@@ -743,56 +761,157 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
743 return nil761 return nil
744 }762 }
745 763
764+// slab hands out detached values or sub-slices from one exactly sized block,
765+// so a display list costs a few allocations rather than one per shadow, run or
766+// text command. Blocks are never resized or reused, so pointers and slices
767+// stay valid for the life of the returned Output. Sub-slices have their
768+// capacity clipped, so a caller's append cannot overwrite a neighbour. A
769+// miscount, or a nil slab, only falls back to individual allocations.
770+type slab[T any] struct{ block []T }
771+
772+func newSlab[T any](n int) *slab[T] {
773+ if n <= 0 {
774+ return nil
775+ }
776+ return &slab[T]{block: make([]T, 0, n)}
777+}
778+
779+// one stores v in the slab and returns its address.
780+func (a *slab[T]) one(v T) *T {
781+ if a == nil || len(a.block) == cap(a.block) {
782+ p := new(T) // copy, so v itself never escapes
783+ *p = v
784+ return p
785+ }
786+ a.block = append(a.block, v)
787+ return &a.block[len(a.block)-1]
788+}
789+
790+// take returns an empty slice with capacity exactly n (nil for n == 0).
791+func (a *slab[T]) take(n int) []T {
792+ if n <= 0 {
793+ return nil
794+ }
795+ if a == nil || cap(a.block)-len(a.block) < n {
796+ return make([]T, 0, n)
797+ }
798+ i := len(a.block)
799+ a.block = a.block[:i+n]
800+ return a.block[i : i : i+n]
801+}
802+
803+// radiiOf resolves border-radius percentages against basis.
804+func radiiOf(s *css.Style, basis float64) [4]float64 {
805+ return [4]float64{positive(s.BorderRadius.TopLeft, basis), positive(s.BorderRadius.TopRight, basis), positive(s.BorderRadius.BottomRight, basis), positive(s.BorderRadius.BottomLeft, basis)}
806+}
807+
808+func hasOutline(s *css.Style, r *Result) bool {
809+ return s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0
810+}
811+
812+// countCommands returns how many commands and shadow copies paint emits for
813+// the subtree, so Layout can size the display list once. It mirrors paint's
814+// conditions; if it ever drifts, paint still appends correctly and only regrows.
815+func countCommands(n *Node, r *Result) (c counts) {
816+ if r == nil {
817+ return c
818+ }
819+ s := style(n)
820+ total := len(s.BoxShadow)
821+ c.shadows = len(s.BoxShadow)
822+ if s.BackgroundColor.A > 0 {
823+ total++
824+ }
825+ if r.BorderWidths != (Edges{}) || radiiOf(&s, r.Border.W) != ([4]float64{}) {
826+ total++
827+ }
828+ if hasOutline(&s, r) {
829+ total++
830+ }
831+ if r.Clipped {
832+ total += 2
833+ }
834+ if n.Kind == Image || n.Kind == Text {
835+ total++
836+ }
837+ if n.Kind == Text {
838+ for i := range r.Lines {
839+ c.runs += len(r.Lines[i].Runs)
840+ for j := range r.Lines[i].Runs {
841+ c.glyphs += len(r.Lines[i].Runs[j].Glyphs)
842+ }
843+ }
844+ }
845+ j := 0
846+ for _, child := range n.Children {
847+ if child == nil || style(child).Display == css.KeywordNone {
848+ continue
849+ }
850+ if j < len(r.Children) {
851+ cc := countCommands(child, r.Children[j])
852+ total += cc.commands
853+ c.shadows += cc.shadows
854+ c.runs += cc.runs
855+ c.glyphs += cc.glyphs
856+ j++
857+ }
858+ }
859+ c.commands += total
860+ return c
861+}
862+
863+type counts struct{ commands, shadows, runs, glyphs int }
864+
746 func (c context) paint(n *Node, r *Result, out *[]Command) {865 func (c context) paint(n *Node, r *Result, out *[]Command) {
747 if r == nil {866 if r == nil {
748 return867 return
749 }868 }
750 s := style(n)869 s := style(n)
751 base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))}870 base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))}
871+ borderRadii := radiiOf(&s, r.Border.W)
752 for i := range s.BoxShadow {872 for i := range s.BoxShadow {
753- sh := s.BoxShadow[i]873+ if s.BoxShadow[i].Inset {
754- if sh.Inset {
755 continue874 continue
756 }875 }
757 cmd := base876 cmd := base
758 cmd.Op = "shadow"877 cmd.Op = "shadow"
759- cmd.Shadow = &sh878+ cmd.Shadow = c.shadows.one(s.BoxShadow[i])
760- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)}879+ cmd.Radii = borderRadii
761 *out = append(*out, cmd)880 *out = append(*out, cmd)
762 }881 }
763 if s.BackgroundColor.A > 0 {882 if s.BackgroundColor.A > 0 {
764 cmd := base883 cmd := base
765 cmd.Op = "rect"884 cmd.Op = "rect"
766 cmd.Color = s.BackgroundColor885 cmd.Color = s.BackgroundColor
767- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)}886+ cmd.Radii = borderRadii
768 *out = append(*out, cmd)887 *out = append(*out, cmd)
769 }888 }
770 cmd := base889 cmd := base
771 cmd.Op = "border"890 cmd.Op = "border"
772 cmd.Widths = r.BorderWidths891 cmd.Widths = r.BorderWidths
773 cmd.Colors = s.BorderColor892 cmd.Colors = s.BorderColor
774- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)}893+ cmd.Radii = borderRadii
775 if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) {894 if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) {
776 cmd.Color = s.BorderColor.Top895 cmd.Color = s.BorderColor.Top
777 *out = append(*out, cmd)896 *out = append(*out, cmd)
778 }897 }
779 for i := range s.BoxShadow {898 for i := range s.BoxShadow {
780- sh := s.BoxShadow[i]899+ if !s.BoxShadow[i].Inset {
781- if !sh.Inset {
782 continue900 continue
783 }901 }
784 cmd := base902 cmd := base
785 cmd.Op = "shadow"903 cmd.Op = "shadow"
786- cmd.Shadow = &sh904+ cmd.Shadow = c.shadows.one(s.BoxShadow[i])
787- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)}905+ cmd.Radii = borderRadii
788 *out = append(*out, cmd)906 *out = append(*out, cmd)
789 }907 }
790- if s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 {908+ if hasOutline(&s, r) {
791 cmd = base909 cmd = base
792 cmd.Op = "outline"910 cmd.Op = "outline"
793 cmd.Color = s.OutlineColor911 cmd.Color = s.OutlineColor
794- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)}912+ cmd.Radii = borderRadii
795- cmd.Widths = Edges{Top: positive(s.OutlineWidth, r.Border.W), Right: positive(s.OutlineWidth, r.Border.W), Bottom: positive(s.OutlineWidth, r.Border.W), Left: positive(s.OutlineWidth, r.Border.W)}913+ width := positive(s.OutlineWidth, r.Border.W)
914+ cmd.Widths = Edges{Top: width, Right: width, Bottom: width, Left: width}
796 offset := coordValue(s.OutlineOffset, r.Border.W)915 offset := coordValue(s.OutlineOffset, r.Border.W)
797 cmd.Rect = Rect{X: coord(r.Border.X - offset), Y: coord(r.Border.Y - offset), W: safe(r.Border.W + 2*offset), H: safe(r.Border.H + 2*offset)}916 cmd.Rect = Rect{X: coord(r.Border.X - offset), Y: coord(r.Border.Y - offset), W: safe(r.Border.W + 2*offset), H: safe(r.Border.H + 2*offset)}
798 *out = append(*out, cmd)917 *out = append(*out, cmd)
@@ -808,7 +927,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
808 cmd.Op = "image"927 cmd.Op = "image"
809 cmd.Rect = r.Content928 cmd.Rect = r.Content
810 cmd.Image = n.Image929 cmd.Image = n.Image
811- cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Content.W), positive(s.BorderRadius.TopRight, r.Content.W), positive(s.BorderRadius.BottomRight, r.Content.W), positive(s.BorderRadius.BottomLeft, r.Content.W)}930+ cmd.Radii = radiiOf(&s, r.Content.W)
812 *out = append(*out, cmd)931 *out = append(*out, cmd)
813 }932 }
814 if n.Kind == Text {933 if n.Kind == Text {
@@ -817,6 +936,15 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
817 cmd.Rect = r.Content936 cmd.Rect = r.Content
818 cmd.Text = n.Content937 cmd.Text = n.Content
819 cmd.Color = s.Color938 cmd.Color = s.Color
939+ runs, glyphs := 0, 0
940+ for i := range r.Lines {
941+ runs += len(r.Lines[i].Runs)
942+ for j := range r.Lines[i].Runs {
943+ glyphs += len(r.Lines[i].Runs[j].Glyphs)
944+ }
945+ }
946+ cmd.Runs = c.runs.take(runs)
947+ backing := c.glyphs.take(glyphs)
820 for _, line := range r.Lines {948 for _, line := range r.Lines {
821 offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width)949 offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width)
822 for _, run := range line.Runs {950 for _, run := range line.Runs {
@@ -827,8 +955,12 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
827 if run.Face != nil {955 if run.Face != nil {
828 rr.FaceID = run.Face.ID956 rr.FaceID = run.Face.ID
829 }957 }
830- for _, g := range run.Glyphs {958+ if len(run.Glyphs) > 0 { // keep nil for glyph-less runs
831- rr.Glyphs = append(rr.Glyphs, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster})959+ first := len(backing)
960+ for _, g := range run.Glyphs {
961+ backing = append(backing, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster})
962+ }
963+ rr.Glyphs = backing[first:len(backing):len(backing)]
832 }964 }
833 cmd.Runs = append(cmd.Runs, rr)965 cmd.Runs = append(cmd.Runs, rr)
834 }966 }
A internal/layout/paint_bench_test.go +62 -0
@@ -0,0 +1,62 @@
1+package layout
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/nefergui/internal/css"
8+ "github.com/bnema/nefergui/internal/text"
9+)
10+
11+// paintFixture is a product-like tree: cards with background, border, shadows,
12+// a clip and shaped text children.
13+func paintFixture(tb testing.TB, cards int) (*Node, Options) {
14+ tb.Helper()
15+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
16+ if err != nil {
17+ tb.Fatal(err)
18+ }
19+ engine := text.NewEngine(catalog)
20+ card := css.Style{
21+ Display: css.KeywordBlock, Height: px(60), Padding: css.LengthSides{Top: px(4), Left: px(4)},
22+ BackgroundColor: css.Color{B: 1, A: 1}, BorderWidth: css.LengthSides{Top: px(1), Right: px(1), Bottom: px(1), Left: px(1)},
23+ BorderStyle: css.KeywordSides{Top: css.KeywordSolid, Right: css.KeywordSolid, Bottom: css.KeywordSolid, Left: css.KeywordSolid},
24+ OverflowX: css.KeywordHidden, OverflowY: css.KeywordHidden,
25+ BoxShadow: []css.Shadow{{Y: px(2), Blur: px(4), Color: css.Color{A: .3}}, {Inset: true, Blur: px(2), Color: css.Color{A: .2}}},
26+ }
27+ label := css.Style{Display: css.KeywordBlock, FontSize: px(14), Color: css.Color{A: 1}}
28+ children := make([]*Node, cards)
29+ for i := range children {
30+ t1 := node("t", Text, label)
31+ t1.Content = fmt.Sprintf("Élément %d — une interface native écrite en Go", i)
32+ children[i] = node("card", Box, card, t1)
33+ }
34+ return node("root", Column, css.Style{Display: css.KeywordBlock}, children...), Options{Width: 400, Height: 3000, TextEngine: engine}
35+}
36+
37+// BenchmarkPaint measures only context.paint on an already placed tree.
38+func BenchmarkPaint(b *testing.B) {
39+ root, opts := paintFixture(b, 100)
40+ ctx := context{options: opts}
41+ tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
42+ if err != nil {
43+ b.Fatal(err)
44+ }
45+ b.ReportAllocs()
46+ b.ResetTimer()
47+ for i := 0; i < b.N; i++ {
48+ if display := ctx.display(root, tree); len(display) == 0 {
49+ b.Fatal("empty")
50+ }
51+ }
52+}
53+
54+func BenchmarkLayoutPaintTree(b *testing.B) {
55+ root, opts := paintFixture(b, 100)
56+ b.ReportAllocs()
57+ for i := 0; i < b.N; i++ {
58+ if _, err := Layout(root, opts); err != nil {
59+ b.Fatal(err)
60+ }
61+ }
62+}
@@ -0,0 +1,62 @@
1+package layout
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/nefergui/internal/css"
8+ "github.com/bnema/nefergui/internal/text"
9+)
10+
11+// paintFixture is a product-like tree: cards with background, border, shadows,
12+// a clip and shaped text children.
13+func paintFixture(tb testing.TB, cards int) (*Node, Options) {
14+ tb.Helper()
15+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
16+ if err != nil {
17+ tb.Fatal(err)
18+ }
19+ engine := text.NewEngine(catalog)
20+ card := css.Style{
21+ Display: css.KeywordBlock, Height: px(60), Padding: css.LengthSides{Top: px(4), Left: px(4)},
22+ BackgroundColor: css.Color{B: 1, A: 1}, BorderWidth: css.LengthSides{Top: px(1), Right: px(1), Bottom: px(1), Left: px(1)},
23+ BorderStyle: css.KeywordSides{Top: css.KeywordSolid, Right: css.KeywordSolid, Bottom: css.KeywordSolid, Left: css.KeywordSolid},
24+ OverflowX: css.KeywordHidden, OverflowY: css.KeywordHidden,
25+ BoxShadow: []css.Shadow{{Y: px(2), Blur: px(4), Color: css.Color{A: .3}}, {Inset: true, Blur: px(2), Color: css.Color{A: .2}}},
26+ }
27+ label := css.Style{Display: css.KeywordBlock, FontSize: px(14), Color: css.Color{A: 1}}
28+ children := make([]*Node, cards)
29+ for i := range children {
30+ t1 := node("t", Text, label)
31+ t1.Content = fmt.Sprintf("Élément %d — une interface native écrite en Go", i)
32+ children[i] = node("card", Box, card, t1)
33+ }
34+ return node("root", Column, css.Style{Display: css.KeywordBlock}, children...), Options{Width: 400, Height: 3000, TextEngine: engine}
35+}
36+
37+// BenchmarkPaint measures only context.paint on an already placed tree.
38+func BenchmarkPaint(b *testing.B) {
39+ root, opts := paintFixture(b, 100)
40+ ctx := context{options: opts}
41+ tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
42+ if err != nil {
43+ b.Fatal(err)
44+ }
45+ b.ReportAllocs()
46+ b.ResetTimer()
47+ for i := 0; i < b.N; i++ {
48+ if display := ctx.display(root, tree); len(display) == 0 {
49+ b.Fatal("empty")
50+ }
51+ }
52+}
53+
54+func BenchmarkLayoutPaintTree(b *testing.B) {
55+ root, opts := paintFixture(b, 100)
56+ b.ReportAllocs()
57+ for i := 0; i < b.N; i++ {
58+ if _, err := Layout(root, opts); err != nil {
59+ b.Fatal(err)
60+ }
61+ }
62+}
A internal/layout/paint_test.go +165 -0
@@ -0,0 +1,165 @@
1+package layout
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/nefergui/internal/css"
8+)
9+
10+// Layout output is detached: neither the shared paint storage nor a later
11+// Layout call, nor a mutation of the input style, may change earlier results.
12+func TestPaintOutputDetached(t *testing.T) {
13+ root, opts := paintFixture(t, 6)
14+ first, err := Layout(root, opts)
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ want := deepCopyDisplay(first.Display)
19+ shadows := 0
20+ for _, cmd := range first.Display {
21+ if cmd.Shadow != nil {
22+ shadows++
23+ }
24+ }
25+ if shadows != 12 {
26+ t.Fatalf("fixture shadows=%d, want 12", shadows)
27+ }
28+ // Mutating the input styles must not reach painted shadows.
29+ for _, card := range root.Children {
30+ card.Style.BoxShadow[0].Blur = px(99)
31+ }
32+ second, err := Layout(root, opts)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if !reflect.DeepEqual(first.Display, want) {
37+ t.Fatal("first output changed after style mutation and second Layout")
38+ }
39+ if reflect.DeepEqual(second.Display, want) {
40+ t.Fatal("second output should reflect the mutated style")
41+ }
42+ // Writing through one command's storage must not change any other.
43+ for i := range second.Display {
44+ if second.Display[i].Shadow != nil {
45+ second.Display[i].Shadow.X = px(-7)
46+ }
47+ }
48+ if !reflect.DeepEqual(first.Display, want) {
49+ t.Fatal("outputs share shadow storage")
50+ }
51+}
52+
53+func deepCopyDisplay(in []Command) []Command {
54+ out := make([]Command, len(in))
55+ for i, c := range in {
56+ if c.Shadow != nil {
57+ s := *c.Shadow
58+ c.Shadow = &s
59+ }
60+ c.Runs = append([]Run(nil), c.Runs...)
61+ for j := range c.Runs {
62+ c.Runs[j].Glyphs = append([]Glyph(nil), c.Runs[j].Glyphs...)
63+ }
64+ out[i] = c
65+ }
66+ return out
67+}
68+
69+// Runs and glyphs come from shared blocks; a caller appending to one must
70+// reallocate rather than overwrite the next run or command.
71+func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) {
72+ root, opts := paintFixture(t, 3)
73+ out, err := Layout(root, opts)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ want := deepCopyDisplay(out.Display)
78+ texts := 0
79+ for i := range out.Display {
80+ cmd := &out.Display[i]
81+ if cmd.Op != "text" {
82+ continue
83+ }
84+ texts++
85+ if len(cmd.Runs) == 0 || cap(cmd.Runs) != len(cmd.Runs) {
86+ t.Fatalf("runs len=%d cap=%d", len(cmd.Runs), cap(cmd.Runs))
87+ }
88+ for j := range cmd.Runs {
89+ if g := cmd.Runs[j].Glyphs; len(g) == 0 || cap(g) != len(g) {
90+ t.Fatalf("glyphs len=%d cap=%d", len(g), cap(g))
91+ }
92+ }
93+ _ = append(cmd.Runs, Run{Start: -1})
94+ for j := range cmd.Runs {
95+ _ = append(cmd.Runs[j].Glyphs, Glyph{ID: 0xdead})
96+ }
97+ }
98+ if texts != 3 || !reflect.DeepEqual(out.Display, want) {
99+ t.Fatal("append through a command overwrote neighbouring data")
100+ }
101+}
102+
103+// countCommands must match paint exactly, otherwise the single-allocation
104+// guarantee silently degrades.
105+func TestPaintCountsMatch(t *testing.T) {
106+ root, opts := paintFixture(t, 10)
107+ ctx := context{options: opts}
108+ tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ n := countCommands(root, tree)
113+ out := ctx.display(root, tree)
114+ runs, glyphs, shadows := 0, 0, 0
115+ for _, cmd := range out {
116+ runs += len(cmd.Runs)
117+ for _, r := range cmd.Runs {
118+ glyphs += len(r.Glyphs)
119+ }
120+ if cmd.Shadow != nil {
121+ shadows++
122+ }
123+ }
124+ if n.commands != len(out) || cap(out) != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows {
125+ t.Fatalf("counts %+v vs commands=%d cap=%d runs=%d glyphs=%d shadows=%d", n, len(out), cap(out), runs, glyphs, shadows)
126+ }
127+}
128+
129+func TestPaintAllocations(t *testing.T) {
130+ root, opts := paintFixture(t, 50)
131+ ctx := context{options: opts}
132+ tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ // One block each for commands, shadows, runs and glyphs, whatever the tree size.
137+ if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n > 4 {
138+ t.Fatalf("paint allocs=%v, want <= 4", n)
139+ }
140+}
141+
142+func TestPaintEmptyAndShadowOrder(t *testing.T) {
143+ out, err := Layout(&Node{ID: "n", Kind: Box, Style: &css.Style{Display: css.KeywordBlock}}, Options{Width: 10, Height: 10})
144+ if err != nil || out.Display != nil {
145+ t.Fatalf("empty paint: %+v %v", out.Display, err)
146+ }
147+ s := css.Style{Display: css.KeywordBlock, Width: px(10), Height: px(10), BackgroundColor: css.Color{A: 1}, BoxShadow: []css.Shadow{
148+ {Inset: true, Blur: px(1)}, {Blur: px(2)}, {Blur: px(3)}, {Inset: true, Blur: px(4)},
149+ }}
150+ out, err = Layout(node("n", Box, s), Options{Width: 10, Height: 10})
151+ if err != nil {
152+ t.Fatal(err)
153+ }
154+ var got []float64
155+ var ops []string
156+ for _, c := range out.Display {
157+ ops = append(ops, c.Op)
158+ if c.Shadow != nil {
159+ got = append(got, c.Shadow.Blur.Value)
160+ }
161+ }
162+ if !reflect.DeepEqual(got, []float64{2, 3, 1, 4}) || !reflect.DeepEqual(ops, []string{"shadow", "shadow", "rect", "shadow", "shadow"}) {
163+ t.Fatalf("order ops=%v blur=%v", ops, got)
164+ }
165+}
@@ -0,0 +1,165 @@
1+package layout
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/nefergui/internal/css"
8+)
9+
10+// Layout output is detached: neither the shared paint storage nor a later
11+// Layout call, nor a mutation of the input style, may change earlier results.
12+func TestPaintOutputDetached(t *testing.T) {
13+ root, opts := paintFixture(t, 6)
14+ first, err := Layout(root, opts)
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ want := deepCopyDisplay(first.Display)
19+ shadows := 0
20+ for _, cmd := range first.Display {
21+ if cmd.Shadow != nil {
22+ shadows++
23+ }
24+ }
25+ if shadows != 12 {
26+ t.Fatalf("fixture shadows=%d, want 12", shadows)
27+ }
28+ // Mutating the input styles must not reach painted shadows.
29+ for _, card := range root.Children {
30+ card.Style.BoxShadow[0].Blur = px(99)
31+ }
32+ second, err := Layout(root, opts)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if !reflect.DeepEqual(first.Display, want) {
37+ t.Fatal("first output changed after style mutation and second Layout")
38+ }
39+ if reflect.DeepEqual(second.Display, want) {
40+ t.Fatal("second output should reflect the mutated style")
41+ }
42+ // Writing through one command's storage must not change any other.
43+ for i := range second.Display {
44+ if second.Display[i].Shadow != nil {
45+ second.Display[i].Shadow.X = px(-7)
46+ }
47+ }
48+ if !reflect.DeepEqual(first.Display, want) {
49+ t.Fatal("outputs share shadow storage")
50+ }
51+}
52+
53+func deepCopyDisplay(in []Command) []Command {
54+ out := make([]Command, len(in))
55+ for i, c := range in {
56+ if c.Shadow != nil {
57+ s := *c.Shadow
58+ c.Shadow = &s
59+ }
60+ c.Runs = append([]Run(nil), c.Runs...)
61+ for j := range c.Runs {
62+ c.Runs[j].Glyphs = append([]Glyph(nil), c.Runs[j].Glyphs...)
63+ }
64+ out[i] = c
65+ }
66+ return out
67+}
68+
69+// Runs and glyphs come from shared blocks; a caller appending to one must
70+// reallocate rather than overwrite the next run or command.
71+func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) {
72+ root, opts := paintFixture(t, 3)
73+ out, err := Layout(root, opts)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ want := deepCopyDisplay(out.Display)
78+ texts := 0
79+ for i := range out.Display {
80+ cmd := &out.Display[i]
81+ if cmd.Op != "text" {
82+ continue
83+ }
84+ texts++
85+ if len(cmd.Runs) == 0 || cap(cmd.Runs) != len(cmd.Runs) {
86+ t.Fatalf("runs len=%d cap=%d", len(cmd.Runs), cap(cmd.Runs))
87+ }
88+ for j := range cmd.Runs {
89+ if g := cmd.Runs[j].Glyphs; len(g) == 0 || cap(g) != len(g) {
90+ t.Fatalf("glyphs len=%d cap=%d", len(g), cap(g))
91+ }
92+ }
93+ _ = append(cmd.Runs, Run{Start: -1})
94+ for j := range cmd.Runs {
95+ _ = append(cmd.Runs[j].Glyphs, Glyph{ID: 0xdead})
96+ }
97+ }
98+ if texts != 3 || !reflect.DeepEqual(out.Display, want) {
99+ t.Fatal("append through a command overwrote neighbouring data")
100+ }
101+}
102+
103+// countCommands must match paint exactly, otherwise the single-allocation
104+// guarantee silently degrades.
105+func TestPaintCountsMatch(t *testing.T) {
106+ root, opts := paintFixture(t, 10)
107+ ctx := context{options: opts}
108+ tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ n := countCommands(root, tree)
113+ out := ctx.display(root, tree)
114+ runs, glyphs, shadows := 0, 0, 0
115+ for _, cmd := range out {
116+ runs += len(cmd.Runs)
117+ for _, r := range cmd.Runs {
118+ glyphs += len(r.Glyphs)
119+ }
120+ if cmd.Shadow != nil {
121+ shadows++
122+ }
123+ }
124+ if n.commands != len(out) || cap(out) != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows {
125+ t.Fatalf("counts %+v vs commands=%d cap=%d runs=%d glyphs=%d shadows=%d", n, len(out), cap(out), runs, glyphs, shadows)
126+ }
127+}
128+
129+func TestPaintAllocations(t *testing.T) {
130+ root, opts := paintFixture(t, 50)
131+ ctx := context{options: opts}
132+ tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ // One block each for commands, shadows, runs and glyphs, whatever the tree size.
137+ if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n > 4 {
138+ t.Fatalf("paint allocs=%v, want <= 4", n)
139+ }
140+}
141+
142+func TestPaintEmptyAndShadowOrder(t *testing.T) {
143+ out, err := Layout(&Node{ID: "n", Kind: Box, Style: &css.Style{Display: css.KeywordBlock}}, Options{Width: 10, Height: 10})
144+ if err != nil || out.Display != nil {
145+ t.Fatalf("empty paint: %+v %v", out.Display, err)
146+ }
147+ s := css.Style{Display: css.KeywordBlock, Width: px(10), Height: px(10), BackgroundColor: css.Color{A: 1}, BoxShadow: []css.Shadow{
148+ {Inset: true, Blur: px(1)}, {Blur: px(2)}, {Blur: px(3)}, {Inset: true, Blur: px(4)},
149+ }}
150+ out, err = Layout(node("n", Box, s), Options{Width: 10, Height: 10})
151+ if err != nil {
152+ t.Fatal(err)
153+ }
154+ var got []float64
155+ var ops []string
156+ for _, c := range out.Display {
157+ ops = append(ops, c.Op)
158+ if c.Shadow != nil {
159+ got = append(got, c.Shadow.Blur.Value)
160+ }
161+ }
162+ if !reflect.DeepEqual(got, []float64{2, 3, 1, 4}) || !reflect.DeepEqual(ops, []string{"shadow", "shadow", "rect", "shadow", "shadow"}) {
163+ t.Fatalf("order ops=%v blur=%v", ops, got)
164+ }
165+}
M internal/presentation/session/app.go +1 -5
@@ -63,11 +63,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
6363 }
6464 }
6565 committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) {
66- instances, batches, stats := render.ListBatches(prepared)
67- if len(stats.Skipped) != 0 {
68- return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
69- }
70- return s.tick(nil, instances, prepared.Uploads, batches)
66+ return s.tickFrame(prepared)
7167 })
7268 if err != nil {
7369 return err
@@ -63,11 +63,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
63 }63 }
64 }64 }
65 committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) {65 committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) {
66- instances, batches, stats := render.ListBatches(prepared)66+ return s.tickFrame(prepared)
67- if len(stats.Skipped) != 0 {
68- return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
69- }
70- return s.tick(nil, instances, prepared.Uploads, batches)
71 })67 })
72 if err != nil {68 if err != nil {
73 return err69 return err
M internal/presentation/session/session.go +16 -5
@@ -49,6 +49,7 @@ type Session struct {
4949 Pipeline *vkdevice.Pipeline
5050 ListPipeline *vkdevice.Pipeline
5151 Preparer *render.Preparer
52+ list render.ListBuffer // owner-loop scratch; see tickFrame
5253 Slots map[uint64]*Slot
5354 retiredPipelines []retiredPipelines
5455 Acquire uint32
@@ -342,6 +343,20 @@ func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload)
342343 return s.tick(nil, instances, uploads, batches)
343344 }
344345
346+// tickFrame lists and submits one prepared frame on the owner loop. The list
347+// buffer's slices are borrowed only for this call: tick copies instances into
348+// mapped GPU memory and resolve copies batches before returning, and no pending
349+// GPU work or retained frame refers to them (a blocked frame is re-listed from
350+// its render.Frame on retry). Release then drops borrowed image references.
351+func (s *Session) tickFrame(frame render.Frame) (bool, error) {
352+ defer s.list.Release()
353+ instances, batches, stats := s.list.List(frame)
354+ if len(stats.Skipped) != 0 {
355+ return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
356+ }
357+ return s.tick(nil, instances, frame.Uploads, batches)
358+}
359+
345360 func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {
346361 started := time.Now()
347362 if s.Window.Dirty {
@@ -682,11 +697,7 @@ func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, dra
682697 if err != nil {
683698 return false, err
684699 }
685- instances, batches, stats := render.ListBatches(frame)
686- if len(stats.Skipped) != 0 {
687- return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
688- }
689- return s.tick(nil, instances, frame.Uploads, batches)
700+ return s.tickFrame(frame)
690701 }, committed)
691702 }
692703
@@ -49,6 +49,7 @@ type Session struct {
49 Pipeline *vkdevice.Pipeline49 Pipeline *vkdevice.Pipeline
50 ListPipeline *vkdevice.Pipeline50 ListPipeline *vkdevice.Pipeline
51 Preparer *render.Preparer51 Preparer *render.Preparer
52+ list render.ListBuffer // owner-loop scratch; see tickFrame
52 Slots map[uint64]*Slot53 Slots map[uint64]*Slot
53 retiredPipelines []retiredPipelines54 retiredPipelines []retiredPipelines
54 Acquire uint3255 Acquire uint32
@@ -342,6 +343,20 @@ func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload)
342 return s.tick(nil, instances, uploads, batches)343 return s.tick(nil, instances, uploads, batches)
343 }344 }
344 345
346+// tickFrame lists and submits one prepared frame on the owner loop. The list
347+// buffer's slices are borrowed only for this call: tick copies instances into
348+// mapped GPU memory and resolve copies batches before returning, and no pending
349+// GPU work or retained frame refers to them (a blocked frame is re-listed from
350+// its render.Frame on retry). Release then drops borrowed image references.
351+func (s *Session) tickFrame(frame render.Frame) (bool, error) {
352+ defer s.list.Release()
353+ instances, batches, stats := s.list.List(frame)
354+ if len(stats.Skipped) != 0 {
355+ return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
356+ }
357+ return s.tick(nil, instances, frame.Uploads, batches)
358+}
359+
345 func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {360 func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {
346 started := time.Now()361 started := time.Now()
347 if s.Window.Dirty {362 if s.Window.Dirty {
@@ -682,11 +697,7 @@ func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, dra
682 if err != nil {697 if err != nil {
683 return false, err698 return false, err
684 }699 }
685- instances, batches, stats := render.ListBatches(frame)700+ return s.tickFrame(frame)
686- if len(stats.Skipped) != 0 {
687- return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
688- }
689- return s.tick(nil, instances, frame.Uploads, batches)
690 }, committed)701 }, committed)
691 }702 }
692 703
A internal/presentation/session/tickframe_test.go +27 -0
@@ -0,0 +1,27 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "image"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/nefergui/internal/render"
11+)
12+
13+// An unsupported quad is rejected before any GPU work, and the borrowed list
14+// buffer is released so it never pins the frame's image.
15+func TestTickFrameRejectsUnsupportedAndReleasesImages(t *testing.T) {
16+ img := image.NewRGBA(image.Rect(0, 0, 1, 1))
17+ s := &Session{}
18+ _, err := s.tickFrame(render.Frame{Quads: []render.Quad{{Op: "image", Image: img}, {Op: "bogus"}}})
19+ if err == nil || !strings.Contains(err.Error(), "unsupported list operations") {
20+ t.Fatalf("err=%v", err)
21+ }
22+ instances, batches, _ := s.list.List(render.Frame{})
23+ if len(instances) != 0 || len(batches) != 0 {
24+ t.Fatal("stale list state")
25+ }
26+ s.list.Release()
27+}
@@ -0,0 +1,27 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "image"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/nefergui/internal/render"
11+)
12+
13+// An unsupported quad is rejected before any GPU work, and the borrowed list
14+// buffer is released so it never pins the frame's image.
15+func TestTickFrameRejectsUnsupportedAndReleasesImages(t *testing.T) {
16+ img := image.NewRGBA(image.Rect(0, 0, 1, 1))
17+ s := &Session{}
18+ _, err := s.tickFrame(render.Frame{Quads: []render.Quad{{Op: "image", Image: img}, {Op: "bogus"}}})
19+ if err == nil || !strings.Contains(err.Error(), "unsupported list operations") {
20+ t.Fatalf("err=%v", err)
21+ }
22+ instances, batches, _ := s.list.List(render.Frame{})
23+ if len(instances) != 0 || len(batches) != 0 {
24+ t.Fatal("stale list state")
25+ }
26+ s.list.Release()
27+}
M internal/presentation/syncobj/syncobj.go +30 -11
@@ -6,7 +6,6 @@
66 package syncobj
77
88 import (
9- "errors"
109 "fmt"
1110 "runtime"
1211 "time"
@@ -74,10 +73,23 @@ func (n *Node) Close() error {
7473 n.fd = -1
7574 return err
7675 }
77-func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error {
76+
77+// ioctlErrno performs the ioctl and returns the raw errno without allocating.
78+// arg must stay reachable by the caller until this returns (callers use
79+// runtime.KeepAlive when arg embeds uintptr-encoded pointers).
80+func (n *Node) ioctlErrno(request uintptr, arg unsafe.Pointer) unix.Errno {
7881 _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg))
79- if errno != 0 {
80- return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno)
82+ return errno
83+}
84+
85+// ioctlError adds request context to a real errno. Only failure paths pay for it.
86+func ioctlError(request uintptr, errno unix.Errno) error {
87+ return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno)
88+}
89+
90+func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error {
91+ if errno := n.ioctlErrno(request, arg); errno != 0 {
92+ return ioctlError(request, errno)
8193 }
8294 return nil
8395 }
@@ -174,16 +186,23 @@ func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bo
174186 return false, err
175187 }
176188 a := timelineWaitArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), TimeoutNS: now.Nano() + timeout.Nanoseconds(), Count: 1, Flags: 2}
177- err := n.ioctl(ioctlTimelineWait, unsafe.Pointer(&a))
189+ errno := n.ioctlErrno(ioctlTimelineWait, unsafe.Pointer(&a))
178190 runtime.KeepAlive(h)
179191 runtime.KeepAlive(p)
180- if err != nil {
181- if errors.Is(err, unix.ETIME) || errors.Is(err, unix.EBUSY) {
182- return false, nil
183- }
184- return false, err
192+ return waitResult(errno)
193+}
194+
195+// waitResult maps a TIMELINE_WAIT errno. ETIME and EBUSY are the expected idle
196+// timeout outcomes and build no error value; anything else keeps its context.
197+func waitResult(errno unix.Errno) (bool, error) {
198+ switch errno {
199+ case 0:
200+ return true, nil
201+ case unix.ETIME, unix.EBUSY:
202+ return false, nil
203+ default:
204+ return false, ioctlError(ioctlTimelineWait, errno)
185205 }
186- return true, nil
187206 }
188207
189208 // Signaled performs a zero-time wait for tests and shutdown diagnostics.
@@ -6,7 +6,6 @@
6 package syncobj6 package syncobj
7 7
8 import (8 import (
9- "errors"
10 "fmt"9 "fmt"
11 "runtime"10 "runtime"
12 "time"11 "time"
@@ -74,10 +73,23 @@ func (n *Node) Close() error {
74 n.fd = -173 n.fd = -1
75 return err74 return err
76 }75 }
77-func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error {76+
77+// ioctlErrno performs the ioctl and returns the raw errno without allocating.
78+// arg must stay reachable by the caller until this returns (callers use
79+// runtime.KeepAlive when arg embeds uintptr-encoded pointers).
80+func (n *Node) ioctlErrno(request uintptr, arg unsafe.Pointer) unix.Errno {
78 _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg))81 _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg))
79- if errno != 0 {82+ return errno
80- return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno)83+}
84+
85+// ioctlError adds request context to a real errno. Only failure paths pay for it.
86+func ioctlError(request uintptr, errno unix.Errno) error {
87+ return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno)
88+}
89+
90+func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error {
91+ if errno := n.ioctlErrno(request, arg); errno != 0 {
92+ return ioctlError(request, errno)
81 }93 }
82 return nil94 return nil
83 }95 }
@@ -174,16 +186,23 @@ func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bo
174 return false, err186 return false, err
175 }187 }
176 a := timelineWaitArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), TimeoutNS: now.Nano() + timeout.Nanoseconds(), Count: 1, Flags: 2}188 a := timelineWaitArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), TimeoutNS: now.Nano() + timeout.Nanoseconds(), Count: 1, Flags: 2}
177- err := n.ioctl(ioctlTimelineWait, unsafe.Pointer(&a))189+ errno := n.ioctlErrno(ioctlTimelineWait, unsafe.Pointer(&a))
178 runtime.KeepAlive(h)190 runtime.KeepAlive(h)
179 runtime.KeepAlive(p)191 runtime.KeepAlive(p)
180- if err != nil {192+ return waitResult(errno)
181- if errors.Is(err, unix.ETIME) || errors.Is(err, unix.EBUSY) {193+}
182- return false, nil194+
183- }195+// waitResult maps a TIMELINE_WAIT errno. ETIME and EBUSY are the expected idle
184- return false, err196+// timeout outcomes and build no error value; anything else keeps its context.
197+func waitResult(errno unix.Errno) (bool, error) {
198+ switch errno {
199+ case 0:
200+ return true, nil
201+ case unix.ETIME, unix.EBUSY:
202+ return false, nil
203+ default:
204+ return false, ioctlError(ioctlTimelineWait, errno)
185 }205 }
186- return true, nil
187 }206 }
188 207
189 // Signaled performs a zero-time wait for tests and shutdown diagnostics.208 // Signaled performs a zero-time wait for tests and shutdown diagnostics.
A internal/presentation/syncobj/wait_test.go +144 -0
@@ -0,0 +1,144 @@
1+//go:build linux
2+
3+package syncobj
4+
5+import (
6+ "errors"
7+ "os"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "golang.org/x/sys/unix"
13+)
14+
15+func TestWaitResultMapping(t *testing.T) {
16+ if ok, err := waitResult(0); !ok || err != nil {
17+ t.Fatalf("success: got %v, %v", ok, err)
18+ }
19+ for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} {
20+ if ok, err := waitResult(e); ok || err != nil {
21+ t.Fatalf("%v: want (false,nil), got %v, %v", e, ok, err)
22+ }
23+ }
24+ for _, e := range []unix.Errno{unix.EINTR, unix.EBADF, unix.EINVAL, unix.ENOENT, unix.ENOTTY, unix.EFAULT} {
25+ ok, err := waitResult(e)
26+ if ok || err == nil {
27+ t.Fatalf("%v: want error, got %v, %v", e, ok, err)
28+ }
29+ if !errors.Is(err, e) {
30+ t.Errorf("%v: error does not wrap errno: %v", e, err)
31+ }
32+ if !strings.Contains(err.Error(), "DRM syncobj ioctl 0xc03064ca") {
33+ t.Errorf("%v: lost request context: %v", e, err)
34+ }
35+ }
36+}
37+
38+func TestWaitResultIdleDoesNotAllocate(t *testing.T) {
39+ for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} {
40+ if n := testing.AllocsPerRun(1000, func() { _, _ = waitResult(e) }); n != 0 {
41+ t.Errorf("%v: %v allocs per idle result, want 0", e, n)
42+ }
43+ }
44+}
45+
46+func TestWaitPointBadFDKeepsRealError(t *testing.T) {
47+ for _, fd := range []int{-1, 1 << 30} {
48+ n := &Node{fd: fd}
49+ ok, err := n.WaitPoint(1, 1, 20*time.Millisecond)
50+ if ok || !errors.Is(err, unix.EBADF) {
51+ t.Fatalf("fd %d: want (false, EBADF), got %v, %v", fd, ok, err)
52+ }
53+ if !strings.Contains(err.Error(), "DRM syncobj ioctl") {
54+ t.Errorf("fd %d: lost context: %v", fd, err)
55+ }
56+ }
57+ // Closed Node (fd -1 after Close) must also surface EBADF.
58+ n := &Node{fd: -1}
59+ if _, err := n.Signaled(1, 1); !errors.Is(err, unix.EBADF) {
60+ t.Fatalf("Signaled on closed node: %v", err)
61+ }
62+}
63+
64+func TestWaitPointNonDRMFDIsNotSwallowed(t *testing.T) {
65+ fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC)
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ defer unix.Close(fd)
70+ n := &Node{fd: fd}
71+ if ok, err := n.WaitPoint(1, 1, 0); ok || !errors.Is(err, unix.ENOTTY) {
72+ t.Fatalf("want (false, ENOTTY), got %v, %v", ok, err)
73+ }
74+}
75+
76+func TestWaitPointTimeoutBounds(t *testing.T) {
77+ n := &Node{fd: -1}
78+ for _, d := range []time.Duration{-1, time.Second + 1} {
79+ if _, err := n.WaitPoint(1, 1, d); err == nil || errors.Is(err, unix.EBADF) {
80+ t.Errorf("timeout %s: want bounds error before ioctl, got %v", d, err)
81+ }
82+ }
83+ // Boundaries stay accepted (fail later at the ioctl with EBADF).
84+ for _, d := range []time.Duration{0, time.Second} {
85+ if _, err := n.WaitPoint(1, 1, d); !errors.Is(err, unix.EBADF) {
86+ t.Errorf("timeout %s: want EBADF, got %v", d, err)
87+ }
88+ }
89+}
90+
91+// openBenchNode opts in to a real render node, like the other hardware tests.
92+func openBenchNode(tb testing.TB) *Node {
93+ path := os.Getenv("NEFERGUI_RENDER_NODE")
94+ if path == "" {
95+ tb.Skip("set NEFERGUI_RENDER_NODE=/dev/dri/renderD... for real idle-wait measurement")
96+ }
97+ n, err := Open(path)
98+ if err != nil {
99+ tb.Fatal(err)
100+ }
101+ tb.Cleanup(func() { _ = n.Close() })
102+ return n
103+}
104+
105+func TestWaitPointIdleTimeoutRealNodeNoAllocs(t *testing.T) {
106+ n := openBenchNode(t)
107+ h, err := n.Create()
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ defer n.Destroy(h)
112+ ready, err := n.WaitPoint(h, 1, 0)
113+ if ready || err != nil {
114+ t.Fatalf("idle unsignaled point: got %v, %v", ready, err)
115+ }
116+ if a := testing.AllocsPerRun(200, func() { _, _ = n.WaitPoint(h, 1, 0) }); a != 0 {
117+ t.Errorf("idle WaitPoint allocs/op = %v, want 0", a)
118+ }
119+}
120+
121+func BenchmarkWaitResultIdle(b *testing.B) {
122+ b.ReportAllocs()
123+ for i := 0; i < b.N; i++ {
124+ _, _ = waitResult(unix.ETIME)
125+ }
126+}
127+
128+// BenchmarkWaitPointIdle measures the presentation waiter's idle path on a
129+// real render node: an unsignaled point that times out (ETIME/EBUSY).
130+func BenchmarkWaitPointIdle(b *testing.B) {
131+ n := openBenchNode(b)
132+ h, err := n.Create()
133+ if err != nil {
134+ b.Fatal(err)
135+ }
136+ defer n.Destroy(h)
137+ b.ReportAllocs()
138+ b.ResetTimer()
139+ for i := 0; i < b.N; i++ {
140+ if ready, err := n.WaitPoint(h, 1, 20*time.Millisecond); ready || err != nil {
141+ b.Fatalf("got %v, %v", ready, err)
142+ }
143+ }
144+}
@@ -0,0 +1,144 @@
1+//go:build linux
2+
3+package syncobj
4+
5+import (
6+ "errors"
7+ "os"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "golang.org/x/sys/unix"
13+)
14+
15+func TestWaitResultMapping(t *testing.T) {
16+ if ok, err := waitResult(0); !ok || err != nil {
17+ t.Fatalf("success: got %v, %v", ok, err)
18+ }
19+ for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} {
20+ if ok, err := waitResult(e); ok || err != nil {
21+ t.Fatalf("%v: want (false,nil), got %v, %v", e, ok, err)
22+ }
23+ }
24+ for _, e := range []unix.Errno{unix.EINTR, unix.EBADF, unix.EINVAL, unix.ENOENT, unix.ENOTTY, unix.EFAULT} {
25+ ok, err := waitResult(e)
26+ if ok || err == nil {
27+ t.Fatalf("%v: want error, got %v, %v", e, ok, err)
28+ }
29+ if !errors.Is(err, e) {
30+ t.Errorf("%v: error does not wrap errno: %v", e, err)
31+ }
32+ if !strings.Contains(err.Error(), "DRM syncobj ioctl 0xc03064ca") {
33+ t.Errorf("%v: lost request context: %v", e, err)
34+ }
35+ }
36+}
37+
38+func TestWaitResultIdleDoesNotAllocate(t *testing.T) {
39+ for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} {
40+ if n := testing.AllocsPerRun(1000, func() { _, _ = waitResult(e) }); n != 0 {
41+ t.Errorf("%v: %v allocs per idle result, want 0", e, n)
42+ }
43+ }
44+}
45+
46+func TestWaitPointBadFDKeepsRealError(t *testing.T) {
47+ for _, fd := range []int{-1, 1 << 30} {
48+ n := &Node{fd: fd}
49+ ok, err := n.WaitPoint(1, 1, 20*time.Millisecond)
50+ if ok || !errors.Is(err, unix.EBADF) {
51+ t.Fatalf("fd %d: want (false, EBADF), got %v, %v", fd, ok, err)
52+ }
53+ if !strings.Contains(err.Error(), "DRM syncobj ioctl") {
54+ t.Errorf("fd %d: lost context: %v", fd, err)
55+ }
56+ }
57+ // Closed Node (fd -1 after Close) must also surface EBADF.
58+ n := &Node{fd: -1}
59+ if _, err := n.Signaled(1, 1); !errors.Is(err, unix.EBADF) {
60+ t.Fatalf("Signaled on closed node: %v", err)
61+ }
62+}
63+
64+func TestWaitPointNonDRMFDIsNotSwallowed(t *testing.T) {
65+ fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC)
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ defer unix.Close(fd)
70+ n := &Node{fd: fd}
71+ if ok, err := n.WaitPoint(1, 1, 0); ok || !errors.Is(err, unix.ENOTTY) {
72+ t.Fatalf("want (false, ENOTTY), got %v, %v", ok, err)
73+ }
74+}
75+
76+func TestWaitPointTimeoutBounds(t *testing.T) {
77+ n := &Node{fd: -1}
78+ for _, d := range []time.Duration{-1, time.Second + 1} {
79+ if _, err := n.WaitPoint(1, 1, d); err == nil || errors.Is(err, unix.EBADF) {
80+ t.Errorf("timeout %s: want bounds error before ioctl, got %v", d, err)
81+ }
82+ }
83+ // Boundaries stay accepted (fail later at the ioctl with EBADF).
84+ for _, d := range []time.Duration{0, time.Second} {
85+ if _, err := n.WaitPoint(1, 1, d); !errors.Is(err, unix.EBADF) {
86+ t.Errorf("timeout %s: want EBADF, got %v", d, err)
87+ }
88+ }
89+}
90+
91+// openBenchNode opts in to a real render node, like the other hardware tests.
92+func openBenchNode(tb testing.TB) *Node {
93+ path := os.Getenv("NEFERGUI_RENDER_NODE")
94+ if path == "" {
95+ tb.Skip("set NEFERGUI_RENDER_NODE=/dev/dri/renderD... for real idle-wait measurement")
96+ }
97+ n, err := Open(path)
98+ if err != nil {
99+ tb.Fatal(err)
100+ }
101+ tb.Cleanup(func() { _ = n.Close() })
102+ return n
103+}
104+
105+func TestWaitPointIdleTimeoutRealNodeNoAllocs(t *testing.T) {
106+ n := openBenchNode(t)
107+ h, err := n.Create()
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ defer n.Destroy(h)
112+ ready, err := n.WaitPoint(h, 1, 0)
113+ if ready || err != nil {
114+ t.Fatalf("idle unsignaled point: got %v, %v", ready, err)
115+ }
116+ if a := testing.AllocsPerRun(200, func() { _, _ = n.WaitPoint(h, 1, 0) }); a != 0 {
117+ t.Errorf("idle WaitPoint allocs/op = %v, want 0", a)
118+ }
119+}
120+
121+func BenchmarkWaitResultIdle(b *testing.B) {
122+ b.ReportAllocs()
123+ for i := 0; i < b.N; i++ {
124+ _, _ = waitResult(unix.ETIME)
125+ }
126+}
127+
128+// BenchmarkWaitPointIdle measures the presentation waiter's idle path on a
129+// real render node: an unsignaled point that times out (ETIME/EBUSY).
130+func BenchmarkWaitPointIdle(b *testing.B) {
131+ n := openBenchNode(b)
132+ h, err := n.Create()
133+ if err != nil {
134+ b.Fatal(err)
135+ }
136+ defer n.Destroy(h)
137+ b.ReportAllocs()
138+ b.ResetTimer()
139+ for i := 0; i < b.N; i++ {
140+ if ready, err := n.WaitPoint(h, 1, 20*time.Millisecond); ready || err != nil {
141+ b.Fatalf("got %v, %v", ready, err)
142+ }
143+ }
144+}
M internal/render/instances.go +128 -47
@@ -21,57 +21,138 @@ func ListInstances(frame Frame) ([]vkdevice.Instance, ListStats) {
2121
2222 // ListBatches groups consecutive atlas draws and isolates each image draw.
2323 // First indexes the returned instances, retaining exact painter's order.
24+// The returned slices and map are freshly allocated and owned by the caller.
2425 func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) {
25- out := make([]vkdevice.Instance, 0, len(frame.Quads))
26- stats := ListStats{Skipped: make(map[string]int)}
27- var batches []vkdevice.Batch
28- for _, q := range frame.Quads {
29- switch q.Op {
30- case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image":
31- v := vkdevice.Instance{
32- Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)},
33- Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)},
34- Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)},
35- }
36- v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)}
37- v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)}
38- v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])}
39- for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} {
40- v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))}
41- }
42- switch q.Op {
43- case "image":
44- v.KindLayer[0] = 6
45- v.UV = [4]float32{0, 0, 1, 1}
46- case "border":
47- v.KindLayer[0] = 2
48- case "outline":
49- v.KindLayer[0] = 3
50- case "shadow":
51- v.KindLayer[0] = 4
52- if q.Inset {
53- v.KindLayer[0] = 5
54- }
55- }
56- for i, r := range q.Radii {
57- v.Radii[i] = float32(r)
58- }
59- if q.Op == "glyph" {
60- v.KindLayer = [4]float32{1, float32(q.Glyph.Page)}
61- v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize}
62- }
63- if q.Op == "image" {
64- batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image})
65- } else if len(batches) == 0 || batches[len(batches)-1].Source != nil {
66- batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1})
67- } else {
68- batches[len(batches)-1].Count++
69- }
70- out = append(out, v)
71- default:
26+ return listBatches(frame, nil, nil, make(map[string]int))
27+}
28+
29+// ListBuffer is a single-owner scratch area for repeated ListBatches calls.
30+// The slices it returns alias its storage: they are valid only until the next
31+// List or Release call, and must not be retained, shared across goroutines, or
32+// used while another List may run. This is safe for consumers that copy the
33+// data synchronously (Session.tick uploads instances into GPU memory and
34+// resolve copies the batches before recording returns); anything that keeps the
35+// slices past that point must use ListBatches instead. Retained frames hold
36+// only render.Frame, never these slices, so a blocked frame is re-listed on retry.
37+// Stats.Skipped shares the same lifetime. The zero value is ready to use.
38+type ListBuffer struct {
39+ instances []vkdevice.Instance
40+ batches []vkdevice.Batch
41+ skipped map[string]int
42+}
43+
44+// bufferSlack is how much larger than needed retained storage may be before
45+// List drops it, so one huge frame does not pin memory forever.
46+const bufferSlack = 4
47+
48+// List is ListBatches reusing this buffer's storage where capacity allows.
49+func (b *ListBuffer) List(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) {
50+ b.Release()
51+ if b.skipped == nil {
52+ b.skipped = make(map[string]int)
53+ }
54+ clear(b.skipped)
55+ instances, batches, stats := listBatches(frame, b.instances[:0], b.batches[:0], b.skipped)
56+ b.instances, b.batches = instances[:0], batches[:0]
57+ return instances, batches, stats
58+}
59+
60+// Release drops borrowed image references from the previous result. Call it
61+// once the result has been consumed so an idle buffer does not pin images.
62+func (b *ListBuffer) Release() {
63+ clear(b.batches[:cap(b.batches)])
64+}
65+
66+// drawn reports whether the operation draws an instance.
67+func drawn(op string) bool {
68+ switch op {
69+ case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image":
70+ return true
71+ }
72+ return false
73+}
74+
75+// listBatches fills out and batches (both length zero), growing them only when
76+// their capacity is short of the exact counts or absurdly larger than needed.
77+func listBatches(frame Frame, out []vkdevice.Instance, batches []vkdevice.Batch, skipped map[string]int) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) {
78+ // Count first so both slices are sized exactly: no append growth, and no
79+ // over-allocation for frames that skip quads.
80+ drawCount, batchCount, open := 0, 0, false
81+ for i := range frame.Quads {
82+ op := frame.Quads[i].Op
83+ if !drawn(op) {
84+ continue
85+ }
86+ drawCount++
87+ if op == "image" {
88+ batchCount++
89+ open = false
90+ } else if !open {
91+ batchCount++
92+ open = true
93+ }
94+ }
95+ if out == nil || cap(out) < drawCount || cap(out) > bufferSlack*drawCount+64 {
96+ out = make([]vkdevice.Instance, 0, drawCount)
97+ }
98+ if batchCount == 0 {
99+ batches = nil // as before: no draws, no batches
100+ } else if cap(batches) < batchCount || cap(batches) > bufferSlack*batchCount+16 {
101+ batches = make([]vkdevice.Batch, 0, batchCount)
102+ }
103+ stats := ListStats{Skipped: skipped}
104+ for i := range frame.Quads {
105+ q := &frame.Quads[i]
106+ if !drawn(q.Op) {
72107 stats.Skipped[q.Op]++
108+ continue
73109 }
110+ if q.Op == "image" {
111+ batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image})
112+ } else if len(batches) == 0 || batches[len(batches)-1].Source != nil {
113+ batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1})
114+ } else {
115+ batches[len(batches)-1].Count++
116+ }
117+ out = append(out, instanceOf(q))
74118 }
75119 stats.Drawn = len(out)
76120 return out, batches, stats
77121 }
122+
123+// instanceOf converts one drawable quad to its GPU instance.
124+func instanceOf(q *Quad) vkdevice.Instance {
125+ v := vkdevice.Instance{
126+ Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)},
127+ Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)},
128+ Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)},
129+ }
130+ v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)}
131+ v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)}
132+ v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])}
133+ for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} {
134+ v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))}
135+ }
136+ switch q.Op {
137+ case "image":
138+ v.KindLayer[0] = 6
139+ v.UV = [4]float32{0, 0, 1, 1}
140+ case "border":
141+ v.KindLayer[0] = 2
142+ case "outline":
143+ v.KindLayer[0] = 3
144+ case "shadow":
145+ v.KindLayer[0] = 4
146+ if q.Inset {
147+ v.KindLayer[0] = 5
148+ }
149+ }
150+ for i, r := range q.Radii {
151+ v.Radii[i] = float32(r)
152+ }
153+ if q.Op == "glyph" {
154+ v.KindLayer = [4]float32{1, float32(q.Glyph.Page)}
155+ v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize}
156+ }
157+ return v
158+}
@@ -21,57 +21,138 @@ func ListInstances(frame Frame) ([]vkdevice.Instance, ListStats) {
21 21
22 // ListBatches groups consecutive atlas draws and isolates each image draw.22 // ListBatches groups consecutive atlas draws and isolates each image draw.
23 // First indexes the returned instances, retaining exact painter's order.23 // First indexes the returned instances, retaining exact painter's order.
24+// The returned slices and map are freshly allocated and owned by the caller.
24 func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) {25 func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) {
25- out := make([]vkdevice.Instance, 0, len(frame.Quads))26+ return listBatches(frame, nil, nil, make(map[string]int))
26- stats := ListStats{Skipped: make(map[string]int)}27+}
27- var batches []vkdevice.Batch28+
28- for _, q := range frame.Quads {29+// ListBuffer is a single-owner scratch area for repeated ListBatches calls.
29- switch q.Op {30+// The slices it returns alias its storage: they are valid only until the next
30- case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image":31+// List or Release call, and must not be retained, shared across goroutines, or
31- v := vkdevice.Instance{32+// used while another List may run. This is safe for consumers that copy the
32- Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)},33+// data synchronously (Session.tick uploads instances into GPU memory and
33- Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)},34+// resolve copies the batches before recording returns); anything that keeps the
34- Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)},35+// slices past that point must use ListBatches instead. Retained frames hold
35- }36+// only render.Frame, never these slices, so a blocked frame is re-listed on retry.
36- v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)}37+// Stats.Skipped shares the same lifetime. The zero value is ready to use.
37- v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)}38+type ListBuffer struct {
38- v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])}39+ instances []vkdevice.Instance
39- for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} {40+ batches []vkdevice.Batch
40- v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))}41+ skipped map[string]int
41- }42+}
42- switch q.Op {43+
43- case "image":44+// bufferSlack is how much larger than needed retained storage may be before
44- v.KindLayer[0] = 645+// List drops it, so one huge frame does not pin memory forever.
45- v.UV = [4]float32{0, 0, 1, 1}46+const bufferSlack = 4
46- case "border":47+
47- v.KindLayer[0] = 248+// List is ListBatches reusing this buffer's storage where capacity allows.
48- case "outline":49+func (b *ListBuffer) List(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) {
49- v.KindLayer[0] = 350+ b.Release()
50- case "shadow":51+ if b.skipped == nil {
51- v.KindLayer[0] = 452+ b.skipped = make(map[string]int)
52- if q.Inset {53+ }
53- v.KindLayer[0] = 554+ clear(b.skipped)
54- }55+ instances, batches, stats := listBatches(frame, b.instances[:0], b.batches[:0], b.skipped)
55- }56+ b.instances, b.batches = instances[:0], batches[:0]
56- for i, r := range q.Radii {57+ return instances, batches, stats
57- v.Radii[i] = float32(r)58+}
58- }59+
59- if q.Op == "glyph" {60+// Release drops borrowed image references from the previous result. Call it
60- v.KindLayer = [4]float32{1, float32(q.Glyph.Page)}61+// once the result has been consumed so an idle buffer does not pin images.
61- v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize}62+func (b *ListBuffer) Release() {
62- }63+ clear(b.batches[:cap(b.batches)])
63- if q.Op == "image" {64+}
64- batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image})65+
65- } else if len(batches) == 0 || batches[len(batches)-1].Source != nil {66+// drawn reports whether the operation draws an instance.
66- batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1})67+func drawn(op string) bool {
67- } else {68+ switch op {
68- batches[len(batches)-1].Count++69+ case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image":
69- }70+ return true
70- out = append(out, v)71+ }
71- default:72+ return false
73+}
74+
75+// listBatches fills out and batches (both length zero), growing them only when
76+// their capacity is short of the exact counts or absurdly larger than needed.
77+func listBatches(frame Frame, out []vkdevice.Instance, batches []vkdevice.Batch, skipped map[string]int) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) {
78+ // Count first so both slices are sized exactly: no append growth, and no
79+ // over-allocation for frames that skip quads.
80+ drawCount, batchCount, open := 0, 0, false
81+ for i := range frame.Quads {
82+ op := frame.Quads[i].Op
83+ if !drawn(op) {
84+ continue
85+ }
86+ drawCount++
87+ if op == "image" {
88+ batchCount++
89+ open = false
90+ } else if !open {
91+ batchCount++
92+ open = true
93+ }
94+ }
95+ if out == nil || cap(out) < drawCount || cap(out) > bufferSlack*drawCount+64 {
96+ out = make([]vkdevice.Instance, 0, drawCount)
97+ }
98+ if batchCount == 0 {
99+ batches = nil // as before: no draws, no batches
100+ } else if cap(batches) < batchCount || cap(batches) > bufferSlack*batchCount+16 {
101+ batches = make([]vkdevice.Batch, 0, batchCount)
102+ }
103+ stats := ListStats{Skipped: skipped}
104+ for i := range frame.Quads {
105+ q := &frame.Quads[i]
106+ if !drawn(q.Op) {
72 stats.Skipped[q.Op]++107 stats.Skipped[q.Op]++
108+ continue
73 }109 }
110+ if q.Op == "image" {
111+ batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image})
112+ } else if len(batches) == 0 || batches[len(batches)-1].Source != nil {
113+ batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1})
114+ } else {
115+ batches[len(batches)-1].Count++
116+ }
117+ out = append(out, instanceOf(q))
74 }118 }
75 stats.Drawn = len(out)119 stats.Drawn = len(out)
76 return out, batches, stats120 return out, batches, stats
77 }121 }
122+
123+// instanceOf converts one drawable quad to its GPU instance.
124+func instanceOf(q *Quad) vkdevice.Instance {
125+ v := vkdevice.Instance{
126+ Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)},
127+ Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)},
128+ Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)},
129+ }
130+ v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)}
131+ v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)}
132+ v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])}
133+ for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} {
134+ v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))}
135+ }
136+ switch q.Op {
137+ case "image":
138+ v.KindLayer[0] = 6
139+ v.UV = [4]float32{0, 0, 1, 1}
140+ case "border":
141+ v.KindLayer[0] = 2
142+ case "outline":
143+ v.KindLayer[0] = 3
144+ case "shadow":
145+ v.KindLayer[0] = 4
146+ if q.Inset {
147+ v.KindLayer[0] = 5
148+ }
149+ }
150+ for i, r := range q.Radii {
151+ v.Radii[i] = float32(r)
152+ }
153+ if q.Op == "glyph" {
154+ v.KindLayer = [4]float32{1, float32(q.Glyph.Page)}
155+ v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize}
156+ }
157+ return v
158+}
A internal/render/instances_bench_test.go +52 -0
@@ -0,0 +1,52 @@
1+package render
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/nefergui/internal/css"
8+ "github.com/bnema/nefergui/internal/layout"
9+ "github.com/bnema/nefergui/internal/text"
10+)
11+
12+// listFixture is a product-like mix: atlas draws with an image every 50 quads.
13+func listFixture(n int) Frame {
14+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
15+ quads := make([]Quad, n)
16+ for i := range quads {
17+ switch {
18+ case i%50 == 49:
19+ quads[i] = Quad{Op: "image", Image: img, Rect: layout.Rect{W: 4, H: 4}}
20+ case i%3 == 0:
21+ quads[i] = Quad{Op: "glyph", Glyph: text.Placement{Page: 1, Rect: image.Rect(1, 2, 9, 12)}, Color: css.Color{A: 1}}
22+ case i%7 == 0:
23+ quads[i] = Quad{Op: "border", Color: css.Color{A: 1}}
24+ default:
25+ quads[i] = Quad{Op: "rect", Rect: layout.Rect{X: float64(i), W: 3, H: 3}, Color: css.Color{A: 1}}
26+ }
27+ }
28+ return Frame{Quads: quads}
29+}
30+
31+func BenchmarkListBatches(b *testing.B) {
32+ frame := listFixture(2000)
33+ b.ReportAllocs()
34+ for i := 0; i < b.N; i++ {
35+ instances, batches, stats := ListBatches(frame)
36+ if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 {
37+ b.Fatal("list")
38+ }
39+ }
40+}
41+
42+func BenchmarkListBufferReuse(b *testing.B) {
43+ frame := listFixture(2000)
44+ var buf ListBuffer
45+ b.ReportAllocs()
46+ for i := 0; i < b.N; i++ {
47+ instances, batches, stats := buf.List(frame)
48+ if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 {
49+ b.Fatal("list")
50+ }
51+ }
52+}
@@ -0,0 +1,52 @@
1+package render
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/nefergui/internal/css"
8+ "github.com/bnema/nefergui/internal/layout"
9+ "github.com/bnema/nefergui/internal/text"
10+)
11+
12+// listFixture is a product-like mix: atlas draws with an image every 50 quads.
13+func listFixture(n int) Frame {
14+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
15+ quads := make([]Quad, n)
16+ for i := range quads {
17+ switch {
18+ case i%50 == 49:
19+ quads[i] = Quad{Op: "image", Image: img, Rect: layout.Rect{W: 4, H: 4}}
20+ case i%3 == 0:
21+ quads[i] = Quad{Op: "glyph", Glyph: text.Placement{Page: 1, Rect: image.Rect(1, 2, 9, 12)}, Color: css.Color{A: 1}}
22+ case i%7 == 0:
23+ quads[i] = Quad{Op: "border", Color: css.Color{A: 1}}
24+ default:
25+ quads[i] = Quad{Op: "rect", Rect: layout.Rect{X: float64(i), W: 3, H: 3}, Color: css.Color{A: 1}}
26+ }
27+ }
28+ return Frame{Quads: quads}
29+}
30+
31+func BenchmarkListBatches(b *testing.B) {
32+ frame := listFixture(2000)
33+ b.ReportAllocs()
34+ for i := 0; i < b.N; i++ {
35+ instances, batches, stats := ListBatches(frame)
36+ if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 {
37+ b.Fatal("list")
38+ }
39+ }
40+}
41+
42+func BenchmarkListBufferReuse(b *testing.B) {
43+ frame := listFixture(2000)
44+ var buf ListBuffer
45+ b.ReportAllocs()
46+ for i := 0; i < b.N; i++ {
47+ instances, batches, stats := buf.List(frame)
48+ if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 {
49+ b.Fatal("list")
50+ }
51+ }
52+}
M internal/render/instances_test.go +68 -0
@@ -5,6 +5,7 @@ import (
55 "github.com/bnema/nefergui/internal/layout"
66 "github.com/bnema/nefergui/internal/text"
77 "image"
8+ "reflect"
89 "testing"
910 )
1011
@@ -44,3 +45,70 @@ func TestListInstances(t *testing.T) {
4445 t.Fatalf("glyph=%+v", got[2])
4546 }
4647 }
48+
49+func TestListBufferMatchesListBatches(t *testing.T) {
50+ a := image.NewRGBA(image.Rect(0, 0, 2, 2))
51+ frames := []Frame{
52+ listFixture(200),
53+ {Quads: []Quad{{Op: "rect"}, {Op: "mystery"}, {Op: "image", Image: a}, {Op: "mystery"}, {Op: "glyph"}, {Op: "glyph"}}},
54+ {},
55+ listFixture(7),
56+ }
57+ var buf ListBuffer
58+ for round := 0; round < 2; round++ {
59+ for i, f := range frames {
60+ wi, wb, ws := ListBatches(f)
61+ gi, gb, gs := buf.List(f)
62+ if !reflect.DeepEqual(wi, gi) || !reflect.DeepEqual(wb, gb) || !reflect.DeepEqual(ws, gs) {
63+ t.Fatalf("round %d frame %d differs:\nwant %v %v %v\ngot %v %v %v", round, i, len(wi), wb, ws, len(gi), gb, gs)
64+ }
65+ }
66+ }
67+}
68+
69+// A result stays valid until the next List; a later smaller frame must not
70+// leave stale instances, batches, image references or skip counts visible.
71+func TestListBufferReuseLifetime(t *testing.T) {
72+ a := image.NewRGBA(image.Rect(0, 0, 2, 2))
73+ var buf ListBuffer
74+ i1, b1, s1 := buf.List(Frame{Quads: []Quad{{Op: "rect"}, {Op: "image", Image: a}, {Op: "nope"}}})
75+ if len(i1) != 2 || len(b1) != 2 || s1.Skipped["nope"] != 1 {
76+ t.Fatalf("first: %v %v %v", i1, b1, s1)
77+ }
78+ i2, b2, s2 := buf.List(Frame{Quads: []Quad{{Op: "glyph"}}})
79+ if len(i2) != 1 || len(b2) != 1 || b2[0].Source != nil || len(s2.Skipped) != 0 || i2[0].KindLayer[0] != 1 {
80+ t.Fatalf("second: %v %v %v", i2, b2, s2)
81+ }
82+ buf.Release()
83+ for _, batch := range b2[:cap(b2)] {
84+ if batch.Source != nil {
85+ t.Fatal("Release left an image reference")
86+ }
87+ }
88+ // Results from the non-buffer API never alias the buffer.
89+ fi, fb, _ := ListBatches(Frame{Quads: []Quad{{Op: "rect"}}})
90+ buf.List(listFixture(50))
91+ if fi[0].KindLayer != [4]float32{} || len(fb) != 1 || fb[0].Count != 1 {
92+ t.Fatalf("ListBatches result aliased: %v %v", fi, fb)
93+ }
94+}
95+
96+func TestListBufferDropsOversizedStorage(t *testing.T) {
97+ var buf ListBuffer
98+ buf.List(listFixture(20000))
99+ if _, _, _ = buf.List(listFixture(10)); cap(buf.instances) > 4*10+64 {
100+ t.Fatalf("retained %d instances for 10 quads", cap(buf.instances))
101+ }
102+}
103+
104+func TestListBatchesAllocations(t *testing.T) {
105+ frame := listFixture(2000)
106+ if n := testing.AllocsPerRun(20, func() { ListBatches(frame) }); n > 4 {
107+ t.Fatalf("ListBatches allocs=%v, want <= 4 (instances, batches, skipped map)", n)
108+ }
109+ var buf ListBuffer
110+ buf.List(frame)
111+ if n := testing.AllocsPerRun(20, func() { buf.List(frame) }); n != 0 {
112+ t.Fatalf("ListBuffer.List allocs=%v, want 0 after warmup", n)
113+ }
114+}
@@ -5,6 +5,7 @@ import (
5 "github.com/bnema/nefergui/internal/layout"5 "github.com/bnema/nefergui/internal/layout"
6 "github.com/bnema/nefergui/internal/text"6 "github.com/bnema/nefergui/internal/text"
7 "image"7 "image"
8+ "reflect"
8 "testing"9 "testing"
9 )10 )
10 11
@@ -44,3 +45,70 @@ func TestListInstances(t *testing.T) {
44 t.Fatalf("glyph=%+v", got[2])45 t.Fatalf("glyph=%+v", got[2])
45 }46 }
46 }47 }
48+
49+func TestListBufferMatchesListBatches(t *testing.T) {
50+ a := image.NewRGBA(image.Rect(0, 0, 2, 2))
51+ frames := []Frame{
52+ listFixture(200),
53+ {Quads: []Quad{{Op: "rect"}, {Op: "mystery"}, {Op: "image", Image: a}, {Op: "mystery"}, {Op: "glyph"}, {Op: "glyph"}}},
54+ {},
55+ listFixture(7),
56+ }
57+ var buf ListBuffer
58+ for round := 0; round < 2; round++ {
59+ for i, f := range frames {
60+ wi, wb, ws := ListBatches(f)
61+ gi, gb, gs := buf.List(f)
62+ if !reflect.DeepEqual(wi, gi) || !reflect.DeepEqual(wb, gb) || !reflect.DeepEqual(ws, gs) {
63+ t.Fatalf("round %d frame %d differs:\nwant %v %v %v\ngot %v %v %v", round, i, len(wi), wb, ws, len(gi), gb, gs)
64+ }
65+ }
66+ }
67+}
68+
69+// A result stays valid until the next List; a later smaller frame must not
70+// leave stale instances, batches, image references or skip counts visible.
71+func TestListBufferReuseLifetime(t *testing.T) {
72+ a := image.NewRGBA(image.Rect(0, 0, 2, 2))
73+ var buf ListBuffer
74+ i1, b1, s1 := buf.List(Frame{Quads: []Quad{{Op: "rect"}, {Op: "image", Image: a}, {Op: "nope"}}})
75+ if len(i1) != 2 || len(b1) != 2 || s1.Skipped["nope"] != 1 {
76+ t.Fatalf("first: %v %v %v", i1, b1, s1)
77+ }
78+ i2, b2, s2 := buf.List(Frame{Quads: []Quad{{Op: "glyph"}}})
79+ if len(i2) != 1 || len(b2) != 1 || b2[0].Source != nil || len(s2.Skipped) != 0 || i2[0].KindLayer[0] != 1 {
80+ t.Fatalf("second: %v %v %v", i2, b2, s2)
81+ }
82+ buf.Release()
83+ for _, batch := range b2[:cap(b2)] {
84+ if batch.Source != nil {
85+ t.Fatal("Release left an image reference")
86+ }
87+ }
88+ // Results from the non-buffer API never alias the buffer.
89+ fi, fb, _ := ListBatches(Frame{Quads: []Quad{{Op: "rect"}}})
90+ buf.List(listFixture(50))
91+ if fi[0].KindLayer != [4]float32{} || len(fb) != 1 || fb[0].Count != 1 {
92+ t.Fatalf("ListBatches result aliased: %v %v", fi, fb)
93+ }
94+}
95+
96+func TestListBufferDropsOversizedStorage(t *testing.T) {
97+ var buf ListBuffer
98+ buf.List(listFixture(20000))
99+ if _, _, _ = buf.List(listFixture(10)); cap(buf.instances) > 4*10+64 {
100+ t.Fatalf("retained %d instances for 10 quads", cap(buf.instances))
101+ }
102+}
103+
104+func TestListBatchesAllocations(t *testing.T) {
105+ frame := listFixture(2000)
106+ if n := testing.AllocsPerRun(20, func() { ListBatches(frame) }); n > 4 {
107+ t.Fatalf("ListBatches allocs=%v, want <= 4 (instances, batches, skipped map)", n)
108+ }
109+ var buf ListBuffer
110+ buf.List(frame)
111+ if n := testing.AllocsPerRun(20, func() { buf.List(frame) }); n != 0 {
112+ t.Fatalf("ListBuffer.List allocs=%v, want 0 after warmup", n)
113+ }
114+}
M internal/render/prepare.go +24 -0
@@ -74,6 +74,25 @@ func scaled(e layout.Edges, scale float64) layout.Edges {
7474 return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale}
7575 }
7676
77+// quadCapacity is an upper bound on the quads Prepare can emit: one per glyph
78+// and one per drawable command. Clipped-out primitives make it a slight
79+// over-estimate, never an under-estimate, so appends do not reallocate.
80+func quadCapacity(commands []layout.Command) int {
81+ n := 0
82+ for i := range commands {
83+ switch cmd := &commands[i]; cmd.Op {
84+ case "clip-push", "clip-pop":
85+ case "text", "preedit-text":
86+ for j := range cmd.Runs {
87+ n += len(cmd.Runs[j].Glyphs)
88+ }
89+ default:
90+ n++
91+ }
92+ }
93+ return n
94+}
95+
7796 // Prepare converts logical rectangles once, using layout.Physical as the sole
7897 // logical-to-physical edge-rounding boundary. A clip-push intersects its parent
7998 // clip; rounded clips intentionally use only their axis-aligned bounds in V1.
@@ -86,6 +105,11 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig
86105 }
87106 p.Atlas.BeginFrame()
88107 frame := Frame{}
108+ if n := quadCapacity(commands); n > 0 {
109+ // Frames are returned detached and may outlive later Prepare calls, so
110+ // the backing array is never reused; size it once instead of regrowing.
111+ frame.Quads = make([]Quad, 0, n)
112+ }
89113 clip := layout.Rect{W: float64(width), H: float64(height)}
90114 stack := []layout.Rect{}
91115 for _, cmd := range commands {
@@ -74,6 +74,25 @@ func scaled(e layout.Edges, scale float64) layout.Edges {
74 return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale}74 return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale}
75 }75 }
76 76
77+// quadCapacity is an upper bound on the quads Prepare can emit: one per glyph
78+// and one per drawable command. Clipped-out primitives make it a slight
79+// over-estimate, never an under-estimate, so appends do not reallocate.
80+func quadCapacity(commands []layout.Command) int {
81+ n := 0
82+ for i := range commands {
83+ switch cmd := &commands[i]; cmd.Op {
84+ case "clip-push", "clip-pop":
85+ case "text", "preedit-text":
86+ for j := range cmd.Runs {
87+ n += len(cmd.Runs[j].Glyphs)
88+ }
89+ default:
90+ n++
91+ }
92+ }
93+ return n
94+}
95+
77 // Prepare converts logical rectangles once, using layout.Physical as the sole96 // Prepare converts logical rectangles once, using layout.Physical as the sole
78 // logical-to-physical edge-rounding boundary. A clip-push intersects its parent97 // logical-to-physical edge-rounding boundary. A clip-push intersects its parent
79 // clip; rounded clips intentionally use only their axis-aligned bounds in V1.98 // clip; rounded clips intentionally use only their axis-aligned bounds in V1.
@@ -86,6 +105,11 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig
86 }105 }
87 p.Atlas.BeginFrame()106 p.Atlas.BeginFrame()
88 frame := Frame{}107 frame := Frame{}
108+ if n := quadCapacity(commands); n > 0 {
109+ // Frames are returned detached and may outlive later Prepare calls, so
110+ // the backing array is never reused; size it once instead of regrowing.
111+ frame.Quads = make([]Quad, 0, n)
112+ }
89 clip := layout.Rect{W: float64(width), H: float64(height)}113 clip := layout.Rect{W: float64(width), H: float64(height)}
90 stack := []layout.Rect{}114 stack := []layout.Rect{}
91 for _, cmd := range commands {115 for _, cmd := range commands {
A internal/render/prepare_alloc_test.go +140 -0
@@ -0,0 +1,140 @@
1+package render
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefergui/internal/css"
7+ "github.com/bnema/nefergui/internal/layout"
8+)
9+
10+func rectCommands(n int) []layout.Command {
11+ cmds := make([]layout.Command, n)
12+ for i := range cmds {
13+ cmds[i] = layout.Command{Op: "rect", Rect: layout.Rect{X: 1, Y: float64(i), W: 10, H: 1}, Color: css.Color{A: 1}, Opacity: 1}
14+ }
15+ return cmds
16+}
17+
18+func TestQuadCapacityIsSufficientAndExactWithoutCulling(t *testing.T) {
19+ glyphs := glyphCommand(t, "office")
20+ n := 0
21+ for _, r := range glyphs.Runs {
22+ n += len(r.Glyphs)
23+ }
24+ cmds := []layout.Command{{Op: "clip-push", Rect: layout.Rect{W: 200, H: 100}}, rectCommands(1)[0], glyphs, {Op: "clip-pop"}}
25+ if got := quadCapacity(cmds); got != n+1 {
26+ t.Fatalf("capacity %d, want %d", got, n+1)
27+ }
28+ p, err := NewPreparer(256, 2)
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ f, err := p.Prepare(cmds, 1, 200, 100)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if len(f.Quads) == 0 || len(f.Quads) > n+1 || cap(f.Quads) != n+1 {
37+ t.Fatalf("len=%d cap=%d, want cap %d", len(f.Quads), cap(f.Quads), n+1)
38+ }
39+}
40+
41+// Frames are detached: a later Prepare must not alias or overwrite an earlier
42+// frame that is still queued, blocked or superseded.
43+func TestSuccessiveFramesAreDetached(t *testing.T) {
44+ p, err := NewPreparer(256, 2)
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ a, err := p.Prepare(rectCommands(4), 1, 100, 100)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ want := append([]Quad(nil), a.Quads...)
53+ b, err := p.Prepare(rectCommands(3), 2, 100, 100)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if len(a.Quads) != 4 || len(b.Quads) != 3 || &a.Quads[0] == &b.Quads[0] {
58+ t.Fatalf("frames alias: %d %d", len(a.Quads), len(b.Quads))
59+ }
60+ for i := range want {
61+ if a.Quads[i] != want[i] {
62+ t.Fatalf("earlier frame quad %d overwritten", i)
63+ }
64+ }
65+ // Appending to a returned frame must not write into another frame.
66+ b.Quads = append(b.Quads[:len(b.Quads):len(b.Quads)], Quad{Op: "x"})
67+ if a.Quads[0] != want[0] {
68+ t.Fatal("append leaked into earlier frame")
69+ }
70+}
71+
72+func TestFailedPrepareReturnsNoPartialFrame(t *testing.T) {
73+ p, err := NewPreparer(256, 2)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ cmds := append(rectCommands(8), layout.Command{Op: "bogus"})
78+ f, err := p.Prepare(cmds, 1, 100, 100)
79+ if err == nil || f.Quads != nil || f.Uploads != nil {
80+ t.Fatalf("partial frame on failure: %v %+v", err, f)
81+ }
82+ if f, err = p.Prepare(nil, 1, 100, 100); err != nil || f.Quads != nil {
83+ t.Fatalf("empty frame: %v %+v", err, f)
84+ }
85+}
86+
87+// Preallocation must not disturb owed-upload accounting across frames whose
88+// quads were sized differently.
89+func TestPreallocatedFramesKeepUploadDebt(t *testing.T) {
90+ cmd := glyphCommand(t, "office")
91+ p, err := NewPreparer(256, 2)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ first, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
96+ if err != nil || len(first.Uploads) == 0 {
97+ t.Fatalf("first: %v %d", err, len(first.Uploads))
98+ }
99+ // Superseding frame with no glyphs still resends the owed pages.
100+ second, err := p.Prepare(rectCommands(2), 1, 200, 100)
101+ if err != nil || len(second.Uploads) == 0 {
102+ t.Fatalf("debt lost: %v %d", err, len(second.Uploads))
103+ }
104+ p.Submitted(first.Uploads)
105+ if f, err := p.Prepare(rectCommands(2), 1, 200, 100); err != nil || len(f.Uploads) == 0 {
106+ t.Fatalf("stale ack cleared debt: %v %d", err, len(f.Uploads))
107+ }
108+}
109+
110+// Box-only frames must cost one Quads allocation regardless of command count.
111+func TestPrepareQuadAllocationsDoNotScaleWithCommands(t *testing.T) {
112+ p, err := NewPreparer(256, 2)
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ cmds := rectCommands(512)
117+ allocs := testing.AllocsPerRun(20, func() {
118+ if _, err := p.Prepare(cmds, 1, 100, 1000); err != nil {
119+ t.Fatal(err)
120+ }
121+ })
122+ if allocs > 3 {
123+ t.Fatalf("%v allocs for 512 rects, want a small constant", allocs)
124+ }
125+}
126+
127+func BenchmarkPrepareBoxes(b *testing.B) {
128+ p, err := NewPreparer(256, 2)
129+ if err != nil {
130+ b.Fatal(err)
131+ }
132+ cmds := rectCommands(512)
133+ b.ReportAllocs()
134+ b.ResetTimer()
135+ for i := 0; i < b.N; i++ {
136+ if f, err := p.Prepare(cmds, 1, 100, 1000); err != nil || len(f.Quads) != 512 {
137+ b.Fatal(err)
138+ }
139+ }
140+}
@@ -0,0 +1,140 @@
1+package render
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefergui/internal/css"
7+ "github.com/bnema/nefergui/internal/layout"
8+)
9+
10+func rectCommands(n int) []layout.Command {
11+ cmds := make([]layout.Command, n)
12+ for i := range cmds {
13+ cmds[i] = layout.Command{Op: "rect", Rect: layout.Rect{X: 1, Y: float64(i), W: 10, H: 1}, Color: css.Color{A: 1}, Opacity: 1}
14+ }
15+ return cmds
16+}
17+
18+func TestQuadCapacityIsSufficientAndExactWithoutCulling(t *testing.T) {
19+ glyphs := glyphCommand(t, "office")
20+ n := 0
21+ for _, r := range glyphs.Runs {
22+ n += len(r.Glyphs)
23+ }
24+ cmds := []layout.Command{{Op: "clip-push", Rect: layout.Rect{W: 200, H: 100}}, rectCommands(1)[0], glyphs, {Op: "clip-pop"}}
25+ if got := quadCapacity(cmds); got != n+1 {
26+ t.Fatalf("capacity %d, want %d", got, n+1)
27+ }
28+ p, err := NewPreparer(256, 2)
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ f, err := p.Prepare(cmds, 1, 200, 100)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if len(f.Quads) == 0 || len(f.Quads) > n+1 || cap(f.Quads) != n+1 {
37+ t.Fatalf("len=%d cap=%d, want cap %d", len(f.Quads), cap(f.Quads), n+1)
38+ }
39+}
40+
41+// Frames are detached: a later Prepare must not alias or overwrite an earlier
42+// frame that is still queued, blocked or superseded.
43+func TestSuccessiveFramesAreDetached(t *testing.T) {
44+ p, err := NewPreparer(256, 2)
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ a, err := p.Prepare(rectCommands(4), 1, 100, 100)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ want := append([]Quad(nil), a.Quads...)
53+ b, err := p.Prepare(rectCommands(3), 2, 100, 100)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if len(a.Quads) != 4 || len(b.Quads) != 3 || &a.Quads[0] == &b.Quads[0] {
58+ t.Fatalf("frames alias: %d %d", len(a.Quads), len(b.Quads))
59+ }
60+ for i := range want {
61+ if a.Quads[i] != want[i] {
62+ t.Fatalf("earlier frame quad %d overwritten", i)
63+ }
64+ }
65+ // Appending to a returned frame must not write into another frame.
66+ b.Quads = append(b.Quads[:len(b.Quads):len(b.Quads)], Quad{Op: "x"})
67+ if a.Quads[0] != want[0] {
68+ t.Fatal("append leaked into earlier frame")
69+ }
70+}
71+
72+func TestFailedPrepareReturnsNoPartialFrame(t *testing.T) {
73+ p, err := NewPreparer(256, 2)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ cmds := append(rectCommands(8), layout.Command{Op: "bogus"})
78+ f, err := p.Prepare(cmds, 1, 100, 100)
79+ if err == nil || f.Quads != nil || f.Uploads != nil {
80+ t.Fatalf("partial frame on failure: %v %+v", err, f)
81+ }
82+ if f, err = p.Prepare(nil, 1, 100, 100); err != nil || f.Quads != nil {
83+ t.Fatalf("empty frame: %v %+v", err, f)
84+ }
85+}
86+
87+// Preallocation must not disturb owed-upload accounting across frames whose
88+// quads were sized differently.
89+func TestPreallocatedFramesKeepUploadDebt(t *testing.T) {
90+ cmd := glyphCommand(t, "office")
91+ p, err := NewPreparer(256, 2)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ first, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
96+ if err != nil || len(first.Uploads) == 0 {
97+ t.Fatalf("first: %v %d", err, len(first.Uploads))
98+ }
99+ // Superseding frame with no glyphs still resends the owed pages.
100+ second, err := p.Prepare(rectCommands(2), 1, 200, 100)
101+ if err != nil || len(second.Uploads) == 0 {
102+ t.Fatalf("debt lost: %v %d", err, len(second.Uploads))
103+ }
104+ p.Submitted(first.Uploads)
105+ if f, err := p.Prepare(rectCommands(2), 1, 200, 100); err != nil || len(f.Uploads) == 0 {
106+ t.Fatalf("stale ack cleared debt: %v %d", err, len(f.Uploads))
107+ }
108+}
109+
110+// Box-only frames must cost one Quads allocation regardless of command count.
111+func TestPrepareQuadAllocationsDoNotScaleWithCommands(t *testing.T) {
112+ p, err := NewPreparer(256, 2)
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ cmds := rectCommands(512)
117+ allocs := testing.AllocsPerRun(20, func() {
118+ if _, err := p.Prepare(cmds, 1, 100, 1000); err != nil {
119+ t.Fatal(err)
120+ }
121+ })
122+ if allocs > 3 {
123+ t.Fatalf("%v allocs for 512 rects, want a small constant", allocs)
124+ }
125+}
126+
127+func BenchmarkPrepareBoxes(b *testing.B) {
128+ p, err := NewPreparer(256, 2)
129+ if err != nil {
130+ b.Fatal(err)
131+ }
132+ cmds := rectCommands(512)
133+ b.ReportAllocs()
134+ b.ResetTimer()
135+ for i := 0; i < b.N; i++ {
136+ if f, err := p.Prepare(cmds, 1, 100, 1000); err != nil || len(f.Quads) != 512 {
137+ b.Fatal(err)
138+ }
139+ }
140+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close