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