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