diff --git a/README.md b/README.md index 301cc1b..af8ce2d 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ A lightweight, cross-platform utility to prevent your system from going to sleep ## Features - -- 🔄 Configurable keep-alive duration +- 🔄 Configurable keep-alive duration with flexible time format - 💻 Cross-platform support (macOS, Windows, Linux) - ⚡ Lightweight and efficient - 🎯 Simple and intuitive to use @@ -51,31 +50,44 @@ sudo mv keepalive /usr/local/bin/ ## Usage -1. Start the application: +### Command-Line Options + +``` +Flags: + -d, --duration string Duration to keep system alive (e.g., "2h30m" or "150") + -v, --version Show version information + -h, --help Show help message +``` + +The duration can be specified in two formats: +- As a time duration (e.g., "2h30m", "1h", "45m") +- As minutes (e.g., "150" for 2.5 hours) + +### Interactive Mode + +1. Start the application without flags to enter interactive mode: ```bash keepalive ``` 2. Use arrow keys (↑/↓) or j/k to navigate the menu 3. Press Enter to select an option -4. When entering minutes, use numbers only (e.g., "150" for 2.5 hours) -5. Press q or Esc to quit +4. Press q or Esc to quit ## How It Works Keep-Alive uses platform-specific APIs to prevent your system from entering sleep mode: - **macOS**: Uses the `caffeinate` command to prevent system and display sleep -- **Windows**: Uses SetThreadExecutionState to prevent system sleep +- **Windows**: Uses SetThreadExecutionState API to prevent system sleep - **Linux**: Uses systemd-inhibit to prevent the system from going idle/sleep The application provides three main options: 1. Keep system awake indefinitely -2. Keep system awake for X minutes (enter the number of minutes) +2. Keep system awake for a specified duration 3. Quit the application When running with a timer, the application shows a countdown of the remaining time. You can stop the keep-alive at any time by pressing Enter to return to the menu or q/Esc to quit the application. - ## Dependencies ### Runtime Dependencies @@ -101,8 +113,6 @@ cd keep-alive go build -o keepalive ./cmd/keepalive ``` - - ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/cmd/keepalive/main.go b/cmd/keepalive/main.go index 50a7beb..bc3c3a7 100644 --- a/cmd/keepalive/main.go +++ b/cmd/keepalive/main.go @@ -2,27 +2,47 @@ package main import ( "log" - "os" + "keepalive/internal/config" "keepalive/internal/ui" tea "github.com/charmbracelet/bubbletea" ) +const appVersion = "1.0.2" + func main() { + cfg, err := config.ParseFlags(appVersion) + if err != nil { + log.Fatal(err) + } + f, err := tea.LogToFile("debug.log", "debug") if err != nil { log.Fatal(err) } defer f.Close() + var model ui.Model + if cfg.Duration > 0 { + model = ui.InitialModelWithDuration(cfg.Duration) + p := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + if _, err := p.Run(); err != nil { + log.Fatal(err) + } + return + } + + model = ui.InitialModel() p := tea.NewProgram( - ui.InitialModel(), + model, tea.WithAltScreen(), - tea.WithInput(os.Stdin), - tea.WithOutput(os.Stdout), + tea.WithMouseCellMotion(), ) - if _, err := p.Run(); err != nil { log.Fatal(err) } diff --git a/docs/demo.gif b/docs/demo.gif index d21b17d..98e1761 100644 Binary files a/docs/demo.gif and b/docs/demo.gif differ diff --git a/docs/demo.tape b/docs/demo.tape index 8b034f7..79fe01c 100644 --- a/docs/demo.tape +++ b/docs/demo.tape @@ -6,7 +6,7 @@ Set Framerate 60 Set TypingSpeed 0.1 Set PlaybackSpeed 0.5 Set Shell "bash" -Set FontSize 32 +Set FontSize 26 Set Width 1200 Set Height 600 @@ -27,4 +27,9 @@ Type "20" Sleep 500ms Enter Sleep 15s +Enter +Sleep 1s +Type "j" +Sleep 1s +Enter diff --git a/internal/config/flags.go b/internal/config/flags.go new file mode 100644 index 0000000..7b58dbc --- /dev/null +++ b/internal/config/flags.go @@ -0,0 +1,54 @@ +package config + +import ( + "flag" + "fmt" + "os" + "keepalive/internal/ui" + "keepalive/internal/util" +) + +type Config struct { + Duration int + ShowVersion bool +} + +func ParseFlags(version string) (*Config, error) { + flags := flag.NewFlagSet("keepalive", flag.ExitOnError) + flags.Usage = func() { + model := ui.InitialModel() + model.ShowHelp = true + fmt.Print(model.View()) + } + + duration := flags.String("duration", "", "Duration to keep system alive (e.g., \"2h30m\")") + flags.StringVar(duration, "d", "", "Duration to keep system alive (e.g., \"2h30m\")") + showVersion := flags.Bool("version", false, "Show version information") + flags.BoolVar(showVersion, "v", false, "Show version information") + + if err := flags.Parse(os.Args[1:]); err != nil { + if err == flag.ErrHelp { + os.Exit(0) + } + return nil, err + } + + if *showVersion { + fmt.Printf("Keep-Alive Version: %s\n", version) + os.Exit(0) + } + + var minutes int + if *duration != "" { + d, err := util.ParseDuration(*duration) + if err != nil { + return nil, fmt.Errorf("error parsing duration: %v", err) + } + minutes = int(d.Minutes()) + } + + return &Config{ + Duration: minutes, + ShowVersion: *showVersion, + }, nil +} diff --git a/internal/keepalive/keepalive.go b/internal/keepalive/keepalive.go index 8763aec..3dbab93 100644 --- a/internal/keepalive/keepalive.go +++ b/internal/keepalive/keepalive.go @@ -1,111 +1,81 @@ package keepalive import ( - "context" "errors" - "os/exec" - "runtime" "sync" "time" ) -// Keeper manages the keep-alive state across platforms. +// Keeper manages the system's keep-alive state type Keeper struct { - mu sync.Mutex - cancel context.CancelFunc - platformStop func() error - running bool + running bool + mu sync.Mutex + timer *time.Timer } -// StartIndefinite keeps the system awake indefinitely using platform-specific methods. +// IsRunning returns whether the keep-alive is currently active +func (k *Keeper) IsRunning() bool { + k.mu.Lock() + defer k.mu.Unlock() + return k.running +} + +// StartIndefinite starts keeping the system alive indefinitely func (k *Keeper) StartIndefinite() error { k.mu.Lock() defer k.mu.Unlock() + if k.running { - return nil + return errors.New("keep-alive already running") } - switch runtime.GOOS { - case "windows": - // Windows-specific logic in keepalive_windows.go - if err := setWindowsKeepAlive(); err != nil { - return err - } - k.platformStop = stopWindowsKeepAlive - - case "darwin": - ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, "caffeinate", "-dims") - if err := cmd.Start(); err != nil { - cancel() - return err - } - k.cancel = func() { - cancel() - _ = cmd.Wait() - } - k.platformStop = func() error { return nil } - - case "linux": - ctx, cancel := context.WithCancel(context.Background()) - // systemd-inhibit prevents the system from going idle/sleep. - cmd := exec.CommandContext(ctx, "systemd-inhibit", "--what=idle", "--mode=block", "bash", "-c", "while true; do sleep 3600; done") - if err := cmd.Start(); err != nil { - cancel() - return err - } - k.cancel = func() { - cancel() - _ = cmd.Wait() - } - k.platformStop = func() error { return nil } - - default: - return errors.New("unsupported platform") + if err := setWindowsKeepAlive(); err != nil { + return err } k.running = true return nil } -// StartTimed keeps the system awake for the specified number of minutes, then stops. -func (k *Keeper) StartTimed(minutes int) error { - if minutes <= 0 { - return errors.New("minutes must be > 0") +// StartTimed starts keeping the system alive for the specified duration +func (k *Keeper) StartTimed(d time.Duration) error { + k.mu.Lock() + defer k.mu.Unlock() + + if k.running { + return errors.New("keep-alive already running") } - if err := k.StartIndefinite(); err != nil { + + if err := setWindowsKeepAlive(); err != nil { return err } - go func() { - time.Sleep(time.Duration(minutes) * time.Minute) - _ = k.Stop() - }() + k.running = true + k.timer = time.AfterFunc(d, func() { + k.Stop() + }) + return nil } -// Stop stops keeping the system awake, restoring normal behavior. +// Stop stops keeping the system alive func (k *Keeper) Stop() error { k.mu.Lock() defer k.mu.Unlock() + if !k.running { return nil } - if k.cancel != nil { - k.cancel() + + if k.timer != nil { + k.timer.Stop() + k.timer = nil } - if k.platformStop != nil { - if err := k.platformStop(); err != nil { - return err - } + + if err := stopWindowsKeepAlive(); err != nil { + return err } + k.running = false return nil } - -// IsRunning returns whether the system is currently being kept awake. -func (k *Keeper) IsRunning() bool { - k.mu.Lock() - defer k.mu.Unlock() - return k.running -} diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 0000000..1b83bc1 --- /dev/null +++ b/internal/platform/platform.go @@ -0,0 +1,68 @@ +package platform + +import ( + "context" + "fmt" + "os/exec" + "runtime" +) + +type KeepAlive interface { + Start(ctx context.Context) error + Stop() error +} + +func NewKeepAlive() (KeepAlive, error) { + switch runtime.GOOS { + case "darwin": + return &darwinKeepAlive{}, nil + case "linux": + return &linuxKeepAlive{}, nil + case "windows": + return &windowsKeepAlive{}, nil + default: + return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } +} + +type darwinKeepAlive struct { + cmd *exec.Cmd +} + +func (d *darwinKeepAlive) Start(ctx context.Context) error { + d.cmd = exec.CommandContext(ctx, "caffeinate", "-dims") + return d.cmd.Start() +} + +func (d *darwinKeepAlive) Stop() error { + if d.cmd != nil && d.cmd.Process != nil { + return d.cmd.Process.Kill() + } + return nil +} + +type linuxKeepAlive struct { + cmd *exec.Cmd +} + +func (l *linuxKeepAlive) Start(ctx context.Context) error { + l.cmd = exec.CommandContext(ctx, "systemd-inhibit", "--what=idle", "--mode=block", "bash", "-c", "while true; do sleep 3600; done") + return l.cmd.Start() +} + +func (l *linuxKeepAlive) Stop() error { + if l.cmd != nil && l.cmd.Process != nil { + return l.cmd.Process.Kill() + } + return nil +} + +type windowsKeepAlive struct{} + +func (w *windowsKeepAlive) Start(ctx context.Context) error { + return setWindowsKeepAlive() +} + +func (w *windowsKeepAlive) Stop() error { + return stopWindowsKeepAlive() +} diff --git a/internal/platform/platform_other.go b/internal/platform/platform_other.go new file mode 100644 index 0000000..46155ee --- /dev/null +++ b/internal/platform/platform_other.go @@ -0,0 +1,10 @@ +//go:build !windows +package platform + +func setWindowsKeepAlive() error { + return nil +} + +func stopWindowsKeepAlive() error { + return nil +} diff --git a/internal/platform/platform_windows.go b/internal/platform/platform_windows.go new file mode 100644 index 0000000..631b50e --- /dev/null +++ b/internal/platform/platform_windows.go @@ -0,0 +1,32 @@ +//go:build windows +package platform + +import ( + "syscall" +) + +const ( + esSystemRequired = 0x00000001 + esContinuous = 0x80000000 +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procSetThreadExecutionState = kernel32.NewProc("SetThreadExecutionState") +) + +func setWindowsKeepAlive() error { + r1, _, err := procSetThreadExecutionState.Call(uintptr(esSystemRequired | esContinuous)) + if r1 == 0 { + return err + } + return nil +} + +func stopWindowsKeepAlive() error { + r1, _, err := procSetThreadExecutionState.Call(uintptr(esContinuous)) + if r1 == 0 { + return err + } + return nil +} diff --git a/internal/ui/model.go b/internal/ui/model.go index b283f2a..3bbc453 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -2,6 +2,7 @@ package ui import ( "keepalive/internal/keepalive" + "strconv" "time" tea "github.com/charmbracelet/bubbletea" @@ -14,6 +15,7 @@ const ( stateMenu state = iota stateTimedInput stateRunning + stateHelp ) // Model holds the current state of the UI, including user input and keep-alive state. @@ -25,6 +27,7 @@ type Model struct { ErrorMessage string StartTime time.Time Duration time.Duration + ShowHelp bool } // InitialModel returns the initial model for the TUI. @@ -34,11 +37,34 @@ func InitialModel() Model { Selected: 0, Input: "", KeepAlive: &keepalive.Keeper{}, + ShowHelp: false, } } +// InitialModelWithDuration returns a model initialized with a specific duration and starts running. +func InitialModelWithDuration(minutes int) Model { + m := InitialModel() + m.Input = strconv.Itoa(minutes) + m.State = stateRunning + m.StartTime = time.Now() + m.Duration = time.Duration(minutes) * time.Minute + + // Start the keep-alive process + err := m.KeepAlive.StartTimed(time.Duration(minutes) * time.Minute) + if err != nil { + m.ErrorMessage = err.Error() + m.State = stateMenu + return m + } + + return m +} + // Init implements tea.Model func (m Model) Init() tea.Cmd { + if m.State == stateRunning { + return tick() + } return nil } @@ -55,7 +81,7 @@ func (m Model) View() string { // TimeRemaining returns the remaining duration for timed keep-alive func (m Model) TimeRemaining() time.Duration { - if !m.KeepAlive.IsRunning() || m.Duration == 0 { + if m.State != stateRunning { return 0 } elapsed := time.Since(m.StartTime) diff --git a/internal/ui/state.go b/internal/ui/state.go new file mode 100644 index 0000000..6e6062a --- /dev/null +++ b/internal/ui/state.go @@ -0,0 +1,25 @@ +package ui + +type State int + +const ( + StateMenu State = iota + StateTimedInput + StateRunning + StateHelp +) + +func (s State) String() string { + switch s { + case StateMenu: + return "Menu" + case StateTimedInput: + return "TimedInput" + case StateRunning: + return "Running" + case StateHelp: + return "Help" + default: + return "Unknown" + } +} diff --git a/internal/ui/update.go b/internal/ui/update.go index 25d96c5..ef49d67 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -15,11 +15,27 @@ type tickMsg time.Time func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { var cmd tea.Cmd + // Handle help state first + if m.ShowHelp { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + m.ShowHelp = false + return m, nil + } + } + return m, nil + } + switch m.State { case stateMenu: switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { + case "h", "?": + m.ShowHelp = true + return m, nil case "up", "k": if m.Selected > 0 { m.Selected-- @@ -55,68 +71,51 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { } return m, tea.Quit } - case "q", "esc", "ctrl+c": - return m, tea.Quit - } - - case tickMsg: - if m.Duration > 0 && time.Since(m.StartTime) >= m.Duration { - if err := m.KeepAlive.Stop(); err != nil { - m.ErrorMessage = err.Error() - return m, nil + case "q", "ctrl+c": + if m.KeepAlive.IsRunning() { + if err := m.KeepAlive.Stop(); err != nil { + m.ErrorMessage = err.Error() + return m, nil + } } - m.State = stateMenu - m.ErrorMessage = "" - return m, nil + return m, tea.Quit } - return m, tick() } case stateTimedInput: switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { + case "esc": + m.State = stateMenu + m.Input = "" + m.ErrorMessage = "" case "enter": if m.Input == "" { - m.ErrorMessage = "Please enter a duration" + m.ErrorMessage = "Please enter a duration in minutes" return m, nil } minutes, err := strconv.Atoi(m.Input) - if err != nil { - m.ErrorMessage = "Invalid duration" + if err != nil || minutes <= 0 { + m.ErrorMessage = "Please enter a valid positive number" return m, nil } - if minutes <= 0 { - m.ErrorMessage = "Duration must be positive" - return m, nil - } - if err := m.KeepAlive.StartTimed(minutes); err != nil { + if err := m.KeepAlive.StartTimed(time.Duration(minutes) * time.Minute); err != nil { m.ErrorMessage = err.Error() return m, nil } m.State = stateRunning m.StartTime = time.Now() m.Duration = time.Duration(minutes) * time.Minute - m.ErrorMessage = "" return m, tick() - case "esc": - m.State = stateMenu - m.ErrorMessage = "" - return m, nil case "backspace": if len(m.Input) > 0 { m.Input = m.Input[:len(m.Input)-1] - m.ErrorMessage = "" } - return m, nil default: if len(msg.String()) == 1 && unicode.IsDigit(rune(msg.String()[0])) { - if len(m.Input) < 4 { // Limit input to 4 digits - m.Input += msg.String() - m.ErrorMessage = "" - } + m.Input += msg.String() } - return m, nil } } @@ -124,6 +123,12 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { + case "q", "ctrl+c", "esc": + if err := m.KeepAlive.Stop(); err != nil { + m.ErrorMessage = err.Error() + return m, nil + } + return m, tea.Quit case "enter": if err := m.KeepAlive.Stop(); err != nil { m.ErrorMessage = err.Error() @@ -132,8 +137,6 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { m.State = stateMenu m.ErrorMessage = "" return m, nil - case "q", "esc", "ctrl+c": - return m, tea.Quit } case tickMsg: if m.Duration > 0 && time.Since(m.StartTime) >= m.Duration { @@ -141,9 +144,7 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { m.ErrorMessage = err.Error() return m, nil } - m.State = stateMenu - m.ErrorMessage = "" - return m, nil + return m, tea.Quit } return m, tick() } diff --git a/internal/ui/view.go b/internal/ui/view.go index 732a7ce..4af1070 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -4,10 +4,61 @@ import ( "fmt" "strings" "time" + + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FAFAFA")). + Background(lipgloss.Color("#7D56F4")). + PaddingLeft(2). + PaddingRight(2) + + selectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7D56F4")). + PaddingLeft(2) + + unselectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FAFAFA")). + PaddingLeft(2) + + awakeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#43BF6D")). + PaddingLeft(2) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). + PaddingLeft(2) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FAFAFA")). + PaddingLeft(2). + PaddingRight(2). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7D56F4")) + + inputBoxStyle = lipgloss.NewStyle(). + Width(10). + PaddingLeft(2). + PaddingRight(2). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7D56F4")) ) -// View renders the UI based on the current model state. +// View renders the current state of the model to a string. func View(m Model) string { + if m.ShowHelp { + return helpView() + } + + var s strings.Builder + + // Title + s.WriteString(titleStyle.Render("Keep-Alive")) + s.WriteString("\n\n") + switch m.State { case stateMenu: return menuView(m) @@ -23,19 +74,12 @@ func View(m Model) string { func menuView(m Model) string { var b strings.Builder - // Title - b.WriteString(Current.Title.Render("Keep Alive Options")) + b.WriteString(titleStyle.Render("Keep Alive Options")) b.WriteString("\n\n") - // Status - if m.KeepAlive.IsRunning() { - b.WriteString(Current.ActiveStatus.Render("System is being kept awake")) - } else { - b.WriteString(Current.InactiveStatus.Render("System is in normal state")) - } + b.WriteString(unselectedStyle.Render("Select an option:")) b.WriteString("\n\n") - // Menu options menuItems := []string{ "Keep system awake indefinitely", "Keep system awake for X minutes", @@ -45,53 +89,52 @@ func menuView(m Model) string { for i, opt := range menuItems { var menuLine strings.Builder - // Cursor if i == m.Selected { - menuLine.WriteString("> ") + menuLine.WriteString(selectedStyle.Render("> ")) } else { - menuLine.WriteString(" ") + menuLine.WriteString(unselectedStyle.Render(" ")) } - // Option text with styling if i == m.Selected { - menuLine.WriteString(Current.SelectedItem.Render(opt)) - } else if i == 2 && !m.KeepAlive.IsRunning() { - menuLine.WriteString(Current.DisabledItem.Render(opt)) + menuLine.WriteString(selectedStyle.Render(opt)) } else { - menuLine.WriteString(Current.Menu.Render(opt)) + menuLine.WriteString(unselectedStyle.Render(opt)) } b.WriteString(menuLine.String() + "\n") } if m.ErrorMessage != "" { - b.WriteString("\n" + Current.Error.Render(m.ErrorMessage)) + b.WriteString("\n" + errorStyle.Render(m.ErrorMessage)) } - b.WriteString("\n\n" + Current.Help.Render("Press j/k or ↑/↓ to select • enter to confirm • q or esc to quit")) + b.WriteString("\n\n" + helpStyle.Render("Press j/k or ↑/↓ to select • enter to confirm • q or esc to quit")) return b.String() } func timedInputView(m Model) string { var b strings.Builder - b.WriteString(Current.Title.Render("Enter Duration")) + b.WriteString(titleStyle.Render("Enter Duration")) b.WriteString("\n\n") + b.WriteString(unselectedStyle.Render("Enter duration in minutes:")) + b.WriteString("\n") input := m.Input if input == "" { input = " " } - b.WriteString(Current.InputBox.Render(input)) + b.WriteString(inputBoxStyle.Render(input)) + // b.WriteString(Current.InputBox.Render(input)) b.WriteString("\n\n") // Help text - b.WriteString(Current.Help.Render("Enter number of minutes")) + // b.WriteString(helpStyle.Render("Enter number of minutes")) b.WriteString("\n") - b.WriteString(Current.Help.Render("Press enter to start • backspace to clear • esc to cancel")) + b.WriteString(helpStyle.Render("Press enter to start • backspace to clear • esc to cancel")) if m.ErrorMessage != "" { - b.WriteString("\n\n" + Current.Error.Render(m.ErrorMessage)) + b.WriteString("\n\n" + errorStyle.Render(m.ErrorMessage)) } return b.String() @@ -100,10 +143,10 @@ func timedInputView(m Model) string { func runningView(m Model) string { var b strings.Builder - b.WriteString(Current.Title.Render("Keep Alive Active")) + b.WriteString(titleStyle.Render("Keep Alive Active")) b.WriteString("\n\n") - b.WriteString(Current.ActiveStatus.Render("System is being kept awake")) + b.WriteString(awakeStyle.Render("System is being kept awake")) b.WriteString("\n") // Show countdown if this is a timed session @@ -112,15 +155,43 @@ func runningView(m Model) string { minutes := int(remaining.Minutes()) seconds := int(remaining.Seconds()) % 60 countdown := fmt.Sprintf("%d:%02d remaining", minutes, seconds) - b.WriteString(Current.Countdown.Render(countdown)) + b.WriteString(unselectedStyle.Render(countdown)) b.WriteString("\n") } - b.WriteString("\n" + Current.Help.Render("Press enter to stop and return to menu • q or esc to quit")) + b.WriteString("\n" + helpStyle.Render("Press enter to stop and return to menu • q or esc to quit")) if m.ErrorMessage != "" { - b.WriteString("\n\n" + Current.Error.Render(m.ErrorMessage)) + b.WriteString("\n\n" + errorStyle.Render(m.ErrorMessage)) } return b.String() } + +func helpView() string { + help := `Keep-Alive Help + +Usage: + keepalive [flags] + +Flags: + -d, --duration string Duration to keep system alive (e.g., "2h30m") + -v, --version Show version information + -h, --help Show help message + +Examples: + keepalive # Start with interactive TUI + keepalive -d 2h30m # Keep system awake for 2 hours and 30 minutes + keepalive -d 150 # Keep system awake for 150 minutes + keepalive --version # Show version information + +Navigation: + ↑/k, ↓/j : Navigate menu + Enter : Select option + h : Show this help + q/Esc : Quit/Back + +Press 'q' or 'Esc' to close help` + + return helpStyle.Render(help) +} diff --git a/internal/util/duration.go b/internal/util/duration.go new file mode 100644 index 0000000..857023b --- /dev/null +++ b/internal/util/duration.go @@ -0,0 +1,19 @@ +package util + +import ( + "fmt" + "strconv" + "time" +) + +func ParseDuration(input string) (time.Duration, error) { + if minutes, err := strconv.Atoi(input); err == nil { + return time.Duration(minutes) * time.Minute, nil + } + + duration, err := time.ParseDuration(input) + if err != nil { + return 0, fmt.Errorf("invalid duration format: %s", input) + } + return duration, nil +}