1package picker
2
3import (
4 "math"
5
6 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/usecase/ui"
9)
10
11const (
12 MinPreviewWidth = 24
13 MinHorizontalPreviewWidth = 48
14 MinListWidth = 16
15 MaxListWidth = 32
16 MinPaneHeight = 4
17 MinStackHeight = 12
18)
19
20type RemoteAvailability uint8
21
22const (
23 RemoteNone RemoteAvailability = iota
24 RemoteCached
25 RemoteFresh
26 RemoteStale
27 RemoteVersionMismatch
28)
29
30// RemoteActivation is the picker action authorized by the current remote
31// catalog snapshot. It is presentation state only; RemoteSessionTarget remains
32// the exact route and is revalidated when the picker hands off the client.
33type RemoteActivation uint8
34
35const (
36 RemoteUnavailable RemoteActivation = iota
37 RemoteAttach
38 RemoteRestart
39)
40
41type SessionView struct {
42 ID domain.SessionID
43 Incarnation domain.IncarnationID
44 Name string
45 TargetName string
46 Tabs []TabEntry
47 Active int
48 Stopped bool
49 ExpectedCreatedAt *int64
50 RemoteKey *domain.RemoteSessionKey
51 // RemoteTarget is the structured route/lifecycle identity for picker rows.
52 // It is never reconstructed from Name or a rendered label.
53 RemoteTarget *domain.RemoteSessionTarget
54 // RemoteHost marks remote host status rows that have no session key.
55 RemoteHost string
56 RemoteAvailability RemoteAvailability
57 RemoteDetail string
58 RemoteReason string
59 RemoteActivation RemoteActivation
60 ConnectOnly bool
61 // CannotAcceptMoves reports whether this session cannot receive a moved tab
62 // or pane. False for ordinary local (and stopped) sessions; true for
63 // restricted remote rows.
64 CannotAcceptMoves bool
65}
66
67// TabEntry is one tab row; Name is drawn emphasized, Detail muted.
68type TabEntry struct {
69 TabID domain.TabStableID // stable tab identity; independent of its current index
70 Name string // tab display name
71 RawName string // unformatted name used by exact remote selectors
72 Detail string // " (paneTitle)" or "", drawn muted
73 Attention bool // draw the attention marker right after Name, before Detail
74}
75
76type SelectionMode uint8
77
78const (
79 SelectNavigationTab SelectionMode = iota
80 SelectMovePaneTab
81 SelectMoveTabSession
82)
83
84type SourceFilter struct {
85 Session domain.SessionID
86 Incarnation domain.IncarnationID
87 TabID domain.TabStableID
88 RemoteKey *domain.RemoteSessionKey
89}
90
91// SelectionConfig describes which rows are selectable, which stable target
92// should remain selected, and which source must not be offered as a move
93// destination.
94type SelectionConfig struct {
95 Mode SelectionMode
96 Current SourceFilter
97 Source SourceFilter
98}
99
100// RenderStyles are the styles Render uses to draw list rows. The zero value
101// (all renderer.Style{}) is never used directly; Render falls back to
102// defaultRenderStyles when no RenderStyles is supplied, mirroring the styling
103// of a non-truecolor client.
104type RenderStyles struct {
105 Selection renderer.Style // selected row fill + suffixes
106 SelectionName renderer.Style // selected row name segment
107 SelectionMuted renderer.Style // selected row detail segment
108 Name renderer.Style // non-selected name segment
109 Detail renderer.Style // non-selected detail segment
110 // Background fills otherwise unused interior cells. Base owns ordinary
111 // rows, preserving a distinct inactive surface from modal chrome.
112 Background renderer.Style
113 Base renderer.Style // non-selected row fill + suffixes
114 Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix)
115 Separator renderer.Style // preview separator
116}
117
118func defaultRenderStyles() RenderStyles {
119 selection := renderer.DefaultStyle()
120 selection.Inverse = true
121 base := renderer.DefaultStyle()
122 separator := renderer.DefaultStyle()
123 separator.Attrs = renderer.AttrDim
124 stopped := renderer.DefaultStyle()
125 stopped.Attrs = renderer.AttrDim
126 stopped.Italic = true
127 return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped}
128}
129
130type Target struct {
131 Session domain.SessionID
132 Incarnation domain.IncarnationID
133 Name string
134 RemoteKey *domain.RemoteSessionKey
135 RemoteTarget *domain.RemoteSessionTarget
136 RemoteHost string
137 UnavailableReason string
138 TabID domain.TabStableID
139 TabIndex int
140 Stopped bool
141 // ExpectedCreatedAt optionally pins this target to a particular named
142 // session lifecycle. Callers that obtain a snapshot outside the daemon use
143 // it to reject a same-name replacement at commit time.
144 ExpectedCreatedAt *int64
145}
146
147// Preview is a bounded view of the selected pane's visible frame.
148type Preview = ui.FrameView
149
150type LayoutMode uint8
151
152const (
153 LayoutListOnly LayoutMode = iota
154 LayoutStacked
155 LayoutHorizontal
156)
157
158type Layout struct {
159 Mode LayoutMode
160 List domain.Rect
161 Separator domain.Rect
162 Preview domain.Rect
163}
164
165type Model struct {
166 mode SelectionMode
167 rows []row
168 selected int
169}
170
171type rowKind uint8
172
173const (
174 rowSession rowKind = iota
175 rowTab
176)
177
178func (k rowKind) rendersAsHeader() bool {
179 return k == rowSession
180}
181
182func (k rowKind) selectable(mode SelectionMode) bool {
183 switch mode {
184 case SelectNavigationTab, SelectMovePaneTab:
185 return k == rowTab
186 case SelectMoveTabSession:
187 return k == rowSession
188 default:
189 return false
190 }
191}
192
193type row struct {
194 kind rowKind
195 dispName string // display name segment; bold on truecolor
196 detail string // " (paneTitle)" segment, muted; "" for session rows
197 attention bool // draw the attention marker right after the name, before detail; tab rows only
198 session domain.SessionID
199 incarnation domain.IncarnationID
200 // targetName is the named-session lookup name threaded into Target;
201 // distinct from dispName, which is what gets drawn.
202 targetName string
203 tabID domain.TabStableID
204 tabIndex int
205 stopped bool
206 expectedCreatedAt int64
207 hasExpectedCreatedAt bool
208 remoteKey domain.RemoteSessionKey
209 hasRemoteKey bool
210 remote bool
211 remoteTarget domain.RemoteSessionTarget
212 hasRemoteTarget bool
213 remoteHost string
214 unavailableReason string
215 selectable bool
216 focusable bool
217 dim bool
218}
219
220func New(sessions []SessionView, config SelectionConfig) *Model {
221 m := &Model{mode: config.Mode, selected: -1}
222 activeSelection := -1
223 for _, session := range sessions {
224 sessionRows := rowsForSession(session, config)
225 for _, pickerRow := range sessionRows {
226 idx := len(m.rows)
227 m.rows = append(m.rows, pickerRow)
228 if selectionMatches(pickerRow, config.Current, config.Mode) {
229 m.selected = idx
230 }
231 if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.focusable && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
232 activeSelection = idx
233 }
234 }
235 }
236 if m.selected < 0 {
237 m.selected = activeSelection
238 }
239 if m.selected < 0 {
240 m.selected = m.firstFocusable()
241 }
242 if m.selected < 0 && len(m.rows) > 0 {
243 m.selected = 0
244 }
245 return m
246}
247
248// rowsForSession is the sole owner of mode-specific destination eligibility.
249// The daemon supplies canonical lifecycle/tab snapshots without prefiltering.
250func rowsForSession(session SessionView, config SelectionConfig) []row {
251 if config.Mode != SelectNavigationTab && session.Stopped {
252 return nil
253 }
254 if config.Mode == SelectMoveTabSession && sourceMatchesSession(config.Source, session) {
255 return nil
256 }
257 if config.Mode == SelectMoveTabSession && len(session.Tabs) == 0 && !session.CannotAcceptMoves {
258 return nil
259 }
260
261 targetName := session.TargetName
262 if config.Mode == SelectNavigationTab && !session.Stopped && session.ExpectedCreatedAt == nil {
263 targetName = ""
264 } else if targetName == "" {
265 targetName = session.Name
266 }
267 expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt)
268 common := row{
269 session: session.ID, incarnation: session.Incarnation, targetName: targetName,
270 stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,
271 hasExpectedCreatedAt: hasExpectedCreatedAt,
272 }
273 if session.RemoteKey != nil {
274 common.remoteKey, common.hasRemoteKey = *session.RemoteKey, true
275 }
276 if session.RemoteTarget != nil {
277 common.remoteTarget, common.hasRemoteTarget = *session.RemoteTarget, true
278 }
279 common.remoteHost = session.RemoteHost
280 common.remote = common.hasRemoteKey || common.hasRemoteTarget || session.RemoteHost != ""
281 header := common
282 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
283 if common.hasRemoteKey {
284 header.dispName = common.remoteKey.Name
285 display := common.remoteKey.Display()
286 header.detail = display[len(common.remoteKey.Name):]
287 }
288 header.selectable = header.kind.selectable(config.Mode)
289 header.focusable = header.selectable
290 if common.remote {
291 if session.RemoteDetail != "" {
292 if header.detail != "" {
293 header.detail += " (" + session.RemoteDetail + ")"
294 } else {
295 header.detail = session.RemoteDetail
296 }
297 }
298 if common.hasRemoteTarget {
299 header.selectable = false
300 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0
301 } else {
302 header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteActivatable(session)
303 header.focusable = header.selectable
304 if !common.hasRemoteKey && !common.hasRemoteTarget {
305 header.focusable = config.Mode == SelectNavigationTab
306 }
307 }
308 header.unavailableReason = session.RemoteReason
309 header.dim = session.RemoteActivation == RemoteUnavailable
310 }
311 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
312 header.selectable, header.focusable, header.dim = false, false, true
313 }
314 rows := []row{header}
315 if !session.ConnectOnly || common.hasRemoteTarget {
316 for i, tab := range session.Tabs {
317 if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
318 continue
319 }
320 tabRow := common
321 tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
322 tabRow.tabID, tabRow.tabIndex = tab.TabID, i
323 tabRow.selectable = tabRow.kind.selectable(config.Mode)
324 tabRow.focusable = tabRow.selectable
325 if common.remote {
326 tabRow.unavailableReason = session.RemoteReason
327 if common.hasRemoteTarget {
328 remoteTarget := common.remoteTarget
329 if session.Stopped {
330 remoteTarget.LiveTabID = ""
331 if tab.TabID != "" {
332 remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)
333 } else {
334 tabCount := min(len(session.Tabs), math.MaxUint16)
335 remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount))
336 }
337 } else {
338 remoteTarget.StoppedTab = domain.TabSelector{}
339 remoteTarget.LiveTabID = tab.TabID
340 }
341 // Keep the selected tab's structured identity even when it is
342 // invalid. Focus remains possible for diagnostics, while both
343 // the readiness gate and daemon wire validation reject it.
344 tabRow.remoteTarget = remoteTarget
345 tabRow.hasRemoteTarget = true
346 }
347 if !tabRow.hasRemoteTarget {
348 legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteActivatable(session)
349 tabRow.focusable = legacyRemote
350 tabRow.selectable = legacyRemote
351 } else {
352 tabRow.focusable = true
353 tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)
354 }
355 tabRow.dim = session.RemoteActivation == RemoteUnavailable
356 }
357 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
358 tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
359 }
360 rows = append(rows, tabRow)
361 }
362 }
363 if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {
364 return nil
365 }
366 return rows
367}
368
369func remoteActivatable(session SessionView) bool {
370 return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart
371}
372
373func sourceMatchesSession(source SourceFilter, session SessionView) bool {
374 if source.Session != session.ID {
375 return false
376 }
377 return source.Incarnation == (domain.IncarnationID{}) || source.Incarnation == session.Incarnation
378}
379
380func sourceMatchesTab(source SourceFilter, session SessionView, tab TabEntry) bool {
381 return sourceMatchesSession(source, session) && source.TabID == tab.TabID
382}
383
384func normalizedActive(session SessionView) int {
385 if session.Active < 0 || session.Active >= len(session.Tabs) {
386 return 0
387 }
388 return session.Active
389}
390
391func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) bool {
392 if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) {
393 return false
394 }
395 if pickerRow.hasRemoteKey && pickerRow.kind == rowSession {
396 return current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey
397 }
398 if mode == SelectMoveTabSession {
399 return pickerRow.kind == rowSession
400 }
401 if pickerRow.kind != rowTab {
402 return false
403 }
404 // A stopped session contributes one synthetic tab row carrying no stable tab
405 // identity, so it matches on session identity alone. Its session ID is
406 // namespaced ("stopped:<name>"), and move modes drop stopped sessions
407 // entirely, so no live row can be reached this way.
408 if current.TabID == "" {
409 return pickerRow.stopped
410 }
411 return pickerRow.tabID == current.TabID
412}
413
414func int64Value(value *int64) (int64, bool) {
415 if value == nil {
416 return 0, false
417 }
418 return *value, true
419}
420
421func ChooseLayout(inner domain.Size) Layout {
422 if inner.Cols <= 0 || inner.Rows <= 0 {
423 return Layout{}
424 }
425 if inner.Rows >= MinPaneHeight {
426 listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth)
427 previewWidth := inner.Cols - listWidth - 1
428 if previewWidth >= MinHorizontalPreviewWidth {
429 return Layout{
430 Mode: LayoutHorizontal,
431 List: domain.Rect{Width: listWidth, Height: inner.Rows},
432 Separator: domain.Rect{X: listWidth, Width: 1, Height: inner.Rows},
433 Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
434 }
435 }
436 }
437 if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth {
438 listHeight := max(MinPaneHeight, inner.Rows*40/100)
439 previewHeight := inner.Rows - listHeight - 1
440 return Layout{
441 Mode: LayoutStacked,
442 List: domain.Rect{Width: inner.Cols, Height: listHeight},
443 Separator: domain.Rect{Y: listHeight, Width: inner.Cols, Height: 1},
444 Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight},
445 }
446 }
447 return Layout{Mode: LayoutListOnly, List: domain.Rect{Width: inner.Cols, Height: inner.Rows}}
448}
449
450func (m *Model) Up() {
451 m.move(-1)
452}
453
454func (m *Model) Down() {
455 m.move(1)
456}
457
458// Cursor reports the identity under the picker cursor independently from
459// whether that row may be activated. Remote refreshes use it to preserve a
460// structured remote session key while Selected continues to enforce safety.
461func (m *Model) Cursor() (Target, bool) {
462 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
463 return Target{}, false
464 }
465 return m.rows[m.selected].target(), true
466}
467
468func (m *Model) Selected() (Target, bool) {
469 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
470 return Target{}, false
471 }
472 r := m.rows[m.selected]
473 if !r.selectable {
474 return Target{}, false
475 }
476 return r.target(), true
477}
478
479// SelectedIndex reports the raw selected row index. It is -1 only when the
480// model is nil or has no rows at all; otherwise it is a real row index even
481// when that row is not selectable (see Selected). Row indices are only
482// meaningful against this exact model.
483func (m *Model) SelectedIndex() int {
484 if m == nil {
485 return -1
486 }
487 return m.selected
488}
489
490// SelectNearestRow selects the first selectable row at or after idx, falling
491// back to the last selectable row before it. Callers use it to keep the
492// cursor on the row that takes a removed item's place.
493func (m *Model) SelectNearestRow(idx int) {
494 if m == nil || len(m.rows) == 0 {
495 return
496 }
497 idx = clamp(idx, 0, len(m.rows)-1)
498 for i := idx; i < len(m.rows); i++ {
499 if m.rows[i].focusable {
500 m.selected = i
501 return
502 }
503 }
504 for i := idx - 1; i >= 0; i-- {
505 if m.rows[i].focusable {
506 m.selected = i
507 return
508 }
509 }
510}
511
512func (m *Model) Clone() *Model {
513 if m == nil {
514 return nil
515 }
516 clone := *m
517 clone.rows = append([]row(nil), m.rows...)
518 return &clone
519}
520
521func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame {
522 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
523 layout := ChooseLayout(inner)
524 styleSet := defaultRenderStyles()
525 if len(styles) > 0 {
526 styleSet = styles[0]
527 }
528 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background})
529 m.renderList(frame, layout.List, styleSet)
530 switch layout.Mode {
531 case LayoutHorizontal:
532 ui.DrawSeparator(frame, layout.Separator, ui.SeparatorVertical, styleSet.Separator)
533 case LayoutStacked:
534 ui.DrawSeparator(frame, layout.Separator, ui.SeparatorHorizontal, styleSet.Separator)
535 }
536 ui.BlitFrame(frame, layout.Preview, preview, ui.VerticalAnchorBottom)
537 return frame
538}
539
540func (m *Model) move(delta int) {
541 if m == nil || len(m.rows) == 0 {
542 return
543 }
544 if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].focusable {
545 m.selected = m.firstFocusable()
546 return
547 }
548 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
549 if m.rows[i].focusable {
550 m.selected = i
551 return
552 }
553 }
554}
555
556func (m *Model) firstFocusable() int {
557 for i, r := range m.rows {
558 if r.focusable {
559 return i
560 }
561 }
562 return -1
563}
564
565func (r row) target() Target {
566 var remoteTarget *domain.RemoteSessionTarget
567 if r.hasRemoteTarget {
568 copyTarget := r.remoteTarget
569 remoteTarget = ©Target
570 }
571 return Target{
572 Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), RemoteTarget: remoteTarget,
573 RemoteHost: r.remoteHost, UnavailableReason: r.unavailableReason, TabID: r.tabID, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
574 }
575}
576
577func (r row) expectedCreatedAtPointer() *int64 {
578 if !r.hasExpectedCreatedAt {
579 return nil
580 }
581 value := r.expectedCreatedAt
582 return &value
583}
584
585func (r row) remoteKeyPointer() *domain.RemoteSessionKey {
586 if !r.hasRemoteKey {
587 return nil
588 }
589 key := r.remoteKey
590 return &key
591}
592
593// renderList draws each visible row as up to three segments: a name segment
594// (bold when styles came from a truecolor theme), a base-styled attention
595// marker right after the name, and a muted detail segment (tab rows only) —
596// or a base-styled "(down)" suffix for down session headers. A tight width
597// ellipsizes the detail segment before eating into the name.
598func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
599 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
600 return
601 }
602 visible := min(rect.Height, frame.Height-rect.Y)
603 offset := m.scrollOffset(visible)
604 clipX := rect.X + rect.Width
605 for y := range visible {
606 idx := offset + y
607 if idx >= len(m.rows) {
608 break
609 }
610 r := m.rows[idx]
611 base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
612 if idx == m.selected {
613 base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
614 }
615 if r.stopped && idx != m.selected {
616 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
617 }
618 if r.dim {
619 base.Attrs |= renderer.AttrDim
620 nameStyle.Attrs |= renderer.AttrDim
621 detailStyle.Attrs |= renderer.AttrDim
622 }
623 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
624
625 name := r.dispName
626 if !r.kind.rendersAsHeader() {
627 name = " " + name
628 }
629 suffix := ""
630 if r.kind.rendersAsHeader() && r.stopped {
631 suffix = "(down)"
632 }
633 name = ui.TruncateText(name, max(rect.Width-len(suffix), 0))
634 x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)
635
636 if r.kind.rendersAsHeader() {
637 if r.stopped {
638 detail := r.detail
639 if detail == "" {
640 detail = " "
641 } else {
642 detail += " "
643 }
644 detail = ui.TruncateText(detail, max(clipX-x-len(suffix), 0))
645 x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, base)
646 ui.DrawText(frame, x, rect.Y+y, clipX, suffix, base)
647 } else {
648 ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle)
649 }
650 continue
651 }
652
653 if r.attention {
654 x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base)
655 }
656
657 detail := ui.TruncateText(r.detail, clipX-x)
658 ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle)
659 }
660}
661
662func (m *Model) scrollOffset(visible int) int {
663 if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
664 return 0
665 }
666 if m.selected < visible {
667 return 0
668 }
669 offset := m.selected - visible + 1
670 return min(offset, len(m.rows)-visible)
671}
672
673func clamp(n, low, high int) int {
674 if n < low {
675 return low
676 }
677 if n > high {
678 return high
679 }
680 return n
681}