wrds-download

TUI/CLI tool for browsing and downloading WRDS data
Log | Files | Refs | README

loginform.go (6475B)


      1 package tui
      2 
      3 import (
      4 	"os"
      5 	"strings"
      6 
      7 	"github.com/charmbracelet/bubbles/textinput"
      8 	tea "github.com/charmbracelet/bubbletea"
      9 	"github.com/charmbracelet/lipgloss"
     10 )
     11 
     12 type loginField int
     13 
     14 const (
     15 	loginFieldSaved loginField = iota // "Login with saved credentials" button
     16 	loginFieldUser
     17 	loginFieldPassword
     18 	loginFieldDatabase
     19 	loginFieldSave
     20 	loginFieldCount
     21 )
     22 
     23 const loginTextInputs = 3 // user, password, database
     24 
     25 // LoginForm is the login dialog overlay shown when credentials are missing.
     26 type LoginForm struct {
     27 	inputs    [loginTextInputs]textinput.Model
     28 	save      bool
     29 	focused   loginField
     30 	savedUser string // non-empty when saved credentials are available
     31 	savedPw   string
     32 	savedDB   string
     33 }
     34 
     35 // LoginSubmitMsg is sent when the user confirms the login form.
     36 type LoginSubmitMsg struct {
     37 	User     string
     38 	Password string
     39 	Database string
     40 	Save     bool
     41 }
     42 
     43 // LoginCancelMsg is sent when the user cancels the login form.
     44 type LoginCancelMsg struct{}
     45 
     46 func newLoginForm() LoginForm {
     47 	f := LoginForm{}
     48 
     49 	f.inputs[loginFieldUser-1] = textinput.New()
     50 	f.inputs[loginFieldUser-1].Placeholder = "WRDS username"
     51 	f.inputs[loginFieldUser-1].CharLimit = 128
     52 
     53 	f.inputs[loginFieldPassword-1] = textinput.New()
     54 	f.inputs[loginFieldPassword-1].Placeholder = "WRDS password"
     55 	f.inputs[loginFieldPassword-1].CharLimit = 128
     56 	f.inputs[loginFieldPassword-1].EchoMode = textinput.EchoPassword
     57 	f.inputs[loginFieldPassword-1].EchoCharacter = '*'
     58 
     59 	f.inputs[loginFieldDatabase-1] = textinput.New()
     60 	f.inputs[loginFieldDatabase-1].Placeholder = "wrds"
     61 	f.inputs[loginFieldDatabase-1].CharLimit = 128
     62 	f.inputs[loginFieldDatabase-1].SetValue("wrds")
     63 
     64 	// Check for saved credentials in env (set by config.ApplyCredentials).
     65 	f.savedUser = os.Getenv("PGUSER")
     66 	f.savedPw = os.Getenv("PGPASSWORD")
     67 	f.savedDB = os.Getenv("PGDATABASE")
     68 	if f.savedDB == "" {
     69 		f.savedDB = "wrds"
     70 	}
     71 
     72 	f.save = true
     73 
     74 	if f.hasSaved() {
     75 		f.focused = loginFieldSaved
     76 	} else {
     77 		f.focused = loginFieldUser
     78 		f.inputs[loginFieldUser-1].Focus()
     79 	}
     80 	return f
     81 }
     82 
     83 func (f *LoginForm) hasSaved() bool {
     84 	return f.savedUser != "" && f.savedPw != ""
     85 }
     86 
     87 // inputIndex maps a loginField to the inputs array index.
     88 // Returns -1 for non-input fields (saved, save).
     89 func inputIndex(field loginField) int {
     90 	switch field {
     91 	case loginFieldUser, loginFieldPassword, loginFieldDatabase:
     92 		return int(field) - 1
     93 	}
     94 	return -1
     95 }
     96 
     97 func (f *LoginForm) blurCurrent() {
     98 	if idx := inputIndex(f.focused); idx >= 0 {
     99 		f.inputs[idx].Blur()
    100 	}
    101 }
    102 
    103 func (f *LoginForm) focusCurrent() tea.Cmd {
    104 	if idx := inputIndex(f.focused); idx >= 0 {
    105 		f.inputs[idx].Focus()
    106 		return textinput.Blink
    107 	}
    108 	return nil
    109 }
    110 
    111 func (f *LoginForm) advance(delta int) tea.Cmd {
    112 	f.blurCurrent()
    113 	start := loginFieldSaved
    114 	if !f.hasSaved() {
    115 		start = loginFieldUser
    116 	}
    117 	count := int(loginFieldCount) - int(start)
    118 	pos := (int(f.focused) - int(start) + delta%count + count) % count
    119 	f.focused = loginField(pos + int(start))
    120 	return f.focusCurrent()
    121 }
    122 
    123 func (f LoginForm) submit() tea.Cmd {
    124 	user := strings.TrimSpace(f.inputs[loginFieldUser-1].Value())
    125 	pw := f.inputs[loginFieldPassword-1].Value()
    126 	if user == "" || pw == "" {
    127 		return nil
    128 	}
    129 	database := strings.TrimSpace(f.inputs[loginFieldDatabase-1].Value())
    130 	if database == "" {
    131 		database = "wrds"
    132 	}
    133 	return func() tea.Msg {
    134 		return LoginSubmitMsg{User: user, Password: pw, Database: database, Save: f.save}
    135 	}
    136 }
    137 
    138 func (f LoginForm) Update(msg tea.Msg) (LoginForm, tea.Cmd) {
    139 	switch msg := msg.(type) {
    140 	case tea.KeyMsg:
    141 		switch msg.String() {
    142 		case "esc":
    143 			return f, func() tea.Msg { return LoginCancelMsg{} }
    144 
    145 		case "enter":
    146 			if f.focused == loginFieldSaved {
    147 				// Connect using saved credentials.
    148 				return f, func() tea.Msg {
    149 					return LoginSubmitMsg{
    150 						User:     f.savedUser,
    151 						Password: f.savedPw,
    152 						Database: f.savedDB,
    153 						Save:     false,
    154 					}
    155 				}
    156 			}
    157 			if f.focused == loginFieldSave {
    158 				return f, f.submit()
    159 			}
    160 			// Advance to next field.
    161 			cmd := f.advance(1)
    162 			return f, cmd
    163 
    164 		case "tab", "down":
    165 			cmd := f.advance(1)
    166 			return f, cmd
    167 
    168 		case "shift+tab", "up":
    169 			cmd := f.advance(-1)
    170 			return f, cmd
    171 
    172 		case " ":
    173 			if f.focused == loginFieldSave {
    174 				f.save = !f.save
    175 				return f, nil
    176 			}
    177 		}
    178 	}
    179 
    180 	// Forward to focused text input.
    181 	if idx := inputIndex(f.focused); idx >= 0 {
    182 		var cmd tea.Cmd
    183 		f.inputs[idx], cmd = f.inputs[idx].Update(msg)
    184 		return f, cmd
    185 	}
    186 	return f, nil
    187 }
    188 
    189 func (f LoginForm) View(width int, errMsg string) string {
    190 	var sb strings.Builder
    191 
    192 	title := stylePanelHeader.Render("WRDS Login")
    193 	sb.WriteString(title + "\n\n")
    194 
    195 	// "Login with saved credentials" button.
    196 	if f.hasSaved() {
    197 		btnLabel := "Login as " + f.savedUser
    198 		btnStyle := lipgloss.NewStyle().Foreground(colorMuted)
    199 		if f.focused == loginFieldSaved {
    200 			btnStyle = lipgloss.NewStyle().
    201 				Foreground(lipgloss.Color("#FFFFFF")).
    202 				Background(colorFocus).
    203 				Padding(0, 1)
    204 			btnLabel += "  [enter]"
    205 		}
    206 		sb.WriteString(btnStyle.Render(btnLabel) + "\n\n")
    207 		sb.WriteString(lipgloss.NewStyle().Foreground(colorMuted).Render("── or enter credentials manually ──") + "\n\n")
    208 	}
    209 
    210 	labels := []string{"Username", "Password", "Database"}
    211 	fields := []loginField{loginFieldUser, loginFieldPassword, loginFieldDatabase}
    212 	for i, label := range labels {
    213 		style := lipgloss.NewStyle().Foreground(colorMuted)
    214 		if fields[i] == f.focused {
    215 			style = lipgloss.NewStyle().Foreground(colorFocus)
    216 		}
    217 		sb.WriteString(style.Render(label+"  ") + "\n")
    218 		sb.WriteString(f.inputs[i].View() + "\n\n")
    219 	}
    220 
    221 	// Save toggle.
    222 	check := "[ ]"
    223 	if f.save {
    224 		check = "[x]"
    225 	}
    226 	saveStyle := lipgloss.NewStyle().Foreground(colorMuted)
    227 	if f.focused == loginFieldSave {
    228 		saveStyle = lipgloss.NewStyle().Foreground(colorFocus)
    229 	}
    230 	sb.WriteString(saveStyle.Render(check+" Save to ~/.config/wrds-dl/credentials") + "\n\n")
    231 
    232 	if errMsg != "" {
    233 		maxLen := 52
    234 		if len(errMsg) > maxLen {
    235 			errMsg = errMsg[:maxLen-1] + "…"
    236 		}
    237 		sb.WriteString(styleError.Render("Error: "+errMsg) + "\n\n")
    238 	}
    239 
    240 	hint := styleStatusBar.Render("[tab] next field   [enter] submit   [esc] quit")
    241 	sb.WriteString(hint)
    242 
    243 	content := sb.String()
    244 	boxWidth := 60
    245 	if boxWidth > width-4 {
    246 		boxWidth = width - 4
    247 	}
    248 	if boxWidth < 40 {
    249 		boxWidth = 40
    250 	}
    251 
    252 	box := lipgloss.NewStyle().
    253 		Border(lipgloss.RoundedBorder()).
    254 		BorderForeground(colorFocus).
    255 		Padding(1, 2).
    256 		Width(boxWidth).
    257 		Render(content)
    258 
    259 	return lipgloss.Place(width, 24, lipgloss.Center, lipgloss.Center, box)
    260 }