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
+7
View File
@@ -0,0 +1,7 @@
gosleep-timer
*.test
*.out
*.exe
.DS_Store
cover.out
vendor/
+266
View File
@@ -0,0 +1,266 @@
# gosleep-timer — Go TUI Timer
## Цель
Переписать `timer.py` на Go с модульной архитектурой, поддержкой нескольких WM/DE (niri, Hyprland, KDE), пользовательскими командами, профилями, историей, CLI-режимом и QR-экспортом.
---
## Структура проекта
```
gosleep-timer/
├── main.go # CLI entrypoint
├── go.mod / go.sum
├── .gitignore
├── TODO.md
├── cicd.yaml # CI/CD pipeline
├── .golangci.yml # linter config
├── config/
│ ├── config.go # загрузка YAML
│ ├── defaults.go # дефолтные значения
│ └── types.go # структуры Config, Module, Profile, Stage
├── engine/
│ ├── timer.go # ядро отсчёта
│ ├── executor.go # запуск команд, таймауты, kill
│ └── stages.go # фазы: pre → timer → post
├── modules/
│ ├── module.go # интерфейс Module
│ ├── registry.go # глобальный реестр модулей
│ ├── workspace/
│ │ └── workspace.go # переключение workspace (niri/hyprland/kde)
│ ├── media/
│ │ └── media.go # playerctl / MPRIS
│ ├── lock/
│ │ └── lock.go # блокировка экрана
│ ├── kill/
│ │ └── kill.go # pkill процессов
│ ├── notify/
│ │ └── notify.go # notify-send / dunst
│ ├── sound/
│ │ └── sound.go # звуковой сигнал
│ ├── brightness/
│ │ └── brightness.go # управление яркостью (brightnessctl/light)
│ ├── mute/
│ │ └── mute.go # mute/unmute (pactl/wpctl)
│ ├── custom/
│ │ └── custom.go # пользовательские команды pre/post
│ └── script/
│ └── script.go # запуск произвольного скрипта
├── profiles/
│ ├── profile.go # структура Profile
│ └── manager.go # CRUD профилей
├── history/
│ ├── history.go # JSONL лог запусков
│ └── stats.go # статистика
├── tui/
│ ├── app.go # bubbletea App
│ ├── widgets.go # кастомные виджеты
│ ├── styles.go # темы
│ ├── keybinds.go # горячие клавиши
│ └── qr.go # QR код
```
---
## Очередность (от важного к мелкому)
### [x] 1. TODO.md
Этот файл.
### [ ] 2. Фундамент
- Инициализация Go модуля
- `.gitignore`
- Структура папок
- Первый коммит
### [ ] 3. Конфиг
- `config/types.go` — типы данных
- `config/defaults.go` — дефолты
- `config/config.go` — парсинг YAML
- Автоопределение WM (niri/hyprland/kde/none)
### [ ] 4. Ядро
- `engine/timer.go` — обратный отсчёт, тики
- `engine/executor.go` — exec.Command, таймауты, принудительное завершение
- `engine/stages.go` — фазы с callback-ами
### [ ] 5. Модули
- `modules/module.go` — интерфейс
- `modules/registry.go` — реестр
- workspace, media, lock, kill, notify, sound, brightness, mute, custom, script
### [ ] 6. Профили
- `profiles/profile.go`
- `profiles/manager.go`
- Загрузка/сохранение профилей, список, apply
### [ ] 7. История
- `history/history.go` — запись в JSONL
- `history/stats.go` — агрегация
### [ ] 8. TUI
- `tui/app.go`, `widgets.go`, `styles.go`, `keybinds.go`, `qr.go`
### [ ] 9. CLI
- `main.go` — cobra/флаги
- Режимы: `tui` (по умолчанию), `run`, `list-profiles`, `export`, `import`
### [ ] 10. Линтеры + CI/CD
- `.golangci.yml` (golint, staticcheck, gosec, revive, errcheck)
- `cicd.yaml` на основе `cicd.example.yaml`
- Go-специфичные линтеры
---
## Модули (детально)
### workspace
- Поле `Backend` (niri | hyprland | kde | auto)
- Поле `Workspace` (int)
- Автоопределение: `loginctl`, `$XDG_CURRENT_DESKTOP`, `hyprctl`, `niri msg`
- Команды:
- niri: `niri msg action focus-workspace {n}`
- hyprland: `hyprctl dispatch workspace {n}`
- kde: `qdbus org.kde.KWin /KWin setCurrentDesktop {n}`
### media
- Поле `Action` (stop | pause | play-pause | next | previous | none)
- fallback: `playerctl`
### lock
- Поле `Command` (кастомная строка)
- fallback: `loginctl lock-session`
### kill
- Поле `Processes` ([]string)
- `pkill {name}` для каждого
### notify
- Поле `Sound` (путь к файлу или none)
- `notify-send` + опционально звук
### sound
- Поле `File` (путь к wav/oga)
- `paplay`, `aplay`, `ffplay`
### brightness
- Поле `Value` (int 0-100)
- `brightnessctl set {n}%` или `light -S {n}`
### mute
- Поле `Mute` (bool)
- `pactl set-sink-mute @DEFAULT_SINK@ 1` / `0`
- `wpctl set-mute @DEFAULT_AUDIO_SINK@ 1` / `0`
### custom
- Поля `PreCommands`, `PostCommands` ([]string)
### script
- Поле `Path` (путь к скрипту)
- Аргументы из контекста таймера
---
## CLI флаги (`main.go`)
```
gosleep-timer # TUI режим
gosleep-timer run 25m # CLI режим
gosleep-timer run --profile work 25m
gosleep-timer list-profiles
gosleep-timer export > config.yaml
gosleep-timer import < config.yaml
gosleep-timer qr # показать QR с конфигом
```
---
## TUI (bubbletea)
### Экраны
1. **Main** — ввод времени, чекбоксы модулей, выбор профиля, Start/Stop
2. **Running** — прогресс-бар, оставшееся время, текущий этап
3. **History** — последние запуски
4. **Stats** — статистика
### Горячие клавиши
- `s` — Start
- `S` — Stop
- `q` / `Esc` — Quit
- `h` — History
- `Tab` — фокус следующий элемент
- `Enter` — toggle/action
---
## Формат конфига (`~/.config/gosleep-timer/config.yaml`)
```yaml
profiles:
default:
modules:
workspace:
enabled: true
backend: auto
workspace: 12
media:
enabled: true
action: stop
lock:
enabled: true
command: ""
kill:
enabled: false
processes: []
notify:
enabled: true
sound: ""
sound:
enabled: true
file: /usr/share/sounds/freedesktop/stereo/complete.oga
brightness:
enabled: false
value: 0
mute:
enabled: false
custom:
enabled: false
pre: []
post: []
script:
enabled: false
path: ""
work:
extends: default
modules:
workspace:
workspace: 5
gaming:
modules:
kill:
enabled: true
processes: [firefox, slack]
mute:
enabled: true
```
---
## История (`~/.local/share/gosleep-timer/history.jsonl`)
```jsonl
{"ts":"2026-07-03T01:30:00Z","profile":"default","duration":"25m","status":"completed"}
{"ts":"2026-07-03T02:00:00Z","profile":"work","duration":"1h","status":"killed"}
```
---
## CI/CD Pipeline
На основе `cicd.example.yaml`:
- `lint-go` — golangci-lint (все линтеры)
- `lint-yaml` — yamllint
- `lint-dockerfile` — hadolint (если появится)
- `build` — go build
- `test` — go test ./...
- `publish` — docker образ (опционально)
+171
View File
@@ -0,0 +1,171 @@
name: ci
'on':
push:
branches:
- '**'
pull_request:
workflow_dispatch:
env:
# Shared image coordinates for the publish job.
REGISTRY: gcr.forust.xyz
IMAGE_NAME: forust/telegram-scraper
jobs:
lint-prettier:
runs-on: [self-hosted, linux, arch, homelab]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check formatting with Prettier
shell: bash
run: |
mapfile -t prettier_files < <(
git ls-files \
| grep -E '\.(md|json|ya?ml|html|css)$' \
| grep -Ev '^(\.docs/|\.zed/|errorpages/html/|homepages/(forust_files|xdfnx_files)/)'
)
if [ "${#prettier_files[@]}" -eq 0 ]; then
echo "No Prettier-managed files found."
exit 0
fi
docker run --rm \
-v "$PWD:/work" \
-w /work \
node:22-alpine \
sh -lc 'npx --yes prettier@3 --check --ignore-unknown "$@"' sh "${prettier_files[@]}"
lint-ruff:
runs-on: [self-hosted, linux, arch, homelab]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Lint Python with Ruff
shell: bash
run: |
docker run --rm \
-v "$PWD:/work" \
-w /work \
ghcr.io/astral-sh/ruff:latest \
check .
lint-yaml:
runs-on: [self-hosted, linux, arch, homelab]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Lint YAML syntax
shell: bash
run: |
docker run --rm \
-v "$PWD:/work" \
-w /work \
cytopia/yamllint:latest \
-c .yamllint .
lint-dockerfiles:
runs-on: [self-hosted, linux, arch, homelab]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Lint Dockerfiles
shell: bash
run: |
mapfile -t dockerfiles < <(
git ls-files ':(glob)**/Dockerfile' ':(glob)**/Dockerfile.*'
)
if [ "${#dockerfiles[@]}" -eq 0 ]; then
echo "No Dockerfiles found."
exit 0
fi
docker run --rm \
-v "$PWD:/work" \
-w /work \
--entrypoint hadolint \
hadolint/hadolint:latest-debian \
-c .hadolint.yaml "${dockerfiles[@]}"
validate:
runs-on: [self-hosted, linux, arch, homelab]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Validate Kubernetes manifests
shell: bash
run: |
mapfile -t manifests < <(
git ls-files ':(glob)**/k8s/**/*.yaml' ':(glob)**/k8s/**/*.yml' \
| grep -Ev '(^|/)(kustomization\.ya?ml|.*\.example\.ya?ml|.*values\.ya?ml|patch-.*\.ya?ml)$'
)
if [ "${#manifests[@]}" -eq 0 ]; then
echo "No Kubernetes manifests found."
exit 0
fi
docker run --rm \
-v "$PWD:/work" \
-w /work \
ghcr.io/yannh/kubeconform:latest \
-strict \
-ignore-missing-schemas \
-summary \
"${manifests[@]}"
publish:
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev')
runs-on: [self-hosted, linux, arch, homelab]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Read project version
id: version
shell: bash
run: |
version="$(python3 -c 'from pathlib import Path; import tomllib; print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"])')"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Log in to registry
shell: bash
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" \
-u "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Build and push image
shell: bash
run: |
image="${REGISTRY}/${IMAGE_NAME}"
tags=("latest" "${{ steps.version.outputs.version }}")
case "${GITHUB_REF_NAME}" in
main)
tags+=("main" "prod")
;;
dev)
tags+=("dev")
;;
esac
build_args=()
for tag in "${tags[@]}"; do
build_args+=(-t "${image}:${tag}")
done
docker build "${build_args[@]}" .
for tag in "${tags[@]}"; do
docker push "${image}:${tag}"
done
+281
View File
@@ -0,0 +1,281 @@
package config
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
func DefaultConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("home dir: %w", err)
}
return filepath.Join(home, ".config", "gosleep-timer", "config.yaml"), nil
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if cfg.Profiles == nil {
cfg.Profiles = make(map[string]Profile)
}
resolved := make(map[string]Profile, len(cfg.Profiles))
for name := range cfg.Profiles {
seen := make(map[string]bool)
r := resolveExtends(cfg.Profiles, name, seen)
if r == nil {
return nil, fmt.Errorf("circular extends detected for profile %q", name)
}
resolved[name] = *r
}
cfg.Profiles = resolved
return &cfg, nil
}
func Save(path string, cfg *Config) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("mkdir config dir: %w", err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}
func LoadOrCreate(path string) (*Config, error) {
cfg, err := Load(path)
if err == nil {
return cfg, nil
}
if !os.IsNotExist(err) {
return nil, err
}
cfg = DefaultConfig()
if err := Save(path, cfg); err != nil {
return nil, fmt.Errorf("create default config: %w", err)
}
return cfg, nil
}
func SaveToString(cfg *Config) (string, error) {
data, err := yaml.Marshal(cfg)
if err != nil {
return "", fmt.Errorf("marshal config: %w", err)
}
return string(data), nil
}
func DetectDesktop() string {
if de := os.Getenv("XDG_CURRENT_DESKTOP"); de != "" {
lower := strings.ToLower(de)
if strings.Contains(lower, "hyprland") {
return "hyprland"
}
if strings.Contains(lower, "kde") || strings.Contains(lower, "plasma") {
return "kde"
}
if strings.Contains(lower, "niri") {
return "niri"
}
}
if err := exec.Command("hyprctl", "instances").Run(); err == nil {
return "hyprland"
}
if err := exec.Command("niri", "msg", "-h").Run(); err == nil {
return "niri"
}
return "generic"
}
func resolveExtends(profiles map[string]Profile, name string, seen map[string]bool) *Profile {
if seen[name] {
return nil
}
seen[name] = true
p, ok := profiles[name]
if !ok {
return nil
}
base := &Profile{}
if p.Extends != "" {
base = resolveExtends(profiles, p.Extends, seen)
if base == nil {
return nil
}
}
merged := &Profile{
Extends: p.Extends,
Modules: copyModules(base.Modules),
}
if p.Modules != nil {
if merged.Modules == nil {
merged.Modules = &Modules{}
}
overlayModules(merged.Modules, p.Modules)
}
return merged
}
func copyModules(m *Modules) *Modules {
if m == nil {
return nil
}
c := &Modules{}
if m.Workspace != nil {
v := *m.Workspace
c.Workspace = &v
}
if m.Media != nil {
v := *m.Media
c.Media = &v
}
if m.Lock != nil {
v := *m.Lock
c.Lock = &v
}
if m.Kill != nil {
v := *m.Kill
v.Processes = append([]string(nil), m.Kill.Processes...)
c.Kill = &v
}
if m.Notify != nil {
v := *m.Notify
c.Notify = &v
}
if m.Sound != nil {
v := *m.Sound
c.Sound = &v
}
if m.Brightness != nil {
v := *m.Brightness
c.Brightness = &v
}
if m.Mute != nil {
v := *m.Mute
c.Mute = &v
}
if m.Custom != nil {
v := *m.Custom
v.Pre = append([]string(nil), m.Custom.Pre...)
v.Post = append([]string(nil), m.Custom.Post...)
c.Custom = &v
}
if m.Script != nil {
v := *m.Script
c.Script = &v
}
return c
}
func overlayModules(base, overlay *Modules) {
if overlay.Workspace != nil {
if base.Workspace == nil {
base.Workspace = &WorkspaceConfig{}
}
base.Workspace.Enabled = overlay.Workspace.Enabled
if overlay.Workspace.Backend != "" {
base.Workspace.Backend = overlay.Workspace.Backend
}
if overlay.Workspace.Workspace != 0 {
base.Workspace.Workspace = overlay.Workspace.Workspace
}
}
if overlay.Media != nil {
if base.Media == nil {
base.Media = &MediaConfig{}
}
base.Media.Enabled = overlay.Media.Enabled
if overlay.Media.Action != "" {
base.Media.Action = overlay.Media.Action
}
}
if overlay.Lock != nil {
if base.Lock == nil {
base.Lock = &LockConfig{}
}
base.Lock.Enabled = overlay.Lock.Enabled
if overlay.Lock.Command != "" {
base.Lock.Command = overlay.Lock.Command
}
}
if overlay.Kill != nil {
base.Kill = &KillConfig{
Enabled: overlay.Kill.Enabled,
Processes: append([]string(nil), overlay.Kill.Processes...),
}
}
if overlay.Notify != nil {
if base.Notify == nil {
base.Notify = &NotifyConfig{}
}
base.Notify.Enabled = overlay.Notify.Enabled
if overlay.Notify.Sound != "" {
base.Notify.Sound = overlay.Notify.Sound
}
}
if overlay.Sound != nil {
if base.Sound == nil {
base.Sound = &SoundConfig{}
}
base.Sound.Enabled = overlay.Sound.Enabled
if overlay.Sound.File != "" {
base.Sound.File = overlay.Sound.File
}
}
if overlay.Brightness != nil {
base.Brightness = &BrightnessConfig{
Enabled: overlay.Brightness.Enabled,
Value: overlay.Brightness.Value,
}
}
if overlay.Mute != nil {
base.Mute = &MuteConfig{
Enabled: overlay.Mute.Enabled,
}
}
if overlay.Custom != nil {
base.Custom = &CustomConfig{
Enabled: overlay.Custom.Enabled,
Pre: append([]string(nil), overlay.Custom.Pre...),
Post: append([]string(nil), overlay.Custom.Post...),
}
}
if overlay.Script != nil {
base.Script = &ScriptConfig{
Enabled: overlay.Script.Enabled,
Path: overlay.Script.Path,
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package config
func DefaultConfig() *Config {
return &Config{
Profiles: map[string]Profile{
"default": {
Modules: DefaultModules(),
},
},
}
}
func DefaultModules() *Modules {
return &Modules{
Workspace: &WorkspaceConfig{
Enabled: true,
Backend: "",
Workspace: 12,
},
Media: &MediaConfig{
Enabled: true,
Action: "stop",
},
Lock: &LockConfig{
Enabled: true,
Command: "",
},
Kill: &KillConfig{
Enabled: false,
Processes: nil,
},
Notify: &NotifyConfig{
Enabled: false,
Sound: "",
},
Sound: &SoundConfig{
Enabled: true,
File: "/usr/share/sounds/freedesktop/stereo/complete.oga",
},
Brightness: &BrightnessConfig{
Enabled: false,
Value: 0,
},
Mute: &MuteConfig{
Enabled: false,
},
Custom: &CustomConfig{
Enabled: false,
Pre: nil,
Post: nil,
},
Script: &ScriptConfig{
Enabled: false,
Path: "",
},
}
}
+74
View File
@@ -0,0 +1,74 @@
package config
type Config struct {
Profiles map[string]Profile `yaml:"profiles"`
}
type Profile struct {
Extends string `yaml:"extends,omitempty"`
Modules *Modules `yaml:"modules,omitempty"`
}
type Modules struct {
Workspace *WorkspaceConfig `yaml:"workspace,omitempty"`
Media *MediaConfig `yaml:"media,omitempty"`
Lock *LockConfig `yaml:"lock,omitempty"`
Kill *KillConfig `yaml:"kill,omitempty"`
Notify *NotifyConfig `yaml:"notify,omitempty"`
Sound *SoundConfig `yaml:"sound,omitempty"`
Brightness *BrightnessConfig `yaml:"brightness,omitempty"`
Mute *MuteConfig `yaml:"mute,omitempty"`
Custom *CustomConfig `yaml:"custom,omitempty"`
Script *ScriptConfig `yaml:"script,omitempty"`
}
type WorkspaceConfig struct {
Enabled bool `yaml:"enabled" default:"true"`
Backend string `yaml:"backend"` // auto, niri, hyprland, kde
Workspace int `yaml:"workspace"`
}
type MediaConfig struct {
Enabled bool `yaml:"enabled" default:"true"`
Action string `yaml:"action"` // stop, pause, play-pause, next, previous, none
}
type LockConfig struct {
Enabled bool `yaml:"enabled" default:"true"`
Command string `yaml:"command"` // custom lock cmd, empty = auto
}
type KillConfig struct {
Enabled bool `yaml:"enabled" default:"false"`
Processes []string `yaml:"processes"`
}
type NotifyConfig struct {
Enabled bool `yaml:"enabled" default:"true"`
Sound string `yaml:"sound"` // path or empty
}
type SoundConfig struct {
Enabled bool `yaml:"enabled" default:"true"`
File string `yaml:"file"`
}
type BrightnessConfig struct {
Enabled bool `yaml:"enabled" default:"false"`
Value int `yaml:"value"` // 0-100
}
type MuteConfig struct {
Enabled bool `yaml:"enabled" default:"false"`
}
type CustomConfig struct {
Enabled bool `yaml:"enabled" default:"false"`
Pre []string `yaml:"pre"`
Post []string `yaml:"post"`
}
type ScriptConfig struct {
Enabled bool `yaml:"enabled" default:"false"`
Path string `yaml:"path"`
}
+42
View File
@@ -0,0 +1,42 @@
package engine
import (
"context"
"os/exec"
"time"
)
type ExecResult struct {
Cmd string
Output string
Err error
Elapsed time.Duration
}
func Execute(ctx context.Context, cmd string, timeout time.Duration) ExecResult {
start := time.Now()
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
out, err := exec.CommandContext(ctx, "bash", "-c", cmd).CombinedOutput()
elapsed := time.Since(start)
return ExecResult{
Cmd: cmd,
Output: string(out),
Err: err,
Elapsed: elapsed,
}
}
func ExecuteAll(ctx context.Context, cmds []string, timeout time.Duration) []ExecResult {
results := make([]ExecResult, len(cmds))
for i, cmd := range cmds {
results[i] = Execute(ctx, cmd, timeout)
}
return results
}
+74
View File
@@ -0,0 +1,74 @@
package engine
import (
"context"
"time"
)
type StageRunner struct {
Timer *Timer
PreCmds []string
PostCmds []string
Events chan TimerEvent
ExecTimeout time.Duration
}
func NewStageRunner(timer *Timer, pre, post []string) *StageRunner {
return &StageRunner{
Timer: timer,
PreCmds: pre,
PostCmds: post,
Events: make(chan TimerEvent, 100),
ExecTimeout: 30 * time.Second,
}
}
func (sr *StageRunner) Run(ctx context.Context) {
defer close(sr.Events)
// PreCmds
for _, cmd := range sr.PreCmds {
result := Execute(ctx, cmd, sr.ExecTimeout)
if result.Err != nil {
sr.Events <- TimerEvent{Stage: StagePre, Status: StatusError, Done: true}
return
}
sr.Events <- TimerEvent{Stage: StagePre, Status: StatusRunning}
}
// Timer
timerEvents := make(chan TimerEvent, 100)
go sr.Timer.Start(ctx, timerEvents)
timerDone := false
timerLoop:
for {
select {
case ev := <-timerEvents:
sr.Events <- ev
if ev.Done {
timerDone = true
break timerLoop
}
case <-ctx.Done():
break timerLoop
}
}
// PostCmds (only on normal timer completion)
if timerDone && sr.Timer.Status == StatusDone {
for _, cmd := range sr.PostCmds {
result := Execute(ctx, cmd, sr.ExecTimeout)
if result.Err != nil {
sr.Events <- TimerEvent{Stage: StagePost, Status: StatusError, Done: true}
return
}
sr.Events <- TimerEvent{Stage: StagePost, Status: StatusRunning}
}
sr.Events <- TimerEvent{Status: StatusDone, Done: true}
}
}
func (sr *StageRunner) Stop() {
sr.Timer.Stop()
}
+116
View File
@@ -0,0 +1,116 @@
package engine
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
type Status int
const (
StatusIdle Status = iota
StatusRunning
StatusPaused
StatusDone
StatusKilled
StatusError
)
type StageType string
const (
StagePre StageType = "pre"
StageTimer StageType = "timer"
StagePost StageType = "post"
)
type TimerEvent struct {
Remaining time.Duration
Total time.Duration
Stage StageType
Status Status
Done bool
}
type Timer struct {
Duration time.Duration
Status Status
StartedAt time.Time
elapsed time.Duration
cancel context.CancelFunc
}
func ParseDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if d, err := time.ParseDuration(s); err == nil {
return d, nil
}
re := regexp.MustCompile(`(\d+)([hms])`)
matches := re.FindAllStringSubmatch(s, -1)
if len(matches) == 0 {
return 0, fmt.Errorf("invalid duration: %s", s)
}
var d time.Duration
for _, m := range matches {
v, _ := strconv.Atoi(m[1])
switch m[2] {
case "h":
d += time.Duration(v) * time.Hour
case "m":
d += time.Duration(v) * time.Minute
case "s":
d += time.Duration(v) * time.Second
}
}
return d, nil
}
func NewTimer(d time.Duration) *Timer {
return &Timer{Duration: d, Status: StatusIdle}
}
func (t *Timer) Start(ctx context.Context, events chan<- TimerEvent) {
ctx, cancel := context.WithCancel(ctx)
t.cancel = cancel
t.StartedAt = time.Now()
t.Status = StatusRunning
go func() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
t.Status = StatusKilled
events <- TimerEvent{Status: StatusKilled, Done: true}
return
case <-ticker.C:
elapsed := time.Since(t.StartedAt)
remaining := t.Duration - elapsed
if remaining <= 0 {
t.Status = StatusDone
events <- TimerEvent{Remaining: 0, Total: t.Duration, Status: StatusDone, Done: true}
return
}
events <- TimerEvent{
Remaining: remaining,
Total: t.Duration,
Stage: StageTimer,
Status: StatusRunning,
}
}
}
}()
}
func (t *Timer) Stop() {
if t.cancel != nil {
t.cancel()
}
t.Status = StatusKilled
}
+34
View File
@@ -0,0 +1,34 @@
module github.com/forust/gosleep-timer
go 1.26.4
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.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.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+62
View File
@@ -0,0 +1,62 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+85
View File
@@ -0,0 +1,85 @@
package history
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
type Record struct {
Timestamp time.Time `json:"ts"`
Profile string `json:"profile"`
Duration string `json:"duration"`
Status string `json:"status"` // completed, killed, error
Error string `json:"error,omitempty"`
}
type Log struct {
path string
file *os.File
}
func DefaultPath() (string, error) {
// $XDG_DATA_HOME/gosleep-timer/history.jsonl
// fallback ~/.local/share/gosleep-timer/history.jsonl
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dataHome = filepath.Join(home, ".local", "share")
}
dir := filepath.Join(dataHome, "gosleep-timer")
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
return filepath.Join(dir, "history.jsonl"), nil
}
func Open(path string) (*Log, error) {
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
return &Log{path: path, file: f}, nil
}
func (l *Log) Append(r Record) error {
r.Timestamp = time.Now().UTC()
data, err := json.Marshal(r)
if err != nil {
return err
}
_, err = fmt.Fprintln(l.file, string(data))
return err
}
func (l *Log) Close() error {
return l.file.Close()
}
func ReadAll(path string) ([]Record, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return []Record{}, nil
}
return nil, err
}
defer f.Close()
var records []Record
scanner := bufio.NewScanner(f)
for scanner.Scan() {
var r Record
if err := json.Unmarshal(scanner.Bytes(), &r); err != nil {
continue // skip corrupt lines
}
records = append(records, r)
}
return records, scanner.Err()
}
+54
View File
@@ -0,0 +1,54 @@
package history
import "time"
type Stats struct {
TotalRuns int
CompletedRuns int
KilledRuns int
ErrorRuns int
TotalDuration time.Duration
AvgDuration time.Duration
MostUsedProfile string
LastRun *Record
}
func Calculate(records []Record) Stats {
var s Stats
profileCount := make(map[string]int)
s.TotalRuns = len(records)
for _, r := range records {
switch r.Status {
case "completed":
s.CompletedRuns++
case "killed":
s.KilledRuns++
case "error":
s.ErrorRuns++
}
profileCount[r.Profile]++
d, err := time.ParseDuration(r.Duration)
if err == nil {
s.TotalDuration += d
}
s.LastRun = &r
}
if s.TotalRuns > 0 {
s.AvgDuration = time.Duration(int64(s.TotalDuration) / int64(s.TotalRuns))
}
maxCount := 0
for name, count := range profileCount {
if count > maxCount {
maxCount = count
s.MostUsedProfile = name
}
}
return s
}
+348
View File
@@ -0,0 +1,348 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"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/tui"
// Register all modules
_ "github.com/forust/gosleep-timer/modules/brightness"
_ "github.com/forust/gosleep-timer/modules/custom"
_ "github.com/forust/gosleep-timer/modules/kill"
_ "github.com/forust/gosleep-timer/modules/lock"
_ "github.com/forust/gosleep-timer/modules/media"
_ "github.com/forust/gosleep-timer/modules/mute"
_ "github.com/forust/gosleep-timer/modules/notify"
_ "github.com/forust/gosleep-timer/modules/script"
_ "github.com/forust/gosleep-timer/modules/sound"
_ "github.com/forust/gosleep-timer/modules/workspace"
)
var (
cfgFile string
profile string
cliMode bool
quiet bool
)
var rootCmd = &cobra.Command{
Use: "gosleep-timer",
Short: "TUI sleep timer with modular WM/DE support",
Long: `gosleep-timer — configurable timer with modules for workspace switching,
media control, screen locking, process killing, notifications, and more.
Supports niri, Hyprland, KDE, and custom commands.`,
RunE: func(cmd *cobra.Command, args []string) error {
// Init modules registry
modules.Init()
// Load config
cfgPath := cfgFile
if cfgPath == "" {
var err error
cfgPath, err = config.DefaultConfigPath()
if err != nil {
return fmt.Errorf("config path: %w", err)
}
}
cfg, err := config.LoadOrCreate(cfgPath)
if err != nil {
return fmt.Errorf("config: %w", err)
}
// CLI mode (run without TUI)
if cliMode {
return runCLI(cfg, args)
}
// Validate modules
if errs := modules.ValidateAll(); len(errs) > 0 {
for _, e := range errs {
fmt.Fprintf(os.Stderr, "⚠ module warning: %v\n", e)
}
}
// Start TUI
m := tui.NewModel(cfg)
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
return fmt.Errorf("tui error: %w", err)
}
return nil
},
}
var listProfilesCmd = &cobra.Command{
Use: "list-profiles",
Short: "List available profiles",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
for name := range cfg.Profiles {
p := cfg.Profiles[name]
extends := ""
if p.Extends != "" {
extends = fmt.Sprintf(" (extends: %s)", p.Extends)
}
fmt.Printf(" %s%s\n", name, extends)
}
return nil
},
}
var exportCmd = &cobra.Command{
Use: "export",
Short: "Export config to stdout",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
data, err := config.SaveToString(cfg)
if err != nil {
return err
}
fmt.Print(data)
return nil
},
}
var importCmd = &cobra.Command{
Use: "import < file",
Short: "Import config from stdin",
RunE: func(cmd *cobra.Command, args []string) error {
cfgPath, err := getConfigPath()
if err != nil {
return err
}
data, err := os.ReadFile("/dev/stdin")
if err != nil {
return fmt.Errorf("read stdin: %w", err)
}
var cfg config.Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parse config: %w", err)
}
if err := config.Save(cfgPath, &cfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
fmt.Printf("Config imported to %s\n", cfgPath)
return nil
},
}
var historyCmd = &cobra.Command{
Use: "history",
Short: "Show timer history",
RunE: func(cmd *cobra.Command, args []string) error {
path, err := history.DefaultPath()
if err != nil {
return err
}
records, err := history.ReadAll(path)
if err != nil {
return err
}
if len(records) == 0 {
fmt.Println("No history.")
return nil
}
for _, r := range records {
errStr := ""
if r.Error != "" {
errStr = fmt.Sprintf(" error=%s", r.Error)
}
fmt.Printf("%s | %-8s | %-10s | %s%s\n",
r.Timestamp.Format("2006-01-02 15:04:05"),
r.Duration,
r.Status,
r.Profile,
errStr,
)
}
return nil
},
}
var statsCmd = &cobra.Command{
Use: "stats",
Short: "Show timer statistics",
RunE: func(cmd *cobra.Command, args []string) error {
path, err := history.DefaultPath()
if err != nil {
return err
}
records, err := history.ReadAll(path)
if err != nil {
return err
}
s := history.Calculate(records)
fmt.Printf("Total runs: %d\n", s.TotalRuns)
fmt.Printf("Completed: %d\n", s.CompletedRuns)
fmt.Printf("Killed: %d\n", s.KilledRuns)
fmt.Printf("Errors: %d\n", s.ErrorRuns)
return nil
},
}
func runCLI(cfg *config.Config, args []string) error {
duration := "25m"
if len(args) > 0 {
duration = args[0]
}
d, err := engine.ParseDuration(duration)
if err != nil {
return fmt.Errorf("invalid duration %q: %w", duration, err)
}
profileName := profile
if profileName == "" {
profileName = "default"
}
p, ok := cfg.Profiles[profileName]
if !ok {
return fmt.Errorf("profile %q not found", profileName)
}
modCfg := p.Modules
if modCfg == nil {
modCfg = config.DefaultModules()
}
modCtx := &modules.ModuleContext{
Context: context.Background(),
Profile: profileName,
Duration: duration,
Config: modCfg,
}
preCmds := modules.CollectPreCmds(modCtx)
postCmds := modules.CollectPostCmds(modCtx)
if !quiet {
fmt.Printf("Timer: %s | Profile: %s\n", duration, profileName)
fmt.Printf("Pre: %s\n", strings.Join(preCmds, "; "))
}
t := engine.NewTimer(d)
sr := engine.NewStageRunner(t, preCmds, postCmds)
// Handle signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
<-sigCh
sr.Stop()
cancel()
if !quiet {
fmt.Println("\n⛔ Timer killed")
}
}()
go sr.Run(ctx)
for evt := range sr.Events {
if evt.Done {
break
}
if evt.Stage == engine.StageTimer && !quiet {
rem := evt.Remaining.Round(time.Second)
fmt.Printf("\r⏱ %s remaining...", rem)
}
}
if !quiet {
fmt.Println("\n✅ Timer completed!")
}
// Record history
histPath, err := history.DefaultPath()
if err == nil {
if l, err := history.Open(histPath); err == nil {
l.Append(history.Record{
Profile: profileName,
Duration: duration,
Status: "completed",
})
l.Close()
}
}
return nil
}
func loadConfig() (*config.Config, error) {
path, err := getConfigPath()
if err != nil {
return nil, err
}
return config.LoadOrCreate(path)
}
func getConfigPath() (string, error) {
if cfgFile != "" {
return cfgFile, nil
}
return config.DefaultConfigPath()
}
func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path")
rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "suppress output")
runCmd := &cobra.Command{
Use: "run [duration]",
Short: "Run timer in CLI mode (no TUI)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cliMode = true
cfg, err := loadConfig()
if err != nil {
return err
}
return runCLI(cfg, args)
},
}
runCmd.Flags().StringVarP(&profile, "profile", "p", "default", "profile name")
rootCmd.AddCommand(runCmd)
rootCmd.AddCommand(listProfilesCmd)
rootCmd.AddCommand(exportCmd)
rootCmd.AddCommand(importCmd)
rootCmd.AddCommand(historyCmd)
rootCmd.AddCommand(statsCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+61
View File
@@ -0,0 +1,61 @@
package brightness
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type BrightnessModule struct {
tool string
}
func (m *BrightnessModule) Name() string {
return "brightness"
}
func (m *BrightnessModule) Enabled(cfg *config.Modules) bool {
return cfg.Brightness != nil && cfg.Brightness.Enabled
}
func (m *BrightnessModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
val := ctx.Config.Brightness.Value
if val < 0 {
val = 0
}
if val > 100 {
val = 100
}
switch stage {
case modules.StagePre:
if m.tool == "light" {
return []string{fmt.Sprintf("light -S %d", val)}
}
return []string{fmt.Sprintf("brightnessctl set %d%%", val)}
case modules.StagePost:
if m.tool == "light" {
return []string{"light -S 100"}
}
return []string{"brightnessctl set 100%"}
}
return nil
}
func (m *BrightnessModule) Validate() error {
if _, err := exec.LookPath("brightnessctl"); err == nil {
m.tool = "brightnessctl"
return nil
}
if _, err := exec.LookPath("light"); err == nil {
m.tool = "light"
return nil
}
return fmt.Errorf("brightness: neither brightnessctl nor light found in PATH")
}
func init() {
modules.Register(&BrightnessModule{})
}
+34
View File
@@ -0,0 +1,34 @@
package custom
import (
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type CustomModule struct{}
func (m *CustomModule) Name() string {
return "custom"
}
func (m *CustomModule) Enabled(cfg *config.Modules) bool {
return cfg.Custom != nil && cfg.Custom.Enabled
}
func (m *CustomModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
switch stage {
case modules.StagePre:
return ctx.Config.Custom.Pre
case modules.StagePost:
return ctx.Config.Custom.Post
}
return nil
}
func (m *CustomModule) Validate() error {
return nil
}
func init() {
modules.Register(&CustomModule{})
}
+43
View File
@@ -0,0 +1,43 @@
package kill
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type KillModule struct{}
func (m *KillModule) Name() string {
return "kill"
}
func (m *KillModule) Enabled(cfg *config.Modules) bool {
return cfg.Kill != nil && cfg.Kill.Enabled && len(cfg.Kill.Processes) > 0
}
func (m *KillModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePre {
return nil
}
var cmds []string
for _, proc := range ctx.Config.Kill.Processes {
if proc == "" {
continue
}
cmds = append(cmds, fmt.Sprintf("pkill %s", proc))
}
return cmds
}
func (m *KillModule) Validate() error {
_, err := exec.LookPath("pkill")
return err
}
func init() {
modules.Register(&KillModule{})
}
+36
View File
@@ -0,0 +1,36 @@
package lock
import (
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type LockModule struct{}
func (m *LockModule) Name() string {
return "lock"
}
func (m *LockModule) Enabled(cfg *config.Modules) bool {
return cfg.Lock != nil && cfg.Lock.Enabled
}
func (m *LockModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePost {
return nil
}
cmd := ctx.Config.Lock.Command
if cmd == "" {
cmd = "loginctl lock-session"
}
return []string{cmd}
}
func (m *LockModule) Validate() error {
// No strict validation — custom commands may be set
return nil
}
func init() {
modules.Register(&LockModule{})
}
+40
View File
@@ -0,0 +1,40 @@
package media
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type MediaModule struct{}
func (m *MediaModule) Name() string {
return "media"
}
func (m *MediaModule) Enabled(cfg *config.Modules) bool {
return cfg.Media != nil && cfg.Media.Enabled
}
func (m *MediaModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePre {
return nil
}
if ctx.Config.Media.Action == "none" {
return nil
}
return []string{fmt.Sprintf("playerctl %s", ctx.Config.Media.Action)}
}
func (m *MediaModule) Validate() error {
if _, err := exec.LookPath("playerctl"); err != nil {
return fmt.Errorf("media: playerctl not found")
}
return nil
}
func init() {
modules.Register(&MediaModule{})
}
+39
View File
@@ -0,0 +1,39 @@
package modules
import (
"context"
"github.com/forust/gosleep-timer/config"
)
type Stage string
const (
StagePre Stage = "pre"
StagePost Stage = "post"
)
type ModuleContext struct {
Context context.Context
Profile string
Duration string
Config *config.Modules
}
type ModuleResult struct {
Module string
Cmd string
Output string
Error error
Stage Stage
}
type Module interface {
Name() string
// Enabled returns true if this module should run for the given config
Enabled(cfg *config.Modules) bool
// BuildCmds returns shell commands for the given stage (pre/post)
BuildCmds(ctx *ModuleContext, stage Stage) []string
// Validate checks if required binaries exist
Validate() error
}
+53
View File
@@ -0,0 +1,53 @@
package mute
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type MuteModule struct {
tool string
}
func (m *MuteModule) Name() string {
return "mute"
}
func (m *MuteModule) Enabled(cfg *config.Modules) bool {
return cfg.Mute != nil && cfg.Mute.Enabled
}
func (m *MuteModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
switch stage {
case modules.StagePre:
if m.tool == "wpctl" {
return []string{"wpctl set-mute @DEFAULT_AUDIO_SINK@ 1"}
}
return []string{"pactl set-sink-mute @DEFAULT_SINK@ 1"}
case modules.StagePost:
if m.tool == "wpctl" {
return []string{"wpctl set-mute @DEFAULT_AUDIO_SINK@ 0"}
}
return []string{"pactl set-sink-mute @DEFAULT_SINK@ 0"}
}
return nil
}
func (m *MuteModule) Validate() error {
if _, err := exec.LookPath("pactl"); err == nil {
m.tool = "pactl"
return nil
}
if _, err := exec.LookPath("wpctl"); err == nil {
m.tool = "wpctl"
return nil
}
return fmt.Errorf("mute: neither pactl nor wpctl found in PATH")
}
func init() {
modules.Register(&MuteModule{})
}
+48
View File
@@ -0,0 +1,48 @@
package notify
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type NotifyModule struct{}
func (m *NotifyModule) Name() string {
return "notify"
}
func (m *NotifyModule) Enabled(cfg *config.Modules) bool {
return cfg.Notify != nil && cfg.Notify.Enabled
}
func (m *NotifyModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePost {
return nil
}
title := "gosleep-timer"
body := fmt.Sprintf("Timer %s finished", ctx.Duration)
cmds := []string{fmt.Sprintf("notify-send %q %q", title, body)}
if ctx.Config.Notify.Sound != "" {
if _, err := exec.LookPath("paplay"); err == nil {
cmds = append(cmds, fmt.Sprintf("paplay %s", ctx.Config.Notify.Sound))
} else if _, err := exec.LookPath("aplay"); err == nil {
cmds = append(cmds, fmt.Sprintf("aplay %s", ctx.Config.Notify.Sound))
}
}
return cmds
}
func (m *NotifyModule) Validate() error {
_, err := exec.LookPath("notify-send")
return err
}
func init() {
modules.Register(&NotifyModule{})
}
+53
View File
@@ -0,0 +1,53 @@
package modules
import "github.com/forust/gosleep-timer/config"
var registry []Module
func Register(m Module) {
registry = append(registry, m)
}
func GetAll() []Module {
return registry
}
func GetEnabled(cfg *config.Modules) []Module {
var enabled []Module
for _, m := range registry {
if m.Enabled(cfg) {
enabled = append(enabled, m)
}
}
return enabled
}
func CollectPreCmds(ctx *ModuleContext) []string {
var cmds []string
for _, m := range GetEnabled(ctx.Config) {
cmds = append(cmds, m.BuildCmds(ctx, StagePre)...)
}
return cmds
}
func CollectPostCmds(ctx *ModuleContext) []string {
var cmds []string
for _, m := range GetEnabled(ctx.Config) {
cmds = append(cmds, m.BuildCmds(ctx, StagePost)...)
}
return cmds
}
func ValidateAll() []error {
var errs []error
for _, m := range registry {
if err := m.Validate(); err != nil {
errs = append(errs, err)
}
}
return errs
}
func Init() {
// registry starts empty; modules register themselves via init() or Register()
}
+40
View File
@@ -0,0 +1,40 @@
package script
import (
"fmt"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type ScriptModule struct{}
func (m *ScriptModule) Name() string {
return "script"
}
func (m *ScriptModule) Enabled(cfg *config.Modules) bool {
return cfg.Script != nil && cfg.Script.Enabled && cfg.Script.Path != ""
}
func (m *ScriptModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
scriptPath := ctx.Config.Script.Path
switch stage {
case modules.StagePre:
return []string{fmt.Sprintf("%s pre %s %s", scriptPath, ctx.Profile, ctx.Duration)}
case modules.StagePost:
return []string{fmt.Sprintf("%s post %s %s", scriptPath, ctx.Profile, ctx.Duration)}
}
return nil
}
func (m *ScriptModule) Validate() error {
// Validation happens after config is loaded, so we check at registration time
// that the module itself is valid, but path is per-profile.
// Return nil here; path validation happens at runtime in BuildCmds.
return nil
}
func init() {
modules.Register(&ScriptModule{})
}
+74
View File
@@ -0,0 +1,74 @@
package sound
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type SoundModule struct {
player string
}
func (m *SoundModule) Name() string {
return "sound"
}
func (m *SoundModule) Enabled(cfg *config.Modules) bool {
return cfg.Sound != nil && cfg.Sound.Enabled && cfg.Sound.File != ""
}
func (m *SoundModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePost {
return nil
}
file := ctx.Config.Sound.File
if file == "" {
return nil
}
player := m.player
if player == "" {
player = detectPlayer()
}
switch player {
case "paplay":
return []string{fmt.Sprintf("paplay %s", file)}
case "aplay":
return []string{fmt.Sprintf("aplay %s", file)}
case "ffplay":
return []string{fmt.Sprintf("ffplay -nodisp -autoexit %s", file)}
default:
return nil
}
}
func (m *SoundModule) Validate() error {
player := detectPlayer()
if player == "" {
return fmt.Errorf("no audio player found (paplay, aplay, or ffplay)")
}
m.player = player
return nil
}
func detectPlayer() string {
if _, err := exec.LookPath("paplay"); err == nil {
return "paplay"
}
if _, err := exec.LookPath("aplay"); err == nil {
return "aplay"
}
if _, err := exec.LookPath("ffplay"); err == nil {
return "ffplay"
}
return ""
}
func init() {
modules.Register(&SoundModule{})
}
+56
View File
@@ -0,0 +1,56 @@
package workspace
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type WorkspaceModule struct{}
func (m *WorkspaceModule) Name() string {
return "workspace"
}
func (m *WorkspaceModule) Enabled(cfg *config.Modules) bool {
return cfg.Workspace != nil && cfg.Workspace.Enabled
}
func (m *WorkspaceModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePre {
return nil
}
backend := ctx.Config.Workspace.Backend
if backend == "" || backend == "auto" {
backend = config.DetectDesktop()
}
n := ctx.Config.Workspace.Workspace
switch backend {
case "niri":
return []string{fmt.Sprintf("niri msg action focus-workspace %d", n)}
case "hyprland":
return []string{fmt.Sprintf("hyprctl dispatch workspace %d", n)}
case "kde":
return []string{fmt.Sprintf("qdbus org.kde.KWin /KWin setCurrentDesktop %d", n)}
default:
return nil
}
}
func (m *WorkspaceModule) Validate() error {
// Check common workspace binaries
for _, bin := range []string{"niri", "hyprctl", "qdbus"} {
if _, err := exec.LookPath(bin); err == nil {
return nil
}
}
return fmt.Errorf("workspace: no supported backend binary found (niri, hyprctl, qdbus)")
}
func init() {
modules.Register(&WorkspaceModule{})
}
+76
View File
@@ -0,0 +1,76 @@
package profiles
import (
"fmt"
"sort"
"github.com/forust/gosleep-timer/config"
)
type Manager struct {
cfg *config.Config
}
func NewManager(cfg *config.Config) *Manager {
return &Manager{cfg: cfg}
}
func (m *Manager) List() []string {
// Return sorted list of profile names
names := make([]string, 0, len(m.cfg.Profiles))
for name := range m.cfg.Profiles {
names = append(names, name)
}
sort.Strings(names)
return names
}
func (m *Manager) Get(name string) (*config.Profile, error) {
p, ok := m.cfg.Profiles[name]
if !ok {
return nil, fmt.Errorf("profile %q not found", name)
}
return &p, nil
}
func (m *Manager) Info(name string) (*Info, error) {
p, err := m.Get(name)
if err != nil {
return nil, err
}
info := &Info{Name: name}
if p.Modules != nil {
m := p.Modules
if m.Workspace != nil && m.Workspace.Enabled {
info.Modules = append(info.Modules, "workspace")
}
if m.Media != nil && m.Media.Enabled {
info.Modules = append(info.Modules, "media")
}
if m.Lock != nil && m.Lock.Enabled {
info.Modules = append(info.Modules, "lock")
}
if m.Kill != nil && m.Kill.Enabled {
info.Modules = append(info.Modules, "kill")
}
if m.Notify != nil && m.Notify.Enabled {
info.Modules = append(info.Modules, "notify")
}
if m.Sound != nil && m.Sound.Enabled {
info.Modules = append(info.Modules, "sound")
}
if m.Brightness != nil && m.Brightness.Enabled {
info.Modules = append(info.Modules, "brightness")
}
if m.Mute != nil && m.Mute.Enabled {
info.Modules = append(info.Modules, "mute")
}
if m.Custom != nil && m.Custom.Enabled {
info.Modules = append(info.Modules, "custom")
}
if m.Script != nil && m.Script.Enabled {
info.Modules = append(info.Modules, "script")
}
}
return info, nil
}
+8
View File
@@ -0,0 +1,8 @@
package profiles
type Info struct {
Name string
Description string
Modules []string // list of enabled module names
Duration string // optional default duration
}
Executable
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
import subprocess
import asyncio
from textual.app import App, ComposeResult
from textual.widgets import (
Static, Input, Checkbox, Button, Footer, Header, Select
)
from textual.containers import Vertical, Horizontal, Center
class TimerTUI(App):
CSS = """
Screen {
align: center middle;
background: #101010;
}
#main {
width: 50%;
border: solid #33ccff;
padding: 1 2;
background: #181818;
border-title-align: center;
border-title-color: cyan;
}
.label {
color: #33ccff;
text-align: center;
height: auto;
}
Input, Select {
width: 100%;
border: round #444;
padding: 0 1;
}
Checkbox {
width: 100%;
}
Button {
width: 48%;
margin: 0 1;
}
#status {
color: cyan;
text-align: center;
padding-top: 1;
}
"""
def __init__(self):
super().__init__()
self.timer_process = None
self.message_label = Static("", id="status")
def compose(self) -> ComposeResult:
yield Header()
with Center():
with Vertical(id="main"):
yield Static("⏱ TIMER CONFIGURATION ⏱", classes="label")
self.input_time = Input(value="", placeholder="Время (например 20m)")
self.cb_workspace = Checkbox("Переключить воркспейс", value=True)
self.input_workspace = Input(value="12", placeholder="Номер воркспейса")
self.cb_player = Checkbox("Управлять плеером", value=True)
self.select_player_action = Select(
options=[
("Ничего", "none"),
("stop", "stop"),
("pause", "pause"),
("next", "next"),
("previous", "previous"),
],
value="stop",
)
self.cb_lock = Checkbox("Заблокировать экран", value=True)
self.cb_kill = Checkbox("Убить приложения", value=False)
self.input_kill = Input(value="firefox, telegram-desktop", placeholder="через запятую")
yield self.input_time
yield self.cb_workspace
yield self.input_workspace
yield self.cb_player
yield self.select_player_action
yield self.cb_lock
yield self.cb_kill
yield self.input_kill
with Horizontal():
yield Button("▶ Start / Restart Timer", id="start", variant="success")
yield Button("✖ Quit", id="quit", variant="error")
yield self.message_label
yield Footer()
async def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "quit":
await self.stop_timer()
await self.action_quit()
elif event.button.id == "start":
await self.restart_timer()
async def stop_timer(self):
if self.timer_process and self.timer_process.poll() is None:
self.timer_process.terminate()
await asyncio.sleep(0.2)
if self.timer_process.poll() is None:
self.timer_process.kill()
async def restart_timer(self):
await self.stop_timer()
self.notify("⏳ Запуск таймера...")
time_str = self.input_time.value.strip()
commands = []
# Workspace switch
if self.cb_workspace.value:
workspace = self.input_workspace.value.strip()
commands.append(f"niri msg action focus-workspace {workspace}")
# Player action
if self.cb_player.value:
action = self.select_player_action.value
if action != "none":
commands.append(f"playerctl {action}")
# Lock screen
if self.cb_lock.value:
commands.append("qs -c noctalia-shell ipc call lockScreen lock")
# Kill apps
if self.cb_kill.value and self.input_kill.value.strip():
for proc in self.input_kill.value.split(","):
proc = proc.strip()
if proc:
commands.append(f"pkill {proc}")
cmd_after = " ; ".join(commands)
full_cmd = f"timer {time_str} && bash -c '{cmd_after}'"
self.timer_process = subprocess.Popen(
full_cmd,
shell=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.notify(f"✅ Таймер запущен на {time_str}")
def notify(self, msg: str):
self.message_label.update(msg)
if __name__ == "__main__":
TimerTUI().run()
+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()
}