okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.pettui: pointer app model, shared styles, reused glamour renderers, own transcript pane, reuse rows across older pages, gogc 500b9f48f

main
4.5 KiB185 linesraw
1package tui
2
3import (
4	"strings"
5
6	"github.com/charmbracelet/x/ansi"
7)
8
9func wrapOrChunkLine(line string, width int) []string {
10	if width <= 0 {
11		width = 80
12	}
13	if ansi.StringWidth(line) <= width {
14		return []string{line}
15	}
16	// Match wrap-ansi's hard:true, trim:false word placement: a separator
17	// consumes a cell before deciding whether the next word fits.
18	words := strings.Split(line, " ")
19	rows := []string{""}
20	used := 0
21	for i, word := range words {
22		if i > 0 {
23			if used >= width {
24				rows = append(rows, "")
25				used = 0
26			}
27			rows[len(rows)-1] += " "
28			used++
29		}
30		wordWidth := ansi.StringWidth(word)
31		if wordWidth > width {
32			remaining := width - used
33			breaksHere := 1 + (wordWidth-remaining-1)/width
34			breaksNext := (wordWidth - 1) / width
35			if breaksNext < breaksHere {
36				rows = append(rows, "")
37				used = 0
38			}
39			wrapped := strings.Split(ansi.Hardwrap(rows[len(rows)-1]+word, width, true), "\n")
40			rows[len(rows)-1] = wrapped[0]
41			rows = append(rows, wrapped[1:]...)
42			used = ansi.StringWidth(rows[len(rows)-1])
43			continue
44		}
45		if used+wordWidth > width && used > 0 && wordWidth > 0 {
46			rows = append(rows, "")
47			used = 0
48		}
49		rows[len(rows)-1] += word
50		used += wordWidth
51	}
52	// Every viewport row can be rendered alone after a history scroll. Reopen
53	// styles that cross a physical wrap, as wrap-ansi does for the TS rows.
54	style := ""
55	for i, row := range rows {
56		if style != "" {
57			rows[i] = style + row
58		}
59		for _, code := range sgrCode.FindAllString(row, -1) {
60			if code == "\x1b[0m" || code == "\x1b[m" {
61				style = ""
62			} else {
63				style += code
64			}
65		}
66		if style != "" && i < len(rows)-1 {
67			rows[i] += "\x1b[0m"
68		}
69	}
70	return rows
71}
72
73func (m ChatModel) promptLines() int {
74	lineCount := m.TextArea.LineCount()
75	if lineCount <= 1 {
76		return max(1, m.TextArea.LineInfo().Height)
77	}
78	cp := m.TextArea
79	for cp.Line() > 0 {
80		cp.CursorUp()
81	}
82	total := 0
83	for line := range lineCount {
84		total += cp.LineInfo().Height
85		if line < lineCount-1 {
86			curr := cp.Line()
87			for cp.Line() == curr {
88				beforeRow := cp.LineInfo().RowOffset
89				cp.CursorDown()
90				if cp.Line() == curr && cp.LineInfo().RowOffset == beforeRow {
91					break
92				}
93			}
94		}
95	}
96	return max(1, total)
97}
98
99func (m ChatModel) maxPromptHeight() int {
100	if m.Height > 0 {
101		return max(1, min(6, (m.Height-8)/2))
102	}
103	return 6
104}
105
106func (m ChatModel) promptHeight() int {
107	return min(m.promptLines(), m.maxPromptHeight())
108}
109
110func (m ChatModel) inputRows() int {
111	if len(m.effortOptions) > 0 {
112		return 4 // breathing room, title, choices, keyboard hint; no composer
113	}
114	menu := min(4, len(m.CommandMenu.Matches(m.TextArea.Value())))
115	return m.promptHeight() + menu + m.chipHeight()
116}
117
118// chipHeight is the rows the attachment strip takes above the composer.
119func (m ChatModel) chipHeight() int {
120	if len(m.chips()) == 0 {
121		return 0
122	}
123	return chipRows
124}
125
126// pane is the transcript's window: its size and the rows it shows. The rows
127// arrive wrapped to the width, so drawing it only joins them.
128type pane struct {
129	rows          []string
130	width, height int
131}
132
133func (p pane) Width() int       { return p.width }
134func (p pane) Height() int      { return p.height }
135func (p *pane) SetWidth(w int)  { p.width = w }
136func (p *pane) SetHeight(h int) { p.height = h }
137
138// lines is exactly height rows: the shown ones, then blank ones.
139func (p pane) lines() []string {
140	lines := make([]string, p.height)
141	copy(lines, p.rows)
142	return lines
143}
144
145// View is the pane as one string, as tests and a sidebar join read it.
146func (p pane) View() string {
147	return strings.Join(p.lines(), "\n")
148}
149
150func (m ChatModel) chromeRows() int {
151	return m.inputRows() + m.Notices.ChromeRows()
152}
153
154func (m *ChatModel) syncLayout() {
155	m.TextArea.SetHeight(m.maxPromptHeight())
156	m.Viewport.SetHeight(max(1, m.Height-6-m.chromeRows()))
157	m.refreshViewportContent()
158}
159
160func (m *ChatModel) SetSize(width, height int) {
161	if m == nil || m.History == nil {
162		return
163	}
164	m.Width, m.Height = max(1, width), max(1, height)
165
166	available := max(1, m.Width-2*m.padding())
167	transcriptWidth := available
168	m.layoutSidebar = m.sidebarWidth()
169	if m.layoutSidebar > 0 {
170		transcriptWidth -= m.layoutSidebar + 2
171	}
172
173	m.Viewport.SetWidth(transcriptWidth)
174	m.TextArea.SetWidth(available)
175	m.TextArea.SetHeight(m.maxPromptHeight())
176	m.Viewport.SetHeight(max(1, m.Height-6-m.chromeRows()))
177
178	// settled rows are rendered at the body width alone, so only a new body
179	// width renders them again
180	if body := min(100, transcriptWidth); body != m.Renderer.BodyWidth {
181		m.Renderer.BodyWidth = body
182		m.rebuildSettledLines()
183	}
184	m.refreshViewportContent()
185}