1package picker
2
3import (
4 "fmt"
5 "strings"
6
7 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/protocol"
10 "github.com/bnema/vev/internal/usecase/ui"
11)
12
13const (
14 MinPreviewWidth = 24
15 MinHorizontalPreviewWidth = 48
16 MinListWidth = 16
17 MinHorizontalListWidth = 20
18 MaxListWidth = 44
19 MinPaneHeight = 4
20 MinStackHeight = 12
21)
22
23// SortMode orders the rows of one source locally. The source publishes its
24// lines with rank and ephemeral markers; the presenting client decides the
25// order, so two attachments of the same daemon never share a sort mode.
26type SortMode uint8
27
28const (
29 // SortRecent orders every run by the source's recency rank.
30 SortRecent SortMode = iota
31 // SortGrouped puts named sessions before ephemeral ones inside each run,
32 // each keeping recency order.
33 SortGrouped
34)
35
36// Config describes one presented model: which intent produced the lines, the
37// source's cursor hint, and the local sort order.
38type Config struct {
39 Intent protocol.PickerIntent
40 Cursor protocol.PickerCursor
41 Sort SortMode
42}
43
44// RenderStyles are the styles Render uses to draw list rows. The zero value
45// (all renderer.Style{}) is never used directly; Render falls back to
46// defaultRenderStyles when no RenderStyles is supplied, mirroring the styling
47// of a non-truecolor client.
48type RenderStyles struct {
49 Selection renderer.Style // selected row fill + suffixes
50 SelectionName renderer.Style // selected row name segment
51 SelectionMuted renderer.Style // selected row detail segment
52 Name renderer.Style // non-selected name segment
53 Detail renderer.Style // non-selected detail segment
54 // Background fills otherwise unused interior cells. Base owns ordinary
55 // rows, preserving a distinct inactive surface from modal chrome.
56 Background renderer.Style
57 Base renderer.Style // non-selected row fill + suffixes
58 Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix)
59 Separator renderer.Style // preview separator
60 Tree renderer.Style // foreground of tree prefixes on non-selected rows; zero keeps them dimmed
61 Status renderer.Style // one-row picker-local contextual help
62 SearchMatch renderer.Style // matched runes on ordinary rows
63 SelectionMatch renderer.Style // matched runes on the selected row
64}
65
66func defaultRenderStyles() RenderStyles {
67 selection := renderer.DefaultStyle()
68 selection.Inverse = true
69 base := renderer.DefaultStyle()
70 separator := renderer.DefaultStyle()
71 separator.Attrs = renderer.AttrDim
72 stopped := renderer.DefaultStyle()
73 stopped.Attrs = renderer.AttrDim
74 stopped.Italic = true
75 searchMatch := base
76 searchMatch.Bold = true
77 selectionMatch := selection
78 selectionMatch.Bold = true
79 // Tree lines use the palette's bright black (the theme's grey) rather
80 // than SGR dim: terminals such as kitty draw box-drawing glyphs
81 // themselves and barely dim them, so dim lines stayed near full white.
82 tree := renderer.DefaultStyle()
83 tree.Foreground = 244 // fixed mid grey: readable on dark and light themes
84 return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Tree: tree, Stopped: stopped, Status: separator, SearchMatch: searchMatch, SelectionMatch: selectionMatch}
85}
86
87// Preview is a bounded view of the selected pane's visible frame.
88type Preview = ui.FrameView
89
90type LayoutMode uint8
91
92const (
93 LayoutListOnly LayoutMode = iota
94 LayoutStacked
95 LayoutHorizontal
96)
97
98type Layout struct {
99 Mode LayoutMode
100 List domain.Rect
101 Separator domain.Rect
102 Preview domain.Rect
103}
104
105// Geometry is the single picker layout contract consumed by rendering and
106// preview sizing. Status always reserves the final available inner row.
107type Geometry struct {
108 Content domain.Rect
109 Status domain.Rect
110 Mode LayoutMode
111 List domain.Rect
112 Separator domain.Rect
113 Preview domain.Rect
114}
115
116// Model is the pure presentation state of one picker: the published lines,
117// the local cursor, the search editor, and the local sort order. It owns no
118// target resolution and no mutation authority: the source that published a
119// line resolves its opaque key when the client commits it.
120type Model struct {
121 intent protocol.PickerIntent
122 sort SortMode
123 lines []protocol.PickerLine
124 rows []row
125 selected int
126 searchActive bool
127 query ui.TextInput
128 searchMatches map[int]searchMatch
129 matchRows []int
130}
131
132// row is one rendered line plus its case-folded search fields.
133type row struct {
134 line protocol.PickerLine
135 foldedLabel string
136 foldedDetail string
137 // foldedContext is the owning session's name for a tab row, so searching a
138 // session name reaches the tab rows that are its destinations.
139 foldedContext string
140 // tree is the dimmed branch prefix drawn before the label ("├─ ", "│ └─ "…).
141 tree string
142}
143
144func (r row) key() string { return r.line.Key }
145
146func (r row) kind() protocol.PickerLineKind { return r.line.Kind }
147
148func (r row) section() bool { return r.line.Kind == protocol.PickerLineSection }
149
150func (r row) selectable() bool { return r.line.Actions != 0 }
151
152// focusable reports the source's authorisation to rest the cursor on this row.
153// A section header is never a destination; every other shape follows the
154// published flag, so a row the source kept for inspection stays reachable while
155// a row it skipped is passed over.
156func (r row) focusable() bool {
157 return r.line.Kind != protocol.PickerLineSection && r.line.Focusable
158}
159
160func (r row) rendersAsHeader() bool {
161 return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost
162}
163
164// New builds a model from one source's published lines. Order is applied
165// locally: recency or grouped, inside each section run.
166func New(lines []protocol.PickerLine, config Config) *Model {
167 m := &Model{intent: config.Intent, sort: config.Sort}
168 m.setLines(lines, config.Cursor)
169 return m
170}
171
172// Intent reports which picker produced this model.
173func (m *Model) Intent() protocol.PickerIntent {
174 if m == nil {
175 return 0
176 }
177 return m.intent
178}
179
180// SortMode reports the local ordering mode.
181func (m *Model) SortMode() SortMode {
182 if m == nil {
183 return SortGrouped
184 }
185 return m.sort
186}
187
188// SetSort re-orders the rows locally, preserving the selected key.
189func (m *Model) SetSort(mode SortMode) {
190 if m == nil {
191 return
192 }
193 m.sort = mode
194 key, hadKey := m.cursorKey()
195 m.rebuild(key, hadKey, m.selected)
196}
197
198// setLines replaces the published lines and restores the cursor by key,
199// falling back to the source hint and then to the nearest focusable row.
200func (m *Model) setLines(lines []protocol.PickerLine, cursor protocol.PickerCursor) {
201 if m == nil {
202 return
203 }
204 m.lines = append(m.lines[:0], lines...)
205 m.rebuild(cursor.Key, cursor.Key != "", cursor.Index)
206}
207
208// ReplaceLines applies a fresh publication of the same source while retaining
209// the local search editor and the selected key when it still exists.
210func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.PickerCursor) {
211 if m == nil {
212 return
213 }
214 key, hadKey := m.cursorKey()
215 above := m.keysAboveCursor()
216 m.lines = append(m.lines[:0], lines...)
217 if !hadKey {
218 key, hadKey = cursor.Key, cursor.Key != ""
219 }
220 m.rebuild(key, hadKey, cursor.Index)
221 if hadKey {
222 if current, ok := m.cursorKey(); !ok || current != key {
223 // The selected row left the publication (for example it was
224 // killed): rest on the nearest surviving row above it instead of
225 // jumping back to the top.
226 m.selectFirstKey(above)
227 }
228 }
229}
230
231// keysAboveCursor lists the eligible row keys above the cursor, nearest first.
232func (m *Model) keysAboveCursor() []string {
233 var keys []string
234 for i := m.selected - 1; i >= 0; i-- {
235 if m.eligible(i) {
236 keys = append(keys, m.rows[i].key())
237 }
238 }
239 return keys
240}
241
242// selectFirstKey moves the cursor to the first key still present, if any.
243func (m *Model) selectFirstKey(keys []string) {
244 index := make(map[string]int, len(m.rows))
245 for idx, candidate := range m.rows {
246 if m.eligible(idx) {
247 index[candidate.key()] = idx
248 }
249 }
250 for _, key := range keys {
251 if idx, ok := index[key]; ok {
252 m.selected = idx
253 return
254 }
255 }
256}
257
258// ReplaceProjection atomically changes the daemon-authored projection and its
259// local title mode while retaining search and selection state.
260func (m *Model) ReplaceProjection(lines []protocol.PickerLine, cursor protocol.PickerCursor, mode SortMode) {
261 if m == nil {
262 return
263 }
264 m.sort = mode
265 m.ReplaceLines(lines, cursor)
266}
267
268// rebuild recomputes the row list from the published lines. Search matches are
269// row-index keyed, so they are recomputed here, before the cursor is restored:
270// restoration consults them to keep the cursor on a row the query shows.
271func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
272 m.rows = m.rows[:0]
273 run := make([]protocol.PickerLine, 0, len(m.lines))
274 flush := func() {
275 if len(run) == 0 {
276 return
277 }
278 for _, line := range m.sortRun(run) {
279 // The picker draws its own gap before a detail. A source such as a
280 // tab title (" (fish)") may already lead with a space, which would
281 // double it; every source, local or remote, is trimmed here.
282 line.Detail = strings.TrimSpace(line.Detail)
283 m.rows = append(m.rows, row{line: line, foldedLabel: strings.ToLower(line.Label), foldedDetail: strings.ToLower(line.Detail)})
284 }
285 run = run[:0]
286 }
287 for _, line := range m.lines {
288 if line.Kind == protocol.PickerLineSection {
289 flush()
290 m.rows = append(m.rows, row{line: line, foldedLabel: strings.ToLower(line.Label)})
291 continue
292 }
293 run = append(run, line)
294 }
295 flush()
296 session := ""
297 for i := range m.rows {
298 switch m.rows[i].line.Kind {
299 case protocol.PickerLineSession:
300 session = m.rows[i].foldedLabel
301 case protocol.PickerLineTab:
302 m.rows[i].foldedContext = session
303 default:
304 session = ""
305 }
306 }
307 m.assignTreePrefixes()
308 m.searchMatches = nil
309 m.matchRows = nil
310 if m.searchActive {
311 m.refreshSearch(false)
312 }
313 m.restoreSelection(key, hadKey, fallbackIndex)
314}
315
316// assignTreePrefixes gives each row its tree branch: sessions and hosts hang
317// off their section, tabs off their session. The last sibling closes its
318// branch with └─ and its children no longer continue the parent's │.
319func (m *Model) assignTreePrefixes() {
320 isHead := func(r row) bool { return r.rendersAsHeader() }
321 parentLast := true
322 for i := range m.rows {
323 r := &m.rows[i]
324 switch {
325 case r.section():
326 r.tree = ""
327 case isHead(*r):
328 last := true
329 for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {
330 if isHead(m.rows[j]) {
331 last = false
332 break
333 }
334 }
335 parentLast = last
336 r.tree = treeBranch(last)
337 case r.kind() == protocol.PickerLineTab:
338 last := i+1 >= len(m.rows) || m.rows[i+1].kind() != protocol.PickerLineTab
339 stem := "│ "
340 if parentLast {
341 stem = " "
342 }
343 r.tree = stem + treeBranch(last)
344 default:
345 r.tree = ""
346 }
347 }
348}
349
350func treeBranch(last bool) string {
351 if last {
352 return "└─ "
353 }
354 return "├─ "
355}
356
357// sortRun orders one section run locally. Recency keeps the source's
358// published order; grouped moves ephemeral sessions behind named ones without
359// disturbing the relative order inside each group.
360func (m *Model) sortRun(lines []protocol.PickerLine) []protocol.PickerLine {
361 if len(lines) < 2 || m.sort != SortGrouped {
362 return lines
363 }
364 named := make([]protocol.PickerLine, 0, len(lines))
365 ephemeral := make([]protocol.PickerLine, 0, len(lines))
366 for _, line := range lines {
367 if line.Ephemeral {
368 ephemeral = append(ephemeral, line)
369 continue
370 }
371 named = append(named, line)
372 }
373 return append(named, ephemeral...)
374}
375
376func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) {
377 if hadKey {
378 for idx, candidate := range m.rows {
379 if m.eligible(idx) && candidate.key() == key {
380 m.selected = idx
381 return
382 }
383 }
384 }
385 m.selected = -1
386 m.SelectNearestRow(fallbackIndex)
387 if m.selected < 0 {
388 m.selected = m.firstEligible()
389 }
390}
391
392// searchRestricted reports whether the active query narrows eligibility. An
393// empty query never hides a row.
394func (m *Model) searchRestricted() bool {
395 return m != nil && m.searchActive && m.query.Value() != ""
396}
397
398// eligible reports whether the cursor may rest on row i: the source authorised
399// the row as a destination and the typed query, when there is one, matches it.
400func (m *Model) eligible(i int) bool {
401 if m == nil || i < 0 || i >= len(m.rows) || !m.rows[i].focusable() {
402 return false
403 }
404 return !m.searchRestricted() || m.rowMatches(i)
405}
406
407// committable reports whether row i may be committed: it admits an action and
408// the typed query, when there is one, does not exclude it.
409func (m *Model) committable(i int) bool {
410 if m == nil || i < 0 || i >= len(m.rows) || !m.rows[i].selectable() {
411 return false
412 }
413 return !m.searchRestricted() || m.rowMatches(i)
414}
415
416func (m *Model) cursorKey() (string, bool) {
417 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
418 return "", false
419 }
420 return m.rows[m.selected].key(), true
421}
422
423func (m *Model) Up() {
424 if m != nil && m.searchActive {
425 m.moveSearch(-1)
426 return
427 }
428 m.move(-1)
429}
430
431func (m *Model) Down() {
432 if m != nil && m.searchActive {
433 m.moveSearch(1)
434 return
435 }
436 m.move(1)
437}
438
439// Cursor reports the line under the picker cursor independently from whether
440// that row may be activated, so a refresh can preserve an anchor row.
441func (m *Model) Cursor() (protocol.PickerLine, bool) {
442 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
443 return protocol.PickerLine{}, false
444 }
445 return m.rows[m.selected].line, true
446}
447
448// Selected reports the line under the cursor when it admits an action and the
449// active query does not exclude it.
450func (m *Model) Selected() (protocol.PickerLine, bool) {
451 if !m.committable(m.selected) {
452 return protocol.PickerLine{}, false
453 }
454 return m.rows[m.selected].line, true
455}
456
457// SelectedIndex reports the raw selected row index. It is -1 when the model has
458// no rows or when nothing in them qualifies as a cursor destination (see
459// Selected); otherwise it is a real row index even when that row is not
460// selectable.
461func (m *Model) SelectedIndex() int {
462 if m == nil {
463 return -1
464 }
465 return m.selected
466}
467
468// SelectNearestRow selects the nearest eligible row at or after idx, falling
469// back to the last eligible row before it.
470func (m *Model) SelectNearestRow(idx int) {
471 if m == nil || len(m.rows) == 0 {
472 return
473 }
474 idx = clamp(idx, 0, len(m.rows)-1)
475 for i := idx; i < len(m.rows); i++ {
476 if m.eligible(i) {
477 m.selected = i
478 return
479 }
480 }
481 for i := idx - 1; i >= 0; i-- {
482 if m.eligible(i) {
483 m.selected = i
484 return
485 }
486 }
487}
488
489func (m *Model) Clone() *Model {
490 if m == nil {
491 return nil
492 }
493 clone := *m
494 clone.lines = append([]protocol.PickerLine(nil), m.lines...)
495 clone.rows = append([]row(nil), m.rows...)
496 clone.query.SetValue(m.query.Value())
497 // Search results are copy-on-write: every query mutation publishes a new
498 // map and row slice, so render snapshots can safely share the old values.
499 clone.matchRows = m.matchRows
500 clone.searchMatches = m.searchMatches
501 return &clone
502}
503
504func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame {
505 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
506 geometry := ChooseGeometry(inner)
507 styleSet := defaultRenderStyles()
508 if len(styles) > 0 {
509 styleSet = styles[0]
510 }
511 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background})
512 m.renderList(frame, geometry.List, styleSet)
513 switch geometry.Mode {
514 case LayoutHorizontal:
515 ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorVertical, styleSet.Separator)
516 case LayoutStacked:
517 ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorHorizontal, styleSet.Separator)
518 }
519 ui.BlitFrame(frame, geometry.Preview, preview, ui.VerticalAnchorBottom)
520 m.renderStatus(frame, geometry.Status, styleSet.Status)
521 return frame
522}
523
524func (m *Model) move(delta int) {
525 if m == nil || len(m.rows) == 0 {
526 return
527 }
528 if !m.eligible(m.selected) {
529 m.selected = m.firstEligible()
530 return
531 }
532 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
533 if m.eligible(i) {
534 m.selected = i
535 return
536 }
537 }
538}
539
540func (m *Model) firstEligible() int {
541 for i := range m.rows {
542 if m.eligible(i) {
543 return i
544 }
545 }
546 return -1
547}
548
549func (s SortMode) Title() string {
550 if s == SortGrouped {
551 return " Sessions · grouped "
552 }
553 return " Sessions · recent "
554}
555
556// statusDot is the one-cell problem marker. It is drawn only when something
557// is wrong, in a fixed xterm-256 color so it reads the same in any terminal
558// and theme: red down or error, yellow stale, magenta version mismatch, blue
559// no daemon. Healthy and stopped rows carry no dot: a stopped row is already
560// muted, and the toast explains each failure in words.
561const statusDot = "●"
562
563// Fixed xterm-256 problem colors, shared with client toast borders so one
564// color always means one thing.
565const (
566 ColorProblemError = 167 // red
567 ColorProblemWarn = 179 // yellow
568)
569
570// lineStatusColor is the xterm-256 index for a problem status, or -1 for none.
571func lineStatusColor(status protocol.PickerLineStatus) int {
572 switch status {
573 case protocol.PickerLineStatusDown, protocol.PickerLineStatusError:
574 return ColorProblemError
575 case protocol.PickerLineStatusStale:
576 return ColorProblemWarn
577 case protocol.PickerLineStatusVersion:
578 return 170 // magenta
579 case protocol.PickerLineStatusNoDaemon:
580 return 67 // blue
581 default:
582 return -1
583 }
584}
585
586// drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps
587// its own color even on the inverse selected row.
588func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) {
589 if badge == "" {
590 return
591 }
592 style := base
593 style.Inverse = false
594 style.Foreground = lineStatusColor(status)
595 style.HasForegroundRGB = false
596 ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style)
597}
598
599func lineStatusBadge(status protocol.PickerLineStatus) string {
600 if lineStatusColor(status) < 0 {
601 return ""
602 }
603 return statusDot
604}
605
606// renderList draws each visible row: a dimmed tree prefix, the name (bold on
607// unselected live session and host headers), the attention marker, then for
608// headers a right-aligned muted detail (tab count, "*" when attached) and a
609// problem dot in a fixed xterm-256 color. Section headers carry their host's
610// problem dot. A tight width truncates the detail before the name.
611func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
612 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
613 return
614 }
615 visible := min(rect.Height, frame.Height-rect.Y)
616 offset := m.scrollOffset(visible)
617 clipX := rect.X + rect.Width
618 for y := range visible {
619 idx := offset + y
620 if idx >= len(m.rows) {
621 break
622 }
623 r := m.rows[idx]
624 base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
625 if r.section() {
626 nameStyle = styles.Detail
627 }
628 if idx == m.selected {
629 base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
630 }
631 if r.line.Stopped && idx != m.selected {
632 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
633 }
634 if r.line.Dim || m.searchActive && m.query.Value() != "" && !m.rowMatches(idx) {
635 base.Attrs |= renderer.AttrDim
636 nameStyle.Attrs |= renderer.AttrDim
637 detailStyle.Attrs |= renderer.AttrDim
638 }
639 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
640
641 name := r.line.Label
642 if r.section() {
643 nameStyle.Bold = true
644 } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped {
645 nameStyle.Bold = true
646 }
647 treeX := rect.X
648 if r.tree != "" {
649 treeStyle := base
650 // A caller style set without its own Tree style keeps the row's
651 // colors dimmed; a zero Tree style would be black on black.
652 if idx == m.selected || r.line.Stopped || styles.Tree == (renderer.Style{}) {
653 treeStyle.Attrs |= renderer.AttrDim
654 } else {
655 // Only the foreground comes from Tree: the row's fill and its
656 // dimming (a Dim line or a search miss) stay as they are.
657 treeStyle.Foreground = styles.Tree.Foreground
658 treeStyle.HasForegroundRGB = styles.Tree.HasForegroundRGB
659 treeStyle.ForegroundRGB = styles.Tree.ForegroundRGB
660 }
661 treeX = ui.DrawText(frame, rect.X, rect.Y+y, max(rect.X, clipX-2), r.tree, treeStyle)
662 }
663 badge := ""
664 if r.rendersAsHeader() || r.section() {
665 badge = lineStatusBadge(r.line.Status)
666 }
667 contentClipX := clipX
668 nameWidth := max(clipX-treeX, 0)
669 badgeWidth := textCellWidth(badge)
670 if badge != "" {
671 nameWidth = max(clipX-treeX-badgeWidth-1, 0)
672 contentClipX = max(rect.X, clipX-badgeWidth-1)
673 }
674 originalName := name
675 name = ui.TruncateText(name, nameWidth)
676 nameMatchStyle := styles.SearchMatch
677 if idx == m.selected {
678 nameMatchStyle = styles.SelectionMatch
679 }
680 namePositions := visibleMatchPositions(m.matchPositions(idx, matchName), name, name != originalName)
681 x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
682
683 if r.section() {
684 drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)
685 continue
686 }
687 if r.rendersAsHeader() {
688 if r.line.Attention && x < contentClipX {
689 // A session header rings when any of its tabs does.
690 x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " "+string(ui.AttentionGlyph), base)
691 }
692 // The short detail (tab count, attached marker) is right-aligned
693 // before the badge, muted but not dimmed so it stays readable.
694 if r.line.Detail != "" && x < contentClipX-1 {
695 detail := ui.TruncateText(r.line.Detail, contentClipX-x-1)
696 detailX := max(x+1, contentClipX-textCellWidth(detail))
697 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
698 drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
699 }
700 drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)
701 continue
702 }
703
704 if r.line.Attention {
705 x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base)
706 }
707
708 if r.line.Detail != "" && x < clipX {
709 x = ui.DrawText(frame, x, rect.Y+y, clipX, " ", detailStyle)
710 }
711 detail := ui.TruncateText(r.line.Detail, clipX-x)
712 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
713 drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions)
714 }
715}
716
717func (m *Model) matchPositions(idx int, field matchField) []int {
718 if m == nil || !m.searchActive || m.query.Value() == "" {
719 return nil
720 }
721 return m.searchMatches[idx].positions(field)
722}
723
724func visibleMatchPositions(positions []int, rendered string, truncated bool) []int {
725 if !truncated || len(positions) == 0 {
726 return positions
727 }
728 limit := max(len([]rune(rendered))-1, 0) // the final rune is the ellipsis
729 visible := make([]int, 0, len(positions))
730 for _, position := range positions {
731 if position < limit {
732 visible = append(visible, position)
733 }
734 }
735 return visible
736}
737
738func drawMatchedText(frame renderer.Frame, x, y, clipX int, text string, base, match renderer.Style, positions []int) int {
739 if len(positions) == 0 {
740 return ui.DrawText(frame, x, y, clipX, text, base)
741 }
742 positionIndex := 0
743 runeIndex := 0
744 for _, r := range text {
745 style := base
746 if positionIndex < len(positions) && positions[positionIndex] == runeIndex {
747 style = match
748 positionIndex++
749 }
750 x = ui.DrawText(frame, x, y, clipX, string(r), style)
751 if x >= clipX {
752 break
753 }
754 runeIndex++
755 }
756 return x
757}
758
759func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style renderer.Style) {
760 if rect.Width <= 0 || rect.Height <= 0 || rect.Y < 0 || rect.Y >= frame.Height {
761 return
762 }
763 ui.FillRect(frame, rect, renderer.Cell{Rune: ' ', Style: style})
764 // The footer describes the effective selection: a row kept for inspection,
765 // a row the query hides, and an empty picker never promise a commit.
766 action := ""
767 deletable := false
768 selected := protocol.PickerLine{}
769 hasCursorRow := m != nil && m.selected >= 0 && m.selected < len(m.rows)
770 if hasCursorRow {
771 selected = m.rows[m.selected].line
772 }
773 if m != nil && m.committable(m.selected) {
774 action = actionVerb(selected, m.intent)
775 deletable = selected.Actions&protocol.PickerCanKill != 0
776 }
777 enter := "Enter " + action
778 if action == "" {
779 enter = "Enter unavailable"
780 }
781 var groups []string
782 if m != nil && m.searchActive {
783 groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"}
784 escape := "Esc exit"
785 if m.query.Value() != "" {
786 escape = "Esc clear"
787 }
788 groups = append(groups, escape)
789 } else {
790 if hasCursorRow && selected.Dim && selected.StatusDetail != "" {
791 groups = append(groups, selected.StatusDetail)
792 }
793 if rect.Width < 60 {
794 groups = append(groups, enter, "/", "Esc", "j/k")
795 if deletable {
796 groups = append(groups, "x")
797 }
798 groups = append(groups, "s")
799 } else {
800 groups = append(groups, "j/k move", enter)
801 if deletable {
802 groups = append(groups, "x delete")
803 }
804 groups = append(groups, "/ search", "s sort", "Esc close")
805 }
806 }
807 text := strings.Join(groups, " ")
808 ui.DrawText(frame, rect.X, rect.Y, rect.X+rect.Width, ui.TruncateText(text, rect.Width), style)
809}
810
811// actionVerb names what Enter does on one row, so the footer never promises
812// an action the source did not authorise.
813func actionVerb(line protocol.PickerLine, intent protocol.PickerIntent) string {
814 if line.Actions == 0 {
815 return "unavailable"
816 }
817 if line.Actions&protocol.PickerCanMove != 0 {
818 if intent == protocol.PickerIntentMoveTab {
819 return "move"
820 }
821 return "move pane"
822 }
823 if line.Stopped {
824 return "restart"
825 }
826 return "open"
827}
828
829func (m *Model) scrollOffset(visible int) int {
830 if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
831 return 0
832 }
833 if m.selected < visible {
834 return 0
835 }
836 offset := m.selected - visible + 1
837 return min(offset, len(m.rows)-visible)
838}
839
840func clamp(n, low, high int) int {
841 if n < low {
842 return low
843 }
844 if n > high {
845 return high
846 }
847 return n
848}
849
850// ChooseGeometry reserves the final inner row for picker-local status before
851// solving the list and preview layout.
852func ChooseGeometry(inner domain.Size) Geometry {
853 if inner.Cols <= 0 || inner.Rows <= 0 {
854 return Geometry{}
855 }
856 contentRows := max(inner.Rows-1, 0)
857 layout := ChooseLayout(domain.Size{Cols: inner.Cols, Rows: contentRows})
858 return Geometry{
859 Content: domain.Rect{Width: inner.Cols, Height: contentRows},
860 Status: domain.Rect{Y: contentRows, Width: inner.Cols, Height: 1},
861 Mode: layout.Mode, List: layout.List, Separator: layout.Separator, Preview: layout.Preview,
862 }
863}
864
865func ChooseLayout(inner domain.Size) Layout {
866 if inner.Cols <= 0 || inner.Rows <= 0 {
867 return Layout{}
868 }
869 if inner.Rows >= MinPaneHeight {
870 listWidth := clamp(inner.Cols*40/100, MinListWidth, MaxListWidth)
871 listWidth = min(listWidth, inner.Cols-MinHorizontalPreviewWidth-1)
872 previewWidth := inner.Cols - listWidth - 1
873 if listWidth >= MinHorizontalListWidth && previewWidth >= MinHorizontalPreviewWidth {
874 return Layout{
875 Mode: LayoutHorizontal,
876 List: domain.Rect{Width: listWidth, Height: inner.Rows},
877 Separator: domain.Rect{X: listWidth, Width: 1, Height: inner.Rows},
878 Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
879 }
880 }
881 }
882 if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth {
883 listHeight := max(MinPaneHeight, inner.Rows*40/100)
884 previewHeight := inner.Rows - listHeight - 1
885 return Layout{
886 Mode: LayoutStacked,
887 List: domain.Rect{Width: inner.Cols, Height: listHeight},
888 Separator: domain.Rect{Y: listHeight, Width: inner.Cols, Height: 1},
889 Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight},
890 }
891 }
892 return Layout{Mode: LayoutListOnly, List: domain.Rect{Width: inner.Cols, Height: inner.Rows}}
893}