Files
lazysecrets/internal/ui/app/modal.go
T
kebandClaude Opus 5.5 46d6d37dc2
Release / Build and publish (push) Successful in 24s
Hello world 👋
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:29:42 +02:00

409 lines
11 KiB
Go

package app
import (
"fmt"
"slices"
"strings"
"unicode/utf8"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// A modal is a centred popup that takes the keyboard, like lazygit's menus:
// a list of options, each with a shortcut key, or a text input.
type option struct {
key string
label string
tip string // shown under the list while the option is selected
note string // dimmed on the right of the row; the filter matches it
// star is the store key ctrl+f stars or unstars (favourites, search);
// starred shows a ★ before the label.
star string
starAccount string // the account the star is for
starred bool
// mark, if set, goes first: how much is loaded (search). reload,
// if set, runs on ctrl+r.
mark string
reload func(m *Model) tea.Cmd
id string // identifies the entry across rebuilds (search)
group string // the source row it's in (search)
kind provider.Kind // the kind of provider it's from (search)
hits []int // characters of the label the filter matched (Go to)
run func(m *Model) tea.Cmd
// header makes this a section title: not selectable, and hidden
// while a filter narrows the list.
header bool
}
type modal struct {
title string
lines []string // explanation above the options
about *ref.Ref // drawn above lines, redrawn as lists load (refLabel)
options []option
cursor int
// A text input instead of options; submit runs on enter.
input *textinput.Model
submit func(m *Model, text string) tea.Cmd
// A filter over the options: typing narrows them (all holds them
// all), so moving uses arrows or ctrl+j/k.
filter *textinput.Model
all []option
footer string // overrides the default footer
onClose func(m *Model) // runs when it closes, picked or not
}
// showFilterMenu opens a menu with a filter input above the options.
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
in := textinput.New()
in.Prompt = "/"
in.Placeholder = "type to filter"
in.SetWidth(40)
m.prompt = &modal{title: title, options: options, all: options, filter: &in}
m.prompt.move(0)
m.flash = flash{}
return m.prompt.filter.Focus()
}
// move moves the cursor by delta, skipping section headers; with nothing
// selectable that way it stays put. move(0) settles on a selectable option.
func (p *modal) move(delta int) {
ok := func(i int) bool { return i >= 0 && i < len(p.options) && !p.options[i].header }
step := 1
if delta < 0 {
step = -1
}
i := max(0, min(p.cursor+delta, len(p.options)-1))
for i >= 0 && i < len(p.options) && !ok(i) {
i += step
}
switch {
case ok(i):
p.cursor = i
case !ok(p.cursor): // e.g. after filtering: first selectable
for p.cursor = 0; p.cursor < len(p.options) && !ok(p.cursor); p.cursor++ {
}
}
}
// counter is "3 of 12" over the selectable options.
func (p *modal) counter() string {
n, at := 0, 0
for i, o := range p.options {
if o.header {
continue
}
n++
if i == p.cursor {
at = n
}
}
if n == 0 {
return ""
}
return fmt.Sprintf("%d of %d", at, n)
}
// refilter narrows the options to fuzzy matches of the filter. Matches
// stay under their section headers, best first within each section, and
// sections without matches drop out.
func (p *modal) refilter() {
q := p.filter.Value()
if q == "" {
p.options = p.all
p.cursor = max(0, min(p.cursor, len(p.options)-1))
p.move(0)
return
}
items := make([]match.Item, len(p.all))
for i, o := range p.all {
label := ansi.Strip(o.label)
items[i] = match.Item{Text: strings.TrimSpace(label + " " + o.note), NameEnd: utf8.RuneCountInString(label)}
}
rank := map[int]int{} // matching options: their place in the ranking
for n, h := range match.Rank(match.Parse(q), items) {
if !p.all[h.Index].header {
rank[h.Index] = n
}
}
p.options = nil
var section []int
header := -1
flush := func() {
if len(section) == 0 {
return
}
slices.SortStableFunc(section, func(a, b int) int { return rank[a] - rank[b] })
if header >= 0 {
p.options = append(p.options, p.all[header])
}
for _, i := range section {
p.options = append(p.options, p.all[i])
}
}
for i, o := range p.all {
if o.header {
flush()
section, header = nil, i
continue
}
if _, ok := rank[i]; ok {
section = append(section, i)
}
}
flush()
p.cursor = max(0, min(p.cursor, len(p.options)-1))
p.move(0)
}
// showMenu opens a menu. Every menu ends with an x Dismiss row.
func (m *Model) showMenu(title string, lines []string, options []option) {
options = append(options, option{key: "x", label: "Dismiss", run: func(*Model) tea.Cmd { return nil }})
m.prompt = &modal{title: title, lines: lines, options: options}
m.flash = flash{}
}
// handleModal routes keys while a modal is shown.
func (m *Model) handleModal(msg tea.KeyPressMsg) tea.Cmd {
p := m.prompt
km := m.keys
textField := p.input != nil || p.filter != nil
closeIt := func() {
m.prompt = nil
if p.onClose != nil {
p.onClose(m)
}
}
if (textField && key.Matches(msg, km.PopupClose)) || (!textField && key.Matches(msg, km.MenuClose)) {
closeIt()
return nil
}
if p.input != nil {
if key.Matches(msg, km.PopupSelect) {
m.prompt = nil
return p.submit(m, p.input.Value())
}
in, cmd := p.input.Update(msg)
p.input = &in
return cmd
}
if p.filter != nil {
switch {
case key.Matches(msg, km.PopupDown):
p.move(1)
case key.Matches(msg, km.PopupUp):
p.move(-1)
case key.Matches(msg, km.PopupSelect):
if len(p.options) > 0 && !p.options[p.cursor].header {
closeIt()
return p.options[p.cursor].run(m)
}
default:
in, cmd := p.filter.Update(msg)
p.filter = &in
p.refilter()
return cmd
}
return nil
}
// A menu: each entry's letter picks it (fixed, part of its label),
// before the moving keys.
for _, o := range p.options {
if o.key != "" && o.key == msg.String() {
m.prompt = nil
return o.run(m)
}
}
switch {
case key.Matches(msg, km.Help):
return m.showKeybindingsFor(keys.Menu)
case key.Matches(msg, km.MenuDown):
p.cursor = min(p.cursor+1, len(p.options)-1)
case key.Matches(msg, km.MenuUp):
p.cursor = max(p.cursor-1, 0)
case key.Matches(msg, km.MenuSelect):
m.prompt = nil
return p.options[p.cursor].run(m)
}
return nil
}
// modalView draws the modal as a box sized to its content.
func (m Model) modalView() string {
p := m.prompt
var body []string
if p.about != nil {
body = append(body, " "+m.refLabel(*p.about))
}
for _, l := range p.lines {
body = append(body, " "+l)
}
if (len(p.lines) > 0 || p.about != nil) && (len(p.options) > 0 || p.input != nil) {
body = append(body, "")
}
km := m.keys
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
if p.input != nil {
body = append(body, " "+p.input.View())
foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel"))
}
// Size from all options, not the ones shown or the cursor's, so the
// popup doesn't change shape while moving or filtering.
all := p.all
if all == nil {
all = p.options
}
optText := func(o option) string {
pre := ""
if o.mark != "" {
pre = o.mark + " "
}
if o.star != "" {
mark := " "
if o.starred {
mark = m.st.Favourite.Render(star)
}
return pre + mark + o.label
}
if o.key == "" {
return o.label
}
return m.st.Label.Render(o.key) + " " + o.label
}
optW := 0
for _, o := range all {
if !o.header {
w := ansi.StringWidth(optText(o)) + 2 // "▸ " or " "
if o.note != "" {
w += 2 + ansi.StringWidth(o.note)
}
optW = max(optW, w)
}
}
optW = min(optW, max(0, m.w-8)) // notes give way to the screen
row := func(o option) string {
return withNote(m.st, optText(o), o.note, optW-2)
}
const window = 15 // rows of a filtered list
options, first, rows := p.options, 0, len(p.options)
if p.filter != nil {
body = append(body, " "+p.filter.View(), "")
foot = footer(move(km.PopupUp, km.PopupDown), hint(km.PopupSelect, "jump"), hint(km.PopupClose, "close"))
rows = min(len(all), window)
first = max(0, min(p.cursor-window/2, len(options)-window))
options = options[first:min(len(options), first+window)]
if len(options) == 0 {
body = append(body, " "+m.st.Muted.Render("no matches"))
rows--
}
}
var headers []int // body lines that are section headers, drawn once w is known
for j, o := range options {
if o.header {
if j > 0 {
body = append(body, "")
}
headers = append(headers, len(body))
body = append(body, o.label)
continue
}
if i := first + j; i == p.cursor {
sel := m.st.Selected
if m.termBlurred {
sel = m.st.SelectedBlurred
}
body = append(body, " "+panes.Highlight("▸ "+row(o), sel, optW))
} else {
body = append(body, " "+" "+row(o))
}
}
for range rows - len(options) {
body = append(body, "") // keep the height while filtering
}
if p.footer != "" {
foot = p.footer
}
w := max(ansi.StringWidth(p.title)+6, optW+4, ansi.StringWidth(foot)+6)
for _, l := range body {
w = max(w, ansi.StringWidth(l)+3)
}
if p.input != nil || p.filter != nil {
w = max(w, min(m.w-4, 72))
}
w = min(w, m.w-2)
// Section headers as rules: "─── Secrets pane ─────────".
for _, i := range headers {
title := body[i]
fill := max(1, w-2-2-3-2-ansi.StringWidth(title)) // borders, margins, "───", spaces
body[i] = " " + m.st.Muted.Render("─── ") + panes.TitleStyle(m.st, !m.termBlurred).Render(title) +
m.st.Muted.Render(" "+strings.Repeat("─", fill))
}
// The selected option's tip, wrapped; room for the longest one is kept
// so the popup doesn't change height while moving.
wrapTip := func(tip string) []string {
if tip == "" {
return nil
}
return strings.Split(ansi.Wordwrap(tip, w-4, " "), "\n")
}
tipRows := 0
for _, o := range all {
tipRows = max(tipRows, len(wrapTip(o.tip)))
}
if tipRows > 0 {
var tip []string
if p.cursor < len(p.options) {
tip = wrapTip(p.options[p.cursor].tip)
}
body = append(body, "")
for i := range tipRows {
line := ""
if i < len(tip) {
line = " " + m.st.Muted.Render(tip[i])
}
body = append(body, line)
}
}
counter := ""
if p.filter != nil {
counter = p.counter()
}
focused := !m.termBlurred // like the panes, no highlight in an unfocused window
return panes.BoxCounted(m.st, panes.TitleStyle(m.st, focused).Render(p.title), foot, counter, body, w, len(body)+2, focused)
}
// overlay draws the modal centred over the screen.
func (m Model) overlay(base string) string {
var box string
if m.search != nil {
box = m.searchViewBox()
} else {
box = m.modalView()
}
x := max(0, (m.w-lipgloss.Width(box))/2)
y := max(0, (strings.Count(base, "\n")+1-lipgloss.Height(box))/3)
return lipgloss.NewCompositor(
lipgloss.NewLayer(base),
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
).Render()
}