diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6609acd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + - name: Build + run: go build ./... + - name: Test + run: go test -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6ac9638 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: stable + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..706101c --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Binaries +*.exe +*.dll +*.so +*.dylib + +# Go tool output +*.out + +# Temporary and test files +*.tmp +*.temp +*.test +*.coverprofile +*.coverage + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*~ + +# macOS files +.DS_Store +.AppleDouble +.LSOverride +._* + +# Logs and environment files +*.log +.env +.env.* + +# Go dependencies and build directories +vendor/ +bin/ +dist/ +go.sum + +# vhs file +*.tape \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..f614348 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,47 @@ +before: + hooks: + - go mod tidy + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + main: ./cmd/keepalive + ldflags: + - -s -w -X main.version={{.Version}} + mod_timestamp: '{{ .CommitTimestamp }}' + +archives: + - format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + format_overrides: + - goos: windows + format: zip + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^ci:' + - '^chore:' + +release: + github: + owner: stigoleg + name: keep-alive diff --git a/README.md b/README.md new file mode 100644 index 0000000..301cc1b --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# Keep-Alive + +A lightweight, cross-platform utility to prevent your system from going to sleep. Perfect for maintaining active connections, downloads, or any process that requires your system to stay awake. + +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/stigoleg/keep-alive)](https://github.com/stigoleg/keep-alive/releases/latest) +[![Go Report Card](https://goreportcard.com/badge/github.com/stigoleg/keep-alive)](https://goreportcard.com/report/github.com/stigoleg/keep-alive) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +![Keep-Alive Demo](docs/demo.gif) + +## Features + + +- 🔄 Configurable keep-alive duration +- 💻 Cross-platform support (macOS, Windows, Linux) +- ⚡ Lightweight and efficient +- 🎯 Simple and intuitive to use +- 🛠 Zero configuration required + +## Installation + +Download the latest binary for your platform from the [GitHub releases page](https://github.com/stigoleg/keep-alive/releases/latest). + +### macOS and Linux + +1. Download the archive for your platform: +```bash +# For macOS: +curl -LO https://github.com/stigoleg/keep-alive/releases/latest/download/keep-alive_Darwin_x86_64.tar.gz + +# For Linux: +curl -LO https://github.com/stigoleg/keep-alive/releases/latest/download/keep-alive_Linux_x86_64.tar.gz +``` + +2. Extract the archive: +```bash +tar xzf keep-alive_*_x86_64.tar.gz +``` + +3. Move the binary to a location in your PATH: +```bash +sudo mv keepalive /usr/local/bin/ +``` + +### Windows + +1. Download the Windows archive from the [releases page](https://github.com/stigoleg/keep-alive/releases/latest) +2. Extract the archive +3. Move `keepalive.exe` to your desired location +4. (Optional) Add the location to your PATH environment variable + +## Usage + +1. Start the application: +```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 + +## 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 +- **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) +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 + +- **Linux**: + - systemd (recommended) or X11 + - A terminal that supports TUI applications + +### Build Dependencies + +- Go 1.21 or later + +## Building from Source + +1. Clone the repository: +```bash +git clone https://github.com/stigoleg/keep-alive.git +cd keep-alive +``` + +2. Build the binary: +```bash +go build -o keepalive ./cmd/keepalive +``` + + + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License. + +## Acknowledgments + +- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - The TUI framework +- [Lipgloss](https://github.com/charmbracelet/lipgloss) - Style definitions for terminal applications \ No newline at end of file diff --git a/cmd/keepalive/main.go b/cmd/keepalive/main.go new file mode 100644 index 0000000..50a7beb --- /dev/null +++ b/cmd/keepalive/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "log" + "os" + + "keepalive/internal/ui" + + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + f, err := tea.LogToFile("debug.log", "debug") + if err != nil { + log.Fatal(err) + } + defer f.Close() + + p := tea.NewProgram( + ui.InitialModel(), + tea.WithAltScreen(), + tea.WithInput(os.Stdin), + tea.WithOutput(os.Stdout), + ) + + if _, err := p.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..257d147 Binary files /dev/null and b/docs/demo.gif differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..45dbeb6 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module keepalive + +go 1.20 + +require ( + github.com/charmbracelet/bubbletea v1.2.4 + github.com/charmbracelet/lipgloss v1.0.0 + golang.org/x/sys v0.28.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/internal/keepalive/keepalive.go b/internal/keepalive/keepalive.go new file mode 100644 index 0000000..8763aec --- /dev/null +++ b/internal/keepalive/keepalive.go @@ -0,0 +1,111 @@ +package keepalive + +import ( + "context" + "errors" + "os/exec" + "runtime" + "sync" + "time" +) + +// Keeper manages the keep-alive state across platforms. +type Keeper struct { + mu sync.Mutex + cancel context.CancelFunc + platformStop func() error + running bool +} + +// StartIndefinite keeps the system awake indefinitely using platform-specific methods. +func (k *Keeper) StartIndefinite() error { + k.mu.Lock() + defer k.mu.Unlock() + if k.running { + return nil + } + + 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") + } + + 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") + } + if err := k.StartIndefinite(); err != nil { + return err + } + + go func() { + time.Sleep(time.Duration(minutes) * time.Minute) + _ = k.Stop() + }() + return nil +} + +// Stop stops keeping the system awake, restoring normal behavior. +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.platformStop != nil { + if err := k.platformStop(); 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/keepalive/keepalive_other.go b/internal/keepalive/keepalive_other.go new file mode 100644 index 0000000..3e8a2c5 --- /dev/null +++ b/internal/keepalive/keepalive_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package keepalive + +// For non-Windows platforms, these are no-ops. +func setWindowsKeepAlive() error { + return nil +} + +func stopWindowsKeepAlive() error { + return nil +} diff --git a/internal/keepalive/keepalive_test.go b/internal/keepalive/keepalive_test.go new file mode 100644 index 0000000..a8dfb8d --- /dev/null +++ b/internal/keepalive/keepalive_test.go @@ -0,0 +1,54 @@ +package keepalive + +import ( + "testing" + "time" +) + +func TestKeepAlive(t *testing.T) { + k := &Keeper{} + if k.IsRunning() { + t.Fatal("expected not running at start") + } + + // Start indefinite + err := k.StartIndefinite() + if err != nil && err.Error() == "unsupported platform" { + t.Skip("Skipping on unsupported platform") + } + if err != nil { + t.Fatalf("StartIndefinite failed: %v", err) + } + if !k.IsRunning() { + t.Fatal("expected running after StartIndefinite") + } + + // Stop + err = k.Stop() + if err != nil { + t.Fatalf("Stop failed: %v", err) + } + if k.IsRunning() { + t.Fatal("expected not running after Stop") + } + + // Start timed + err = k.StartTimed(1) + if err != nil && err.Error() == "unsupported platform" { + t.Skip("Skipping on unsupported platform") + } + if err != nil { + t.Fatalf("StartTimed failed: %v", err) + } + if !k.IsRunning() { + t.Fatal("expected running after StartTimed") + } + time.Sleep(100 * time.Millisecond) + err = k.Stop() + if err != nil { + t.Fatalf("Stop failed: %v", err) + } + if k.IsRunning() { + t.Fatal("expected not running after Stop") + } +} diff --git a/internal/keepalive/keepalive_windows.go b/internal/keepalive/keepalive_windows.go new file mode 100644 index 0000000..f4475a6 --- /dev/null +++ b/internal/keepalive/keepalive_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package keepalive + +import ( + "golang.org/x/sys/windows" +) + +var ( + modkernel32 = windows.NewLazySystemDLL("kernel32.dll") + procSetThreadExecutionState = modkernel32.NewProc("SetThreadExecutionState") +) + +// setWindowsKeepAlive sets thread execution state to prevent sleep. +func setWindowsKeepAlive() error { + const ES_CONTINUOUS = 0x80000000 + const ES_SYSTEM_REQUIRED = 0x00000001 + const ES_DISPLAY_REQUIRED = 0x00000002 + + r, _, err := procSetThreadExecutionState.Call(uintptr(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED)) + if r == 0 { + return err + } + return nil +} + +// stopWindowsKeepAlive resets the thread execution state. +func stopWindowsKeepAlive() error { + const ES_CONTINUOUS = 0x80000000 + r, _, err := procSetThreadExecutionState.Call(uintptr(ES_CONTINUOUS)) + if r == 0 { + return err + } + return nil +} diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..b283f2a --- /dev/null +++ b/internal/ui/model.go @@ -0,0 +1,67 @@ +package ui + +import ( + "keepalive/internal/keepalive" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// state represents the different states of the TUI. +type state int + +const ( + stateMenu state = iota + stateTimedInput + stateRunning +) + +// Model holds the current state of the UI, including user input and keep-alive state. +type Model struct { + State state + Selected int + Input string + KeepAlive *keepalive.Keeper + ErrorMessage string + StartTime time.Time + Duration time.Duration +} + +// InitialModel returns the initial model for the TUI. +func InitialModel() Model { + return Model{ + State: stateMenu, + Selected: 0, + Input: "", + KeepAlive: &keepalive.Keeper{}, + } +} + +// Init implements tea.Model +func (m Model) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + newModel, cmd := Update(msg, m) + return newModel, cmd +} + +// View implements tea.Model +func (m Model) View() string { + return View(m) +} + +// TimeRemaining returns the remaining duration for timed keep-alive +func (m Model) TimeRemaining() time.Duration { + if !m.KeepAlive.IsRunning() || m.Duration == 0 { + return 0 + } + elapsed := time.Since(m.StartTime) + remaining := m.Duration - elapsed + if remaining < 0 { + return 0 + } + return remaining +} diff --git a/internal/ui/style.go b/internal/ui/style.go new file mode 100644 index 0000000..364c744 --- /dev/null +++ b/internal/ui/style.go @@ -0,0 +1,80 @@ +// Package ui provides the terminal user interface for the keep-alive application. +package ui + +import "github.com/charmbracelet/lipgloss" + +// Colors defines the color scheme used throughout the application +type Colors struct { + Subtle lipgloss.AdaptiveColor + Highlight lipgloss.AdaptiveColor + Special lipgloss.AdaptiveColor + Error lipgloss.AdaptiveColor +} + +// DefaultColors returns the default color scheme +var defaultColors = Colors{ + Subtle: lipgloss.AdaptiveColor{Light: "#666666", Dark: "#999999"}, + Highlight: lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}, + Special: lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}, + Error: lipgloss.AdaptiveColor{Light: "#FF0000", Dark: "#FF4040"}, +} + +// Style represents a collection of styles used in the application +type Style struct { + Title lipgloss.Style + ActiveStatus lipgloss.Style + InactiveStatus lipgloss.Style + DisabledItem lipgloss.Style + SelectedItem lipgloss.Style + Menu lipgloss.Style + InputBox lipgloss.Style + Help lipgloss.Style + Error lipgloss.Style + Countdown lipgloss.Style +} + +// DefaultStyle returns the default style configuration +func DefaultStyle() Style { + base := lipgloss.NewStyle(). + PaddingLeft(1). + PaddingRight(1) + + return Style{ + Title: base.Copy(). + Bold(true). + Foreground(defaultColors.Highlight), + + ActiveStatus: base.Copy(). + Foreground(defaultColors.Special), + + InactiveStatus: base.Copy(). + Foreground(defaultColors.Subtle), + + DisabledItem: base.Copy(). + Foreground(defaultColors.Subtle), + + SelectedItem: base.Copy(). + Bold(true). + Foreground(defaultColors.Highlight), + + Menu: base.Copy(), + + InputBox: base.Copy(). + Border(lipgloss.RoundedBorder()). + BorderForeground(defaultColors.Highlight). + Padding(0, 1), + + Help: base.Copy(). + Foreground(defaultColors.Subtle), + + Error: base.Copy(). + Foreground(defaultColors.Error), + + Countdown: base.Copy(). + Foreground(defaultColors.Highlight). + Bold(true), + } +} + +// Current holds the current style configuration +var Current = DefaultStyle() diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go new file mode 100644 index 0000000..263575b --- /dev/null +++ b/internal/ui/ui_test.go @@ -0,0 +1,201 @@ +package ui + +import ( + "keepalive/internal/keepalive" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestInitialModel(t *testing.T) { + m := InitialModel() + if m.State != stateMenu { + t.Error("expected initial state to be stateMenu") + } + if m.Selected != 0 { + t.Error("expected initial selected to be 0") + } + if m.Input != "" { + t.Error("expected initial input to be empty") + } + if m.ErrorMessage != "" { + t.Error("expected initial error message to be empty") + } +} + +func TestMenuView(t *testing.T) { + m := InitialModel() + view := View(m) + + // Check for menu options + expectedOptions := []string{ + "Keep system awake indefinitely", + "Keep system awake for X minutes", + "Quit keep-alive", + } + + for _, opt := range expectedOptions { + if !strings.Contains(view, opt) { + t.Errorf("expected view to contain option %q", opt) + } + } + + // Check cursor position + lines := strings.Split(view, "\n") + foundCursor := false + for _, line := range lines { + if strings.Contains(line, ">") && strings.Contains(line, "Keep system awake indefinitely") { + foundCursor = true + break + } + } + if !foundCursor { + t.Error("expected cursor to be at first option") + } +} + +func TestUpdate(t *testing.T) { + tests := []struct { + name string + msg tea.Msg + model Model + wantType state + }{ + { + name: "up key at top stays at top", + msg: tea.KeyMsg{Type: tea.KeyUp}, + model: Model{State: stateMenu, Selected: 0}, + wantType: stateMenu, + }, + { + name: "down key moves selection", + msg: tea.KeyMsg{Type: tea.KeyDown}, + model: Model{State: stateMenu, Selected: 0}, + wantType: stateMenu, + }, + { + name: "enter on timed input moves to input state", + msg: tea.KeyMsg{Type: tea.KeyEnter}, + model: Model{State: stateMenu, Selected: 1}, + wantType: stateTimedInput, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.model.KeepAlive = &keepalive.Keeper{} + got, _ := Update(tt.msg, tt.model) + if got.State != tt.wantType { + t.Errorf("Update() state = %v, want %v", got.State, tt.wantType) + } + }) + } +} + +func TestTimedInputView(t *testing.T) { + m := Model{ + State: stateTimedInput, + Input: "5", + KeepAlive: &keepalive.Keeper{}, + } + view := View(m) + + if !strings.Contains(view, "minutes") { + t.Error("expected view to contain duration prompt") + } + if !strings.Contains(view, "5") { + t.Error("expected view to show input value") + } +} + +func TestRunningView(t *testing.T) { + m := Model{ + State: stateRunning, + StartTime: time.Now(), + Duration: 5 * time.Minute, + KeepAlive: &keepalive.Keeper{}, + } + view := View(m) + + if !strings.Contains(view, "Keep Alive Active") { + t.Error("expected view to show active status") + } + if !strings.Contains(view, "System is being kept awake") { + t.Error("expected view to show system status") + } + if !strings.Contains(view, "remaining") { + t.Error("expected view to show remaining time") + } +} + +func TestErrorDisplay(t *testing.T) { + m := Model{ + State: stateMenu, + ErrorMessage: "test error", + KeepAlive: &keepalive.Keeper{}, + } + view := View(m) + + if !strings.Contains(view, "test error") { + t.Error("expected view to show error message") + } +} + +func TestTimeRemaining(t *testing.T) { + now := time.Now() + keeper := &keepalive.Keeper{} + _ = keeper.StartIndefinite() // Start the keeper for the test + + tests := []struct { + name string + model Model + wantZero bool + wantRange time.Duration + }{ + { + name: "no duration", + model: Model{ + StartTime: now, + Duration: 0, + KeepAlive: keeper, + }, + wantZero: true, + }, + { + name: "with duration", + model: Model{ + StartTime: now, + Duration: 5 * time.Minute, + KeepAlive: keeper, + }, + wantZero: false, + wantRange: 5 * time.Minute, + }, + { + name: "expired duration", + model: Model{ + StartTime: now.Add(-6 * time.Minute), + Duration: 5 * time.Minute, + KeepAlive: keeper, + }, + wantZero: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.model.TimeRemaining() + if tt.wantZero && got != 0 { + t.Errorf("TimeRemaining() = %v, want 0", got) + } + if !tt.wantZero && (got <= 0 || got > tt.wantRange) { + t.Errorf("TimeRemaining() = %v, want between 0 and %v", got, tt.wantRange) + } + }) + } + + // Clean up + _ = keeper.Stop() +} diff --git a/internal/ui/update.go b/internal/ui/update.go new file mode 100644 index 0000000..25d96c5 --- /dev/null +++ b/internal/ui/update.go @@ -0,0 +1,159 @@ +package ui + +import ( + "strconv" + "time" + "unicode" + + tea "github.com/charmbracelet/bubbletea" +) + +// tickMsg is sent when the countdown timer ticks +type tickMsg time.Time + +// Update handles messages and updates the model accordingly. +func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { + var cmd tea.Cmd + + switch m.State { + case stateMenu: + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if m.Selected > 0 { + m.Selected-- + } + case "down", "j": + if m.Selected < 2 { + m.Selected++ + } + case "enter", " ": + switch m.Selected { + case 0: + // Indefinite keep-alive + if err := m.KeepAlive.StartIndefinite(); err != nil { + m.ErrorMessage = err.Error() + return m, nil + } + m.State = stateRunning + m.Duration = 0 // Clear any previous duration + return m, nil + case 1: + // Timed input + m.State = stateTimedInput + m.Input = "" + m.ErrorMessage = "" + return m, nil + case 2: + // Quit keep-alive + if m.KeepAlive.IsRunning() { + if err := m.KeepAlive.Stop(); err != nil { + m.ErrorMessage = err.Error() + return m, nil + } + } + 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 + } + m.State = stateMenu + m.ErrorMessage = "" + return m, nil + } + return m, tick() + } + + case stateTimedInput: + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + if m.Input == "" { + m.ErrorMessage = "Please enter a duration" + return m, nil + } + minutes, err := strconv.Atoi(m.Input) + if err != nil { + m.ErrorMessage = "Invalid duration" + return m, nil + } + if minutes <= 0 { + m.ErrorMessage = "Duration must be positive" + return m, nil + } + if err := m.KeepAlive.StartTimed(minutes); 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 = "" + } + } + return m, nil + } + } + + case stateRunning: + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + if err := m.KeepAlive.Stop(); err != nil { + m.ErrorMessage = err.Error() + return m, nil + } + 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 { + if err := m.KeepAlive.Stop(); err != nil { + m.ErrorMessage = err.Error() + return m, nil + } + m.State = stateMenu + m.ErrorMessage = "" + return m, nil + } + return m, tick() + } + } + + return m, cmd +} + +func tick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} diff --git a/internal/ui/view.go b/internal/ui/view.go new file mode 100644 index 0000000..732a7ce --- /dev/null +++ b/internal/ui/view.go @@ -0,0 +1,126 @@ +package ui + +import ( + "fmt" + "strings" + "time" +) + +// View renders the UI based on the current model state. +func View(m Model) string { + switch m.State { + case stateMenu: + return menuView(m) + case stateTimedInput: + return timedInputView(m) + case stateRunning: + return runningView(m) + default: + return "Invalid state" + } +} + +func menuView(m Model) string { + var b strings.Builder + + // Title + b.WriteString(Current.Title.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("\n\n") + + // Menu options + menuItems := []string{ + "Keep system awake indefinitely", + "Keep system awake for X minutes", + "Quit keep-alive", + } + + for i, opt := range menuItems { + var menuLine strings.Builder + + // Cursor + if i == m.Selected { + menuLine.WriteString("> ") + } else { + menuLine.WriteString(" ") + } + + // 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)) + } else { + menuLine.WriteString(Current.Menu.Render(opt)) + } + + b.WriteString(menuLine.String() + "\n") + } + + if m.ErrorMessage != "" { + b.WriteString("\n" + Current.Error.Render(m.ErrorMessage)) + } + + b.WriteString("\n\n" + Current.Help.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("\n\n") + + input := m.Input + if input == "" { + input = " " + } + b.WriteString(Current.InputBox.Render(input)) + b.WriteString("\n\n") + + // Help text + b.WriteString(Current.Help.Render("Enter number of minutes")) + b.WriteString("\n") + b.WriteString(Current.Help.Render("Press enter to start • backspace to clear • esc to cancel")) + + if m.ErrorMessage != "" { + b.WriteString("\n\n" + Current.Error.Render(m.ErrorMessage)) + } + + return b.String() +} + +func runningView(m Model) string { + var b strings.Builder + + b.WriteString(Current.Title.Render("Keep Alive Active")) + b.WriteString("\n\n") + + b.WriteString(Current.ActiveStatus.Render("System is being kept awake")) + b.WriteString("\n") + + // Show countdown if this is a timed session + if m.Duration > time.Duration(0) { + remaining := m.TimeRemaining() + 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("\n") + } + + b.WriteString("\n" + Current.Help.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)) + } + + return b.String() +}