Files
hyprvoice/internal/tui/screens.go
T
2026-02-01 22:04:42 +01:00

643 lines
15 KiB
Go

package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type listScreen struct {
state *wizardState
title string
desc []string
list list.Model
footer string
errText string
onPick func(optionItem) screen
onBack func() screen
}
func newListScreen(state *wizardState, title string, desc []string, items []optionItem, onPick func(optionItem) screen, onBack func() screen) *listScreen {
delegate := list.NewDefaultDelegate()
l := list.New(itemsToList(items), delegate, 0, 0)
l.DisableQuitKeybindings()
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.SetShowStatusBar(false)
l.Title = title
return &listScreen{
state: state,
title: title,
desc: desc,
list: l,
footer: "enter select • esc back • / filter",
onPick: onPick,
onBack: onBack,
}
}
func (s *listScreen) Init() tea.Cmd {
return nil
}
func (s *listScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.list.SetSize(msg.Width-4, msg.Height-8)
case tea.KeyMsg:
switch msg.String() {
case "enter":
if s.list.FilterState() != list.Filtering {
if item, ok := s.list.SelectedItem().(optionItem); ok {
if item.disabled {
s.errText = "That option isn't available in this environment."
break
}
if s.onPick != nil {
return s.onPick(item), nil
}
}
}
case "esc", "q":
if s.list.FilterState() == list.Unfiltered {
if s.onBack != nil {
return s.onBack(), nil
}
}
}
}
var cmd tea.Cmd
s.list, cmd = s.list.Update(msg)
return s, cmd
}
func (s *listScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, s.list.FilterState() != list.Unfiltered)
return header + s.list.View() + "\n" + footer
}
type confirmScreen struct {
state *wizardState
title string
desc []string
list list.Model
footer string
onYes func() screen
onNo func() screen
onBack func() screen
errText string
}
func newConfirmScreen(state *wizardState, title string, desc []string, yesLabel, yesDesc, noLabel, noDesc string, onYes func() screen, onNo func() screen, onBack func() screen) *confirmScreen {
items := []optionItem{
{title: yesLabel, desc: yesDesc, value: "yes"},
{title: noLabel, desc: noDesc, value: "no"},
}
footer := "enter select • esc cancel"
if onBack != nil {
footer = "enter select • esc back"
}
delegate := list.NewDefaultDelegate()
l := list.New(itemsToList(items), delegate, 0, 0)
l.DisableQuitKeybindings()
l.SetShowHelp(false)
l.SetFilteringEnabled(false)
l.SetShowStatusBar(false)
l.Title = title
return &confirmScreen{state: state, title: title, desc: desc, list: l, footer: footer, onYes: onYes, onNo: onNo, onBack: onBack}
}
func (s *confirmScreen) Init() tea.Cmd { return nil }
func (s *confirmScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.list.SetSize(msg.Width-4, msg.Height-8)
case tea.KeyMsg:
switch msg.String() {
case "enter":
if item, ok := s.list.SelectedItem().(optionItem); ok {
if item.value == "yes" && s.onYes != nil {
return s.onYes(), nil
}
if item.value == "no" && s.onNo != nil {
return s.onNo(), nil
}
}
case "esc", "q":
if s.onBack != nil {
return s.onBack(), nil
}
if s.onNo != nil {
return s.onNo(), nil
}
}
}
var cmd tea.Cmd
s.list, cmd = s.list.Update(msg)
return s, cmd
}
func (s *confirmScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, false)
return header + s.list.View() + "\n" + footer
}
type inputScreen struct {
state *wizardState
title string
desc []string
input textinput.Model
footer string
errText string
onSubmit func(string) screen
onCancel func() screen
validateFn func(string) error
}
func newInputScreen(state *wizardState, title string, desc []string, value string, placeholder string, password bool, validateFn func(string) error, onSubmit func(string) screen, onCancel func() screen) *inputScreen {
input := textinput.New()
input.SetValue(value)
input.Placeholder = placeholder
if password {
input.EchoMode = textinput.EchoPassword
input.EchoCharacter = '*'
}
input.Focus()
input.CharLimit = 0
return &inputScreen{
state: state,
title: title,
desc: desc,
input: input,
footer: "enter save • esc back",
onSubmit: onSubmit,
onCancel: onCancel,
validateFn: validateFn,
}
}
func (s *inputScreen) Init() tea.Cmd { return textinput.Blink }
func (s *inputScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
value := strings.TrimSpace(s.input.Value())
if s.validateFn != nil {
if err := s.validateFn(value); err != nil {
s.errText = err.Error()
break
}
}
if s.onSubmit != nil {
return s.onSubmit(value), nil
}
case "esc", "q":
if s.onCancel != nil {
return s.onCancel(), nil
}
}
}
var cmd tea.Cmd
s.input, cmd = s.input.Update(msg)
return s, cmd
}
func (s *inputScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, false)
return header + s.input.View() + "\n\n" + footer
}
type multiSelectScreen struct {
state *wizardState
title string
desc []string
list list.Model
footer string
errText string
onSubmit func([]toggleItem) screen
onCancel func() screen
requireOne bool
}
func newMultiSelectScreen(state *wizardState, title string, desc []string, items []toggleItem, requireOne bool, onSubmit func([]toggleItem) screen, onCancel func() screen) *multiSelectScreen {
delegate := list.NewDefaultDelegate()
l := list.New(toggleItemsToList(items), delegate, 0, 0)
l.DisableQuitKeybindings()
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.SetShowStatusBar(false)
l.Title = title
return &multiSelectScreen{
state: state,
title: title,
desc: desc,
list: l,
footer: "space toggle • enter save • esc back • / filter",
onSubmit: onSubmit,
onCancel: onCancel,
requireOne: requireOne,
}
}
func (s *multiSelectScreen) Init() tea.Cmd { return nil }
func (s *multiSelectScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.list.SetSize(msg.Width-4, msg.Height-8)
case tea.KeyMsg:
switch msg.String() {
case " ":
idx := s.list.Index()
items := s.list.Items()
if idx >= 0 && idx < len(items) {
if item, ok := items[idx].(toggleItem); ok {
item.selected = !item.selected
items[idx] = item
s.list.SetItems(items)
}
}
case "enter":
items := listToToggleItems(s.list.Items())
if s.requireOne {
has := false
for _, item := range items {
if item.selected {
has = true
break
}
}
if !has {
s.errText = "Select at least one option to continue."
break
}
}
if s.onSubmit != nil {
return s.onSubmit(items), nil
}
case "esc", "q":
if s.list.FilterState() == list.Unfiltered {
if s.onCancel != nil {
return s.onCancel(), nil
}
}
}
}
var cmd tea.Cmd
s.list, cmd = s.list.Update(msg)
return s, cmd
}
func (s *multiSelectScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, s.list.FilterState() != list.Unfiltered)
return header + s.list.View() + "\n" + footer
}
type infoScreen struct {
state *wizardState
title string
desc []string
footer string
next func() screen
back func() screen
}
func newInfoScreen(state *wizardState, title string, desc []string, next func() screen, back func() screen) *infoScreen {
return &infoScreen{state: state, title: title, desc: desc, footer: "enter continue • esc back", next: next, back: back}
}
func (s *infoScreen) Init() tea.Cmd {
return nil
}
func (s *infoScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
if s.next != nil {
return s.next(), nil
}
case "esc", "q":
if s.back != nil {
return s.back(), nil
}
}
}
return s, nil
}
func (s *infoScreen) View() string {
header := renderHeader(s.title, s.desc, "")
footer := renderFooter(s.footer, false)
return header + "\n" + footer
}
type formField struct {
key string
label string
desc string
input textinput.Model
validate func(string) error
required bool
sensitive bool
}
type formScreen struct {
state *wizardState
title string
desc []string
fields []formField
focused int
footer string
errText string
onSubmit func(map[string]string) screen
onCancel func() screen
}
func newFormScreen(state *wizardState, title string, desc []string, fields []formField, onSubmit func(map[string]string) screen, onCancel func() screen) *formScreen {
if len(fields) > 0 {
fields[0].input.Focus()
}
return &formScreen{state: state, title: title, desc: desc, fields: fields, onSubmit: onSubmit, onCancel: onCancel}
}
func (s *formScreen) Init() tea.Cmd { return textinput.Blink }
func (s *formScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
if s.onCancel != nil {
return s.onCancel(), nil
}
case "tab", "down":
s.moveFocus(1)
case "shift+tab", "up":
s.moveFocus(-1)
case "enter":
if s.focused == len(s.fields)-1 {
values, err := s.validateAll()
if err != nil {
s.errText = err.Error()
break
}
if s.onSubmit != nil {
return s.onSubmit(values), nil
}
} else {
s.moveFocus(1)
}
}
}
var cmd tea.Cmd
if s.focused >= 0 && s.focused < len(s.fields) {
s.fields[s.focused].input, cmd = s.fields[s.focused].input.Update(msg)
}
return s, cmd
}
func (s *formScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
body := strings.Builder{}
for i, field := range s.fields {
label := StyleLabel.Render(field.label)
if i == s.focused {
label = StyleHighlight.Render(field.label)
}
body.WriteString(label)
if field.desc != "" {
body.WriteString("\n")
body.WriteString(StyleSubtle.Render(field.desc))
}
body.WriteString("\n")
body.WriteString(field.input.View())
body.WriteString("\n\n")
}
footer := renderFooter(s.footer, false)
return header + body.String() + footer
}
func (s *formScreen) moveFocus(delta int) {
if len(s.fields) == 0 {
return
}
s.fields[s.focused].input.Blur()
s.focused = (s.focused + delta + len(s.fields)) % len(s.fields)
s.fields[s.focused].input.Focus()
}
func (s *formScreen) validateAll() (map[string]string, error) {
values := make(map[string]string, len(s.fields))
for _, field := range s.fields {
value := strings.TrimSpace(field.input.Value())
if field.required && value == "" {
return nil, fmt.Errorf("%s is required", field.label)
}
if field.validate != nil {
if err := field.validate(value); err != nil {
return nil, err
}
}
values[field.key] = value
}
return values, nil
}
type downloadProgressMsg struct {
downloaded int64
total int64
}
type downloadDoneMsg struct {
err error
}
type downloadScreen struct {
state *wizardState
title string
desc []string
modelID string
progress int
total int64
footer string
errText string
onSuccess func() screen
onCancel func() screen
updates chan tea.Msg
started bool
}
func newDownloadScreen(state *wizardState, title string, desc []string, modelID string, onSuccess func() screen, onCancel func() screen) *downloadScreen {
return &downloadScreen{
state: state,
title: title,
desc: desc,
modelID: modelID,
onSuccess: onSuccess,
onCancel: onCancel,
updates: make(chan tea.Msg),
}
}
func (s *downloadScreen) Init() tea.Cmd {
if s.started {
return listenForDownload(s.updates)
}
s.started = true
return tea.Batch(s.startDownloadCmd(), listenForDownload(s.updates))
}
func (s *downloadScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case downloadProgressMsg:
s.total = msg.total
if msg.total > 0 {
s.progress = int(msg.downloaded * 100 / msg.total)
}
return s, listenForDownload(s.updates)
case downloadDoneMsg:
if msg.err != nil {
s.errText = msg.err.Error()
return s, nil
}
if s.onSuccess != nil {
return s.onSuccess(), nil
}
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
if s.onCancel != nil {
return s.onCancel(), nil
}
}
}
return s, nil
}
func (s *downloadScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
progressLine := "Downloading"
if s.total > 0 {
progressLine = fmt.Sprintf("Downloading... %d%%", s.progress)
}
body := StyleMuted.Render(progressLine) + "\n\n"
footer := renderFooter(s.footer, false)
return header + body + footer
}
func (s *downloadScreen) startDownloadCmd() tea.Cmd {
modelID := s.modelID
ch := s.updates
return func() tea.Msg {
err := downloadWhisperModel(modelID, func(downloaded, total int64) {
ch <- downloadProgressMsg{downloaded: downloaded, total: total}
})
ch <- downloadDoneMsg{err: err}
return nil
}
}
func listenForDownload(ch <-chan tea.Msg) tea.Cmd {
return func() tea.Msg {
msg, ok := <-ch
if !ok {
return nil
}
return msg
}
}
func itemsToList(items []optionItem) []list.Item {
result := make([]list.Item, len(items))
for i, item := range items {
result[i] = item
}
return result
}
func toggleItemsToList(items []toggleItem) []list.Item {
result := make([]list.Item, len(items))
for i, item := range items {
result[i] = item
}
return result
}
func listToToggleItems(items []list.Item) []toggleItem {
result := make([]toggleItem, 0, len(items))
for _, item := range items {
if t, ok := item.(toggleItem); ok {
result = append(result, t)
}
}
return result
}
func renderHeader(title string, desc []string, errText string) string {
var b strings.Builder
if title != "" {
b.WriteString(StyleHeader.Render(title))
b.WriteString("\n")
}
for _, line := range desc {
if line == "" {
continue
}
b.WriteString(StyleMuted.Render(line))
b.WriteString("\n")
}
if errText != "" {
b.WriteString("\n")
b.WriteString(StyleError.Render(errText))
b.WriteString("\n")
}
b.WriteString("\n")
return b.String()
}
func renderFooter(extra string, filtering bool) string {
if extra != "" {
return StyleSubtle.Render(extra)
}
if filtering {
return StyleSubtle.Render("enter apply • esc clear")
}
return StyleSubtle.Render("enter select • esc back")
}
func makeInputField(key, label, desc, value, placeholder string, validate func(string) error) formField {
input := textinput.New()
input.SetValue(value)
input.Placeholder = placeholder
input.Prompt = ""
input.Cursor.Style = lipgloss.NewStyle().Foreground(ColorPrimary)
return formField{key: key, label: label, desc: desc, input: input, validate: validate}
}
func parseDurationOrEmpty(value string) (time.Duration, error) {
if strings.TrimSpace(value) == "" {
return 0, fmt.Errorf("duration is required")
}
return time.ParseDuration(value)
}