cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm leaflet readability golang
at main 255 lines 5.9 kB view raw
1package ui 2 3import ( 4 "fmt" 5 "io" 6 "os" 7 "strings" 8 9 "github.com/charmbracelet/bubbles/key" 10 "github.com/charmbracelet/bubbles/textinput" 11 tea "github.com/charmbracelet/bubbletea" 12) 13 14// AuthFormOptions configures the auth form display 15type AuthFormOptions struct { 16 Output io.Writer 17 Input io.Reader 18 Width int 19 Height int 20} 21 22// AuthFormResult holds the submitted credentials 23type AuthFormResult struct { 24 Handle string 25 Password string 26 Canceled bool 27} 28 29// AuthForm provides an interactive form for AT Protocol authentication 30type AuthForm struct { 31 initialHandle string 32 opts AuthFormOptions 33} 34 35// NewAuthForm creates a new authentication form 36func NewAuthForm(initialHandle string, opts AuthFormOptions) *AuthForm { 37 if opts.Output == nil { 38 opts.Output = os.Stdout 39 } 40 if opts.Input == nil { 41 opts.Input = os.Stdin 42 } 43 if opts.Width == 0 { 44 opts.Width = 80 45 } 46 if opts.Height == 0 { 47 opts.Height = 24 48 } 49 50 return &AuthForm{ 51 initialHandle: initialHandle, 52 opts: opts, 53 } 54} 55 56type authFormKeyMap struct { 57 Up key.Binding 58 Down key.Binding 59 Tab key.Binding 60 ShiftTab key.Binding 61 Enter key.Binding 62 Submit key.Binding 63 Cancel key.Binding 64} 65 66var authFormKeys = authFormKeyMap{ 67 Up: key.NewBinding(key.WithKeys("up", "shift+tab"), key.WithHelp("↑/shift+tab", "previous field")), 68 Down: key.NewBinding(key.WithKeys("down", "tab"), key.WithHelp("↓/tab", "next field")), 69 Tab: key.NewBinding(key.WithKeys("tab")), 70 ShiftTab: key.NewBinding(key.WithKeys("shift+tab")), 71 Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), 72 Submit: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "submit")), 73 Cancel: key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc/ctrl+c", "cancel")), 74} 75 76type authFormModel struct { 77 handleInput textinput.Model 78 passwordInput textinput.Model 79 focusIndex int 80 keys authFormKeyMap 81 submitted bool 82 canceled bool 83 handleLocked bool 84} 85 86func (m authFormModel) Init() tea.Cmd { 87 if m.handleLocked { 88 return m.passwordInput.Focus() 89 } 90 return m.handleInput.Focus() 91} 92 93func (m authFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 94 var cmds []tea.Cmd 95 96 switch msg := msg.(type) { 97 case tea.KeyMsg: 98 switch { 99 case key.Matches(msg, m.keys.Cancel): 100 m.canceled = true 101 return m, tea.Quit 102 103 case key.Matches(msg, m.keys.Submit), key.Matches(msg, m.keys.Enter): 104 if m.handleInput.Value() == "" { 105 return m, nil 106 } 107 if m.passwordInput.Value() == "" { 108 return m, nil 109 } 110 m.submitted = true 111 return m, tea.Quit 112 case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab): 113 m.nextInput() 114 cmds = append(cmds, m.updateFocus()) 115 case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.ShiftTab): 116 m.prevInput() 117 cmds = append(cmds, m.updateFocus()) 118 } 119 } 120 121 cmd := m.updateInputs(msg) 122 cmds = append(cmds, cmd) 123 124 return m, tea.Batch(cmds...) 125} 126 127func (m *authFormModel) nextInput() { 128 if m.handleLocked { 129 m.focusIndex = 1 130 } else { 131 m.focusIndex = (m.focusIndex + 1) % 2 132 } 133} 134 135func (m *authFormModel) prevInput() { 136 if m.handleLocked { 137 m.focusIndex = 1 138 } else { 139 m.focusIndex = (m.focusIndex - 1 + 2) % 2 140 } 141} 142 143func (m *authFormModel) updateFocus() tea.Cmd { 144 if m.focusIndex == 0 && !m.handleLocked { 145 m.handleInput.Focus() 146 m.passwordInput.Blur() 147 return textinput.Blink 148 } 149 150 m.handleInput.Blur() 151 m.passwordInput.Focus() 152 return textinput.Blink 153} 154 155func (m *authFormModel) updateInputs(msg tea.Msg) tea.Cmd { 156 var cmds []tea.Cmd 157 var cmd tea.Cmd 158 159 if !m.handleLocked { 160 m.handleInput, cmd = m.handleInput.Update(msg) 161 cmds = append(cmds, cmd) 162 } 163 164 m.passwordInput, cmd = m.passwordInput.Update(msg) 165 cmds = append(cmds, cmd) 166 167 return tea.Batch(cmds...) 168} 169 170func (m authFormModel) View() string { 171 var b strings.Builder 172 173 b.WriteString(TitleStyle.Render("AT Protocol Authentication")) 174 b.WriteString("\n\n") 175 176 b.WriteString(TextStyle.Render("BlueSky Handle:")) 177 b.WriteString("\n") 178 if m.handleLocked { 179 b.WriteString(MutedStyle.Render(m.handleInput.Value())) 180 b.WriteString(MutedStyle.Render(" (locked)")) 181 } else { 182 b.WriteString(m.handleInput.View()) 183 } 184 b.WriteString("\n\n") 185 186 b.WriteString(TextStyle.Render("App Password:")) 187 b.WriteString("\n") 188 b.WriteString(m.passwordInput.View()) 189 b.WriteString("\n\n") 190 191 if m.handleInput.Value() == "" { 192 b.WriteString(ErrorStyle.Render("Handle is required")) 193 b.WriteString("\n") 194 } 195 if m.passwordInput.Value() == "" { 196 b.WriteString(ErrorStyle.Render("Password is required")) 197 b.WriteString("\n") 198 } 199 200 b.WriteString("\n") 201 helpText := "tab/shift+tab: navigate • enter/ctrl+s: submit • esc/ctrl+c: cancel" 202 b.WriteString(MutedStyle.MarginTop(1).Render(helpText)) 203 204 return b.String() 205} 206 207// Run displays the auth form and returns the entered credentials 208func (af *AuthForm) Run() (*AuthFormResult, error) { 209 handleInput := textinput.New() 210 handleInput.Placeholder = "username.bsky.social" 211 handleInput.Width = 40 212 handleInput.CharLimit = 253 213 214 passwordInput := textinput.New() 215 passwordInput.Placeholder = "App password" 216 passwordInput.Width = 40 217 passwordInput.EchoMode = textinput.EchoPassword 218 passwordInput.EchoCharacter = '•' 219 220 handleLocked := false 221 focusIndex := 0 222 223 if af.initialHandle != "" { 224 handleInput.SetValue(af.initialHandle) 225 handleLocked = true 226 focusIndex = 1 227 } 228 229 model := authFormModel{ 230 handleInput: handleInput, 231 passwordInput: passwordInput, 232 focusIndex: focusIndex, 233 keys: authFormKeys, 234 handleLocked: handleLocked, 235 } 236 237 program := tea.NewProgram( 238 model, 239 tea.WithInput(af.opts.Input), 240 tea.WithOutput(af.opts.Output), 241 ) 242 243 finalModel, err := program.Run() 244 if err != nil { 245 return nil, fmt.Errorf("failed to run auth form: %w", err) 246 } 247 248 result := finalModel.(authFormModel) 249 250 return &AuthFormResult{ 251 Handle: result.handleInput.Value(), 252 Password: result.passwordInput.Value(), 253 Canceled: result.canceled, 254 }, nil 255}