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