8501fd074c
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
676 lines
16 KiB
Go
676 lines
16 KiB
Go
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())
|
|
}
|