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