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())
|
||||
}
|
||||
Reference in New Issue
Block a user