package picker import ( tea "github.com/charmbracelet/bubbletea" "gitea.apointless.space/bsncubed/dial/internal/store" ) // Layout budget (lines) reserved around the scrolling list. const ( headerLines = 2 // title + blank spacer footerLines = 1 // key hints previewLines = 8 // bordered preview box (2 border + 6 fields) linesPerRow = 3 // alias line + detail line + blank spacer ) // visibleListLines returns how many rendered lines the scrolling list may // occupy, reserving space for the header, preview box, and footer. func (m *model) visibleListLines() int { reserved := headerLines + previewLines + footerLines + 1 // +1 slack for separators if m.filtering { reserved++ // filter input line } n := m.height - reserved if n < linesPerRow { return linesPerRow // always show at least one host block } return n } // visibleCount estimates how many host rows fit on screen, used only as the // page size for PgUp/PgDn. func (m *model) visibleCount() int { n := m.visibleListLines() / linesPerRow if n < 1 { return 1 } return n } // ensureVisible clamps the cursor to a valid host position. The actual line // scrolling (m.top) is computed during render, where header/host line heights // are known. func (m *model) ensureVisible() { if m.cursor >= len(m.view) { m.cursor = len(m.view) - 1 } if m.cursor < 0 { m.cursor = 0 } } func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.ensureVisible() return m, nil case tea.KeyMsg: if m.filtering { return m.updateFiltering(msg) } return m.updateNormal(msg) } return m, nil } // updateNormal handles keys in the resting (static list) state. func (m *model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = "" switch msg.String() { case "ctrl+c", "q", "esc": m.quitting = true return m, tea.Quit case "up", "k", "ctrl+p": m.move(-1) case "down", "j", "ctrl+n": m.move(1) case "home": m.cursor = 0 m.ensureVisible() case "end", "G": m.cursor = len(m.view) - 1 m.ensureVisible() case "pgup": m.move(-m.visibleCount()) case "pgdown": m.move(m.visibleCount()) case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Jump the cursor to the row bearing that number (1-based, absolute). n := int(msg.String()[0] - '0') if n >= 1 && n <= len(m.view) { m.cursor = n - 1 m.ensureVisible() } case "/": m.filtering = true m.query = "" return m, nil case "s": // Cycle sort mode (recent → frequent → alpha) and persist the preference. m.sortM = m.sortM.Next() m.persistSort() m.reindex() case "g": // Toggle tag grouping (orthogonal to sort) and persist. m.grouped = !m.grouped m.persistGroup() if m.grouped { m.status = "grouped by tag" } else { m.status = "grouping off" } m.reindex() case "f": m.toggleFavorite() case "enter": return m.selectWith(ActionConnect) } return m, nil } // updateFiltering handles keys while the on-demand search box is open. func (m *model) updateFiltering(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc", "ctrl+c": // Leave filter mode and return to the clean, static resting list. m.filtering = false m.query = "" m.applyFilter() return m, nil case "enter": return m.selectWith(ActionConnect) case "up", "ctrl+p": m.move(-1) return m, nil case "down", "ctrl+n": m.move(1) return m, nil case "backspace": if len(m.query) > 0 { // Trim one rune from the query. r := []rune(m.query) m.query = string(r[:len(r)-1]) m.applyFilter() } return m, nil default: if msg.Type == tea.KeyRunes { m.query += string(msg.Runes) m.applyFilter() } return m, nil } } func (m *model) move(delta int) { if len(m.view) == 0 { return } m.cursor += delta if m.cursor < 0 { m.cursor = 0 } if m.cursor >= len(m.view) { m.cursor = len(m.view) - 1 } m.ensureVisible() } // reindex re-sorts and re-filters while keeping the highlighted host under the // cursor where possible. func (m *model) reindex() { var keepAlias string if m.cursor >= 0 && m.cursor < len(m.view) { keepAlias = m.rows[m.view[m.cursor]].host.Alias } m.sortAll() m.applyFilter() if keepAlias != "" { for i, vi := range m.view { if m.rows[vi].host.Alias == keepAlias { m.cursor = i break } } } m.ensureVisible() } func (m *model) selectWith(action Action) (tea.Model, tea.Cmd) { if m.cursor < 0 || m.cursor >= len(m.view) { return m, nil } m.result = Result{Alias: m.rows[m.view[m.cursor]].host.Alias, Action: action} m.quitting = true return m, tea.Quit } func (m *model) toggleFavorite() { if m.cursor < 0 || m.cursor >= len(m.view) { return } ri := m.view[m.cursor] alias := m.rows[ri].host.Alias on, err := store.ToggleFavorite(alias) if err != nil { m.status = "could not save favourite: " + err.Error() return } m.rows[ri].fav = on if on { m.status = "★ favourited " + alias } else { m.status = "removed favourite " + alias } m.reindex() } func (m *model) persistSort() { s := store.LoadSettings() s.Sort = m.sortM _ = store.SaveSettings(s) // non-fatal: preference only } func (m *model) persistGroup() { s := store.LoadSettings() s.GroupByTag = m.grouped _ = store.SaveSettings(s) // non-fatal: preference only }