| // Copyright 2015 The Vanadium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package main |
| |
| import ( |
| "flag" |
| "fmt" |
| "io/ioutil" |
| "log" |
| "os" |
| "strings" |
| "sync" |
| "time" |
| |
| "github.com/fatih/color" |
| "github.com/nlacasse/gocui" |
| |
| "v.io/v23" |
| "v.io/x/lib/vlog" |
| ) |
| |
| var ( |
| mounttable = flag.String("mounttable", "/ns.dev.v.io:8101", "Mounttable where channel is mounted.") |
| proxy = flag.String("proxy", "proxy.dev.v.io:8100", "Proxy to listen on.") |
| channelName = flag.String("channel", "users/vanadium.bot@gmail.com/apps/chat/public", "Channel to join.") |
| ) |
| |
| const welcomeText = `***Welcome to Vanadium Chat*** |
| Press Ctrl-C to exit. |
| ` |
| |
| func init() { |
| logDir, err := ioutil.TempDir("", "chat-logs") |
| if err != nil { |
| panic(err) |
| } |
| err = vlog.Log.Configure(vlog.LogDir(logDir)) |
| if err != nil { |
| panic(err) |
| } |
| |
| // Make sure that *nothing* ever gets printed to stderr. |
| os.Stderr.Close() |
| } |
| |
| // Defines the layout of the UI. |
| func layout(g *gocui.Gui) error { |
| maxX, maxY := g.Size() |
| |
| membersViewWidth := 30 |
| messageInputViewHeight := 3 |
| |
| if _, err := g.SetView("history", -1, -1, maxX-membersViewWidth, maxY-messageInputViewHeight); err != nil { |
| if err != gocui.ErrorUnkView { |
| return err |
| } |
| } |
| if membersView, err := g.SetView("members", maxX-membersViewWidth, -1, maxX, maxY-messageInputViewHeight); err != nil { |
| if err != gocui.ErrorUnkView { |
| return err |
| } |
| membersView.FgColor = gocui.ColorCyan |
| } |
| if messageInputView, err := g.SetView("messageInput", -1, maxY-messageInputViewHeight, maxX, maxY-1); err != nil { |
| if err != gocui.ErrorUnkView { |
| return err |
| } |
| messageInputView.Editable = true |
| } |
| if err := g.SetCurrentView("messageInput"); err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| // app encapsulates the UI and the channel logic. |
| type app struct { |
| cr *channel |
| g *gocui.Gui |
| hw *historyWriter |
| cachedMembers []string |
| // Function to call when shutting down the app. |
| shutdown func() |
| // Mutex to protect read/writes to cachedMembers array. |
| mu sync.Mutex |
| } |
| |
| // Initialize the UI and channel. |
| func newApp() *app { |
| // Set up the UI. |
| g := gocui.NewGui() |
| if err := g.Init(); err != nil { |
| log.Panicln(err) |
| } |
| g.ShowCursor = true |
| g.SetLayout(layout) |
| |
| // Draw the layout. |
| g.Flush() |
| |
| ctx, ctxShutdown := v23.Init() |
| |
| shutdown := func() { |
| ctxShutdown() |
| g.Close() |
| } |
| |
| cr, err := newChannel(ctx, *mounttable, *proxy, *channelName) |
| if err != nil { |
| log.Panicln(err) |
| } |
| |
| historyView, err := g.View("history") |
| if err != nil { |
| log.Panicln(err) |
| } |
| hw := newHistoryWriter(historyView, cr.UserName()) |
| hw.Write([]byte(color.RedString(welcomeText))) |
| |
| hw.Write([]byte(fmt.Sprintf("You have joined channel '%s' on mounttable '%s'.\n"+ |
| "Your username is '%s'.\n\n", *channelName, *mounttable, cr.UserName()))) |
| |
| a := &app{ |
| cr: cr, |
| g: g, |
| hw: hw, |
| shutdown: shutdown, |
| } |
| |
| if err := a.setKeybindings(); err != nil { |
| log.Panicln(err) |
| } |
| |
| return a |
| } |
| |
| // Helper method to log to the history console when debugging. |
| func (a *app) log(m string) { |
| a.hw.Write([]byte("LOG: " + m + "\n")) |
| } |
| |
| func (a *app) quit(g *gocui.Gui, v *gocui.View) error { |
| return gocui.Quit |
| } |
| |
| func (a *app) handleSendMessage(g *gocui.Gui, v *gocui.View) error { |
| text := strings.TrimSpace(v.Buffer()) |
| if text == "" { |
| return nil |
| } |
| if err := a.cr.broadcastMessage(text); err != nil { |
| return err |
| } |
| v.Clear() |
| return nil |
| } |
| |
| func (a *app) handleTabComplete(g *gocui.Gui, v *gocui.View) error { |
| lastWord, err := v.Word(v.Cursor()) |
| if err != nil { |
| // The view buffer is empty. Just return early. |
| return nil |
| } |
| |
| // Get a list of names that match the last word. |
| matchedNames := []string{} |
| a.mu.Lock() |
| for _, name := range a.cachedMembers { |
| if strings.HasPrefix(name, lastWord) { |
| matchedNames = append(matchedNames, name) |
| } |
| } |
| a.mu.Unlock() |
| |
| if len(matchedNames) == 0 { |
| return nil |
| } |
| |
| // Get the longest common prefix between the matchedNames. |
| lcp := longestCommonPrefix(matchedNames) |
| |
| // Suffix is the part of the lcp that is not already part of the |
| // lastWord. |
| suffix := lcp[len(lastWord):] |
| |
| // If the name was matched uniquely, append a space. |
| if len(matchedNames) == 1 { |
| suffix = suffix + " " |
| } |
| |
| // Simply writing the suffix to the buffer causes strange whitespace |
| // additions. To work around this, we calculate the desired content of |
| // the buffer, then clear the buffer and write the entire new content. |
| newLine := strings.TrimSpace(v.Buffer()) + suffix |
| v.Clear() |
| v.Write([]byte(newLine)) |
| |
| // Set the cursor to the end of the new line, and reset the origin. |
| v.SetCursor(len(newLine), 0) |
| v.SetOrigin(0, 0) |
| |
| return nil |
| } |
| |
| func (a *app) setKeybindings() error { |
| // Ctrl-C => Exit. |
| if err := a.g.SetKeybinding("", gocui.KeyCtrlC, 0, a.quit); err != nil { |
| return err |
| } |
| |
| // Enter => Send message. |
| if err := a.g.SetKeybinding("messageInput", gocui.KeyEnter, 0, a.handleSendMessage); err != nil { |
| return err |
| } |
| |
| // Tab => Tab-complete member names. |
| if err := a.g.SetKeybinding("messageInput", gocui.KeyTab, 0, a.handleTabComplete); err != nil { |
| return err |
| } |
| |
| return nil |
| } |
| |
| // updateMembers gets the members from the channel and writes them to the |
| // members view. It also caches the members in app.cachedMembers for use in tab |
| // autocomplete. |
| func (a *app) updateMembers() { |
| membersView, err := a.g.View("members") |
| if err != nil { |
| log.Panicln(err) |
| } |
| |
| members, err := a.cr.getMembers() |
| if err != nil { |
| log.Panicln(err) |
| } |
| |
| memberNames := make([]string, len(members)) |
| |
| for i, member := range members { |
| memberNames[i] = member.Name |
| } |
| |
| uniqMemberNames := uniqStrings(memberNames) |
| |
| membersView.Clear() |
| for _, memberName := range uniqMemberNames { |
| membersView.Write([]byte(memberName + "\n")) |
| } |
| |
| a.mu.Lock() |
| a.cachedMembers = uniqMemberNames |
| a.mu.Unlock() |
| a.g.Flush() |
| } |
| |
| // displayIncomingMessages listens for incoming messages and writes them to the |
| // historyWriter. |
| func (a *app) displayIncomingMessages() { |
| go func() { |
| for { |
| m := <-a.cr.messages |
| a.hw.writeMessage(m) |
| } |
| }() |
| } |
| |
| // run joins the channel and starts the main app loop. |
| func (a *app) run() error { |
| // Join the channel. |
| if err := a.cr.join(); err != nil { |
| log.Panicln(err) |
| } |
| defer a.cr.leave() |
| |
| // Update the members view in a loop. |
| go func() { |
| for { |
| a.updateMembers() |
| time.Sleep(2 * time.Second) |
| } |
| }() |
| |
| a.displayIncomingMessages() |
| |
| // Start the main UI loop. |
| if err := a.g.MainLoop(); err != nil && err != gocui.Quit { |
| return err |
| } |
| |
| return nil |
| } |
| |
| func main() { |
| flag.Parse() |
| |
| a := newApp() |
| defer a.shutdown() |
| if err := a.run(); err != nil { |
| log.Panicln(err) |
| } |
| } |