commit 8501fd074c02768365b3f57a74afac2d43cf77c2 Author: mr-forust Date: Fri Jul 3 01:43:22 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0815c4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +gosleep-timer +*.test +*.out +*.exe +.DS_Store +cover.out +vendor/ diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..37a5f3e --- /dev/null +++ b/TODO.md @@ -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 образ (опционально) diff --git a/cicd.example.yaml b/cicd.example.yaml new file mode 100644 index 0000000..3238cac --- /dev/null +++ b/cicd.example.yaml @@ -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 diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..43bf027 --- /dev/null +++ b/config/config.go @@ -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, + } + } +} diff --git a/config/defaults.go b/config/defaults.go new file mode 100644 index 0000000..3b90e7c --- /dev/null +++ b/config/defaults.go @@ -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: "", + }, + } +} diff --git a/config/types.go b/config/types.go new file mode 100644 index 0000000..c37721b --- /dev/null +++ b/config/types.go @@ -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"` +} diff --git a/engine/executor.go b/engine/executor.go new file mode 100644 index 0000000..e14c378 --- /dev/null +++ b/engine/executor.go @@ -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 +} diff --git a/engine/stages.go b/engine/stages.go new file mode 100644 index 0000000..ae0dece --- /dev/null +++ b/engine/stages.go @@ -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() +} diff --git a/engine/timer.go b/engine/timer.go new file mode 100644 index 0000000..7f634d4 --- /dev/null +++ b/engine/timer.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7da223c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..77f2cf9 --- /dev/null +++ b/go.sum @@ -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= diff --git a/history/history.go b/history/history.go new file mode 100644 index 0000000..19a7622 --- /dev/null +++ b/history/history.go @@ -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() +} diff --git a/history/stats.go b/history/stats.go new file mode 100644 index 0000000..b924caf --- /dev/null +++ b/history/stats.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..54ed60e --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/modules/brightness/brightness.go b/modules/brightness/brightness.go new file mode 100644 index 0000000..a0f473b --- /dev/null +++ b/modules/brightness/brightness.go @@ -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{}) +} diff --git a/modules/custom/custom.go b/modules/custom/custom.go new file mode 100644 index 0000000..0eca2b6 --- /dev/null +++ b/modules/custom/custom.go @@ -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{}) +} diff --git a/modules/kill/kill.go b/modules/kill/kill.go new file mode 100644 index 0000000..52ba8d2 --- /dev/null +++ b/modules/kill/kill.go @@ -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{}) +} diff --git a/modules/lock/lock.go b/modules/lock/lock.go new file mode 100644 index 0000000..9bcb618 --- /dev/null +++ b/modules/lock/lock.go @@ -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{}) +} diff --git a/modules/media/media.go b/modules/media/media.go new file mode 100644 index 0000000..ffe5ee4 --- /dev/null +++ b/modules/media/media.go @@ -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{}) +} diff --git a/modules/module.go b/modules/module.go new file mode 100644 index 0000000..bd35816 --- /dev/null +++ b/modules/module.go @@ -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 +} diff --git a/modules/mute/mute.go b/modules/mute/mute.go new file mode 100644 index 0000000..05ff36a --- /dev/null +++ b/modules/mute/mute.go @@ -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{}) +} diff --git a/modules/notify/notify.go b/modules/notify/notify.go new file mode 100644 index 0000000..65313db --- /dev/null +++ b/modules/notify/notify.go @@ -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{}) +} diff --git a/modules/registry.go b/modules/registry.go new file mode 100644 index 0000000..ba0a5a4 --- /dev/null +++ b/modules/registry.go @@ -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() +} diff --git a/modules/script/script.go b/modules/script/script.go new file mode 100644 index 0000000..a8093e1 --- /dev/null +++ b/modules/script/script.go @@ -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{}) +} diff --git a/modules/sound/sound.go b/modules/sound/sound.go new file mode 100644 index 0000000..c438441 --- /dev/null +++ b/modules/sound/sound.go @@ -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{}) +} diff --git a/modules/workspace/workspace.go b/modules/workspace/workspace.go new file mode 100644 index 0000000..2385d67 --- /dev/null +++ b/modules/workspace/workspace.go @@ -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{}) +} diff --git a/profiles/manager.go b/profiles/manager.go new file mode 100644 index 0000000..89eba8d --- /dev/null +++ b/profiles/manager.go @@ -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 +} diff --git a/profiles/profile.go b/profiles/profile.go new file mode 100644 index 0000000..69d91e5 --- /dev/null +++ b/profiles/profile.go @@ -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 +} diff --git a/timer.py b/timer.py new file mode 100755 index 0000000..44b4232 --- /dev/null +++ b/timer.py @@ -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() diff --git a/tui/app.go b/tui/app.go new file mode 100644 index 0000000..7b95d8f --- /dev/null +++ b/tui/app.go @@ -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()) +} diff --git a/tui/keybinds.go b/tui/keybinds.go new file mode 100644 index 0000000..66c240c --- /dev/null +++ b/tui/keybinds.go @@ -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 +} diff --git a/tui/qr.go b/tui/qr.go new file mode 100644 index 0000000..3e7d35d --- /dev/null +++ b/tui/qr.go @@ -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 +} diff --git a/tui/styles.go b/tui/styles.go new file mode 100644 index 0000000..47dbf2d --- /dev/null +++ b/tui/styles.go @@ -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")) +) diff --git a/tui/widgets.go b/tui/widgets.go new file mode 100644 index 0000000..6420ec2 --- /dev/null +++ b/tui/widgets.go @@ -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() +}