feat: use bubbletea framework

- implement TUI in bubbletea
  - split components into separate files
- remove now unused functions
- restructure files
This commit is contained in:
Sebastian Mark 2024-10-31 07:37:50 +01:00
parent 9656db5335
commit 8a0ef32c91
14 changed files with 269 additions and 187 deletions

92
cmd/client/app.go Normal file
View file

@ -0,0 +1,92 @@
package client
import (
"flag"
"github.com/charmbracelet/log"
"git.smsvc.net/pomodoro/ChronoTomato/internal/helper"
"git.smsvc.net/pomodoro/ChronoTomato/internal/websocket"
ChronoTomato "git.smsvc.net/pomodoro/ChronoTomato/pkg/models"
GoTomato "git.smsvc.net/pomodoro/GoTomato/pkg/models"
"github.com/charmbracelet/bubbles/help"
tea "github.com/charmbracelet/bubbletea"
)
var (
config ChronoTomato.Config
client websocket.Client
)
type app struct {
keys keyMap
help help.Model
channel chan GoTomato.ServerMessage
pomodoro GoTomato.ServerMessage
}
func myApp() app {
return app{
keys: keys,
help: help.New(),
channel: make(chan GoTomato.ServerMessage),
pomodoro: GoTomato.ServerMessage{},
}
}
// Wait for channel signal and return the ServerMessage as a tea.Msg.
// Return tea.Quit() if the channel has been closed.
// Excapsulate all this in a tea.Cmd() because bubbletea demands it.
func (a app) waitForChannelSignal() tea.Cmd {
return func() tea.Msg {
content, open := <-a.channel
if !open {
return tea.Quit()
}
return content
}
}
func (a app) Init() tea.Cmd {
client = websocket.Connect(config.URL)
client.Password = config.Password
go client.ProcessServerMessages(a.channel)
return tea.Batch(
tea.ClearScreen,
a.waitForChannelSignal(),
)
}
func Start() {
var (
defaultConfigFile = "~/.config/ChronoTomato.yml"
parameter_url = flag.String("url", "", "GoTomato Server URL (eg ws://localhost:8080/ws)")
parameter_password = flag.String("password", "", "Control password for pomodoro session")
configfile = flag.String("config", "", "Path to config file")
)
flag.Parse()
// read passed config file or try to use default config
if *configfile != "" {
config = helper.ParseConfig(*configfile)
} else {
if helper.FileExists(defaultConfigFile) {
config = helper.ParseConfig(defaultConfigFile)
}
}
// cli parameters always supersede config file
if *parameter_url != "" {
config.URL = *parameter_url
}
if *parameter_password != "" {
config.Password = *parameter_password
}
_, err := tea.NewProgram(myApp()).Run()
if err != nil {
log.Fatal("Could not start program:", "msg", err)
}
}

View file

@ -0,0 +1,39 @@
package helper
import (
"fmt"
"github.com/gen2brain/beeep"
GoTomato "git.smsvc.net/pomodoro/GoTomato/pkg/models"
)
// Send desktop notifications on the start of each segment
func DesktopNotifications(pomodoro GoTomato.ServerMessage) {
var (
duration int
notification string
)
mode := pomodoro.Mode
session := pomodoro.Session
sessions := pomodoro.Settings.Sessions
switch mode {
case "Work":
duration = pomodoro.Settings.Work
notification = fmt.Sprintf("Session %d/%d: %s %0.f minutes", session, sessions, mode, float32(duration)/60)
case "ShortBreak":
duration = pomodoro.Settings.ShortBreak
notification = fmt.Sprintf("Session %d/%d: Take a %0.f minute break", session, sessions, float32(duration)/60)
case "LongBreak":
duration = pomodoro.Settings.LongBreak
notification = fmt.Sprintf("Long Break: Take a %0.f minute break", float32(duration)/60)
case "End":
duration = 0
notification = fmt.Sprintf("Pomodoro sessions complete! Great job! 🎉")
}
if pomodoro.TimeLeft == duration { // start of segment
beeep.Alert("🍅 Pomodoro Timer", notification, "")
}
}

View file

@ -0,0 +1,51 @@
package helper
import (
"fmt"
"github.com/alecthomas/colour"
GoTomato "git.smsvc.net/pomodoro/GoTomato/pkg/models"
)
// Return terminal output based on the passed ServerMessage
func TerminalOutput(pomodoro GoTomato.ServerMessage) string {
var (
output string
modePrefix string
timerOutput string
)
// header
output += colour.Sprintf("^D^4Work: %d ◊ Break: %d ◊ Longbreak: %d^R\n\n",
pomodoro.Settings.Work/60,
pomodoro.Settings.ShortBreak/60,
pomodoro.Settings.LongBreak/60,
)
//body
switch pomodoro.Mode {
case "Idle":
modePrefix = " "
timerOutput = ""
default:
modePrefix = " "
if pomodoro.Paused {
modePrefix = " "
}
minutes := pomodoro.TimeLeft / 60
seconds := pomodoro.TimeLeft % 60
timerOutput = fmt.Sprintf("⏳ %02d:%02d", minutes, seconds)
}
output += fmt.Sprintf("Session: %d/%d\n",
pomodoro.Session,
pomodoro.Settings.Sessions,
)
output += fmt.Sprintf("%s %s\n", modePrefix, pomodoro.Mode)
output += fmt.Sprintf(timerOutput)
return output
}

