1package ui
2
3import (
4 "math"
5 "unicode/utf8"
6
7 "github.com/bnema/nefergui/internal/css"
8 "github.com/bnema/nefergui/internal/edit"
9 "github.com/bnema/nefergui/internal/layout"
10 "github.com/bnema/nefergui/internal/text"
11)
12
13// shapeAt shapes s in the element's font and letter spacing, as layout
14// measures editor text, and places its runs from the logical point (x, y).
15// wrap > 0 breaks lines at that width. box > 0 aligns each line within a box
16// of that width by text-align; zero keeps lines starting at x.
17func shapeAt(engine *text.Engine, st *css.Style, s string, x, y, wrap, box float64) (text.Layout, []layout.Run, bool) {
18 req := text.Request{Families: st.FontFamily, Weight: float32(st.FontWeight), Italic: st.FontStyle == css.KeywordItalic || st.FontStyle == css.KeywordOblique, Size: st.FontSize.Value}
19 if st.LetterSpacing.Unit == "px" { // percentages resolve to 0, as in layout
20 req.LetterSpacing = st.LetterSpacing.Value
21 }
22 if req.Size <= 0 {
23 req.Size = 16
24 }
25 measured, err := engine.Measure(s, req, wrap)
26 if err != nil || len(measured.Lines) == 0 {
27 return text.Layout{}, nil, false
28 }
29 var runs []layout.Run
30 for _, line := range measured.Lines {
31 lx := x
32 if box > 0 {
33 lx += layout.AlignOffset(st.TextAlign, line.Direction, box, line.Width)
34 }
35 for _, run := range line.Runs {
36 rr := layout.Run{Start: run.Start, End: run.End, X: lx + run.X, Y: y + run.Y, Advance: run.Advance, Face: run.Face, FaceID: run.Face.ID, Size: req.Size}
37 for _, g := range run.Glyphs {
38 rr.Glyphs = append(rr.Glyphs, layout.Glyph{ID: uint32(g.ID), X: lx + g.X, Y: y + g.Y, Advance: g.Advance, Cluster: g.Cluster})
39 }
40 runs = append(runs, rr)
41 }
42 }
43 return measured, runs, true
44}
45
46// placeholderCommand paints the placeholder of an empty editor in its content
47// box, aligned like editor text and dimmed to half the text opacity. Textarea
48// placeholders wrap at the content width. It never enters the bound value.
49func placeholderCommand(e *element, n *layout.Result, engine *text.Engine) []layout.Command {
50 if e.computed == nil {
51 return nil
52 }
53 st := e.computed.Style
54 x, y := n.Content.X-n.ScrollX, n.Content.Y-n.ScrollY
55 wrap := 0.0
56 if e.typ == "textarea" {
57 wrap = n.Content.W
58 }
59 measured, runs, ok := shapeAt(engine, st, e.placeholder, x, y, wrap, n.Content.W)
60 if !ok {
61 return nil
62 }
63 opacity := math.Min(1, st.Opacity) * 0.5
64 return []layout.Command{{Op: "text", ID: n.ID + "#placeholder", Rect: layout.Rect{X: x, Y: y, W: n.Content.W, H: measured.Height}, Text: e.placeholder, Runs: runs, Color: st.Color, Opacity: opacity}}
65}
66
67// preeditCommands shapes the composition independently of the bound value.
68// Its text command begins at the model caret and carries its own rune clusters;
69// the underline and composition cursor refer to UTF-8 byte positions.
70func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.Engine, display []layout.Command) []layout.Command {
71 if s.Preedit == "" || n == nil || e.computed == nil {
72 return nil
73 }
74 // The composition starts at the same caret position that is painted.
75 geometry := newEditorGeometry(s, n, display, e.password)
76 origin, found := geometry.caret(s.Cursor)
77 x, y := n.Content.X, n.Content.Y
78 if found {
79 x, y = origin.x, origin.y
80 }
81 st := e.computed.Style
82 preedit := s.Preedit
83 if e.password {
84 preedit = (&edit.State{Value: s.Preedit}).Mask()
85 }
86 measured, runs, ok := shapeAt(engine, st, preedit, x, y, 0, 0)
87 if !ok {
88 return nil
89 }
90 h := measured.LineHeight
91 if h <= 0 {
92 h = n.Content.H
93 }
94 width := measured.Width
95 color := st.Color
96 cmds := []layout.Command{{Op: "preedit-text", ID: n.ID, Rect: layout.Rect{X: x, Y: y, W: width, H: h}, Text: preedit, Runs: runs, Color: color, Opacity: 1}, {Op: "preedit-underline", ID: n.ID, Rect: layout.Rect{X: x, Y: y + h - 1, W: width, H: 1}, Color: color, Opacity: 1}}
97 pos := s.PreeditBegin
98 if pos < 0 {
99 pos = 0
100 }
101 if pos > len(s.Preedit) {
102 pos = len(s.Preedit)
103 }
104 clamped := 0
105 for _, b := range edit.Boundaries(s.Preedit) {
106 if b > pos {
107 break
108 }
109 clamped = b
110 }
111 pos = clamped
112 // Preedit offsets are bytes in the original text; a password shows one
113 // bullet per original grapheme, including combined clusters.
114 pos = newDisplayText(s.Preedit, e.password).toShown(pos)
115 // The preedit cursor uses shaped cluster advances, not raw rune count.
116 px := x
117 pr := utf8.RuneCountInString(preedit[:pos])
118 for _, run := range measured.Lines[0].Runs {
119 for _, g := range run.Glyphs {
120 if g.Cluster < pr {
121 px = math.Max(px, x+g.X+g.Advance)
122 }
123 }
124 }
125 cmds = append(cmds, layout.Command{Op: "preedit-caret", ID: n.ID, Rect: layout.Rect{X: px, Y: y, W: 1, H: h}, Color: color, Opacity: 1})
126 return cmds
127}