diff --git a/ui/buffer.go b/ui/buffer.go new file mode 100644 index 0000000..6ff411d --- /dev/null +++ b/ui/buffer.go @@ -0,0 +1,136 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +const ( + // Status is the status buffer. + Status = iota + // Profile is the profile buffer. + Profile +) + +// BufferHandler is the concrete functionality of a Buffer. +type BufferHandler interface { + validateInput(m model, input string) error // validate incoming input + handleInput(input, hiddenInput string) tea.Msg // handle and dispatch messages from input + logToFile(m model, input string) // log input to file if persistence is enabled +} + +// Buffer is a user-facing interactive buffer in the TUI. +type Buffer struct { + menuBarName string // user-friendly buffer name for the menu + inputChannel chan string // channel for passing input to handlers + viewport viewport.Model // viewport for rendering lines + viewportLines []string // lines for viewport renderer + viewportIsReady bool // whether or not the viewport is ready to render + persistenceIsEnabled bool // whether or not to persist buffer input to file + + BufferHandler +} + +// StatusBuffer is the privileged status buffer where e.g. you input +// program-wide commands. It can also report general status information, e.g. +// the status of ACN connectivity. +type StatusBufferHandler Buffer + +func (s StatusBufferHandler) validateInput(m model, input string) error { + if string(input[0]) != "/" { + return fmt.Errorf("Woops, this is not a chat buffer. Only commands are allowed") + } + return nil +} + +func (s StatusBufferHandler) handleInput(input, hiddenInput string) tea.Msg { + cmds := strings.Split(input, " ") + + switch { + case cmds[0] == "help": + return cmdMsg{output: strings.Split(cmdHelp, "\n")} + + case cmds[0] == "quit": + return turnAcnOffMsg{quitProgram: true} + + case cmds[0] == "start": + return showGettingStartedGuideMsg{} + + case cmds[0] == "profile": + if cmds[1] != "unlock" && cmds[1] != "info" && cmds[1] != "create" { + profileHelp := `Unknown "profile" command? +/profile create | Create a new profile +/profile info | Show profile information +/profile unlock | Unlock profile(s)` + return cmdMsg{output: strings.Split(profileHelp, "\n")} + } + + switch { + case cmds[1] == "unlock": + if len(cmds) != 3 { + return cmdMsg{output: strings.Split(cmdHelp, "\n")} + } + + return profileUnlockMsg{ + password: strings.TrimSpace(hiddenInput), + } + + case cmds[1] == "info": + return profileInfoMsg{} + + case cmds[1] == "create": + if len(cmds) != 5 { + return cmdMsg{output: strings.Split(cmdHelp, "\n")} + } + + passwords := strings.Split(strings.TrimSpace(hiddenInput), " ") + if len(passwords) != 2 { + return cmdMsg{output: []string{"Profile create: unable to parse hidden input"}} + } + + if passwords[0] != passwords[1] { + return cmdMsg{output: []string{"Profile create: passwords do not match?"}} + } + + return profileCreateMsg{ + name: cmds[2], + password: passwords[1], + } + } + + case cmds[0] == "acn": + if cmds[1] != "on" && cmds[1] != "off" { + acnHelp := `Unknown "acn" command? +/acn on | Turn on the Tor ACN +/acn off | Turn off the Tor ACN` + return cmdMsg{output: strings.Split(acnHelp, "\n")} + } + + switch { + case cmds[1] == "on": + return turnAcnOnMsg{} + case cmds[1] == "off": + return turnAcnOffMsg{} + } + + case cmds[0] == "clear": + return clearScreenMsg{} + } + + return cmdMsg{output: []string{unknownCmdHelp}} +} + +func (s StatusBufferHandler) logToFile(m model, input string) { + if !s.persistenceIsEnabled { + return + } + // TODO: implement saving to file under ~/.cairde +} + +// ProfileBuffer is the profile buffer. There can be several profile buffers +// which correspond to several profiles. Profile buffers take input and report +// output for profile-specific information, e.g. invites from a new contact. +type ProfileBuffer Buffer diff --git a/ui/input.go b/ui/input.go index 40faef2..ac4ef88 100644 --- a/ui/input.go +++ b/ui/input.go @@ -2,8 +2,6 @@ package ui import ( "strings" - - tea "github.com/charmbracelet/bubbletea" ) const unknownCmdHelp = "Unknown command? Run /help to see available commands" @@ -18,85 +16,8 @@ const cmdHelp = `/acn on | Turn on the Tor /start | Show getting started guide /quit | Quit the program` -func handleCmd(cmd, hiddenInput string) tea.Msg { - cmds := strings.Split(cmd, " ") - - switch { - case cmds[0] == "help": - return cmdMsg{output: strings.Split(cmdHelp, "\n")} - - case cmds[0] == "quit": - return turnAcnOffMsg{quitProgram: true} - - case cmds[0] == "start": - return showGettingStartedGuideMsg{} - - case cmds[0] == "profile": - if cmds[1] != "unlock" && cmds[1] != "info" && cmds[1] != "create" { - profileHelp := `Unknown "profile" command? -/profile create | Create a new profile -/profile info | Show profile information -/profile unlock | Unlock profile(s)` - return cmdMsg{output: strings.Split(profileHelp, "\n")} - } - - switch { - case cmds[1] == "unlock": - if len(cmds) != 3 { - return cmdMsg{output: strings.Split(cmdHelp, "\n")} - } - - return profileUnlockMsg{ - password: strings.TrimSpace(hiddenInput), - } - - case cmds[1] == "info": - return profileInfoMsg{} - - case cmds[1] == "create": - if len(cmds) != 5 { - return cmdMsg{output: strings.Split(cmdHelp, "\n")} - } - - passwords := strings.Split(strings.TrimSpace(hiddenInput), " ") - if len(passwords) != 2 { - return cmdMsg{output: []string{"Profile create: unable to parse hidden input"}} - } - - if passwords[0] != passwords[1] { - return cmdMsg{output: []string{"Profile create: passwords do not match?"}} - } - - return profileCreateMsg{ - name: cmds[2], - password: passwords[1], - } - } - - case cmds[0] == "acn": - if cmds[1] != "on" && cmds[1] != "off" { - acnHelp := `Unknown "acn" command? -/acn on | Turn on the Tor ACN -/acn off | Turn off the Tor ACN` - return cmdMsg{output: strings.Split(acnHelp, "\n")} - } - - switch { - case cmds[1] == "on": - return turnAcnOnMsg{} - case cmds[1] == "off": - return turnAcnOffMsg{} - } - - case cmds[0] == "clear": - return clearScreenMsg{} - } - - return cmdMsg{output: []string{unknownCmdHelp}} -} - func hidePasswordInput(m *model) { - val := strings.TrimSpace(m.input.Value()) + val := m.input.Value() if len(val) == 0 { return @@ -114,8 +35,8 @@ func hidePasswordInput(m *model) { if cmds[1] == "create" && len(cmds) > 3 || cmds[1] == "unlock" && len(cmds) > 2 { lastChar := string(val[len(val)-1]) + m.hiddenInput += lastChar if lastChar != " " { - m.hiddenInput += lastChar newCmd := []rune(val) newCmd[len(val)-1] = '*' m.input.SetValue(string(newCmd)) diff --git a/ui/model.go b/ui/model.go index fb935aa..c39f62e 100644 --- a/ui/model.go +++ b/ui/model.go @@ -56,38 +56,26 @@ network, initialise Cwtch and create your first profile. It's simple! ` ) +// model is the core data structure for the entire programme type model struct { - username string - userDir string - - acnState int - - menuState int - menuBar []string - - app app.Application - acn connectivity.ACN - - width int - height int - - showWelcomeMessage bool - - statusViewport viewport.Model - statusViewportLines []string - statusViewportReady bool - - profiles profiles - profileState int - - input textinput.Model - hiddenInput string - - version string - - statusBuffer chan string - - debug bool + username string // system username + userDir string // system user home directory + acnState int // ACN state - online, offline (or in between...) + menuState int // which buffer is currently selected + menuBar []string // list of buffers in use (e.g. status, profile1) + app app.Application // cwtch application manager + acn connectivity.ACN // cwtch ACN connectivity manager + width int // width of the TUI + height int // height of the TUI + showWelcomeMessage bool // whether or not we show the welcome message when turning on + buffers []Buffer // all buffers + statusBuffer Buffer // the central status buffer + profiles profiles // all unlocked cwtch profiles + profileState int // which profile is currently selected + input textinput.Model // the central user input buffer + hiddenInput string // the hidden input storage for e.g. passwords + version string // the version of cairde + debug bool // whether or not to run under debug mode } func NewModel(username, homeDir, version string, debug bool) model { // nolint:revive @@ -99,24 +87,26 @@ func NewModel(username, homeDir, version string, debug bool) model { // nolint:r input.TextStyle = inputTextStyle input.Focus() + statusBuffer := Buffer{ + BufferHandler: StatusBufferHandler{}, + menuBarName: "STATUS", + inputChannel: make(chan string), + viewportIsReady: false, + persistenceIsEnabled: true, // TODO: configurable + } + return model{ - username: username, - userDir: path.Join(homeDir, "/.cairde/"), - - acnState: offline, - - menuBar: []string{"STATUS"}, - menuState: 0, - - input: input, - - version: version, - - statusBuffer: make(chan string), - + username: username, + userDir: path.Join(homeDir, "/.cairde/"), + acnState: offline, + menuBar: []string{"STATUS"}, + menuState: 0, + statusBuffer: statusBuffer, + buffers: []Buffer{statusBuffer}, + input: input, + version: version, showWelcomeMessage: true, - - debug: debug, + debug: debug, } } @@ -124,7 +114,7 @@ func NewModel(username, homeDir, version string, debug bool) model { // nolint:r // the statusViewport. This command is repeatedly called by the logic which // responds to a renderStatusMsg in Update. func (m model) receiveStatusCmd() tea.Msg { - return renderStatusMsg{line: <-m.statusBuffer} + return renderStatusMsg{line: <-m.statusBuffer.inputChannel} } // sendStatusCmd delivers a tea.Msg to bubbletea for rendering lines from the @@ -142,7 +132,7 @@ func (m model) sendStatusCmd(lines ...string) tea.Cmd { func (m model) sendStatus(lines ...string) { for _, line := range lines { logToFile(m, line) - m.statusBuffer <- line + m.statusBuffer.inputChannel <- line } } @@ -191,7 +181,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.input, cmd = m.input.Update(msg) cmds = append(cmds, cmd) - m.statusViewport, cmd = m.statusViewport.Update(msg) + m.statusBuffer.viewport, cmd = m.statusBuffer.viewport.Update(msg) cmds = append(cmds, cmd) for _, p := range m.profiles { @@ -218,12 +208,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.input.Width = m.width - 5 - if !m.statusViewportReady { - m.statusViewport = newViewport(m.width, m.height-3) - m.statusViewportReady = true + if !m.statusBuffer.viewportIsReady { + m.statusBuffer.viewport = newViewport(m.width, m.height-3) + m.statusBuffer.viewportIsReady = true } else { - m.statusViewport.Width = m.width - m.statusViewport.Height = m.height - 3 + m.statusBuffer.viewport.Width = m.width + m.statusBuffer.viewport.Height = m.height - 3 } for _, p := range m.profiles { @@ -244,16 +234,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } - if string(cmdInput[0]) != "/" && m.menuState == 0 { + buffer := m.buffers[Status] + + if err := buffer.validateInput(m, cmdInput); err != nil { cmds = append(cmds, func() tea.Msg { - errMsg := "Woops, this is not a chat buffer. Only commands are allowed" - return cmdMsg{output: []string{errMsg}} + return cmdMsg{output: []string{err.Error()}} }) - break } cmds = append(cmds, func() tea.Msg { - return handleCmd(cmdInput[1:], m.hiddenInput) + return buffer.handleInput(cmdInput[1:], m.hiddenInput) }) case "ctrl+n": @@ -432,22 +422,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, func() tea.Msg { for _, line := range msg.lines { logToFile(m, line) - m.statusBuffer <- line + m.statusBuffer.inputChannel <- line } return nil }) case renderStatusMsg: - m.statusViewportLines = append(m.statusViewportLines, renderLines(m.width, msg.line)...) - m.statusViewport.SetContent(strings.Join(m.statusViewportLines, "\n")) - m.statusViewport.GotoBottom() + m.statusBuffer.viewportLines = append(m.statusBuffer.viewportLines, renderLines(m.width, msg.line)...) + m.statusBuffer.viewport.SetContent(strings.Join(m.statusBuffer.viewportLines, "\n")) + m.statusBuffer.viewport.GotoBottom() cmds = append(cmds, m.receiveStatusCmd) case clearScreenMsg: m.input.Reset() - m.statusViewportLines = []string{} - m.statusViewport.SetContent("") - m.statusViewport.GotoTop() + m.statusBuffer.viewportLines = []string{} + m.statusBuffer.viewport.SetContent("") + m.statusBuffer.viewport.GotoTop() } return m, tea.Batch(cmds...) @@ -459,10 +449,10 @@ func (m model) View() string { var chosenViewport viewport.Model switch m.menuState { case 0: - chosenViewport = m.statusViewport + chosenViewport = m.statusBuffer.viewport default: if m.menuState > len(m.profiles) { - chosenViewport = m.statusViewport + chosenViewport = m.statusBuffer.viewport break } chosenViewport = m.profiles[m.menuState-1].statusViewport