]> git.r.bdr.sh - rbdr/mobius/blob - hotline/ui.go
Refactor and cleanup to improve testability
[rbdr/mobius] / hotline / ui.go
1 package hotline
2
3 import (
4 "fmt"
5 "github.com/gdamore/tcell/v2"
6 "github.com/rivo/tview"
7 "gopkg.in/yaml.v3"
8 "io"
9 "io/ioutil"
10 "os"
11 "strconv"
12 "strings"
13 )
14
15 type UI struct {
16 chatBox *tview.TextView
17 chatInput *tview.InputField
18 App *tview.Application
19 Pages *tview.Pages
20 userList *tview.TextView
21 trackerList *tview.List
22 HLClient *Client
23 }
24
25 // pages
26 const (
27 pageServerUI = "serverUI"
28 )
29
30 func NewUI(c *Client) *UI {
31 app := tview.NewApplication()
32 chatBox := tview.NewTextView().
33 SetScrollable(true).
34 SetDynamicColors(true).
35 SetWordWrap(true).
36 SetChangedFunc(func() {
37 app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render??
38 })
39 chatBox.Box.SetBorder(true).SetTitle("| Chat |")
40
41 chatInput := tview.NewInputField()
42 chatInput.
43 SetLabel("> ").
44 SetFieldBackgroundColor(tcell.ColorDimGray).
45 SetDoneFunc(func(key tcell.Key) {
46 // skip send if user hit enter with no other text
47 if len(chatInput.GetText()) == 0 {
48 return
49 }
50
51 _ = c.Send(
52 *NewTransaction(tranChatSend, nil,
53 NewField(fieldData, []byte(chatInput.GetText())),
54 ),
55 )
56 chatInput.SetText("") // clear the input field after chat send
57 })
58
59 chatInput.Box.SetBorder(true).SetTitle("Send")
60
61 userList := tview.
62 NewTextView().
63 SetDynamicColors(true).
64 SetChangedFunc(func() {
65 app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render??
66 })
67 userList.Box.SetBorder(true).SetTitle("Users")
68
69 return &UI{
70 App: app,
71 chatBox: chatBox,
72 Pages: tview.NewPages(),
73 chatInput: chatInput,
74 userList: userList,
75 trackerList: tview.NewList(),
76 HLClient: c,
77 }
78 }
79
80 func (ui *UI) showBookmarks() *tview.List {
81 list := tview.NewList()
82 list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
83 if event.Key() == tcell.KeyEsc {
84 ui.Pages.SwitchToPage("home")
85 }
86 return event
87 })
88 list.Box.SetBorder(true).SetTitle("| Bookmarks |")
89
90 shortcut := 97 // rune for "a"
91 for i, srv := range ui.HLClient.pref.Bookmarks {
92 addr := srv.Addr
93 login := srv.Login
94 pass := srv.Password
95 list.AddItem(srv.Name, srv.Addr, rune(shortcut+i), func() {
96 ui.Pages.RemovePage("joinServer")
97
98 newJS := ui.renderJoinServerForm("", addr, login, pass, "bookmarks", true, true)
99
100 ui.Pages.AddPage("joinServer", newJS, true, true)
101 })
102 }
103
104 return list
105 }
106
107 func (ui *UI) getTrackerList() *tview.List {
108 listing, err := GetListing(ui.HLClient.pref.Tracker)
109 if err != nil {
110 // TODO
111 }
112
113 list := tview.NewList()
114 list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
115 if event.Key() == tcell.KeyEsc {
116 ui.Pages.SwitchToPage("home")
117 }
118 return event
119 })
120 list.Box.SetBorder(true).SetTitle("| Servers |")
121
122 shortcut := 97 // rune for "a"
123 for i, srv := range listing {
124 addr := srv.Addr()
125 srvName := srv.Name
126 list.AddItem(string(srv.Name), string(srv.Description), rune(shortcut+i), func() {
127 ui.Pages.RemovePage("joinServer")
128
129 newJS := ui.renderJoinServerForm(string(srvName), addr, GuestAccount, "", trackerListPage, false, true)
130
131 ui.Pages.AddPage("joinServer", newJS, true, true)
132 ui.Pages.ShowPage("joinServer")
133 })
134 }
135
136 return list
137 }
138
139 func (ui *UI) renderSettingsForm() *tview.Flex {
140 iconStr := strconv.Itoa(ui.HLClient.pref.IconID)
141 settingsForm := tview.NewForm()
142 settingsForm.AddInputField("Your Name", ui.HLClient.pref.Username, 0, nil, nil)
143 settingsForm.AddInputField("IconID", iconStr, 0, func(idStr string, _ rune) bool {
144 _, err := strconv.Atoi(idStr)
145 return err == nil
146 }, nil)
147 settingsForm.AddInputField("Tracker", ui.HLClient.pref.Tracker, 0, nil, nil)
148 settingsForm.AddButton("Save", func() {
149 usernameInput := settingsForm.GetFormItem(0).(*tview.InputField).GetText()
150 if len(usernameInput) == 0 {
151 usernameInput = "unnamed"
152 }
153 ui.HLClient.pref.Username = usernameInput
154 iconStr = settingsForm.GetFormItem(1).(*tview.InputField).GetText()
155 ui.HLClient.pref.IconID, _ = strconv.Atoi(iconStr)
156 ui.HLClient.pref.Tracker = settingsForm.GetFormItem(2).(*tview.InputField).GetText()
157
158 out, err := yaml.Marshal(&ui.HLClient.pref)
159 if err != nil {
160 // TODO: handle err
161 }
162 // TODO: handle err
163 err = ioutil.WriteFile(ui.HLClient.cfgPath, out, 0666)
164 if err != nil {
165 println(ui.HLClient.cfgPath)
166 panic(err)
167 }
168 ui.Pages.RemovePage("settings")
169 })
170 settingsForm.SetBorder(true)
171 settingsForm.SetCancelFunc(func() {
172 ui.Pages.RemovePage("settings")
173 })
174 settingsPage := tview.NewFlex().SetDirection(tview.FlexRow)
175 settingsPage.Box.SetBorder(true).SetTitle("Settings")
176 settingsPage.AddItem(settingsForm, 0, 1, true)
177
178 centerFlex := tview.NewFlex().
179 AddItem(nil, 0, 1, false).
180 AddItem(tview.NewFlex().
181 SetDirection(tview.FlexRow).
182 AddItem(nil, 0, 1, false).
183 AddItem(settingsForm, 15, 1, true).
184 AddItem(nil, 0, 1, false), 40, 1, true).
185 AddItem(nil, 0, 1, false)
186
187 return centerFlex
188 }
189
190 func (ui *UI) joinServer(addr, login, password string) error {
191 // append default port to address if no port supplied
192 if len(strings.Split(addr, ":")) == 1 {
193 addr += ":5500"
194 }
195 if err := ui.HLClient.JoinServer(addr, login, password); err != nil {
196 return fmt.Errorf("Error joining server: %v\n", err)
197 }
198
199 go func() {
200 for {
201 err := ui.HLClient.ReadLoop()
202 if err != nil {
203 ui.HLClient.Logger.Errorw("read error", "err", err)
204
205 if err == io.EOF {
206 loginErrModal := tview.NewModal().
207 AddButtons([]string{"Ok"}).
208 SetText("The server connection has closed.").
209 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
210 ui.Pages.SwitchToPage("home")
211 })
212 loginErrModal.Box.SetTitle("Server Connection Error")
213
214 ui.Pages.AddPage("loginErr", loginErrModal, false, true)
215 ui.App.Draw()
216 return
217 }
218 ui.Pages.SwitchToPage("home")
219
220 return
221 }
222 }
223 }()
224
225 return nil
226 }
227
228 func (ui *UI) renderJoinServerForm(name, server, login, password, backPage string, save, defaultConnect bool) *tview.Flex {
229 joinServerForm := tview.NewForm()
230 joinServerForm.
231 // AddInputField("Name", server, 0, func(textToCheck string, lastChar rune) bool {
232 // return false
233 // }, nil).
234 AddInputField("Server", server, 0, nil, nil).
235 AddInputField("Login", login, 0, nil, nil).
236 AddPasswordField("Password", password, 0, '*', nil).
237 AddCheckbox("Save", save, func(checked bool) {
238 ui.HLClient.Logger.Infow("saving bookmark")
239 // TODO: Implement bookmark saving
240
241 ui.HLClient.pref.AddBookmark(joinServerForm.GetFormItem(0).(*tview.InputField).GetText(), joinServerForm.GetFormItem(0).(*tview.InputField).GetText(), joinServerForm.GetFormItem(1).(*tview.InputField).GetText(), joinServerForm.GetFormItem(2).(*tview.InputField).GetText())
242 out, err := yaml.Marshal(ui.HLClient.pref)
243 if err != nil {
244 panic(err)
245 }
246
247 err = ioutil.WriteFile(ui.HLClient.cfgPath, out, 0666)
248 if err != nil {
249 panic(err)
250 }
251 // pref := ui.HLClient.pref
252 }).
253 AddButton("Cancel", func() {
254 ui.Pages.SwitchToPage(backPage)
255 }).
256 AddButton("Connect", func() {
257 srvAddr := joinServerForm.GetFormItem(0).(*tview.InputField).GetText()
258 loginInput := joinServerForm.GetFormItem(1).(*tview.InputField).GetText()
259 err := ui.joinServer(
260 srvAddr,
261 loginInput,
262 joinServerForm.GetFormItem(2).(*tview.InputField).GetText(),
263 )
264 if name == "" {
265 name = fmt.Sprintf("%s@%s", loginInput, srvAddr)
266 }
267 ui.HLClient.serverName = name
268
269 if err != nil {
270 ui.HLClient.Logger.Errorw("login error", "err", err)
271 loginErrModal := tview.NewModal().
272 AddButtons([]string{"Oh no"}).
273 SetText(err.Error()).
274 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
275 ui.Pages.SwitchToPage(backPage)
276 })
277
278 ui.Pages.AddPage("loginErr", loginErrModal, false, true)
279 }
280
281 // Save checkbox
282 if joinServerForm.GetFormItem(3).(*tview.Checkbox).IsChecked() {
283 // TODO: implement bookmark saving
284 }
285 })
286
287 joinServerForm.Box.SetBorder(true).SetTitle("| Connect |")
288 joinServerForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
289 if event.Key() == tcell.KeyEscape {
290 ui.Pages.SwitchToPage(backPage)
291 }
292 return event
293 })
294
295 if defaultConnect {
296 joinServerForm.SetFocus(5)
297 }
298
299 joinServerPage := tview.NewFlex().
300 AddItem(nil, 0, 1, false).
301 AddItem(tview.NewFlex().
302 SetDirection(tview.FlexRow).
303 AddItem(nil, 0, 1, false).
304 AddItem(joinServerForm, 14, 1, true).
305 AddItem(nil, 0, 1, false), 40, 1, true).
306 AddItem(nil, 0, 1, false)
307
308 return joinServerPage
309 }
310
311 func (ui *UI) renderServerUI() *tview.Flex {
312 ui.chatBox.SetText("") // clear any previously existing chatbox text
313 commandList := tview.NewTextView().SetDynamicColors(true)
314 commandList.
315 SetText("[yellow]^n[-::]: Read News [yellow]^p[-::]: Post News\n[yellow]^l[-::]: View Logs [yellow]^f[-::]: View Files\n").
316 SetBorder(true).
317 SetTitle("| Keyboard Shortcuts| ")
318
319 modal := tview.NewModal().
320 SetText("Disconnect from the server?").
321 AddButtons([]string{"Cancel", "Exit"}).
322 SetFocus(1)
323 modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
324 if buttonIndex == 1 {
325 _ = ui.HLClient.Disconnect()
326 ui.Pages.RemovePage(pageServerUI)
327 } else {
328 ui.Pages.HidePage("modal")
329 }
330 })
331
332 serverUI := tview.NewFlex().
333 AddItem(tview.NewFlex().
334 SetDirection(tview.FlexRow).
335 AddItem(commandList, 4, 0, false).
336 AddItem(ui.chatBox, 0, 8, false).
337 AddItem(ui.chatInput, 3, 0, true), 0, 1, true).
338 AddItem(ui.userList, 25, 1, false)
339 serverUI.SetBorder(true).SetTitle("| Mobius - Connected to " + ui.HLClient.serverName + " |").SetTitleAlign(tview.AlignLeft)
340 serverUI.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
341 if event.Key() == tcell.KeyEscape {
342 ui.Pages.AddPage("modal", modal, false, true)
343 }
344
345 // List files
346 if event.Key() == tcell.KeyCtrlF {
347 if err := ui.HLClient.Send(*NewTransaction(tranGetFileNameList, nil)); err != nil {
348 ui.HLClient.Logger.Errorw("err", "err", err)
349 }
350 }
351
352 // Show News
353 if event.Key() == tcell.KeyCtrlN {
354 if err := ui.HLClient.Send(*NewTransaction(tranGetMsgs, nil)); err != nil {
355 ui.HLClient.Logger.Errorw("err", "err", err)
356 }
357 }
358
359 // Post news
360 if event.Key() == tcell.KeyCtrlP {
361
362 newsFlex := tview.NewFlex()
363 newsFlex.SetBorderPadding(0, 0, 1, 1)
364 newsPostTextArea := tview.NewTextView()
365 newsPostTextArea.SetBackgroundColor(tcell.ColorDarkSlateGrey)
366 newsPostTextArea.SetChangedFunc(func() {
367 ui.App.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render??
368 })
369
370 newsPostForm := tview.NewForm().
371 SetButtonsAlign(tview.AlignRight).
372 // AddButton("Cancel", nil). // TODO: implement cancel button behavior
373 AddButton("Send", nil)
374 newsPostForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
375 switch event.Key() {
376 case tcell.KeyEscape:
377 ui.Pages.RemovePage("newsInput")
378 case tcell.KeyTab:
379 ui.App.SetFocus(newsPostTextArea)
380 case tcell.KeyEnter:
381 newsText := strings.ReplaceAll(newsPostTextArea.GetText(true), "\n", "\r")
382 if len(newsText) == 0 {
383 return event
384 }
385 err := ui.HLClient.Send(
386 *NewTransaction(tranOldPostNews, nil,
387 NewField(fieldData, []byte(newsText)),
388 ),
389 )
390 if err != nil {
391 ui.HLClient.Logger.Errorw("Error posting news", "err", err)
392 // TODO: display errModal to user
393 }
394 ui.Pages.RemovePage("newsInput")
395 }
396
397 return event
398 })
399
400 newsFlex.
401 SetDirection(tview.FlexRow).
402 SetBorder(true).
403 SetTitle("| Post Message |")
404
405 newsPostTextArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
406 switch event.Key() {
407 case tcell.KeyEscape:
408 ui.Pages.RemovePage("newsInput")
409 case tcell.KeyTab:
410 ui.App.SetFocus(newsPostForm)
411 case tcell.KeyEnter:
412 _, _ = fmt.Fprintf(newsPostTextArea, "\n")
413 default:
414 const windowsBackspaceRune = 8
415 const macBackspaceRune = 127
416 switch event.Rune() {
417 case macBackspaceRune, windowsBackspaceRune:
418 curTxt := newsPostTextArea.GetText(true)
419 if len(curTxt) > 0 {
420 curTxt = curTxt[:len(curTxt)-1]
421 newsPostTextArea.SetText(curTxt)
422 }
423 default:
424 _, _ = fmt.Fprintf(newsPostTextArea, string(event.Rune()))
425 }
426 }
427
428 return event
429 })
430
431 newsFlex.AddItem(newsPostTextArea, 10, 0, true)
432 newsFlex.AddItem(newsPostForm, 3, 0, false)
433
434 newsPostPage := tview.NewFlex().
435 AddItem(nil, 0, 1, false).
436 AddItem(tview.NewFlex().
437 SetDirection(tview.FlexRow).
438 AddItem(nil, 0, 1, false).
439 AddItem(newsFlex, 15, 1, true).
440 // AddItem(newsPostForm, 3, 0, false).
441 AddItem(nil, 0, 1, false), 40, 1, false).
442 AddItem(nil, 0, 1, false)
443
444 ui.Pages.AddPage("newsInput", newsPostPage, true, true)
445 ui.App.SetFocus(newsPostTextArea)
446 }
447
448 return event
449 })
450 return serverUI
451 }
452
453 func (ui *UI) Start() {
454 home := tview.NewFlex().SetDirection(tview.FlexRow)
455 home.Box.SetBorder(true).SetTitle("| Mobius v" + VERSION + " |").SetTitleAlign(tview.AlignLeft)
456 mainMenu := tview.NewList()
457
458 bannerItem := tview.NewTextView().
459 SetText(randomBanner()).
460 SetDynamicColors(true).
461 SetTextAlign(tview.AlignCenter)
462
463 home.AddItem(
464 tview.NewFlex().AddItem(bannerItem, 0, 1, false),
465 14, 1, false)
466 home.AddItem(tview.NewFlex().
467 AddItem(nil, 0, 1, false).
468 AddItem(mainMenu, 0, 1, true).
469 AddItem(nil, 0, 1, false),
470 0, 1, true,
471 )
472
473 mainMenu.AddItem("Join Server", "", 'j', func() {
474 joinServerPage := ui.renderJoinServerForm("", "", GuestAccount, "", "home", false, false)
475 ui.Pages.AddPage("joinServer", joinServerPage, true, true)
476 }).
477 AddItem("Bookmarks", "", 'b', func() {
478 ui.Pages.AddAndSwitchToPage("bookmarks", ui.showBookmarks(), true)
479 }).
480 AddItem("Browse Tracker", "", 't', func() {
481 ui.trackerList = ui.getTrackerList()
482 ui.Pages.AddAndSwitchToPage("trackerList", ui.trackerList, true)
483 }).
484 AddItem("Settings", "", 's', func() {
485 ui.Pages.AddPage("settings", ui.renderSettingsForm(), true, true)
486 }).
487 AddItem("Quit", "", 'q', func() {
488 ui.App.Stop()
489 })
490
491 ui.Pages.AddPage("home", home, true, true)
492
493 // App level input capture
494 ui.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
495 if event.Key() == tcell.KeyCtrlC {
496 ui.HLClient.Logger.Infow("Exiting")
497 ui.App.Stop()
498 os.Exit(0)
499 }
500 // Show Logs
501 if event.Key() == tcell.KeyCtrlL {
502 ui.HLClient.DebugBuf.TextView.ScrollToEnd()
503 ui.HLClient.DebugBuf.TextView.SetBorder(true).SetTitle("Logs")
504 ui.HLClient.DebugBuf.TextView.SetDoneFunc(func(key tcell.Key) {
505 if key == tcell.KeyEscape {
506 ui.Pages.RemovePage("logs")
507 }
508 })
509
510 ui.Pages.AddPage("logs", ui.HLClient.DebugBuf.TextView, true, true)
511 }
512 return event
513 })
514
515 if err := ui.App.SetRoot(ui.Pages, true).SetFocus(ui.Pages).Run(); err != nil {
516 ui.App.Stop()
517 os.Exit(1)
518 }
519 }