返回 DeepSeek-Reasonix
composer_selection.go
根目录 / internal / cli / composer_selection.go
1 package cli
2
3 import (
4 "strings"
5 "unicode"
6
7 "charm.land/bubbles/v2/key"
8 "charm.land/bubbles/v2/textarea"
9 tea "charm.land/bubbletea/v2"
10 "charm.land/lipgloss/v2"
11 rw "github.com/mattn/go-runewidth"
12 "github.com/rivo/uniseg"
13 )
14
15 // composerSelection is an editable textarea selection expressed as rune offsets
16 // into input.Value(). The value snapshot invalidates stale offsets whenever an
17 // unrelated path replaces the composer contents.
18 type composerSelection struct {
19 active bool
20 anchor, head int
21 value string
22 }
23
24 // composerPromptWidth is reserved by textarea on every visual row. The first
25 // row paints "❯ "; continuation rows use the same-width blank gutter.
26 const composerPromptWidth = 2
27
28 const composerWheelRows = 3
29
30 type composerLayoutCache struct {
31 value string
32 width int
33 rows []composerVisualRow
34 }
35
36 func (s composerSelection) ordered() (start, end int) {
37 if s.anchor > s.head {
38 return s.head, s.anchor
39 }
40 return s.anchor, s.head
41 }
42
43 func (s composerSelection) empty() bool { return s.anchor == s.head }
44
45 type composerCell struct {
46 r rune
47 offset int
48 lineCol int
49 }
50
51 type composerVisualRow struct {
52 cells []composerCell
53 logicalRow, logicalStart int
54 logicalEnd, visualRow int
55 startOffset, endOffset int
56 }
57
58 type composerCaret struct {
59 offset, logicalRow, logicalCol, visualRow int
60 }
61
62 type composerCluster struct {
63 startOffset, endOffset int
64 startLineCol, endLineCol int
65 startVisualCol, endVisualCol int
66 }
67
68 func composerCellsWidth(cells []composerCell) int {
69 var b strings.Builder
70 for _, cell := range cells {
71 b.WriteRune(cell.r)
72 }
73 return uniseg.StringWidth(b.String())
74 }
75
76 func composerSpaces(cells []composerCell) []composerCell {
77 out := make([]composerCell, len(cells))
78 for i, cell := range cells {
79 cell.r = ' '
80 out[i] = cell
81 }
82 return out
83 }
84
85 // wrapComposerLine mirrors bubbles/textarea.wrap. The dependency does not
86 // export its visual rows, but mouse hit-testing must use the exact same word
87 // wrapping and wide-rune rules as the rendered textarea.
88 func wrapComposerLine(runes []rune, width, logicalRow, logicalStart int) []composerVisualRow {
89 if width < 1 {
90 width = 1
91 }
92 lines := [][]composerCell{{}}
93 var word, spaces []composerCell
94 row := 0
95 for col, r := range runes {
96 cell := composerCell{r: r, offset: logicalStart + col, lineCol: col}
97 if unicode.IsSpace(r) {
98 spaces = append(spaces, cell)
99 } else {
100 word = append(word, cell)
101 }
102
103 if len(spaces) > 0 {
104 if composerCellsWidth(lines[row])+composerCellsWidth(word)+len(spaces) > width {
105 row++
106 lines = append(lines, []composerCell{})
107 }
108 lines[row] = append(lines[row], word...)
109 lines[row] = append(lines[row], composerSpaces(spaces)...)
110 word = nil
111 spaces = nil
112 } else if len(word) > 0 {
113 lastWidth := rw.RuneWidth(word[len(word)-1].r)
114 if composerCellsWidth(word)+lastWidth > width {
115 if len(lines[row]) > 0 {
116 row++
117 lines = append(lines, []composerCell{})
118 }
119 lines[row] = append(lines[row], word...)
120 word = nil
121 }
122 }
123 }
124
125 if composerCellsWidth(lines[row])+composerCellsWidth(word)+len(spaces) >= width {
126 row++
127 lines = append(lines, append([]composerCell{}, word...))
128 lines[row] = append(lines[row], composerSpaces(spaces)...)
129 } else {
130 lines[row] = append(lines[row], word...)
131 lines[row] = append(lines[row], composerSpaces(spaces)...)
132 }
133
134 logicalEnd := logicalStart + len(runes)
135 // textarea appends one non-value space so a caret can sit at line end.
136 lines[row] = append(lines[row], composerCell{r: ' ', offset: -1, lineCol: len(runes)})
137 result := make([]composerVisualRow, len(lines))
138 for i, cells := range lines {
139 start, end := logicalEnd, logicalEnd
140 for _, cell := range cells {
141 if cell.offset < 0 {
142 continue
143 }
144 if start == logicalEnd {
145 start = cell.offset
146 }
147 end = cell.offset + 1
148 }
149 result[i] = composerVisualRow{
150 cells: cells, logicalRow: logicalRow, logicalStart: logicalStart,
151 logicalEnd: logicalEnd, startOffset: start, endOffset: end,
152 }
153 }
154 return result
155 }
156
157 func composerLayout(value string, width int) []composerVisualRow {
158 logicalLines := strings.Split(value, "\n")
159 rows := make([]composerVisualRow, 0, len(logicalLines))
160 offset := 0
161 for logicalRow, line := range logicalLines {
162 runes := []rune(line)
163 wrapped := wrapComposerLine(runes, width, logicalRow, offset)
164 for i := range wrapped {
165 wrapped[i].visualRow = len(rows)
166 rows = append(rows, wrapped[i])
167 }
168 offset += len(runes)
169 if logicalRow+1 < len(logicalLines) {
170 offset++ // explicit newline in input.Value()
171 }
172 }
173 return rows
174 }
175
176 func (m *chatTUI) composerRows() []composerVisualRow {
177 value, width := m.input.Value(), m.input.Width()
178 if m.composerMap.value != value || m.composerMap.width != width || m.composerMap.rows == nil {
179 m.composerMap = composerLayoutCache{value: value, width: width, rows: composerLayout(value, width)}
180 }
181 return m.composerMap.rows
182 }
183
184 func (m chatTUI) composerRowsForRender() []composerVisualRow {
185 value, width := m.input.Value(), m.input.Width()
186 if m.composerMap.value == value && m.composerMap.width == width && m.composerMap.rows != nil {
187 return m.composerMap.rows
188 }
189 return composerLayout(value, width)
190 }
191
192 // composerViewOffset is the first visual row currently painted in the input.
193 // Normally bubbles/textarea owns it and keeps the insertion cursor visible. A
194 // mouse-wheel gesture temporarily detaches the painted viewport while leaving
195 // that cursor and the textarea's own offset untouched.
196 func (m chatTUI) composerViewOffset() int {
197 rows := m.composerRowsForRender()
198 maximum := max(0, len(rows)-m.input.Height())
199 offset := m.input.ScrollYOffset()
200 if m.composerScrollDetached {
201 offset = m.composerScrollOffset
202 }
203 return min(max(offset, 0), maximum)
204 }
205
206 func (m *chatTUI) followComposerCursor() {
207 m.composerScrollDetached = false
208 m.composerScrollOffset = m.input.ScrollYOffset()
209 }
210
211 // scrollComposer moves only the composer's painted viewport. It returns false
212 // at an edge so the caller can continue the same wheel gesture in the transcript.
213 func (m *chatTUI) scrollComposer(delta int) bool {
214 if delta == 0 || m.hideComposer() || m.input.Height() <= 0 {
215 return false
216 }
217 maximum := max(0, len(m.composerRows())-m.input.Height())
218 if maximum == 0 {
219 return false
220 }
221 current := m.composerViewOffset()
222 next := min(max(current+delta, 0), maximum)
223 if next == current {
224 return false
225 }
226 m.composerScrollOffset = next
227 m.composerScrollDetached = next != m.input.ScrollYOffset()
228 return true
229 }
230
231 func (m chatTUI) mouseOverComposer(screenX, screenY int) bool {
232 if m.hideComposer() || screenX < 0 || screenX >= max(m.width, 10) {
233 return false
234 }
235 _, contentY, ok := m.composerOrigin()
236 if !ok {
237 return false
238 }
239 // Include both horizontal border rows so a wheel gesture anywhere over the
240 // visible composer card has the same target.
241 return screenY >= contentY-1 && screenY <= contentY+m.input.Height()
242 }
243
244 // composerCursor maps the textarea's real insertion cursor into the manually
245 // scrolled viewport. The cursor is hidden when the user has scrolled it out of
246 // view; typing or a cursor key reattaches the viewport and shows it again.
247 func (m chatTUI) composerCursor() *tea.Cursor {
248 cur := m.input.Cursor()
249 if cur == nil || !m.composerScrollDetached {
250 return cur
251 }
252 absoluteRow := m.input.ScrollYOffset() + cur.Y
253 cur.Y = absoluteRow - m.composerViewOffset()
254 if cur.Y < 0 || cur.Y >= m.input.Height() {
255 return nil
256 }
257 return cur
258 }
259
260 func composerClusters(row composerVisualRow) []composerCluster {
261 actual := make([]composerCell, 0, len(row.cells))
262 var text strings.Builder
263 for _, cell := range row.cells {
264 if cell.offset < 0 {
265 continue
266 }
267 actual = append(actual, cell)
268 text.WriteRune(cell.r)
269 }
270 clusters := make([]composerCluster, 0, len(actual))
271 graphemes := uniseg.NewGraphemes(text.String())
272 cellIndex := 0
273 visualCol := 0
274 for graphemes.Next() {
275 clusterRunes := graphemes.Runes()
276 if len(clusterRunes) == 0 || cellIndex >= len(actual) {
277 continue
278 }
279 endIndex := min(cellIndex+len(clusterRunes), len(actual))
280 first := actual[cellIndex]
281 last := actual[endIndex-1]
282 width := graphemes.Width()
283 clusters = append(clusters, composerCluster{
284 startOffset: first.offset, endOffset: last.offset + 1,
285 startLineCol: first.lineCol, endLineCol: last.lineCol + 1,
286 startVisualCol: visualCol, endVisualCol: visualCol + width,
287 })
288 visualCol += width
289 cellIndex = endIndex
290 }
291 return clusters
292 }
293
294 func (row composerVisualRow) caretAt(x int) composerCaret {
295 if x < 0 {
296 x = 0
297 }
298 lastOffset := row.startOffset
299 lastLineCol := 0
300 for _, cluster := range composerClusters(row) {
301 width := cluster.endVisualCol - cluster.startVisualCol
302 if x <= cluster.startVisualCol ||
303 (x < cluster.endVisualCol && x-cluster.startVisualCol < (width+1)/2) {
304 return composerCaret{cluster.startOffset, row.logicalRow, cluster.startLineCol, row.visualRow}
305 }
306 lastOffset = cluster.endOffset
307 lastLineCol = cluster.endLineCol
308 if x < cluster.endVisualCol {
309 return composerCaret{lastOffset, row.logicalRow, lastLineCol, row.visualRow}
310 }
311 }
312 return composerCaret{lastOffset, row.logicalRow, lastLineCol, row.visualRow}
313 }
314
315 func composerCaretForOffset(rows []composerVisualRow, offset int) composerCaret {
316 if len(rows) == 0 {
317 return composerCaret{}
318 }
319 for i, row := range rows {
320 if offset < row.endOffset || offset == row.startOffset {
321 return composerCaret{offset, row.logicalRow, offset - row.logicalStart, row.visualRow}
322 }
323 if offset == row.endOffset {
324 if i+1 < len(rows) && rows[i+1].startOffset == offset {
325 continue
326 }
327 return composerCaret{offset, row.logicalRow, offset - row.logicalStart, row.visualRow}
328 }
329 }
330 last := rows[len(rows)-1]
331 return composerCaret{last.logicalEnd, last.logicalRow, last.logicalEnd - last.logicalStart, last.visualRow}
332 }
333
334 func (m chatTUI) validComposerSelection() bool {
335 return m.composerSel.active && m.composerSel.value == m.input.Value()
336 }
337
338 func (m chatTUI) selectedComposerText() string {
339 if !m.validComposerSelection() || m.composerSel.empty() {
340 return ""
341 }
342 start, end := m.composerSel.ordered()
343 runes := []rune(m.input.Value())
344 if start < 0 || end > len(runes) {
345 return ""
346 }
347 return string(runes[start:end])
348 }
349
350 // composerOrigin returns the terminal cell occupied by textarea content (after
351 // the input box's top border, left padding, and prompt gutter). Deriving it from
352 // the two cursor positions keeps hit-testing aligned with every optional panel
353 // above the box.
354 func (m chatTUI) composerOrigin() (x, y int, ok bool) {
355 if m.hideComposer() {
356 return 0, 0, false
357 }
358 local := m.input.Cursor()
359 // Derive the stable layout origin from the normal caret-following frame even
360 // while the manually scrolled frame has hidden the insertion cursor.
361 normal := m
362 normal.composerScrollDetached = false
363 view := normal.View()
364 if local == nil || view.Cursor == nil {
365 return 0, 0, false
366 }
367 return view.Cursor.X - local.X + composerPromptWidth, view.Cursor.Y - local.Y, true
368 }
369
370 func (m *chatTUI) composerCaretAt(screenX, screenY int, clamp bool) (composerCaret, bool) {
371 x, y, ok := m.composerOrigin()
372 if !ok {
373 return composerCaret{}, false
374 }
375 relY := screenY - y
376 if !clamp && (relY < 0 || relY >= m.input.Height()) {
377 return composerCaret{}, false
378 }
379 if relY < 0 {
380 relY = 0
381 }
382 if relY >= m.input.Height() {
383 relY = m.input.Height() - 1
384 }
385 rows := m.composerRows()
386 visualRow := m.composerViewOffset() + relY
387 if visualRow < 0 {
388 visualRow = 0
389 }
390 if visualRow >= len(rows) {
391 visualRow = len(rows) - 1
392 }
393 return rows[visualRow].caretAt(screenX - x), true
394 }
395
396 func (m *chatTUI) setComposerCursor(offset int) {
397 m.followComposerCursor()
398 rows := m.composerRows()
399 caret := composerCaretForOffset(rows, offset)
400 m.input.MoveToBegin()
401 for i := 0; i < caret.visualRow; i++ {
402 m.input.CursorDown()
403 }
404 m.input.SetCursorColumn(caret.logicalCol)
405 }
406
407 func (m *chatTUI) deleteComposerSelection() bool {
408 if !m.validComposerSelection() || m.composerSel.empty() {
409 m.composerSel = composerSelection{}
410 return false
411 }
412 start, end := m.composerSel.ordered()
413 runes := []rune(m.input.Value())
414 if start < 0 || end > len(runes) {
415 m.composerSel = composerSelection{}
416 return false
417 }
418 m.input.SetValue(string(runes[:start]) + string(runes[end:]))
419 m.composerSel = composerSelection{}
420 m.setComposerCursor(start)
421 return true
422 }
423
424 func composerSelectionDeletes(msg tea.KeyPressMsg, keyMap textarea.KeyMap) bool {
425 return key.Matches(msg, keyMap.DeleteAfterCursor) ||
426 key.Matches(msg, keyMap.DeleteBeforeCursor) ||
427 key.Matches(msg, keyMap.DeleteCharacterBackward) ||
428 key.Matches(msg, keyMap.DeleteCharacterForward) ||
429 key.Matches(msg, keyMap.DeleteWordBackward) ||
430 key.Matches(msg, keyMap.DeleteWordForward)
431 }
432
433 func composerSelectionReplaces(msg tea.KeyPressMsg, keyMap textarea.KeyMap) bool {
434 if key.Matches(msg, keyMap.InsertNewline) {
435 return true
436 }
437 if msg.Text == "" {
438 return false
439 }
440 commandMods := tea.ModCtrl | tea.ModMeta | tea.ModHyper | tea.ModSuper
441 return msg.Key().Mod&commandMods == 0
442 }
443
444 func composerRowSelectionSpan(row composerVisualRow, start, end int) (lo, hi int, ok bool) {
445 visualCol := 0
446 for _, cluster := range composerClusters(row) {
447 if cluster.endOffset > start && cluster.startOffset < end {
448 if !ok {
449 lo = cluster.startVisualCol
450 ok = true
451 }
452 hi = cluster.endVisualCol
453 }
454 visualCol = cluster.endVisualCol
455 }
456 // Make an explicitly selected newline visible, including on blank lines:
457 // it occupies the textarea's trailing caret space, one cell past the row
458 // content (after the loop visualCol is the row's full content width).
459 if end > row.logicalEnd && start <= row.logicalEnd && row.endOffset == row.logicalEnd {
460 if !ok {
461 lo = visualCol
462 ok = true
463 }
464 hi = max(hi, visualCol+1)
465 }
466 return lo, hi, ok
467 }
468
469 func (m chatTUI) renderComposerInput() string {
470 view := m.input.View()
471 visualStart := m.input.ScrollYOffset()
472 if m.composerScrollDetached {
473 view = m.renderDetachedComposerInput()
474 visualStart = m.composerViewOffset()
475 }
476 if !m.validComposerSelection() || m.composerSel.empty() {
477 return view
478 }
479 start, end := m.composerSel.ordered()
480 rows := m.composerRowsForRender()
481 lines := strings.Split(view, "\n")
482 for i := range lines {
483 visualRow := visualStart + i
484 if visualRow >= len(rows) {
485 break
486 }
487 if lo, hi, ok := composerRowSelectionSpan(rows[visualRow], start, end); ok {
488 lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange(
489 lo+composerPromptWidth,
490 hi+composerPromptWidth,
491 selStyle,
492 ))
493 }
494 }
495 return strings.Join(lines, "\n")
496 }
497
498 // renderDetachedComposerInput asks the same textarea implementation to render
499 // the manually selected slice. A temporary model preserves exact wrapping,
500 // padding, prompt styling, and wide-rune behavior without mutating the real
501 // textarea's cursor or viewport.
502 func (m chatTUI) renderDetachedComposerInput() string {
503 display := textarea.New()
504 configureChatTextarea(&display)
505 display.SetStyles(m.input.Styles())
506 display.DynamicHeight = false
507 display.SetWidth(m.input.Width() + composerPromptWidth)
508 display.SetHeight(m.input.Height())
509 display.SetValue(m.input.Value())
510 // textarea's public cursor motions scroll its embedded viewport only after
511 // that viewport has content. Seed it once, then position the throwaway cursor
512 // on the last row of the requested slice so caret-following yields the exact
513 // top offset we want to paint.
514 _ = display.View()
515 display.MoveToBegin()
516
517 rows := m.composerRowsForRender()
518 targetRow := min(m.composerViewOffset()+m.input.Height()-1, len(rows)-1)
519 for range max(targetRow, 0) {
520 display.CursorDown()
521 }
522 return display.View()
523 }
524
524 lines GO