feat: initial gosleep-timer implementation
Go TUI sleep timer with modular architecture. - Config: YAML-based, profile inheritance, auto WM detection - Engine: timer, executor, stage pipeline (pre→timer→post) - Modules: workspace (niri/hyprland/kde), media, lock, kill, notify, sound, brightness, mute, custom, script - TUI: bubbletea app with profiles, module toggles, progress bar - CLI: run, list-profiles, export, import, history, stats - History: JSONL log with statistics - QR: config export as ASCII QR
This commit is contained in:
+675
@@ -0,0 +1,675 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/engine"
|
||||
"github.com/forust/gosleep-timer/history"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
"github.com/forust/gosleep-timer/profiles"
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
screenMain screen = iota
|
||||
screenRunning
|
||||
screenHistory
|
||||
screenStats
|
||||
screenQR
|
||||
screenHelp
|
||||
)
|
||||
|
||||
type focusField int
|
||||
|
||||
const (
|
||||
focusTimeInput focusField = iota
|
||||
focusProfile
|
||||
focusStartButton
|
||||
focusStopButton
|
||||
)
|
||||
|
||||
type model struct {
|
||||
cfg *config.Config
|
||||
pMgr *profiles.Manager
|
||||
|
||||
// UI state
|
||||
screen screen
|
||||
focus focusField
|
||||
err error
|
||||
|
||||
// Timer input
|
||||
timeInput string
|
||||
|
||||
// Profile
|
||||
profiles []string
|
||||
selProfile int
|
||||
|
||||
// Running timer
|
||||
timer *engine.Timer
|
||||
stageRunner *engine.StageRunner
|
||||
remaining time.Duration
|
||||
total time.Duration
|
||||
currentStage engine.StageType
|
||||
timerStatus engine.Status
|
||||
statusMsg string
|
||||
|
||||
// History
|
||||
records []history.Record
|
||||
histLog *history.Log
|
||||
|
||||
// Module toggles (map profileName -> map moduleName -> enabled)
|
||||
moduleToggles map[string]map[string]bool
|
||||
}
|
||||
|
||||
func NewModel(cfg *config.Config) *model {
|
||||
pMgr := profiles.NewManager(cfg)
|
||||
profilesList := pMgr.List()
|
||||
if len(profilesList) == 0 {
|
||||
profilesList = []string{"default"}
|
||||
}
|
||||
|
||||
// Init module toggles
|
||||
toggles := make(map[string]map[string]bool)
|
||||
for _, name := range profilesList {
|
||||
toggles[name] = initModuleToggles(cfg, name)
|
||||
}
|
||||
|
||||
// Open history log
|
||||
histPath, err := history.DefaultPath()
|
||||
histLog := &history.Log{}
|
||||
if err == nil {
|
||||
if l, err := history.Open(histPath); err == nil {
|
||||
histLog = l
|
||||
}
|
||||
}
|
||||
|
||||
return &model{
|
||||
cfg: cfg,
|
||||
pMgr: pMgr,
|
||||
screen: screenMain,
|
||||
profiles: profilesList,
|
||||
selProfile: 0,
|
||||
timeInput: "25m",
|
||||
timerStatus: engine.StatusIdle,
|
||||
moduleToggles: toggles,
|
||||
histLog: histLog,
|
||||
}
|
||||
}
|
||||
|
||||
func initModuleToggles(cfg *config.Config, profileName string) map[string]bool {
|
||||
t := map[string]bool{
|
||||
"workspace": true,
|
||||
"media": true,
|
||||
"lock": true,
|
||||
"kill": false,
|
||||
"notify": true,
|
||||
"sound": true,
|
||||
"brightness": false,
|
||||
"mute": false,
|
||||
"custom": false,
|
||||
"script": false,
|
||||
}
|
||||
p, ok := cfg.Profiles[profileName]
|
||||
if !ok || p.Modules == nil {
|
||||
return t
|
||||
}
|
||||
m := p.Modules
|
||||
if m.Workspace != nil { t["workspace"] = m.Workspace.Enabled }
|
||||
if m.Media != nil { t["media"] = m.Media.Enabled }
|
||||
if m.Lock != nil { t["lock"] = m.Lock.Enabled }
|
||||
if m.Kill != nil { t["kill"] = m.Kill.Enabled }
|
||||
if m.Notify != nil { t["notify"] = m.Notify.Enabled }
|
||||
if m.Sound != nil { t["sound"] = m.Sound.Enabled }
|
||||
if m.Brightness != nil { t["brightness"] = m.Brightness.Enabled }
|
||||
if m.Mute != nil { t["mute"] = m.Mute.Enabled }
|
||||
if m.Custom != nil { t["custom"] = m.Custom.Enabled }
|
||||
if m.Script != nil { t["script"] = m.Script.Enabled }
|
||||
return t
|
||||
}
|
||||
|
||||
func (m *model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Messages
|
||||
type timerTickMsg struct {
|
||||
remaining time.Duration
|
||||
total time.Duration
|
||||
status engine.Status
|
||||
stage engine.StageType
|
||||
done bool
|
||||
}
|
||||
|
||||
type timerDoneMsg struct {
|
||||
status engine.Status
|
||||
}
|
||||
|
||||
type errorMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
case timerTickMsg:
|
||||
m.remaining = msg.remaining
|
||||
m.total = msg.total
|
||||
m.timerStatus = msg.status
|
||||
m.currentStage = msg.stage
|
||||
if msg.done {
|
||||
m.screen = screenMain
|
||||
m.timerStatus = engine.StatusIdle
|
||||
m.statusMsg = "✅ Timer completed"
|
||||
m.recordRun("completed", "")
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
case timerDoneMsg:
|
||||
m.screen = screenMain
|
||||
m.timerStatus = engine.StatusIdle
|
||||
if msg.status == engine.StatusKilled {
|
||||
m.statusMsg = "⛔ Timer killed"
|
||||
}
|
||||
return m, nil
|
||||
case errorMsg:
|
||||
m.err = msg.err
|
||||
m.statusMsg = fmt.Sprintf("❌ %v", msg.err)
|
||||
m.screen = screenMain
|
||||
m.timerStatus = engine.StatusIdle
|
||||
return m, nil
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch m.screen {
|
||||
case screenMain:
|
||||
return m.handleMainKey(msg)
|
||||
case screenRunning:
|
||||
return m.handleRunningKey(msg)
|
||||
case screenHistory:
|
||||
return m.handleHistoryKey(msg)
|
||||
case screenStats:
|
||||
return m.handleStatsKey(msg)
|
||||
case screenQR:
|
||||
return m.handleQRKey(msg)
|
||||
case screenHelp:
|
||||
return m.handleHelpKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleMainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c", "esc":
|
||||
m.histLog.Close()
|
||||
return m, tea.Quit
|
||||
case "tab":
|
||||
m.focus++
|
||||
if m.focus > focusStopButton {
|
||||
m.focus = focusTimeInput
|
||||
}
|
||||
case "shift+tab":
|
||||
m.focus--
|
||||
if m.focus < focusTimeInput {
|
||||
m.focus = focusStopButton
|
||||
}
|
||||
case "enter", " ":
|
||||
switch m.focus {
|
||||
case focusTimeInput:
|
||||
m.focus = focusProfile
|
||||
case focusProfile:
|
||||
m.focus = focusStartButton
|
||||
case focusStartButton:
|
||||
return m, m.startTimer()
|
||||
case focusStopButton:
|
||||
m.stopTimer()
|
||||
}
|
||||
case "s":
|
||||
return m, m.startTimer()
|
||||
case "S":
|
||||
m.stopTimer()
|
||||
case "h":
|
||||
m.loadHistory()
|
||||
m.screen = screenHistory
|
||||
case "?":
|
||||
m.screen = screenHelp
|
||||
case "up", "k":
|
||||
m.selProfile--
|
||||
if m.selProfile < 0 {
|
||||
m.selProfile = len(m.profiles) - 1
|
||||
}
|
||||
m.loadModuleToggles()
|
||||
case "down", "j":
|
||||
m.selProfile++
|
||||
if m.selProfile >= len(m.profiles) {
|
||||
m.selProfile = 0
|
||||
}
|
||||
m.loadModuleToggles()
|
||||
case "1", "2", "3", "4", "5", "6", "7", "8", "9", "0":
|
||||
// Toggle modules by number (1-9,0=10)
|
||||
idx := int(msg.String()[0] - '1')
|
||||
if msg.String() == "0" {
|
||||
idx = 9
|
||||
}
|
||||
m.toggleModule(idx)
|
||||
default:
|
||||
// Handle time input when focused
|
||||
if m.focus == focusTimeInput {
|
||||
if msg.Type == tea.KeyBackspace || msg.Type == tea.KeyDelete {
|
||||
if len(m.timeInput) > 0 {
|
||||
m.timeInput = m.timeInput[:len(m.timeInput)-1]
|
||||
}
|
||||
} else if msg.Type == tea.KeyRunes {
|
||||
m.timeInput += msg.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) loadModuleToggles() {
|
||||
name := m.profiles[m.selProfile]
|
||||
if _, ok := m.moduleToggles[name]; !ok {
|
||||
m.moduleToggles[name] = initModuleToggles(m.cfg, name)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) toggleModule(idx int) {
|
||||
modules := []string{"workspace", "media", "lock", "kill", "notify", "sound", "brightness", "mute", "custom", "script"}
|
||||
if idx < 0 || idx >= len(modules) {
|
||||
return
|
||||
}
|
||||
name := m.profiles[m.selProfile]
|
||||
key := modules[idx]
|
||||
if toggles, ok := m.moduleToggles[name]; ok {
|
||||
toggles[key] = !toggles[key]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) handleRunningKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "S", "s":
|
||||
m.stopTimer()
|
||||
m.screen = screenMain
|
||||
m.statusMsg = "⛔ Timer stopped"
|
||||
return m, nil
|
||||
case "q", "esc", "ctrl+c":
|
||||
m.stopTimer()
|
||||
m.screen = screenMain
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "esc", "h":
|
||||
m.screen = screenMain
|
||||
case "s":
|
||||
m.screen = screenStats
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleStatsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "esc", "h":
|
||||
m.screen = screenMain
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleQRKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
m.screen = screenMain
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "esc", "?":
|
||||
m.screen = screenMain
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) startTimer() tea.Cmd {
|
||||
d, err := engine.ParseDuration(m.timeInput)
|
||||
if err != nil {
|
||||
m.statusMsg = fmt.Sprintf("❌ Invalid time: %s", m.timeInput)
|
||||
return nil
|
||||
}
|
||||
if d <= 0 {
|
||||
m.statusMsg = "❌ Duration must be positive"
|
||||
return nil
|
||||
}
|
||||
|
||||
profileName := m.profiles[m.selProfile]
|
||||
profile, err := m.pMgr.Get(profileName)
|
||||
if err != nil {
|
||||
m.statusMsg = fmt.Sprintf("❌ Profile error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build module config based on toggles
|
||||
modCfg := m.buildModuleConfig(profile)
|
||||
|
||||
// Collect commands from modules
|
||||
modCtx := &modules.ModuleContext{
|
||||
Context: context.Background(),
|
||||
Profile: profileName,
|
||||
Duration: m.timeInput,
|
||||
Config: modCfg,
|
||||
}
|
||||
|
||||
preCmds := modules.CollectPreCmds(modCtx)
|
||||
postCmds := modules.CollectPostCmds(modCtx)
|
||||
|
||||
m.timer = engine.NewTimer(d)
|
||||
m.stageRunner = engine.NewStageRunner(m.timer, preCmds, postCmds)
|
||||
m.screen = screenRunning
|
||||
|
||||
// Start runner in background
|
||||
go m.stageRunner.Run(context.Background())
|
||||
|
||||
// Start reading events
|
||||
go func() {
|
||||
for evt := range m.stageRunner.Events {
|
||||
m.handleTimerEvent(evt)
|
||||
}
|
||||
}()
|
||||
|
||||
m.statusMsg = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *model) buildModuleConfig(profile *config.Profile) *config.Modules {
|
||||
if profile.Modules == nil {
|
||||
return config.DefaultModules()
|
||||
}
|
||||
modCfg := *profile.Modules // shallow copy
|
||||
toggles := m.moduleToggles[m.profiles[m.selProfile]]
|
||||
setEnabled(&modCfg, toggles)
|
||||
return &modCfg
|
||||
}
|
||||
|
||||
func setEnabled(mod *config.Modules, toggles map[string]bool) {
|
||||
if mod.Workspace != nil { mod.Workspace.Enabled = toggles["workspace"] }
|
||||
if mod.Media != nil { mod.Media.Enabled = toggles["media"] }
|
||||
if mod.Lock != nil { mod.Lock.Enabled = toggles["lock"] }
|
||||
if mod.Kill != nil { mod.Kill.Enabled = toggles["kill"] }
|
||||
if mod.Notify != nil { mod.Notify.Enabled = toggles["notify"] }
|
||||
if mod.Sound != nil { mod.Sound.Enabled = toggles["sound"] }
|
||||
if mod.Brightness != nil { mod.Brightness.Enabled = toggles["brightness"] }
|
||||
if mod.Mute != nil { mod.Mute.Enabled = toggles["mute"] }
|
||||
if mod.Custom != nil { mod.Custom.Enabled = toggles["custom"] }
|
||||
if mod.Script != nil { mod.Script.Enabled = toggles["script"] }
|
||||
}
|
||||
|
||||
func (m *model) handleTimerEvent(evt engine.TimerEvent) {
|
||||
m.remaining = evt.Remaining
|
||||
m.total = evt.Total
|
||||
m.currentStage = evt.Stage
|
||||
m.timerStatus = evt.Status
|
||||
}
|
||||
|
||||
func (m *model) stopTimer() {
|
||||
if m.stageRunner != nil {
|
||||
m.stageRunner.Stop()
|
||||
}
|
||||
m.timerStatus = engine.StatusIdle
|
||||
m.screen = screenMain
|
||||
m.recordRun("killed", "")
|
||||
}
|
||||
|
||||
func (m *model) recordRun(status, errStr string) {
|
||||
if m.histLog == nil {
|
||||
return
|
||||
}
|
||||
m.histLog.Append(history.Record{
|
||||
Profile: m.profiles[m.selProfile],
|
||||
Duration: m.timeInput,
|
||||
Status: status,
|
||||
Error: errStr,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *model) loadHistory() {
|
||||
path, err := history.DefaultPath()
|
||||
if err != nil {
|
||||
m.statusMsg = fmt.Sprintf("❌ History path: %v", err)
|
||||
return
|
||||
}
|
||||
records, err := history.ReadAll(path)
|
||||
if err != nil {
|
||||
m.statusMsg = fmt.Sprintf("❌ History read: %v", err)
|
||||
return
|
||||
}
|
||||
m.records = records
|
||||
}
|
||||
|
||||
func (m *model) View() string {
|
||||
switch m.screen {
|
||||
case screenMain:
|
||||
return m.mainView()
|
||||
case screenRunning:
|
||||
return m.runningView()
|
||||
case screenHistory:
|
||||
return m.historyView()
|
||||
case screenStats:
|
||||
return m.statsView()
|
||||
case screenQR:
|
||||
return m.qrView()
|
||||
case screenHelp:
|
||||
return m.helpView()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *model) mainView() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(TitleStyle.Render("⏱ gosleep-timer"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Profile selector
|
||||
b.WriteString(SelectWidget("Profile:", m.profiles, m.selProfile))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Time input
|
||||
timeFocused := m.focus == focusTimeInput
|
||||
b.WriteString(InputField("Duration:", m.timeInput, "e.g. 25m, 1h30m, 90s", timeFocused))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Module toggles
|
||||
b.WriteString(LabelStyle.Render("Modules:"))
|
||||
b.WriteString("\n")
|
||||
moduleNames := []string{"workspace", "media", "lock", "kill", "notify", "sound", "brightness", "mute", "custom", "script"}
|
||||
toggles := m.moduleToggles[m.profiles[m.selProfile]]
|
||||
for i, name := range moduleNames {
|
||||
enabled := toggles[name]
|
||||
line := fmt.Sprintf(" %d. ", i+1)
|
||||
if i == 9 {
|
||||
line = " 0. "
|
||||
}
|
||||
line += ModuleCheckbox(name, enabled, enabled)
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Buttons
|
||||
startStyle := SuccessButtonStyle
|
||||
stopStyle := DangerButtonStyle
|
||||
if m.focus == focusStartButton {
|
||||
startStyle = startStyle.Bold(true).Background(lipgloss.Color("#00cc55"))
|
||||
}
|
||||
if m.focus == focusStopButton {
|
||||
stopStyle = stopStyle.Bold(true).Background(lipgloss.Color("#dd4444"))
|
||||
}
|
||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Center,
|
||||
startStyle.Render("[ s ] Start"),
|
||||
" ",
|
||||
stopStyle.Render("[ S ] Stop"),
|
||||
))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Status
|
||||
if m.statusMsg != "" {
|
||||
isErr := strings.HasPrefix(m.statusMsg, "❌")
|
||||
b.WriteString(StatusLine(m.statusMsg, isErr))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Help bar
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpBar("s:start", "S:stop", "h:history", "?:help", "q:quit"))
|
||||
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func (m *model) runningView() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(TitleStyle.Render("⏱ Timer Running"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Big timer display
|
||||
b.WriteString(TimerBlock(m.remaining))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Progress bar
|
||||
totalSec := m.total.Seconds()
|
||||
remainingSec := m.remaining.Seconds()
|
||||
var pct float64
|
||||
if totalSec > 0 {
|
||||
pct = 1.0 - (remainingSec / totalSec)
|
||||
}
|
||||
b.WriteString(ProgressBar(40, pct, ""))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Stage info
|
||||
stageStr := string(m.currentStage)
|
||||
if stageStr == "" {
|
||||
stageStr = "timer"
|
||||
}
|
||||
b.WriteString(StatusStyle.Render(fmt.Sprintf("Stage: %s", stageStr)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Profile info
|
||||
profileName := m.profiles[m.selProfile]
|
||||
b.WriteString(StatusStyle.Render(fmt.Sprintf("Profile: %s", profileName)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Stop hint
|
||||
b.WriteString(HelpStyle.Render("Press S to stop · q to quit"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func (m *model) historyView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(TitleStyle.Render("📋 History"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if len(m.records) == 0 {
|
||||
b.WriteString(StatusStyle.Render("No history yet."))
|
||||
} else {
|
||||
start := 0
|
||||
if len(m.records) > 20 {
|
||||
start = len(m.records) - 20
|
||||
}
|
||||
for i := len(m.records) - 1; i >= start; i-- {
|
||||
r := m.records[i]
|
||||
line := fmt.Sprintf("%s | %-11s | %-6s | %s",
|
||||
r.Timestamp.Format("15:04 02-Jan"),
|
||||
r.Duration,
|
||||
r.Status,
|
||||
r.Profile,
|
||||
)
|
||||
style := ActiveModuleStyle
|
||||
if r.Status == "killed" {
|
||||
style = ErrorStyle
|
||||
}
|
||||
b.WriteString(style.Render(line))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpBar("s:stats", "h/esc:back", "q:quit"))
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func (m *model) statsView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(TitleStyle.Render("📊 Statistics"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
stats := history.Calculate(m.records)
|
||||
b.WriteString(fmt.Sprintf("Total runs: %d\n", stats.TotalRuns))
|
||||
b.WriteString(fmt.Sprintf("Completed: %d\n", stats.CompletedRuns))
|
||||
b.WriteString(fmt.Sprintf("Killed: %d\n", stats.KilledRuns))
|
||||
b.WriteString(fmt.Sprintf("Errors: %d\n", stats.ErrorRuns))
|
||||
b.WriteString(fmt.Sprintf("Total time: %s\n", stats.TotalDuration.Round(time.Second)))
|
||||
if stats.TotalRuns > 0 {
|
||||
b.WriteString(fmt.Sprintf("Avg duration: %s\n", stats.AvgDuration.Round(time.Second)))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Most used: %s\n", stats.MostUsedProfile))
|
||||
if stats.LastRun != nil {
|
||||
b.WriteString(fmt.Sprintf("Last run: %s (%s — %s)\n",
|
||||
stats.LastRun.Timestamp.Format("02 Jan 15:04"),
|
||||
stats.LastRun.Duration,
|
||||
stats.LastRun.Status,
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpBar("h:back", "q:quit"))
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func (m *model) qrView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(TitleStyle.Render("📱 QR Code"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Generate config YAML
|
||||
data, err := config.SaveToString(m.cfg)
|
||||
if err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Config error: %v", err)))
|
||||
} else {
|
||||
qr, err := GenerateQR(data)
|
||||
if err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("QR error: %v", err)))
|
||||
} else {
|
||||
b.WriteString(qr)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpBar("q/esc:back"))
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func (m *model) helpView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(TitleStyle.Render("⌨ Help"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpView())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpBar("q/esc:back"))
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package tui
|
||||
|
||||
type KeyAction int
|
||||
|
||||
const (
|
||||
KeyQuit KeyAction = iota
|
||||
KeyStart
|
||||
KeyStop
|
||||
KeyToggleModule
|
||||
KeyNextProfile
|
||||
KeyPrevProfile
|
||||
KeyFocusNext
|
||||
KeyFocusPrev
|
||||
KeyHistory
|
||||
KeyStats
|
||||
KeyHelp
|
||||
)
|
||||
|
||||
type KeyBinding struct {
|
||||
Key string
|
||||
Action KeyAction
|
||||
Label string
|
||||
}
|
||||
|
||||
var DefaultKeyBindings = []KeyBinding{
|
||||
{Key: "s", Action: KeyStart, Label: "start"},
|
||||
{Key: "S", Action: KeyStop, Label: "stop"},
|
||||
{Key: "tab", Action: KeyFocusNext, Label: "next"},
|
||||
{Key: "shift+tab", Action: KeyFocusPrev, Label: "prev"},
|
||||
{Key: "h", Action: KeyHistory, Label: "history"},
|
||||
{Key: "?", Action: KeyHelp, Label: "help"},
|
||||
{Key: "q", Action: KeyQuit, Label: "quit"},
|
||||
{Key: "esc", Action: KeyQuit, Label: "quit"},
|
||||
}
|
||||
|
||||
func HelpView() string {
|
||||
s := "\n Keys:\n"
|
||||
for _, kb := range DefaultKeyBindings {
|
||||
s += " " + kb.Key + " — " + kb.Label + "\n"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// GenerateQR returns a string of the QR code for the given data
|
||||
func GenerateQR(data string) (string, error) {
|
||||
qr, err := qrcode.New(data, qrcode.Medium)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qr generate: %w", err)
|
||||
}
|
||||
// Convert to ASCII art
|
||||
art := qr.ToString(true)
|
||||
return art, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
AppStyle = lipgloss.NewStyle().
|
||||
Padding(1, 2).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
TitleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#33ccff")).
|
||||
Align(lipgloss.Center).
|
||||
Padding(0, 1)
|
||||
|
||||
LabelStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#33ccff")).
|
||||
Padding(0, 1)
|
||||
|
||||
StatusStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#00ff88")).
|
||||
Align(lipgloss.Center).
|
||||
PaddingTop(1)
|
||||
|
||||
ErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#ff4444")).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
TimerStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#ffffff")).
|
||||
Background(lipgloss.Color("#33ccff")).
|
||||
Align(lipgloss.Center).
|
||||
Padding(0, 2)
|
||||
|
||||
ProgressBarStyle = lipgloss.NewStyle().
|
||||
Height(1)
|
||||
|
||||
HelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#888888"))
|
||||
|
||||
ActiveModuleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#00ff88"))
|
||||
|
||||
InactiveModuleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666"))
|
||||
|
||||
BorderStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("#33ccff")).
|
||||
Padding(1, 2)
|
||||
|
||||
ButtonStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Padding(0, 2)
|
||||
|
||||
SuccessButtonStyle = ButtonStyle.Copy().
|
||||
Foreground(lipgloss.Color("#ffffff")).
|
||||
Background(lipgloss.Color("#00aa44"))
|
||||
|
||||
DangerButtonStyle = ButtonStyle.Copy().
|
||||
Foreground(lipgloss.Color("#ffffff")).
|
||||
Background(lipgloss.Color("#cc3333"))
|
||||
|
||||
DimmedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#444444"))
|
||||
)
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ProgressBar renders a progress bar with given width, percent (0.0-1.0)
|
||||
func ProgressBar(width int, percent float64, label string) string {
|
||||
if width < 10 {
|
||||
width = 10
|
||||
}
|
||||
filled := int(float64(width) * percent)
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
empty := width - filled
|
||||
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", empty)
|
||||
bar = lipgloss.NewStyle().Foreground(lipgloss.Color("#33ccff")).Render(bar)
|
||||
|
||||
pct := fmt.Sprintf("%3d%%", int(percent*100))
|
||||
return fmt.Sprintf("%s %s %s", pct, bar, label)
|
||||
}
|
||||
|
||||
// TimerBlock renders the main timer display (HH:MM:SS)
|
||||
func TimerBlock(remaining time.Duration) string {
|
||||
totalSec := int(remaining.Seconds())
|
||||
h := totalSec / 3600
|
||||
m := (totalSec % 3600) / 60
|
||||
s := totalSec % 60
|
||||
|
||||
timeStr := fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
||||
return TimerStyle.Render(timeStr)
|
||||
}
|
||||
|
||||
// ModuleCheckbox renders a module toggle line
|
||||
func ModuleCheckbox(name string, enabled bool, checked bool) string {
|
||||
checkmark := " "
|
||||
if checked {
|
||||
checkmark = "●"
|
||||
}
|
||||
style := InactiveModuleStyle
|
||||
if enabled {
|
||||
style = ActiveModuleStyle
|
||||
}
|
||||
return style.Render(fmt.Sprintf("[ %s ] %s", checkmark, name))
|
||||
}
|
||||
|
||||
// StatusLine renders a status message
|
||||
func StatusLine(msg string, isError bool) string {
|
||||
if isError {
|
||||
return ErrorStyle.Render(msg)
|
||||
}
|
||||
return StatusStyle.Render(msg)
|
||||
}
|
||||
|
||||
// HelpBar renders the bottom help bar with keybindings
|
||||
func HelpBar(items ...string) string {
|
||||
return HelpStyle.Render(strings.Join(items, " · "))
|
||||
}
|
||||
|
||||
// InputField renders a labeled input field
|
||||
func InputField(label, value, placeholder string, focused bool) string {
|
||||
style := LabelStyle
|
||||
borderColor := "#444"
|
||||
if focused {
|
||||
borderColor = "#33ccff"
|
||||
}
|
||||
val := value
|
||||
if val == "" {
|
||||
val = placeholder
|
||||
style = DimmedStyle
|
||||
}
|
||||
inputBox := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(borderColor)).
|
||||
Width(30).
|
||||
Render(val)
|
||||
|
||||
return fmt.Sprintf("%s\n%s", style.Render(label), inputBox)
|
||||
}
|
||||
|
||||
// SelectWidget renders a simple select list
|
||||
func SelectWidget(label string, options []string, selected int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(LabelStyle.Render(label))
|
||||
b.WriteString("\n")
|
||||
for i, opt := range options {
|
||||
if i == selected {
|
||||
b.WriteString(lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#33ccff")).
|
||||
Bold(true).
|
||||
Render("▸ " + opt))
|
||||
} else {
|
||||
b.WriteString(" " + opt)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user