cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm
leaflet
readability
golang
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}