44
cmd/client/keys.go Normal file
View file

@ -0,0 +1,44 @@
package client
import "github.com/charmbracelet/bubbles/key"
// keyMap defines a set of keybindings. To work for help it must satisfy
// key.Map. It could also very easily be a map[string]key.Binding.
type keyMap struct {
Start key.Binding
Stop key.Binding
Reset key.Binding
Quit key.Binding
}
var keys = keyMap{
Start: key.NewBinding(
key.WithKeys(" "),
key.WithHelp("space", "start/pause/resume"),
),
Stop: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "stop"),
),
Reset: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "reset"),
),
Quit: key.NewBinding(
key.WithKeys("q"),
key.WithHelp("q", "quit"),
),
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{
keys.Start,
keys.Stop,
keys.Reset,
keys.Quit,
}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{}
}

View file

@ -1,60 +0,0 @@
package client
import (
"atomicgo.dev/cursor"
"flag"
"git.smsvc.net/pomodoro/ChronoTomato/internal/frontend"
"git.smsvc.net/pomodoro/ChronoTomato/internal/helper"
"git.smsvc.net/pomodoro/ChronoTomato/internal/websocket"
ChronoTomato "git.smsvc.net/pomodoro/ChronoTomato/pkg/models"
GoTomato "git.smsvc.net/pomodoro/GoTomato/pkg/models"
)
var (
config ChronoTomato.Config
defaultConfigFile = "~/.config/ChronoTomato.yml"
parameter_url = flag.String("url", "", "GoTomato Server URL (eg ws://localhost:8080/ws)")
parameter_password = flag.String("password", "", "Control password for pomodoro session")
configfile = flag.String("config", "", "Path to config file")
)
func Start() {
flag.Parse()
// show/hide cursor for nicer interface
cursor.Hide()
defer cursor.Show()
// read passed config file or try to use default config
if *configfile != "" {
config = helper.ParseConfig(*configfile)
} else {
if helper.FileExists(defaultConfigFile) {
config = helper.ParseConfig(defaultConfigFile)
}
}
// cli parameters always supersede config file
if *parameter_url != "" {
config.URL = *parameter_url
}
if *parameter_password != "" {
config.Password = *parameter_password
}
// Create a client
client := websocket.Connect(config.URL)
client.Password = config.Password
// Receive server messages und update the terminal
channel := make(chan GoTomato.ServerMessage)
go client.ProcessServerMessages(channel)
frontend.UpdateLoop(client, config, channel)
// disconnect from server
client.Disconnect()
}

45
cmd/client/update.go Normal file
View file

@ -0,0 +1,45 @@
package client
import (
GoTomato "git.smsvc.net/pomodoro/GoTomato/pkg/models"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
// sends start/pause/resume based on the state of the pomodoro
func start_pause_resume(message GoTomato.ServerMessage) string {
if !message.Ongoing {
return "start"
}
if message.Paused {
return "resume"
} else {
return "pause"
}
}
func (a app) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case GoTomato.ServerMessage:
a.pomodoro = msg
return a, a.waitForChannelSignal()
case tea.KeyMsg:
switch {
case key.Matches(msg, a.keys.Start):
cmd := start_pause_resume(a.pomodoro)
client.SendCmd(cmd)
case key.Matches(msg, a.keys.Stop):
client.SendCmd("stop")
case key.Matches(msg, a.keys.Reset):
if config.PomodoroConfig != (GoTomato.PomodoroConfig{}) {
client.SendSettingsUpdate(config.PomodoroConfig)
}
case key.Matches(msg, a.keys.Quit):
client.Disconnect()
return a, tea.Quit
}
}
return a, nil
}

24
cmd/client/view.go Normal file
View file

@ -0,0 +1,24 @@
package client
import (
"strings"
"git.smsvc.net/pomodoro/ChronoTomato/cmd/client/helper"
GoTomato "git.smsvc.net/pomodoro/GoTomato/pkg/models"
)
func (a app) View() string {
var body string
if a.pomodoro != (GoTomato.ServerMessage{}) {
body = helper.TerminalOutput(a.pomodoro)
helper.DesktopNotifications(a.pomodoro)
} else {
body = "Waiting for first server message..."
}
helpView := a.help.View(a.keys)
height := 8 - strings.Count(body, "\n") - strings.Count(helpView, "\n")
return body + strings.Repeat("\n", height) + helpView
}