package picker import ( "fmt" "slices" "strings" renderer "github.com/bnema/vev-vt" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/usecase/ui" ) const ( MinPreviewWidth = 24 MinHorizontalPreviewWidth = 48 MinListWidth = 16 MinHorizontalListWidth = 20 MaxListWidth = 44 MinPaneHeight = 4 MinStackHeight = 12 ) // SortMode orders the rows of one source locally. The source publishes its // lines with rank and ephemeral markers; the presenting client decides the // order, so two attachments of the same daemon never share a sort mode. type SortMode uint8 const ( // SortRecent orders every run by the source's recency rank. SortRecent SortMode = iota // SortGrouped puts named sessions before ephemeral ones inside each run, // each keeping recency order. SortGrouped ) // Config describes one presented model: which intent produced the lines, the // source's cursor hint, and the local sort order. type Config struct { Intent protocol.PickerIntent Cursor protocol.PickerCursor Sort SortMode } // RenderStyles are the styles Render uses to draw list rows. The zero value // (all renderer.Style{}) is never used directly; Render falls back to // defaultRenderStyles when no RenderStyles is supplied, mirroring the styling // of a non-truecolor client. type RenderStyles struct { Selection renderer.Style // selected row fill + suffixes SelectionName renderer.Style // selected row name segment SelectionMuted renderer.Style // selected row detail segment Name renderer.Style // non-selected name segment Detail renderer.Style // non-selected detail segment // Background fills otherwise unused interior cells. Base owns ordinary // rows, preserving a distinct inactive surface from modal chrome. Background renderer.Style Base renderer.Style // non-selected row fill + suffixes Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix) Separator renderer.Style // preview separator Tree renderer.Style // foreground of tree prefixes on non-selected rows; zero keeps them dimmed Status renderer.Style // one-row picker-local contextual help SearchMatch renderer.Style // matched runes on ordinary rows SelectionMatch renderer.Style // matched runes on the selected row // ProblemBold draws error and warning dots bold. A monochrome terminal // drops the fixed problem colors, so weight is what sets them apart. ProblemBold bool } // DefaultRenderStyles returns the picker's built-in styles. monochrome selects // the attribute-only variant: tree lines are dimmed instead of grey and // problem dots are bold, since no color survives on such a terminal. func DefaultRenderStyles(monochrome bool) RenderStyles { styles := defaultRenderStyles() if monochrome { styles.Tree = renderer.Style{} // zero Tree keeps the dimmed row style styles.ProblemBold = true } return styles } func defaultRenderStyles() RenderStyles { selection := renderer.DefaultStyle() selection.Inverse = true base := renderer.DefaultStyle() separator := renderer.DefaultStyle() separator.Attrs = renderer.AttrDim stopped := renderer.DefaultStyle() stopped.Attrs = renderer.AttrDim stopped.Italic = true searchMatch := base searchMatch.Bold = true selectionMatch := selection selectionMatch.Bold = true // Tree lines use the palette's bright black (the theme's grey) rather // than SGR dim: terminals such as kitty draw box-drawing glyphs // themselves and barely dim them, so dim lines stayed near full white. tree := renderer.DefaultStyle() tree.Foreground = 244 // fixed mid grey: readable on dark and light themes 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} } // Preview is a bounded view of the selected pane's visible frame. type Preview = ui.FrameView type LayoutMode uint8 const ( LayoutListOnly LayoutMode = iota LayoutStacked LayoutHorizontal ) type Layout struct { Mode LayoutMode List domain.Rect Separator domain.Rect Preview domain.Rect } // Geometry is the single picker layout contract consumed by rendering and // preview sizing. Status always reserves the final available inner row. type Geometry struct { Content domain.Rect Status domain.Rect Mode LayoutMode List domain.Rect Separator domain.Rect Preview domain.Rect } // Model is the pure presentation state of one picker: the published lines, // the local cursor, the search editor, and the local sort order. It owns no // target resolution and no mutation authority: the source that published a // line resolves its opaque key when the client commits it. type Model struct { intent protocol.PickerIntent sort SortMode lines []protocol.PickerLine rows []row selected int searchActive bool query ui.TextInput searchMatches map[int]searchMatch matchRows []int // folded names the section headers the user collapsed. It is local // presentation state, kept across publications by section label. folded map[string]bool // hidden and view describe which rows are drawn: a collapsed section hides // its rows, and a non-empty query shows only matches and their ancestors. // Both are replaced, never mutated, so clones may share them. hidden []bool view []int } // row is one rendered line plus its case-folded search fields. type row struct { line protocol.PickerLine foldedLabel string foldedDetail string // foldedContext is the owning session's name for a tab row, so searching a // session name reaches the tab rows that are its destinations. foldedContext string // tree is the dimmed branch prefix drawn before the label ("├─ ", "│ └─ "…). tree string // sectionID names a section header locally: its label plus its occurrence // among same-label sections, so two hosts sharing a label fold apart. sectionID string } func (r row) kind() protocol.PickerLineKind { return r.line.Kind } func (r row) section() bool { return r.line.Kind == protocol.PickerLineSection } func (r row) selectable() bool { return r.line.Actions != 0 } // focusable reports the source's authorisation to rest the cursor on this row. // A section header is never a destination; every other shape follows the // published flag, so a row the source kept for inspection stays reachable while // a row it skipped is passed over. The model lets the cursor rest on a section // header on its own, only to fold it (see eligible). func (r row) focusable() bool { return r.line.Kind != protocol.PickerLineSection && r.line.Focusable } // id names the row for cursor restoration. A section has no source key, so // it is named by its sectionID, which no source key can collide with. func (r row) id() string { if r.section() { return r.sectionID } return r.line.Key } func (r row) rendersAsHeader() bool { return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost } // New builds a model from one source's published lines. Order is applied // locally: recency or grouped, inside each section run. func New(lines []protocol.PickerLine, config Config) *Model { m := &Model{intent: config.Intent, sort: config.Sort} m.setLines(lines, config.Cursor) return m } // Intent reports which picker produced this model. func (m *Model) Intent() protocol.PickerIntent { if m == nil { return 0 } return m.intent } // SortMode reports the local ordering mode. func (m *Model) SortMode() SortMode { if m == nil { return SortGrouped } return m.sort } // SetSort re-orders the rows locally, preserving the selected key. func (m *Model) SetSort(mode SortMode) { if m == nil { return } m.sort = mode key, hadKey := m.cursorKey() m.rebuild(key, hadKey, m.selected) } // setLines replaces the published lines and restores the cursor by key, // falling back to the source hint and then to the nearest focusable row. func (m *Model) setLines(lines []protocol.PickerLine, cursor protocol.PickerCursor) { if m == nil { return } m.lines = append(m.lines[:0], lines...) m.rebuild(cursor.Key, cursor.Key != "", cursor.Index) } // ReplaceLines applies a fresh publication of the same source while retaining // the local search editor and the selected key when it still exists. func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.PickerCursor) { if m == nil { return } key, hadKey := m.cursorKey() above := m.keysAboveCursor() m.lines = append(m.lines[:0], lines...) if !hadKey { key, hadKey = cursor.Key, cursor.Key != "" } m.rebuild(key, hadKey, cursor.Index) if hadKey && !m.cursorRestsOn(key) { // The selected row left the publication (for example it was killed) // or stopped matching the query: rest on the nearest surviving row // above it instead of jumping elsewhere. m.selectFirstKey(above) } } // cursorRestsOn reports whether the cursor is on the row with this id, or on // the collapsed section that hides it. func (m *Model) cursorRestsOn(id string) bool { current, ok := m.cursorKey() if !ok { return false } if current == id { return true } if !m.rows[m.selected].section() { return false } for idx, r := range m.rows { if r.id() == id { return m.sectionOf(idx) == m.selected } } return false } // keysAboveCursor lists the row keys above the cursor, nearest first. Section // headers are left out: a removed row never hands the cursor to a header. func (m *Model) keysAboveCursor() []string { var keys []string for i := m.selected - 1; i >= 0; i-- { if m.target(i) { keys = append(keys, m.rows[i].id()) } } return keys } // selectFirstKey moves the cursor to the first key still present, if any. func (m *Model) selectFirstKey(keys []string) { index := make(map[string]int, len(m.rows)) for idx, candidate := range m.rows { if m.eligible(idx) { index[candidate.id()] = idx } } for _, key := range keys { if idx, ok := index[key]; ok { m.selected = idx return } } } // ReplaceProjection atomically changes the daemon-authored projection and its // local title mode while retaining search and selection state. func (m *Model) ReplaceProjection(lines []protocol.PickerLine, cursor protocol.PickerCursor, mode SortMode) { if m == nil { return } m.sort = mode m.ReplaceLines(lines, cursor) } // rebuild recomputes the row list from the published lines. Search matches are // row-index keyed, so they are recomputed here, before the cursor is restored: // restoration consults them to keep the cursor on a row the query shows. func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) { m.rows = m.rows[:0] run := make([]protocol.PickerLine, 0, len(m.lines)) flush := func() { if len(run) == 0 { return } for _, line := range m.sortRun(run) { // The picker draws its own gap before a detail. A source such as a // tab title (" (fish)") may already lead with a space, which would // double it; every source, local or remote, is trimmed here. line.Detail = strings.TrimSpace(line.Detail) m.rows = append(m.rows, row{line: line, foldedLabel: strings.ToLower(line.Label), foldedDetail: strings.ToLower(line.Detail)}) } run = run[:0] } sections := make(map[string]int) for _, line := range m.lines { if line.Kind == protocol.PickerLineSection { flush() sectionID := fmt.Sprintf("\x00section:%d:%s", sections[line.Label], line.Label) sections[line.Label]++ m.rows = append(m.rows, row{line: line, foldedLabel: strings.ToLower(line.Label), sectionID: sectionID}) continue } run = append(run, line) } flush() session := "" for i := range m.rows { switch m.rows[i].line.Kind { case protocol.PickerLineSession: session = m.rows[i].foldedLabel case protocol.PickerLineTab: m.rows[i].foldedContext = session default: session = "" } } m.searchMatches = nil m.matchRows = nil if m.searchActive { m.refreshSearch(false) } else { m.refreshView() } m.restoreSelection(key, hadKey, fallbackIndex) } // refreshView recomputes which rows are drawn. A non-empty query shows only // the matching rows plus their section and session headers, in tree order, and // ignores folds. Otherwise every row is drawn except those under a collapsed // section. func (m *Model) refreshView() { hidden := make([]bool, len(m.rows)) if m.searchRestricted() { section, session := -1, -1 for i, r := range m.rows { hidden[i] = true switch r.kind() { case protocol.PickerLineSection: section, session = i, -1 continue case protocol.PickerLineSession: session = i case protocol.PickerLineTab: default: session = -1 } if _, ok := m.searchMatches[i]; !ok { continue } hidden[i] = false if section >= 0 { hidden[section] = false } if r.kind() == protocol.PickerLineTab && session >= 0 { hidden[session] = false } } } else { folded := false for i, r := range m.rows { if r.section() { folded = m.folded[r.sectionID] continue } hidden[i] = folded } } view := make([]int, 0, len(m.rows)) for i := range m.rows { if !hidden[i] { view = append(view, i) } } m.hidden, m.view = hidden, view m.assignTreePrefixes() } // sectionFolded reports whether section row i is drawn collapsed. A query // shows matches regardless of folds, so it never draws a section collapsed. func (m *Model) sectionFolded(i int) bool { return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].sectionID] } // sectionOf reports the section header row i belongs to, or -1. func (m *Model) sectionOf(i int) int { for j := i; j >= 0; j-- { if m.rows[j].section() { return j } } return -1 } // setFold collapses or expands the section under the cursor and reports // whether anything changed. It never applies while a query filters the list. func (m *Model) setFold(fold bool) bool { if m == nil || m.searchRestricted() || m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].section() { return false } label := m.rows[m.selected].sectionID if m.folded[label] == fold { return false } folded := make(map[string]bool, len(m.folded)+1) for k, v := range m.folded { folded[k] = v } if fold { folded[label] = true } else { delete(folded, label) } m.folded = folded m.refreshView() return true } // CursorOnSection reports whether the cursor rests on a section header. func (m *Model) CursorOnSection() bool { return m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].section() } // ToggleSection collapses or expands the section header under the cursor and // reports whether it did. func (m *Model) ToggleSection() bool { if !m.CursorOnSection() { return false } return m.setFold(!m.folded[m.rows[m.selected].sectionID]) } // Left collapses the section under the cursor, or moves the cursor from a row // up to its section header. It reports whether anything changed. func (m *Model) Left() bool { if m == nil || m.selected < 0 || m.selected >= len(m.rows) { return false } if m.rows[m.selected].section() { return m.setFold(true) } if section := m.sectionOf(m.selected); m.eligible(section) { m.selected = section return true } return false } // Right expands the section under the cursor and reports whether it did. func (m *Model) Right() bool { return m.setFold(false) } // assignTreePrefixes gives each drawn row its tree branch: sessions and hosts // hang off their section, tabs off their session. The last drawn sibling // closes its branch with └─ and its children no longer continue the parent's │. func (m *Model) assignTreePrefixes() { parentLast := true for p, i := range m.view { r := &m.rows[i] switch { case r.section(): r.tree = "" case r.rendersAsHeader(): last := true for _, j := range m.view[p+1:] { if m.rows[j].section() { break } if m.rows[j].rendersAsHeader() { last = false break } } parentLast = last r.tree = treeBranch(last) case r.kind() == protocol.PickerLineTab: last := p+1 >= len(m.view) || m.rows[m.view[p+1]].kind() != protocol.PickerLineTab stem := "│ " if parentLast { stem = " " } r.tree = stem + treeBranch(last) default: r.tree = "" } } } func treeBranch(last bool) string { if last { return "└─ " } return "├─ " } // sortRun orders one section run locally. Recency keeps the source's // published order; grouped moves ephemeral sessions behind named ones without // disturbing the relative order inside each group. func (m *Model) sortRun(lines []protocol.PickerLine) []protocol.PickerLine { if len(lines) < 2 || m.sort != SortGrouped { return lines } named := make([]protocol.PickerLine, 0, len(lines)) ephemeral := make([]protocol.PickerLine, 0, len(lines)) for _, line := range lines { if line.Ephemeral { ephemeral = append(ephemeral, line) continue } named = append(named, line) } return append(named, ephemeral...) } func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) { if hadKey { for idx, candidate := range m.rows { if m.eligible(idx) && candidate.id() == key { m.selected = idx return } } // A row inside a collapsed section keeps the cursor on that section. for idx, candidate := range m.rows { if candidate.id() == key && idx < len(m.hidden) && m.hidden[idx] { if section := m.sectionOf(idx); m.eligible(section) { m.selected = section return } } } } m.selected = -1 m.SelectNearestRow(fallbackIndex) if m.selected < 0 { m.selected = m.firstEligible() } } // searchRestricted reports whether the active query narrows eligibility. An // empty query never hides a row. func (m *Model) searchRestricted() bool { return m != nil && m.searchActive && m.query.Value() != "" } // eligible reports whether the cursor may rest on row i: the row is drawn, // and either the source authorised it as a destination and the typed query, // when there is one, matches it, or it is a section header outside a query. func (m *Model) eligible(i int) bool { if m == nil || i < 0 || i >= len(m.rows) || i < len(m.hidden) && m.hidden[i] { return false } if m.rows[i].section() { return !m.searchRestricted() } if !m.rows[i].focusable() { return false } return !m.searchRestricted() || m.rowMatches(i) } // target reports whether the cursor may fall back to row i on its own: an // eligible row that is not a section header. func (m *Model) target(i int) bool { return m.eligible(i) && !m.rows[i].section() } // restable is target, or eligible when section headers are allowed. func (m *Model) restable(i int, sections bool) bool { if sections { return m.eligible(i) } return m.target(i) } // committable reports whether row i may be committed: it admits an action and // the typed query, when there is one, does not exclude it. func (m *Model) committable(i int) bool { if m == nil || i < 0 || i >= len(m.rows) || !m.rows[i].selectable() { return false } return !m.searchRestricted() || m.rowMatches(i) } func (m *Model) cursorKey() (string, bool) { if m == nil || m.selected < 0 || m.selected >= len(m.rows) { return "", false } return m.rows[m.selected].id(), true } func (m *Model) Up() { m.move(-1) } func (m *Model) Down() { m.move(1) } // Cursor reports the line under the picker cursor independently from whether // that row may be activated, so a refresh can preserve an anchor row. func (m *Model) Cursor() (protocol.PickerLine, bool) { if m == nil || m.selected < 0 || m.selected >= len(m.rows) { return protocol.PickerLine{}, false } return m.rows[m.selected].line, true } // Selected reports the line under the cursor when it admits an action and the // active query does not exclude it. func (m *Model) Selected() (protocol.PickerLine, bool) { if !m.committable(m.selected) { return protocol.PickerLine{}, false } return m.rows[m.selected].line, true } // SelectedIndex reports the raw selected row index. It is -1 when the model has // no rows or when nothing in them qualifies as a cursor destination (see // Selected); otherwise it is a real row index even when that row is not // selectable. func (m *Model) SelectedIndex() int { if m == nil { return -1 } return m.selected } // SelectNearestRow selects the nearest eligible row at or after idx, falling // back to the last eligible row before it. Section headers are taken only when // no other row qualifies. func (m *Model) SelectNearestRow(idx int) { if m == nil || len(m.rows) == 0 { return } idx = clamp(idx, 0, len(m.rows)-1) for _, sections := range [2]bool{false, true} { for i := idx; i < len(m.rows); i++ { if m.restable(i, sections) { m.selected = i return } } for i := idx - 1; i >= 0; i-- { if m.restable(i, sections) { m.selected = i return } } } } func (m *Model) Clone() *Model { if m == nil { return nil } clone := *m clone.lines = append([]protocol.PickerLine(nil), m.lines...) clone.rows = append([]row(nil), m.rows...) clone.query.SetValue(m.query.Value()) // Search results are copy-on-write: every query mutation publishes a new // map and row slice, so render snapshots can safely share the old values. clone.matchRows = m.matchRows clone.searchMatches = m.searchMatches return &clone } func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame { frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) geometry := ChooseGeometry(inner) styleSet := defaultRenderStyles() if len(styles) > 0 { styleSet = styles[0] } ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background}) m.renderList(frame, geometry.List, styleSet) switch geometry.Mode { case LayoutHorizontal: ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorVertical, styleSet.Separator) case LayoutStacked: ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorHorizontal, styleSet.Separator) } ui.BlitFrame(frame, geometry.Preview, preview, ui.VerticalAnchorBottom) m.renderStatus(frame, geometry.Status, styleSet.Status) return frame } func (m *Model) move(delta int) { if m == nil || len(m.rows) == 0 { return } if !m.eligible(m.selected) { m.selected = m.firstEligible() return } for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta { if m.eligible(i) { m.selected = i return } } } // firstEligible reports the first row the cursor may rest on, preferring any // row over a section header. func (m *Model) firstEligible() int { for _, sections := range [2]bool{false, true} { for i := range m.rows { if m.restable(i, sections) { return i } } } return -1 } func (s SortMode) Title() string { if s == SortGrouped { return " Sessions · grouped " } return " Sessions · recent " } // statusDot is the one-cell problem marker. It is drawn only when something // is wrong, in a fixed xterm-256 color so it reads the same in any terminal // and theme: red down or error, yellow stale, magenta version mismatch, blue // no daemon. Healthy and stopped rows carry no dot: a stopped row is already // muted, and the toast explains each failure in words. const statusDot = "●" // Fixed xterm-256 problem colors, shared with client toast borders so one // color always means one thing. const ( ColorProblemError = 167 // red ColorProblemWarn = 179 // yellow ) // lineStatusColor is the xterm-256 index for a problem status, or -1 for none. func lineStatusColor(status protocol.PickerLineStatus) int { switch status { case protocol.PickerLineStatusDown, protocol.PickerLineStatusError: return ColorProblemError case protocol.PickerLineStatusStale: return ColorProblemWarn case protocol.PickerLineStatusVersion: return 170 // magenta case protocol.PickerLineStatusNoDaemon: return 67 // blue default: return -1 } } // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps // its own color even on the inverse selected row. func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus, problemBold bool) { if badge == "" { return } style := base style.Inverse = false style.Foreground = lineStatusColor(status) style.HasForegroundRGB = false if color := lineStatusColor(status); problemBold && (color == ColorProblemError || color == ColorProblemWarn) { style.Bold = true } ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style) } func lineStatusBadge(status protocol.PickerLineStatus) string { if lineStatusColor(status) < 0 { return "" } return statusDot } // renderList draws each visible row: a dimmed tree prefix, the name (bold on // unselected live session and host headers), the attention marker, then for // headers a right-aligned muted detail (tab count, "*" when attached) and a // problem dot in a fixed xterm-256 color. Section headers carry their host's // problem dot. A tight width truncates the detail before the name. func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) { if m == nil || rect.Width <= 0 || rect.Height <= 0 { return } visible := min(rect.Height, frame.Height-rect.Y) offset := m.scrollOffset(visible) clipX := rect.X + rect.Width for y := range visible { if offset+y >= len(m.view) { break } idx := m.view[offset+y] r := m.rows[idx] base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail if r.section() { nameStyle = styles.Detail } if idx == m.selected { base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted } if r.line.Stopped && idx != m.selected { base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped } if r.line.Dim && !(r.section() && idx == m.selected) { base.Attrs |= renderer.AttrDim nameStyle.Attrs |= renderer.AttrDim detailStyle.Attrs |= renderer.AttrDim } ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) name := r.line.Label if r.section() { nameStyle.Bold = true marker := "▾ " if m.sectionFolded(idx) { marker = "▸ " name += fmt.Sprintf(" (%d)", m.sectionSessions(idx)) } name = marker + name } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped { nameStyle.Bold = true } treeX := rect.X if r.tree != "" { treeStyle := base // A caller style set without its own Tree style keeps the row's // colors dimmed; a zero Tree style would be black on black. if idx == m.selected || r.line.Stopped || styles.Tree == (renderer.Style{}) { treeStyle.Attrs |= renderer.AttrDim } else { // Only the foreground comes from Tree: the row's fill and its // dimming (a Dim line) stay as they are. treeStyle.Foreground = styles.Tree.Foreground treeStyle.HasForegroundRGB = styles.Tree.HasForegroundRGB treeStyle.ForegroundRGB = styles.Tree.ForegroundRGB } treeX = ui.DrawText(frame, rect.X, rect.Y+y, max(rect.X, clipX-2), r.tree, treeStyle) } badge := "" if r.rendersAsHeader() || r.section() { badge = lineStatusBadge(r.line.Status) } contentClipX := clipX nameWidth := max(clipX-treeX, 0) badgeWidth := textCellWidth(badge) if badge != "" { nameWidth = max(clipX-treeX-badgeWidth-1, 0) contentClipX = max(rect.X, clipX-badgeWidth-1) } originalName := name name = ui.TruncateText(name, nameWidth) nameMatchStyle := styles.SearchMatch if idx == m.selected { nameMatchStyle = styles.SelectionMatch } namePositions := visibleMatchPositions(m.matchPositions(idx, matchName), name, name != originalName) x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) if r.section() { drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold) continue } if r.rendersAsHeader() { if r.line.Attention && x < contentClipX { // A session header rings when any of its tabs does. x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " "+string(ui.AttentionGlyph), base) } // The short detail (tab count, attached marker) is right-aligned // before the badge, muted but not dimmed so it stays readable. if r.line.Detail != "" && x < contentClipX-1 { detail := ui.TruncateText(r.line.Detail, contentClipX-x-1) detailX := max(x+1, contentClipX-textCellWidth(detail)) detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) } drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold) continue } if r.line.Attention { x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base) } if r.line.Detail != "" && x < clipX { x = ui.DrawText(frame, x, rect.Y+y, clipX, " ", detailStyle) } detail := ui.TruncateText(r.line.Detail, clipX-x) detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions) } } func (m *Model) matchPositions(idx int, field matchField) []int { if m == nil || !m.searchActive || m.query.Value() == "" { return nil } return m.searchMatches[idx].positions(field) } func visibleMatchPositions(positions []int, rendered string, truncated bool) []int { if !truncated || len(positions) == 0 { return positions } limit := max(len([]rune(rendered))-1, 0) // the final rune is the ellipsis visible := make([]int, 0, len(positions)) for _, position := range positions { if position < limit { visible = append(visible, position) } } return visible } func drawMatchedText(frame renderer.Frame, x, y, clipX int, text string, base, match renderer.Style, positions []int) int { if len(positions) == 0 { return ui.DrawText(frame, x, y, clipX, text, base) } positionIndex := 0 runeIndex := 0 for _, r := range text { style := base if positionIndex < len(positions) && positions[positionIndex] == runeIndex { style = match positionIndex++ } x = ui.DrawText(frame, x, y, clipX, string(r), style) if x >= clipX { break } runeIndex++ } return x } func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style renderer.Style) { if rect.Width <= 0 || rect.Height <= 0 || rect.Y < 0 || rect.Y >= frame.Height { return } ui.FillRect(frame, rect, renderer.Cell{Rune: ' ', Style: style}) // The footer describes the effective selection: a row kept for inspection, // a row the query hides, and an empty picker never promise a commit. action := "" deletable := false selected := protocol.PickerLine{} hasCursorRow := m != nil && m.selected >= 0 && m.selected < len(m.rows) if hasCursorRow { selected = m.rows[m.selected].line } if m != nil && m.committable(m.selected) { action = actionVerb(selected, m.intent) deletable = selected.Actions&protocol.PickerCanKill != 0 } enter := "Enter " + action if action == "" { enter = "Enter unavailable" } if m != nil && m.CursorOnSection() { enter = "Enter/h/l fold" if m.searchActive { // h and l type into the query while searching. enter = "Enter fold" } } var groups []string if m != nil && m.searchActive { groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"} escape := "Esc exit" if m.query.Value() != "" { escape = "Esc clear" } groups = append(groups, escape) } else { if hasCursorRow && selected.Dim && selected.StatusDetail != "" { groups = append(groups, selected.StatusDetail) } if rect.Width < 60 { groups = append(groups, enter, "/", "Esc", "j/k") if deletable { groups = append(groups, "x") } groups = append(groups, "s") } else { groups = append(groups, "j/k move", enter) if deletable { groups = append(groups, "x delete") } groups = append(groups, "/ search", "s sort", "Esc close") } } text := strings.Join(groups, " ") ui.DrawText(frame, rect.X, rect.Y, rect.X+rect.Width, ui.TruncateText(text, rect.Width), style) } // actionVerb names what Enter does on one row, so the footer never promises // an action the source did not authorise. func actionVerb(line protocol.PickerLine, intent protocol.PickerIntent) string { if line.Actions == 0 { return "unavailable" } if line.Actions&protocol.PickerCanMove != 0 { if intent == protocol.PickerIntentMoveTab { return "move" } return "move pane" } if line.Stopped { return "restart" } return "open" } // scrollOffset is the first drawn position so the cursor row stays visible. func (m *Model) scrollOffset(visible int) int { if visible <= 0 || len(m.view) <= visible || m.selected < 0 { return 0 } position := slices.Index(m.view, m.selected) if position < visible { return 0 } return min(position-visible+1, len(m.view)-visible) } // sectionSessions counts the session and host rows under section row i. func (m *Model) sectionSessions(i int) int { count := 0 for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ { if m.rows[j].rendersAsHeader() { count++ } } return count } func clamp(n, low, high int) int { if n < low { return low } if n > high { return high } return n } // ChooseGeometry reserves the final inner row for picker-local status before // solving the list and preview layout. func ChooseGeometry(inner domain.Size) Geometry { if inner.Cols <= 0 || inner.Rows <= 0 { return Geometry{} } contentRows := max(inner.Rows-1, 0) layout := ChooseLayout(domain.Size{Cols: inner.Cols, Rows: contentRows}) return Geometry{ Content: domain.Rect{Width: inner.Cols, Height: contentRows}, Status: domain.Rect{Y: contentRows, Width: inner.Cols, Height: 1}, Mode: layout.Mode, List: layout.List, Separator: layout.Separator, Preview: layout.Preview, } } func ChooseLayout(inner domain.Size) Layout { if inner.Cols <= 0 || inner.Rows <= 0 { return Layout{} } if inner.Rows >= MinPaneHeight { listWidth := clamp(inner.Cols*40/100, MinListWidth, MaxListWidth) listWidth = min(listWidth, inner.Cols-MinHorizontalPreviewWidth-1) previewWidth := inner.Cols - listWidth - 1 if listWidth >= MinHorizontalListWidth && previewWidth >= MinHorizontalPreviewWidth { return Layout{ Mode: LayoutHorizontal, List: domain.Rect{Width: listWidth, Height: inner.Rows}, Separator: domain.Rect{X: listWidth, Width: 1, Height: inner.Rows}, Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows}, } } } if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth { listHeight := max(MinPaneHeight, inner.Rows*40/100) previewHeight := inner.Rows - listHeight - 1 return Layout{ Mode: LayoutStacked, List: domain.Rect{Width: inner.Cols, Height: listHeight}, Separator: domain.Rect{Y: listHeight, Width: inner.Cols, Height: 1}, Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight}, } } return Layout{Mode: LayoutListOnly, List: domain.Rect{Width: inner.Cols, Height: inner.Rows}} }