WIP: buffers and command history
This commit is contained in:
parent
6db960bb78
commit
15c662b140
|
@ -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 StatusBuffer struct{ Buffer }
|
||||
|
||||
func (s StatusBuffer) validateInput(m model, input string) error {
|
||||
if string(input[0]) != "/" && m.menuState == Status {
|
||||
return fmt.Errorf("Woops, this is not a chat buffer. Only commands are allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s StatusBuffer) 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 <name> <password> <password> | Create a new profile
|
||||
/profile info | Show profile information
|
||||
/profile unlock <password> | 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 StatusBuffer) 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 struct{ Buffer }
|
83
ui/input.go
83
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 <name> <password> <password> | Create a new profile
|
||||
/profile info | Show profile information
|
||||
/profile unlock <password> | 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])
|
||||
if lastChar != " " {
|
||||
m.hiddenInput += lastChar
|
||||
if lastChar != " " {
|
||||
newCmd := []rune(val)
|
||||
newCmd[len(val)-1] = '*'
|
||||
m.input.SetValue(string(newCmd))
|
||||
|
|
111
ui/model.go
111
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,23 +87,24 @@ func NewModel(username, homeDir, version string, debug bool) model { // nolint:r
|
|||
input.TextStyle = inputTextStyle
|
||||
input.Focus()
|
||||
|
||||
statusBuffer := Buffer{
|
||||
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,
|
||||
|
||||
statusBuffer: statusBuffer,
|
||||
buffers: []Buffer{statusBuffer},
|
||||
input: input,
|
||||
|
||||
version: version,
|
||||
|
||||
statusBuffer: make(chan string),
|
||||
|
||||
showWelcomeMessage: true,
|
||||
|
||||
debug: debug,
|
||||
}
|
||||
}
|
||||
|
@ -124,7 +113,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 +131,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 +180,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 +207,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 +233,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, m.hiddenInput)
|
||||
})
|
||||
|
||||
case "ctrl+n":
|
||||
|
@ -432,22 +421,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 +448,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
|
||||
|
|
Reference in New Issue