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:
@@ -0,0 +1,7 @@
|
|||||||
|
gosleep-timer
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
*.exe
|
||||||
|
.DS_Store
|
||||||
|
cover.out
|
||||||
|
vendor/
|
||||||
@@ -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 образ (опционально)
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user