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