detached 7ad3316

fix(layout): preserve natural flex sizes and empty editor line metrics

commit
7ad3316fd1add28c1507e28c36883afaeaaad434
author
brice · · 2026-09-30 05:51 UTC
parent
a510003
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +216 -5
M examples/demo/app.css +2 -2
@@ -50,8 +50,8 @@ button.menu-item.active { background: var(--selected); font-weight: 600; }
5050 button.theme-toggle { width: 104px; font-size: 13px; }
5151 main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; }
5252 row.workspace { height: 100%; align-items: stretch; }
53-column.editor-pane { flex-grow: 1; min-width: 0; background: var(--surface); }
54-row.pane-bar { height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); }
53+column.editor-pane { flex-grow: 1; flex-basis: 0px; min-width: 0; background: var(--surface); }
54+row.pane-bar { width: 100%; height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); }
5555 text.document-title { font-size: 13px; font-weight: 600; }
5656 textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; }
5757 aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); }
@@ -50,8 +50,8 @@ button.menu-item.active { background: var(--selected); font-weight: 600; }
50 button.theme-toggle { width: 104px; font-size: 13px; }50 button.theme-toggle { width: 104px; font-size: 13px; }
51 main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; }51 main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; }
52 row.workspace { height: 100%; align-items: stretch; }52 row.workspace { height: 100%; align-items: stretch; }
53-column.editor-pane { flex-grow: 1; min-width: 0; background: var(--surface); }53+column.editor-pane { flex-grow: 1; flex-basis: 0px; min-width: 0; background: var(--surface); }
54-row.pane-bar { height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); }54+row.pane-bar { width: 100%; height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); }
55 text.document-title { font-size: 13px; font-weight: 600; }55 text.document-title { font-size: 13px; font-weight: 600; }
56 textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; }56 textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; }
57 aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); }57 aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); }
A internal/layout/intrinsic_container_test.go +56 -0
@@ -0,0 +1,56 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefergui/internal/css"
7+)
8+
9+func TestAutoFlexColumnReservesChildrenAndGaps(t *testing.T) {
10+ leaf := func(id string) *Node {
11+ return node(id, Box, css.Style{Display: css.KeywordBlock, Height: px(20), Width: px(80)})
12+ }
13+ column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, RowGap: px(8)}, leaf("one"), leaf("two"))
14+ out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(200)}, column), Options{Width: 300, Height: 200})
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ got := out.Tree.Children[0]
19+ if got.Content.H != 48 || got.Children[1].Border.Y != 28 {
20+ t.Fatalf("column geometry: %+v", got)
21+ }
22+}
23+
24+func TestNestedFlexPercentWidthUsesNaturalSize(t *testing.T) {
25+ leaf := node("leaf", Box, css.Style{Display: css.KeywordBlock, Width: px(80), Height: px(24)})
26+ percent := node("percent", Box, css.Style{Display: css.KeywordBlock, Width: css.Length{Value: 100, Unit: "%"}}, leaf)
27+ nested := node("nested", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn}, percent)
28+ out, err := Layout(node("root", Row, css.Style{Display: css.KeywordFlex, Width: px(300), Height: px(100)}, nested), Options{Width: 300, Height: 100})
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ if got := out.Tree.Children[0].Border.W; got != 80 {
33+ t.Fatalf("nested width=%g", got)
34+ }
35+}
36+
37+func TestAutoFlexHeightBoundsBeforeDistribution(t *testing.T) {
38+ for _, tc := range []struct {
39+ name string
40+ min, max css.Length
41+ child, want float64
42+ }{{"minimum", px(200), css.Length{}, 0, 200}, {"maximum", css.Length{}, px(40), 100, 40}} {
43+ t.Run(tc.name, func(t *testing.T) {
44+ child := node("child", Box, css.Style{Display: css.KeywordBlock, Height: px(tc.child), FlexGrow: 1, FlexShrink: 1})
45+ column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, MinHeight: tc.min, MaxHeight: tc.max}, child)
46+ out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(300)}, column), Options{Width: 300, Height: 300})
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ got := out.Tree.Children[0]
51+ if got.Border.H != tc.want || got.Children[0].Border.H != tc.want {
52+ t.Fatalf("container=%g child=%g want=%g", got.Border.H, got.Children[0].Border.H, tc.want)
53+ }
54+ })
55+ }
56+}
@@ -0,0 +1,56 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefergui/internal/css"
7+)
8+
9+func TestAutoFlexColumnReservesChildrenAndGaps(t *testing.T) {
10+ leaf := func(id string) *Node {
11+ return node(id, Box, css.Style{Display: css.KeywordBlock, Height: px(20), Width: px(80)})
12+ }
13+ column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, RowGap: px(8)}, leaf("one"), leaf("two"))
14+ out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(200)}, column), Options{Width: 300, Height: 200})
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ got := out.Tree.Children[0]
19+ if got.Content.H != 48 || got.Children[1].Border.Y != 28 {
20+ t.Fatalf("column geometry: %+v", got)
21+ }
22+}
23+
24+func TestNestedFlexPercentWidthUsesNaturalSize(t *testing.T) {
25+ leaf := node("leaf", Box, css.Style{Display: css.KeywordBlock, Width: px(80), Height: px(24)})
26+ percent := node("percent", Box, css.Style{Display: css.KeywordBlock, Width: css.Length{Value: 100, Unit: "%"}}, leaf)
27+ nested := node("nested", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn}, percent)
28+ out, err := Layout(node("root", Row, css.Style{Display: css.KeywordFlex, Width: px(300), Height: px(100)}, nested), Options{Width: 300, Height: 100})
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ if got := out.Tree.Children[0].Border.W; got != 80 {
33+ t.Fatalf("nested width=%g", got)
34+ }
35+}
36+
37+func TestAutoFlexHeightBoundsBeforeDistribution(t *testing.T) {
38+ for _, tc := range []struct {
39+ name string
40+ min, max css.Length
41+ child, want float64
42+ }{{"minimum", px(200), css.Length{}, 0, 200}, {"maximum", css.Length{}, px(40), 100, 40}} {
43+ t.Run(tc.name, func(t *testing.T) {
44+ child := node("child", Box, css.Style{Display: css.KeywordBlock, Height: px(tc.child), FlexGrow: 1, FlexShrink: 1})
45+ column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, MinHeight: tc.min, MaxHeight: tc.max}, child)
46+ out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(300)}, column), Options{Width: 300, Height: 300})
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ got := out.Tree.Children[0]
51+ if got.Border.H != tc.want || got.Children[0].Border.H != tc.want {
52+ t.Fatalf("container=%g child=%g want=%g", got.Border.H, got.Children[0].Border.H, tc.want)
53+ }
54+ })
55+ }
56+}
M internal/layout/layout.go +92 -3
@@ -227,6 +227,13 @@ func boxBound(border float64, minL, maxL css.Length, basis, insets float64, bord
227227 return safe(bounded(safe(border-insets), minL, maxL, basis) + insets)
228228 }
229229 func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) {
230+ return c.intrinsicDepth(n, w, 0)
231+}
232+
233+func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Line, error) {
234+ if depth > 256 {
235+ return Size{}, nil, ErrDepth
236+ }
230237 if n.Measure != nil {
231238 s, lines, err := n.Measure(w)
232239 if err != nil {
@@ -248,7 +255,13 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) {
248255 if req.Size == 0 {
249256 req.Size = 16
250257 }
251- l, err := c.options.TextEngine.Measure(n.Content, req, w)
258+ content := n.Content
259+ if content == "" && n.EditorScroll {
260+ // Preserve one font-metric line for empty editors, caret geometry
261+ // and IME composition without painting a visible glyph.
262+ content = " "
263+ }
264+ l, err := c.options.TextEngine.Measure(content, req, w)
252265 if err != nil {
253266 return Size{}, nil, err
254267 }
@@ -282,7 +295,71 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) {
282295 }
283296 return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil
284297 }
285- return Size{}, nil, nil
298+ if depth == 0 {
299+ return Size{}, nil, nil
300+ }
301+ // Containers contribute their children to auto flex sizing. Measuring them
302+ // as zero compresses nested columns and makes empty editors collapse.
303+ st := style(n)
304+ row := st.Display == css.KeywordFlex && st.FlexDirection != css.KeywordColumn
305+ gap := positive(st.RowGap, w)
306+ if row {
307+ gap = positive(st.ColumnGap, w)
308+ }
309+ var size Size
310+ count := 0
311+ for _, child := range n.Children {
312+ if child == nil || style(child).Display == css.KeywordNone {
313+ continue
314+ }
315+ cs := style(child)
316+ inW, inH := flexInsets(cs, w)
317+ measured, _, err := c.intrinsicDepth(child, safe(w-inW), depth+1)
318+ if err != nil {
319+ return Size{}, nil, err
320+ }
321+ cw, ch := measured.W+inW, measured.H+inH
322+ if value, ok := length(cs.Width, w); ok && cs.Width.Unit != "%" {
323+ cw = value
324+ if cs.BoxSizing != css.KeywordBorderBox {
325+ cw += inW
326+ }
327+ }
328+ // Percent heights need a definite containing height, unavailable here.
329+ if cs.Height.Unit != "%" {
330+ if value, ok := length(cs.Height, 0); ok {
331+ ch = value
332+ if cs.BoxSizing != css.KeywordBorderBox {
333+ ch += inH
334+ }
335+ }
336+ }
337+ cw = boxBound(cw, cs.MinWidth, cs.MaxWidth, w, inW, cs.BoxSizing == css.KeywordBorderBox)
338+ ch = boxBound(ch, cs.MinHeight, cs.MaxHeight, 0, inH, cs.BoxSizing == css.KeywordBorderBox)
339+ // Natural measurement is an unwrapped envelope; actual placement
340+ // distributes flex-basis and wraps against the definite container size.
341+ margin := edges(cs.Margin, w)
342+ cw += margin.horizontal()
343+ ch += margin.vertical()
344+ if row {
345+ if count > 0 {
346+ size.W += gap
347+ }
348+ size.W += cw
349+ size.H = math.Max(size.H, ch)
350+ } else if n.Kind == Stack {
351+ size.W = math.Max(size.W, cw)
352+ size.H = math.Max(size.H, ch)
353+ } else {
354+ if count > 0 && st.Display == css.KeywordFlex {
355+ size.H += gap
356+ }
357+ size.W = math.Max(size.W, cw)
358+ size.H += ch
359+ }
360+ count++
361+ }
362+ return size, nil, nil
286363 }
287364 func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) {
288365 if depth > 256 {
@@ -382,6 +459,18 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f
382459 // Block and stack auto heights depend on their children's envelopes.
383460 flex := s.Display == css.KeywordFlex
384461 if flex {
462+ // An auto-height flex container needs its children's natural height
463+ // before distributing space. Otherwise every child is shrunk into zero.
464+ // This repeats subtree measurement for nested auto-height containers;
465+ // a node/width map cache cost more time and memory in our benchmarks.
466+ if !explicitH {
467+ natural, _, measureErr := c.intrinsicDepth(n, contentW, 1)
468+ if measureErr != nil {
469+ return nil, measureErr
470+ }
471+ r.Border.H = boxBound(math.Max(natural.H+insetH, h), s.MinHeight, s.MaxHeight, availableH, insetH, borderBox)
472+ r.Content.H = safe(r.Border.H - insetH)
473+ }
385474 err = c.flex(n, r, s, depth)
386475 } else {
387476 err = c.children(n, r, s, depth)
@@ -627,7 +716,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
627716 } else {
628717 measureWidth = crossSize
629718 }
630- measured, _, err := c.intrinsic(ch, measureWidth)
719+ measured, _, err := c.intrinsicDepth(ch, measureWidth, 1)
631720 if err != nil {
632721 return err
633722 }
@@ -227,6 +227,13 @@ func boxBound(border float64, minL, maxL css.Length, basis, insets float64, bord
227 return safe(bounded(safe(border-insets), minL, maxL, basis) + insets)227 return safe(bounded(safe(border-insets), minL, maxL, basis) + insets)
228 }228 }
229 func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) {229 func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) {
230+ return c.intrinsicDepth(n, w, 0)
231+}
232+
233+func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Line, error) {
234+ if depth > 256 {
235+ return Size{}, nil, ErrDepth
236+ }
230 if n.Measure != nil {237 if n.Measure != nil {
231 s, lines, err := n.Measure(w)238 s, lines, err := n.Measure(w)
232 if err != nil {239 if err != nil {
@@ -248,7 +255,13 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) {
248 if req.Size == 0 {255 if req.Size == 0 {
249 req.Size = 16256 req.Size = 16
250 }257 }
251- l, err := c.options.TextEngine.Measure(n.Content, req, w)258+ content := n.Content
259+ if content == "" && n.EditorScroll {
260+ // Preserve one font-metric line for empty editors, caret geometry
261+ // and IME composition without painting a visible glyph.
262+ content = " "
263+ }
264+ l, err := c.options.TextEngine.Measure(content, req, w)
252 if err != nil {265 if err != nil {
253 return Size{}, nil, err266 return Size{}, nil, err
254 }267 }
@@ -282,7 +295,71 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) {
282 }295 }
283 return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil296 return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil
284 }297 }
285- return Size{}, nil, nil298+ if depth == 0 {
299+ return Size{}, nil, nil
300+ }
301+ // Containers contribute their children to auto flex sizing. Measuring them
302+ // as zero compresses nested columns and makes empty editors collapse.
303+ st := style(n)
304+ row := st.Display == css.KeywordFlex && st.FlexDirection != css.KeywordColumn
305+ gap := positive(st.RowGap, w)
306+ if row {
307+ gap = positive(st.ColumnGap, w)
308+ }
309+ var size Size
310+ count := 0
311+ for _, child := range n.Children {
312+ if child == nil || style(child).Display == css.KeywordNone {
313+ continue
314+ }
315+ cs := style(child)
316+ inW, inH := flexInsets(cs, w)
317+ measured, _, err := c.intrinsicDepth(child, safe(w-inW), depth+1)
318+ if err != nil {
319+ return Size{}, nil, err
320+ }
321+ cw, ch := measured.W+inW, measured.H+inH
322+ if value, ok := length(cs.Width, w); ok && cs.Width.Unit != "%" {
323+ cw = value
324+ if cs.BoxSizing != css.KeywordBorderBox {
325+ cw += inW
326+ }
327+ }
328+ // Percent heights need a definite containing height, unavailable here.
329+ if cs.Height.Unit != "%" {
330+ if value, ok := length(cs.Height, 0); ok {
331+ ch = value
332+ if cs.BoxSizing != css.KeywordBorderBox {
333+ ch += inH
334+ }
335+ }
336+ }
337+ cw = boxBound(cw, cs.MinWidth, cs.MaxWidth, w, inW, cs.BoxSizing == css.KeywordBorderBox)
338+ ch = boxBound(ch, cs.MinHeight, cs.MaxHeight, 0, inH, cs.BoxSizing == css.KeywordBorderBox)
339+ // Natural measurement is an unwrapped envelope; actual placement
340+ // distributes flex-basis and wraps against the definite container size.
341+ margin := edges(cs.Margin, w)
342+ cw += margin.horizontal()
343+ ch += margin.vertical()
344+ if row {
345+ if count > 0 {
346+ size.W += gap
347+ }
348+ size.W += cw
349+ size.H = math.Max(size.H, ch)
350+ } else if n.Kind == Stack {
351+ size.W = math.Max(size.W, cw)
352+ size.H = math.Max(size.H, ch)
353+ } else {
354+ if count > 0 && st.Display == css.KeywordFlex {
355+ size.H += gap
356+ }
357+ size.W = math.Max(size.W, cw)
358+ size.H += ch
359+ }
360+ count++
361+ }
362+ return size, nil, nil
286 }363 }
287 func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) {364 func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) {
288 if depth > 256 {365 if depth > 256 {
@@ -382,6 +459,18 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f
382 // Block and stack auto heights depend on their children's envelopes.459 // Block and stack auto heights depend on their children's envelopes.
383 flex := s.Display == css.KeywordFlex460 flex := s.Display == css.KeywordFlex
384 if flex {461 if flex {
462+ // An auto-height flex container needs its children's natural height
463+ // before distributing space. Otherwise every child is shrunk into zero.
464+ // This repeats subtree measurement for nested auto-height containers;
465+ // a node/width map cache cost more time and memory in our benchmarks.
466+ if !explicitH {
467+ natural, _, measureErr := c.intrinsicDepth(n, contentW, 1)
468+ if measureErr != nil {
469+ return nil, measureErr
470+ }
471+ r.Border.H = boxBound(math.Max(natural.H+insetH, h), s.MinHeight, s.MaxHeight, availableH, insetH, borderBox)
472+ r.Content.H = safe(r.Border.H - insetH)
473+ }
385 err = c.flex(n, r, s, depth)474 err = c.flex(n, r, s, depth)
386 } else {475 } else {
387 err = c.children(n, r, s, depth)476 err = c.children(n, r, s, depth)
@@ -627,7 +716,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
627 } else {716 } else {
628 measureWidth = crossSize717 measureWidth = crossSize
629 }718 }
630- measured, _, err := c.intrinsic(ch, measureWidth)719+ measured, _, err := c.intrinsicDepth(ch, measureWidth, 1)
631 if err != nil {720 if err != nil {
632 return err721 return err
633 }722 }
A internal/ui/empty_caret_test.go +66 -0
@@ -0,0 +1,66 @@
1+package ui
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+func TestEmptyLabelsDoNotReserveEditorLine(t *testing.T) {
12+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ r := newRuntime()
17+ r.setTextEngine(text.NewEngine(catalog))
18+ r.Build(func(f *Frame) {
19+ root := f.Root()
20+ root.Text("")
21+ root.Heading("")
22+ })
23+ for _, child := range r.output.Tree.Children {
24+ if child.Content.H != 0 || len(child.Lines) != 0 {
25+ t.Fatalf("empty label reserves line: %+v", child)
26+ }
27+ }
28+}
29+
30+func TestEmptyTextareaCaretUsesLineHeight(t *testing.T) {
31+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ for _, height := range []string{"normal", "1.5", "30px", "1.5em", "150%"} {
36+ t.Run(height, func(t *testing.T) {
37+ r := newRuntime()
38+ r.setTextEngine(text.NewEngine(catalog))
39+ r.styles = css.Compile(css.UA(), css.Parse(fmt.Sprintf(`textarea {height:400px;width:300px;font-size:20px;line-height:%s}`, height)))
40+ value := ""
41+ view := func(f *Frame) { f.Root().Textarea("Document", &value) }
42+ r.Build(view)
43+ r.route(key("Tab"))
44+ r.Build(view)
45+ caretHeight := func() float64 {
46+ for _, cmd := range r.output.Display {
47+ if cmd.Op == "caret" {
48+ return cmd.Rect.H
49+ }
50+ }
51+ t.Fatal("missing caret")
52+ return 0
53+ }
54+ empty := caretHeight()
55+ if empty <= 0 || empty >= 100 {
56+ t.Fatalf("empty caret height=%g", empty)
57+ }
58+ value = "x"
59+ r.Redraw()
60+ r.Build(view)
61+ if got := caretHeight(); got != empty {
62+ t.Fatalf("empty=%g populated=%g", empty, got)
63+ }
64+ })
65+ }
66+}
@@ -0,0 +1,66 @@
1+package ui
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+func TestEmptyLabelsDoNotReserveEditorLine(t *testing.T) {
12+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ r := newRuntime()
17+ r.setTextEngine(text.NewEngine(catalog))
18+ r.Build(func(f *Frame) {
19+ root := f.Root()
20+ root.Text("")
21+ root.Heading("")
22+ })
23+ for _, child := range r.output.Tree.Children {
24+ if child.Content.H != 0 || len(child.Lines) != 0 {
25+ t.Fatalf("empty label reserves line: %+v", child)
26+ }
27+ }
28+}
29+
30+func TestEmptyTextareaCaretUsesLineHeight(t *testing.T) {
31+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ for _, height := range []string{"normal", "1.5", "30px", "1.5em", "150%"} {
36+ t.Run(height, func(t *testing.T) {
37+ r := newRuntime()
38+ r.setTextEngine(text.NewEngine(catalog))
39+ r.styles = css.Compile(css.UA(), css.Parse(fmt.Sprintf(`textarea {height:400px;width:300px;font-size:20px;line-height:%s}`, height)))
40+ value := ""
41+ view := func(f *Frame) { f.Root().Textarea("Document", &value) }
42+ r.Build(view)
43+ r.route(key("Tab"))
44+ r.Build(view)
45+ caretHeight := func() float64 {
46+ for _, cmd := range r.output.Display {
47+ if cmd.Op == "caret" {
48+ return cmd.Rect.H
49+ }
50+ }
51+ t.Fatal("missing caret")
52+ return 0
53+ }
54+ empty := caretHeight()
55+ if empty <= 0 || empty >= 100 {
56+ t.Fatalf("empty caret height=%g", empty)
57+ }
58+ value = "x"
59+ r.Redraw()
60+ r.Build(view)
61+ if got := caretHeight(); got != empty {
62+ t.Fatalf("empty=%g populated=%g", empty, got)
63+ }
64+ })
65+ }
66+}

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