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