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:
2026-07-03 01:43:22 +02:00
commit 8501fd074c
34 changed files with 3401 additions and 0 deletions
+675
View File
@@ -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())
}
+42
View File
@@ -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
}
+17
View File
@@ -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
}
+69
View File
@@ -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
View File
@@ -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()
}