chore: prepare production rust release
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.rs]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
"on":
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
shell: bash
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
shell: bash
|
||||||
|
run: cargo test --locked
|
||||||
|
|
||||||
|
- name: Run clippy
|
||||||
|
shell: bash
|
||||||
|
run: cargo clippy --locked -- -D warnings
|
||||||
|
|
||||||
|
- name: Build release binary
|
||||||
|
shell: bash
|
||||||
|
run: cargo build --release --locked
|
||||||
|
|
||||||
|
lint-yaml:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Lint YAML
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
cytopia/yamllint:latest \
|
||||||
|
-c .yamllint .
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
"on":
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linux-amd64:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build release binary
|
||||||
|
shell: bash
|
||||||
|
run: cargo build --release --locked
|
||||||
|
|
||||||
|
- name: Package artifact
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
tag="${GITHUB_REF##*/}"
|
||||||
|
version="${tag#v}"
|
||||||
|
mkdir -p dist
|
||||||
|
cp target/release/gosleep-timer dist/gosleep-timer
|
||||||
|
tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer
|
||||||
|
sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256"
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
id: release
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
tag="${GITHUB_REF##*/}"
|
||||||
|
body="$(cat <<EOF
|
||||||
|
Release ${tag}
|
||||||
|
|
||||||
|
See CHANGELOG.md for notable changes.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
payload="$(jq -n \
|
||||||
|
--arg tag "${tag}" \
|
||||||
|
--arg commit "${GITHUB_SHA}" \
|
||||||
|
--arg name "gosleep-timer ${tag}" \
|
||||||
|
--arg body "$body" \
|
||||||
|
'{tag_name: $tag, target_commitish: $commit, name: $name, body: $body, draft: false, prerelease: false}')"
|
||||||
|
response="$(curl -fsS \
|
||||||
|
-X POST "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload")"
|
||||||
|
release_id="$(printf '%s' "$response" | jq -r '.id')"
|
||||||
|
test -n "$release_id" && test "$release_id" != "null"
|
||||||
|
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Upload Gitea release assets
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
release_id="${{ steps.release.outputs.release_id }}"
|
||||||
|
for asset in dist/*.tar.gz dist/*.sha256; do
|
||||||
|
name="$(basename "$asset")"
|
||||||
|
curl -fsS \
|
||||||
|
-X POST "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=${name}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-F "attachment=@${asset}"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: gosleep-timer-linux-amd64
|
||||||
|
path: dist/*
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
name: ci
|
|
||||||
|
|
||||||
'on':
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: gcr.forust.xyz
|
|
||||||
IMAGE_NAME: forust/gosleep-timer
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint-go:
|
|
||||||
runs-on: [self-hosted, linux, arch, homelab]
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.23'
|
|
||||||
|
|
||||||
- name: Lint with golangci-lint
|
|
||||||
uses: golangci/golangci-lint-action@v6
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
args: --config .golangci.yml --timeout 10m
|
|
||||||
|
|
||||||
- name: Run staticcheck
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
|
||||||
staticcheck ./...
|
|
||||||
|
|
||||||
- name: Run gosec
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
|
||||||
gosec -no-fail -fmt=text ./...
|
|
||||||
|
|
||||||
- name: Run revive
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
go install github.com/mgechev/revive@latest
|
|
||||||
revive -config .revive.toml ./...
|
|
||||||
|
|
||||||
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 .
|
|
||||||
|
|
||||||
build:
|
|
||||||
runs-on: [self-hosted, linux, arch, homelab]
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.23'
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
shell: bash
|
|
||||||
run: go build -ldflags="-s -w" -o gosleep-timer .
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
shell: bash
|
|
||||||
run: go test -v -race -coverprofile=cover.out ./...
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: gosleep-timer
|
|
||||||
path: gosleep-timer
|
|
||||||
|
|
||||||
publish:
|
|
||||||
needs: [lint-go, lint-yaml, build]
|
|
||||||
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: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.23'
|
|
||||||
|
|
||||||
- name: Read project version
|
|
||||||
id: version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
version="$(go version -m 2>/dev/null || date +%Y%m%d)"
|
|
||||||
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
|
|
||||||
+7
-6
@@ -1,7 +1,8 @@
|
|||||||
gosleep-timer
|
/target/
|
||||||
*.test
|
/dist/
|
||||||
*.out
|
/.env
|
||||||
*.exe
|
/.env.*
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
.DS_Store
|
.DS_Store
|
||||||
cover.out
|
|
||||||
vendor/
|
|
||||||
|
|||||||
-108
@@ -1,108 +0,0 @@
|
|||||||
run:
|
|
||||||
timeout: 5m
|
|
||||||
skip-dirs:
|
|
||||||
- vendor/
|
|
||||||
|
|
||||||
linters:
|
|
||||||
enable:
|
|
||||||
- errcheck
|
|
||||||
- gosimple
|
|
||||||
- govet
|
|
||||||
- ineffassign
|
|
||||||
- staticcheck
|
|
||||||
- typecheck
|
|
||||||
- unused
|
|
||||||
- asciicheck
|
|
||||||
- bodyclose
|
|
||||||
- cyclop
|
|
||||||
- decorder
|
|
||||||
- dupl
|
|
||||||
- dupword
|
|
||||||
- durationcheck
|
|
||||||
- errname
|
|
||||||
- errorlint
|
|
||||||
- exhaustive
|
|
||||||
- forcetypeassert
|
|
||||||
- gochecknoinits
|
|
||||||
- gocognit
|
|
||||||
- goconst
|
|
||||||
- gocritic
|
|
||||||
- gocyclo
|
|
||||||
- godot
|
|
||||||
- goimports
|
|
||||||
- gomoddirectives
|
|
||||||
- gomodguard
|
|
||||||
- goprintffuncname
|
|
||||||
- gosec
|
|
||||||
- grouper
|
|
||||||
- ifshort
|
|
||||||
- importas
|
|
||||||
- lll
|
|
||||||
- makezero
|
|
||||||
- mirror
|
|
||||||
- misspell
|
|
||||||
- nakedret
|
|
||||||
- nestif
|
|
||||||
- nilerr
|
|
||||||
- nilnil
|
|
||||||
- nlreturn
|
|
||||||
- noctx
|
|
||||||
- nolintlint
|
|
||||||
- nosprintfhostport
|
|
||||||
- prealloc
|
|
||||||
- predeclared
|
|
||||||
- promlinter
|
|
||||||
- reassign
|
|
||||||
- revive
|
|
||||||
- rowserrcheck
|
|
||||||
- sqlclosecheck
|
|
||||||
- stylecheck
|
|
||||||
- tagalign
|
|
||||||
- tagliatelle
|
|
||||||
- tenv
|
|
||||||
- testableexamples
|
|
||||||
- thelper
|
|
||||||
- tparallel
|
|
||||||
- unconvert
|
|
||||||
- unparam
|
|
||||||
- usestdlibvars
|
|
||||||
- wastedassign
|
|
||||||
- whitespace
|
|
||||||
- wsl
|
|
||||||
- zerologlint
|
|
||||||
|
|
||||||
linters-settings:
|
|
||||||
gocyclo:
|
|
||||||
min-complexity: 20
|
|
||||||
gocognit:
|
|
||||||
min-complexity: 30
|
|
||||||
lll:
|
|
||||||
line-length: 140
|
|
||||||
misspell:
|
|
||||||
locale: US
|
|
||||||
funlen:
|
|
||||||
lines: 80
|
|
||||||
statements: 60
|
|
||||||
revive:
|
|
||||||
rules:
|
|
||||||
- name: exported
|
|
||||||
severity: warning
|
|
||||||
- name: unexported-return
|
|
||||||
severity: warning
|
|
||||||
gosec:
|
|
||||||
excludes:
|
|
||||||
- G204 # subprocess launched with variables
|
|
||||||
|
|
||||||
issues:
|
|
||||||
exclude-rules:
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- errcheck
|
|
||||||
- gosec
|
|
||||||
- path: main\.go
|
|
||||||
linters:
|
|
||||||
- gochecknoinits
|
|
||||||
|
|
||||||
output:
|
|
||||||
formats:
|
|
||||||
- format: colored-line-number
|
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
extends: default
|
extends: default
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
|
comments:
|
||||||
|
min-spaces-from-content: 1
|
||||||
|
comments-indentation: false
|
||||||
|
document-start: disable
|
||||||
line-length:
|
line-length:
|
||||||
max: 140
|
max: 140
|
||||||
document-start: disable
|
|
||||||
braces:
|
braces:
|
||||||
min-spaces-inside: 0
|
min-spaces-inside: 0
|
||||||
max-spaces-inside: 1
|
max-spaces-inside: 1
|
||||||
brackets:
|
brackets:
|
||||||
min-spaces-inside: 0
|
min-spaces-inside: 0
|
||||||
max-spaces-inside: 1
|
max-spaces-inside: 1
|
||||||
|
indentation:
|
||||||
|
spaces: 2
|
||||||
|
indent-sequences: consistent
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented here.
|
||||||
|
|
||||||
|
The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`.
|
||||||
|
|
||||||
|
## [1.1.1] - 2026-07-03
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Rebuilt the application as a Rust terminal binary.
|
||||||
|
- Added a Ratatui/Crossterm TUI with command preview wrapping, timer progress, and time-left display.
|
||||||
|
- Added YAML configuration, CLI `init`, `preview`, and `run` commands.
|
||||||
|
- Added Gitea CI and tag-based release workflows.
|
||||||
|
|
||||||
|
[1.1.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.1
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
Run these before pushing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --check
|
||||||
|
cargo test --locked
|
||||||
|
cargo clippy --locked -- -D warnings
|
||||||
|
cargo build --release --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Keep the application single-binary and Rust-first.
|
||||||
|
- Keep config backwards-compatible where possible.
|
||||||
|
- Prefer explicit, small functions over hidden behavior in the TUI.
|
||||||
|
- Do not add runtime dependencies on Python, Node, or external services.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
Use concise conventional commits where practical:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feat: add timer progress gauge
|
||||||
|
fix: wrap preview commands on narrow terminals
|
||||||
|
chore: release v1.1.1
|
||||||
|
```
|
||||||
Generated
+881
@@ -0,0 +1,881 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.103"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cassowary"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "castaway"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compact_str"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
|
||||||
|
dependencies = [
|
||||||
|
"castaway",
|
||||||
|
"cfg-if",
|
||||||
|
"itoa",
|
||||||
|
"rustversion",
|
||||||
|
"ryu",
|
||||||
|
"static_assertions",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "convert_case"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-segmentation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"rustix 0.38.44",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"derive_more",
|
||||||
|
"document-features",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm_winapi"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"darling_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||||
|
dependencies = [
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||||
|
dependencies = [
|
||||||
|
"derive_more-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more-impl"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||||
|
dependencies = [
|
||||||
|
"convert_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gosleep-timer"
|
||||||
|
version = "1.1.1"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"crossterm 0.29.0",
|
||||||
|
"ratatui",
|
||||||
|
"serde",
|
||||||
|
"serde_yaml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indoc"
|
||||||
|
version = "2.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "instability"
|
||||||
|
version = "0.3.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"indoc",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.186"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||||
|
dependencies = [
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot_core"
|
||||||
|
version = "0.9.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"redox_syscall",
|
||||||
|
"smallvec",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ratatui"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cassowary",
|
||||||
|
"compact_str",
|
||||||
|
"crossterm 0.28.1",
|
||||||
|
"indoc",
|
||||||
|
"instability",
|
||||||
|
"itertools",
|
||||||
|
"lru",
|
||||||
|
"paste",
|
||||||
|
"strum",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-truncate",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "0.38.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys 0.4.15",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys 0.12.1",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-mio"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"signal-hook",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.118"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-truncate"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||||
|
dependencies = [
|
||||||
|
"itertools",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width 0.1.14",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "gosleep-timer"
|
||||||
|
version = "1.1.1"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.85"
|
||||||
|
description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://gitea.forust.xyz/forust/gosleep"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["timer", "tui", "linux", "desktop", "sleep"]
|
||||||
|
categories = ["command-line-utilities"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "gosleep-timer"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
crossterm = "0.29"
|
||||||
|
ratatui = "0.29"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 1
|
||||||
|
lto = "thin"
|
||||||
|
strip = true
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
FROM golang:1.23-alpine AS builder
|
|
||||||
RUN apk add --no-cache git ca-certificates
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /gosleep-timer .
|
|
||||||
|
|
||||||
FROM scratch
|
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
|
||||||
COPY --from=builder /gosleep-timer /gosleep-timer
|
|
||||||
ENTRYPOINT ["/gosleep-timer"]
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Forust
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,200 +1,213 @@
|
|||||||
# ⏱ gosleep-timer
|
# gosleep-timer
|
||||||
|
|
||||||
TUI sleep timer with modular WM/DE support. Go rewrite of `timer.py`.
|
Rust TUI sleep timer for Linux desktop actions.
|
||||||
|
|
||||||
## Quick start
|
`gosleep-timer` waits for a configured duration, then runs post-countdown desktop commands such as switching workspace, stopping media, dimming brightness, muting audio, locking the session, killing selected processes, powering off/rebooting, or running custom shell commands.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- Current version: `1.1.1`
|
||||||
|
- Release tags: `vMAJOR.MINOR.PATCH`, for example `v1.1.1`
|
||||||
|
- Primary repository: <https://gitea.forust.xyz/forust/gosleep>
|
||||||
|
- SSH upstream: `ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git`
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Single Rust binary, independent of Python.
|
||||||
|
- Ratatui/Crossterm terminal UI.
|
||||||
|
- YAML config at `~/.config/gosleep-timer/config.yaml` or `$XDG_CONFIG_HOME/gosleep-timer/config.yaml`.
|
||||||
|
- CLI commands for initialization, preview, and direct timer runs.
|
||||||
|
- Time-left display and progress bar.
|
||||||
|
- Wrapped command preview for smaller terminals.
|
||||||
|
- Linux desktop action support for niri, Hyprland, KDE, playerctl, brightnessctl/light, PipeWire/PulseAudio, systemd, and custom shell commands.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run TUI
|
git clone ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git
|
||||||
gosleep-timer
|
cd gosleep
|
||||||
|
cargo build --release --locked
|
||||||
# First time? Interactive setup wizard
|
install -Dm755 target/release/gosleep-timer ~/.local/bin/gosleep-timer
|
||||||
gosleep-timer init
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation
|
### From release artifact
|
||||||
|
|
||||||
|
Download `gosleep-timer-<version>-linux-amd64.tar.gz` from the Gitea release page, then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install github.com/forust/gosleep-timer@latest
|
tar -xzf gosleep-timer-1.1.1-linux-amd64.tar.gz
|
||||||
# or
|
install -Dm755 gosleep-timer ~/.local/bin/gosleep-timer
|
||||||
git clone https://github.com/forust/gosleep-timer
|
|
||||||
cd gosleep-timer
|
|
||||||
go build -o gosleep-timer .
|
|
||||||
sudo cp gosleep-timer /usr/local/bin/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### TUI mode (default)
|
Open the TUI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gosleep-timer
|
gosleep-timer
|
||||||
```
|
```
|
||||||
|
|
||||||
Keys:
|
Initialize the default config:
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| `s` | Start timer |
|
|
||||||
| `S` | Stop timer |
|
|
||||||
| `Tab` | Next field |
|
|
||||||
| `↑`/`↓` | Previous/next profile |
|
|
||||||
| `1`-`0` | Toggle modules |
|
|
||||||
| `h` | History |
|
|
||||||
| `?` | Help |
|
|
||||||
| `q`/`Esc` | Quit |
|
|
||||||
|
|
||||||
### CLI mode
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run with defaults (25m)
|
gosleep-timer init
|
||||||
gosleep-timer run
|
```
|
||||||
|
|
||||||
# Custom duration
|
Preview commands that will run after the countdown:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gosleep-timer preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the saved timer config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gosleep-timer run
|
||||||
|
```
|
||||||
|
|
||||||
|
Override the duration for one run:
|
||||||
|
|
||||||
|
```bash
|
||||||
gosleep-timer run 45m
|
gosleep-timer run 45m
|
||||||
gosleep-timer run 1h30m
|
gosleep-timer run 1h30m
|
||||||
|
|
||||||
# With profile
|
|
||||||
gosleep-timer run 20m --profile work
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Other commands
|
Use a custom config path:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Interactive setup
|
gosleep-timer --config ./config.yaml preview
|
||||||
gosleep-timer init
|
|
||||||
|
|
||||||
# List profiles
|
|
||||||
gosleep-timer list-profiles
|
|
||||||
|
|
||||||
# Export/import config
|
|
||||||
gosleep-timer export > config-backup.yaml
|
|
||||||
gosleep-timer import < config-backup.yaml
|
|
||||||
|
|
||||||
# History and stats
|
|
||||||
gosleep-timer history
|
|
||||||
gosleep-timer stats
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## TUI Keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `j` / `Down` | Move down |
|
||||||
|
| `k` / `Up` | Move up |
|
||||||
|
| `Space` / `Enter` | Toggle, cycle, or edit focused field |
|
||||||
|
| `Enter` while editing | Apply edit |
|
||||||
|
| `Esc` while editing | Cancel edit |
|
||||||
|
| `r` | Start/restart timer |
|
||||||
|
| `x` | Stop timer |
|
||||||
|
| `s` | Save config |
|
||||||
|
| `q` / `Esc` | Quit |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
File: `~/.config/gosleep-timer/config.yaml`
|
Default config:
|
||||||
|
|
||||||
### Profiles
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
profiles:
|
duration: 25m
|
||||||
default:
|
actions:
|
||||||
modules:
|
workspace:
|
||||||
workspace:
|
enabled: true
|
||||||
enabled: true
|
backend: auto
|
||||||
backend: auto # auto, niri, hyprland, kde
|
number: 3
|
||||||
workspace: 12
|
media:
|
||||||
media:
|
enabled: true
|
||||||
enabled: true
|
action: stop
|
||||||
action: stop # stop, pause, play-pause, next, previous
|
brightness:
|
||||||
lock:
|
enabled: false
|
||||||
enabled: true
|
value: 30
|
||||||
command: "" # custom command or auto-detect
|
mute:
|
||||||
kill:
|
enabled: false
|
||||||
enabled: false
|
lock:
|
||||||
processes: [firefox, telegram-desktop]
|
enabled: false
|
||||||
notify:
|
command: loginctl lock-session
|
||||||
enabled: true
|
kill:
|
||||||
sound: "" # path or empty
|
enabled: false
|
||||||
sound:
|
processes: []
|
||||||
enabled: true
|
power:
|
||||||
file: /usr/share/sounds/freedesktop/stereo/complete.oga
|
mode: none
|
||||||
brightness:
|
custom:
|
||||||
enabled: false
|
enabled: false
|
||||||
value: 0
|
commands: []
|
||||||
mute:
|
|
||||||
enabled: false
|
|
||||||
custom:
|
|
||||||
enabled: false
|
|
||||||
pre: []
|
|
||||||
post: []
|
|
||||||
script:
|
|
||||||
enabled: false
|
|
||||||
path: ""
|
|
||||||
|
|
||||||
work:
|
|
||||||
extends: default
|
|
||||||
modules:
|
|
||||||
workspace:
|
|
||||||
workspace: 5
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Profile inheritance
|
Supported values:
|
||||||
|
|
||||||
Profiles can extend others. Modules from the base profile are merged and overlaid.
|
| Field | Values |
|
||||||
|
| --- | --- |
|
||||||
|
| `duration` | Go-style duration strings such as `25m`, `45m`, `1h30m`, `10s` |
|
||||||
|
| `actions.workspace.backend` | `auto`, `niri`, `hyprland`, `kde` |
|
||||||
|
| `actions.media.action` | `none`, `stop`, `pause`, `play-pause`, `next`, `previous` |
|
||||||
|
| `actions.power.mode` | `none`, `poweroff`, `reboot` |
|
||||||
|
|
||||||
## Modules
|
## Commands Run After Countdown
|
||||||
|
|
||||||
| Module | Stage | Description |
|
`auto` workspace backend tries:
|
||||||
|--------|-------|-------------|
|
|
||||||
| **workspace** | pre | Switch WM workspace (niri/Hyprland/KDE) |
|
|
||||||
| **media** | pre | Control music player via playerctl |
|
|
||||||
| **lock** | post | Lock screen |
|
|
||||||
| **kill** | pre | Kill processes by name |
|
|
||||||
| **notify** | post | Desktop notification |
|
|
||||||
| **sound** | post | Play audio file |
|
|
||||||
| **brightness** | pre/post | Dim/restore brightness |
|
|
||||||
| **mute** | pre/post | Mute/unmute audio |
|
|
||||||
| **custom** | pre/post | User-defined shell commands |
|
|
||||||
| **script** | pre/post | Run external script with args |
|
|
||||||
|
|
||||||
### Workspace backends
|
|
||||||
|
|
||||||
| Backend | Command |
|
|
||||||
|---------|---------|
|
|
||||||
| niri | `niri msg action focus-workspace {n}` |
|
|
||||||
| Hyprland | `hyprctl dispatch workspace {n}` |
|
|
||||||
| KDE | `qdbus org.kde.KWin /KWin setCurrentDesktop {n}` |
|
|
||||||
|
|
||||||
Auto-detection checks `$XDG_CURRENT_DESKTOP`, then tries `hyprctl` and `niri msg`.
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
|
```sh
|
||||||
|
niri msg action focus-workspace <number>
|
||||||
|
hyprctl dispatch workspace <number>
|
||||||
|
qdbus org.kde.KWin /KWin org.kde.KWin.setCurrentDesktop <number>
|
||||||
```
|
```
|
||||||
gosleep-timer/
|
|
||||||
├── main.go # CLI entrypoint
|
Other actions use common Linux desktop tools:
|
||||||
├── cmd_init.go # Interactive setup wizard
|
|
||||||
├── config/ # YAML config load/save/types
|
- `playerctl`
|
||||||
├── engine/ # Timer core, executor, stages
|
- `brightnessctl` or `light`
|
||||||
├── modules/ # Module interface + 10 implementations
|
- `wpctl` or `pactl`
|
||||||
├── profiles/ # Profile manager
|
- `loginctl`
|
||||||
├── history/ # JSONL log + statistics
|
- `pkill`
|
||||||
├── tui/ # Bubbletea TUI app
|
- `systemctl`
|
||||||
├── cicd.yaml # CI/CD pipeline
|
|
||||||
└── Dockerfile # Multi-stage build
|
Install only the tools needed for the actions you enable.
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build
|
cargo fmt --check
|
||||||
go build -o gosleep-timer .
|
cargo test --locked
|
||||||
|
cargo clippy --locked -- -D warnings
|
||||||
# Test
|
cargo build --release --locked
|
||||||
go test ./...
|
|
||||||
|
|
||||||
# Lint
|
|
||||||
golangci-lint run --config .golangci.yml
|
|
||||||
|
|
||||||
# Static analysis
|
|
||||||
staticcheck ./...
|
|
||||||
gosec ./...
|
|
||||||
revive -config .revive.toml ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## CI/CD
|
Local smoke test:
|
||||||
|
|
||||||
Pipeline (`.github/workflows/cicd.yaml`):
|
```bash
|
||||||
- **lint-go**: golangci-lint, staticcheck, gosec, revive
|
tmpdir="$(mktemp -d)"
|
||||||
- **lint-yaml**: yamllint
|
cargo run -- --config "$tmpdir/config.yaml" init
|
||||||
- **build**: go build + test with race detector
|
cargo run -- --config "$tmpdir/config.yaml" preview
|
||||||
- **publish**: Docker image to registry (main/dev branches)
|
```
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
Version is stored in:
|
||||||
|
|
||||||
|
- `Cargo.toml`
|
||||||
|
- `Cargo.lock`
|
||||||
|
- `VERSION`
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
|
||||||
|
Release tags must match the package version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a v1.1.1 -m "gosleep-timer v1.1.1"
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Gitea Actions builds releases from tags matching `v*.*.*`.
|
||||||
|
|
||||||
|
Required runner tools:
|
||||||
|
|
||||||
|
- Rust stable toolchain with `cargo`, `rustfmt`, and `clippy`
|
||||||
|
- `curl`
|
||||||
|
- `jq`
|
||||||
|
- `tar`
|
||||||
|
- `sha256sum`
|
||||||
|
|
||||||
|
Release authentication:
|
||||||
|
|
||||||
|
- The workflow uses Gitea Actions' built-in `GITEA_TOKEN`.
|
||||||
|
- The workflow declares `permissions: contents: write` so the token can create releases and upload assets.
|
||||||
|
|
||||||
|
See [RELEASING.md](RELEASING.md) for the full release checklist.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT. See [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Releasing
|
||||||
|
|
||||||
|
This repository uses semantic versioning and Gitea tag-based releases.
|
||||||
|
|
||||||
|
## Repository Description
|
||||||
|
|
||||||
|
Use this short description in Gitea:
|
||||||
|
|
||||||
|
> Rust TUI sleep timer for Linux desktop actions.
|
||||||
|
|
||||||
|
Optional longer description:
|
||||||
|
|
||||||
|
> A single-binary Linux sleep timer with YAML config, command preview, progress display, and post-countdown desktop actions for niri, Hyprland, KDE, media, brightness, audio, lock, power, process kill, and custom shell commands.
|
||||||
|
|
||||||
|
## Upstream
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote add origin ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git
|
||||||
|
git remote set-url --push origin ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Public clone URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://gitea.forust.xyz/forust/gosleep.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gitea Actions Setup
|
||||||
|
|
||||||
|
1. Enable Actions for `forust/gosleep`.
|
||||||
|
2. Register or select a Linux runner with labels used by the workflows:
|
||||||
|
`self-hosted`, `linux`, `arch`, `homelab`.
|
||||||
|
3. Ensure the runner has:
|
||||||
|
- Rust stable toolchain
|
||||||
|
- `rustfmt`
|
||||||
|
- `clippy`
|
||||||
|
- `curl`
|
||||||
|
- `jq`
|
||||||
|
- `tar`
|
||||||
|
- `sha256sum`
|
||||||
|
- Docker, if using the YAML lint job as written
|
||||||
|
4. Confirm Actions job token permissions allow `contents: write`.
|
||||||
|
The release workflow uses Gitea Actions' built-in `GITEA_TOKEN`; no extra repository secret is required.
|
||||||
|
|
||||||
|
## Version Bump Checklist
|
||||||
|
|
||||||
|
For a patch bump, for example `1.1.1` to `1.1.2`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
version=1.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
- `Cargo.toml`: `package.version`
|
||||||
|
- `Cargo.lock`: package version, regenerated by Cargo
|
||||||
|
- `VERSION`
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
|
||||||
|
Then verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --check
|
||||||
|
cargo test --locked
|
||||||
|
cargo clippy --locked -- -D warnings
|
||||||
|
cargo build --release --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create Release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
git commit -am "chore: release v1.1.2"
|
||||||
|
git tag -a v1.1.2 -m "gosleep-timer v1.1.2"
|
||||||
|
git push origin main
|
||||||
|
git push origin v1.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.gitea/workflows/release.yaml` workflow will:
|
||||||
|
|
||||||
|
1. Build `target/release/gosleep-timer`.
|
||||||
|
2. Package `gosleep-timer-<version>-linux-amd64.tar.gz`.
|
||||||
|
3. Generate a SHA-256 checksum.
|
||||||
|
4. Create a Gitea release.
|
||||||
|
5. Upload the archive and checksum as release assets.
|
||||||
|
|
||||||
|
## Manual Asset Build
|
||||||
|
|
||||||
|
If Actions are unavailable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
version="$(cat VERSION)"
|
||||||
|
cargo build --release --locked
|
||||||
|
mkdir -p dist
|
||||||
|
cp target/release/gosleep-timer dist/gosleep-timer
|
||||||
|
tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer
|
||||||
|
sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256"
|
||||||
|
```
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
# 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 образ (опционально)
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
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
|
|
||||||
-263
@@ -1,263 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/forust/gosleep-timer/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
var initCmd = &cobra.Command{
|
|
||||||
Use: "init",
|
|
||||||
Short: "Interactive config setup wizard",
|
|
||||||
Long: `Creates a new config.yaml with interactive prompts.
|
|
||||||
Walks through all modules and profiles step by step.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
cfgPath, err := getConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if config already exists
|
|
||||||
if _, err := os.Stat(cfgPath); err == nil {
|
|
||||||
fmt.Printf("Config already exists at %s\n", cfgPath)
|
|
||||||
fmt.Print("Overwrite? [y/N]: ")
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
answer, _ := reader.ReadString('\n')
|
|
||||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
|
||||||
if answer != "y" && answer != "yes" {
|
|
||||||
fmt.Println("Aborted.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := runWizard()
|
|
||||||
if cfg == nil {
|
|
||||||
fmt.Println("\nSetup aborted.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := config.Save(cfgPath, cfg); err != nil {
|
|
||||||
return fmt.Errorf("save config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\n✅ Config saved to %s\n", cfgPath)
|
|
||||||
fmt.Println("Run 'gosleep-timer' to start the TUI.")
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(initCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runWizard() *config.Config {
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
p := &config.Profile{}
|
|
||||||
|
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
|
||||||
fmt.Println(" ⏱ gosleep-timer Setup Wizard")
|
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
|
||||||
|
|
||||||
// Profile name
|
|
||||||
fmt.Print("\nProfile name [default]: ")
|
|
||||||
name := readLine(reader, "default")
|
|
||||||
|
|
||||||
// Duration
|
|
||||||
fmt.Print("Default duration (e.g. 25m, 1h, 90s) [25m]: ")
|
|
||||||
dur := readLine(reader, "25m")
|
|
||||||
|
|
||||||
// Workspace module
|
|
||||||
fmt.Println(strings.Repeat("-", 50))
|
|
||||||
fmt.Println("Workspace — switch to a workspace on timer start")
|
|
||||||
wsEnabled := askYesNo(reader, "Enable workspace switch?", true)
|
|
||||||
ws := &config.WorkspaceConfig{Enabled: wsEnabled}
|
|
||||||
if wsEnabled {
|
|
||||||
fmt.Print("Workspace number [12]: ")
|
|
||||||
wsStr := readLine(reader, "12")
|
|
||||||
ws.Workspace, _ = strconv.Atoi(wsStr)
|
|
||||||
if ws.Workspace == 0 {
|
|
||||||
ws.Workspace = 12
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Backend (auto-detect / niri / hyprland / kde):")
|
|
||||||
fmt.Print(" [auto]: ")
|
|
||||||
ws.Backend = readLine(reader, "auto")
|
|
||||||
if ws.Backend == "auto" {
|
|
||||||
ws.Backend = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Media module
|
|
||||||
fmt.Println(strings.Repeat("-", 50))
|
|
||||||
fmt.Println("Media — control music player on timer start")
|
|
||||||
mediaEnabled := askYesNo(reader, "Enable media control?", true)
|
|
||||||
media := &config.MediaConfig{Enabled: mediaEnabled}
|
|
||||||
if mediaEnabled {
|
|
||||||
fmt.Println("Player action (stop / pause / play-pause / next / previous / none):")
|
|
||||||
fmt.Print(" [stop]: ")
|
|
||||||
action := readLine(reader, "stop")
|
|
||||||
media.Action = action
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock module
|
|
||||||
fmt.Println(strings.Repeat("-", 50))
|
|
||||||
fmt.Println("Lock — lock screen when timer finishes")
|
|
||||||
lockEnabled := askYesNo(reader, "Enable screen lock?", true)
|
|
||||||
lock := &config.LockConfig{Enabled: lockEnabled}
|
|
||||||
if lockEnabled {
|
|
||||||
fmt.Print("Custom lock command (leave empty for auto): ")
|
|
||||||
cmd := readLine(reader, "")
|
|
||||||
lock.Command = cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kill module
|
|
||||||
fmt.Println(strings.Repeat("-", 50))
|
|
||||||
fmt.Println("Kill — terminate apps when timer starts")
|
|
||||||
killEnabled := askYesNo(reader, "Enable process killer?", false)
|
|
||||||
kill := &config.KillConfig{Enabled: killEnabled}
|
|
||||||
if killEnabled {
|
|
||||||
fmt.Print("Processes to kill (comma separated, e.g. firefox,telegram): ")
|
|
||||||
procs := readLine(reader, "")
|
|
||||||
if procs != "" {
|
|
||||||
for _, p := range strings.Split(procs, ",") {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
if p != "" {
|
|
||||||
kill.Processes = append(kill.Processes, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify module
|
|
||||||
fmt.Println(strings.Repeat("-", 50))
|
|
||||||
fmt.Println("Notify — desktop notification when timer finishes")
|
|
||||||
notifyEnabled := askYesNo(reader, "Enable notifications?", true)
|
|
||||||
notify := &config.NotifyConfig{Enabled: notifyEnabled}
|
|
||||||
if notifyEnabled {
|
|
||||||
fmt.Print("Notification sound path (empty = no sound): ")
|
|
||||||
notify.Sound = readLine(reader, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sound module
|
|
||||||
fmt.Println(strings.Repeat("-", 50))
|
|
||||||
fmt.Println("Sound — play audio when timer finishes")
|
|
||||||
soundEnabled := askYesNo(reader, "Enable sound?", true)
|
|
||||||
sound := &config.SoundConfig{Enabled: soundEnabled}
|
|
||||||
if soundEnabled {
|
|
||||||
sound.File = "/usr/share/sounds/freedesktop/stereo/complete.oga"
|
|
||||||
fmt.Printf(" Default: %s\n", sound.File)
|
|
||||||
fmt.Print("Custom sound file (leave empty for default): ")
|
|
||||||
custom := readLine(reader, "")
|
|
||||||
if custom != "" {
|
|
||||||
sound.File = custom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brightness module
|
|
||||||
fmt.Println(strings.Repeat("-", 50))
|
|
||||||
fmt.Println("Brightness — dim screen during timer")
|
|
||||||
brightnessEnabled := askYesNo(reader, "Enable brightness control?", false)
|
|
||||||
brightness := &config.BrightnessConfig{Enabled: brightnessEnabled}
|
|
||||||
if brightnessEnabled {
|
|
||||||
fmt.Print("Brightness level 0-100 [0]: ")
|
|
||||||
valStr := readLine(reader, "0")
|
|
||||||
val, _ := strconv.Atoi(valStr)
|
|
||||||
brightness.Value = val
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mute module
|
|
||||||
fmt.Println(strings.Repeat("-", 50))
|
|
||||||
fmt.Println("Mute — mute audio during timer")
|
|
||||||
muteEnabled := askYesNo(reader, "Enable mute?", false)
|
|
||||||
mute := &config.MuteConfig{Enabled: muteEnabled}
|
|
||||||
|
|
||||||
// Custom commands
|
|
||||||
fmt.Println(strings.Repeat("-", 50))
|
|
||||||
fmt.Println("Custom — run your own commands")
|
|
||||||
customEnabled := askYesNo(reader, "Enable custom commands?", false)
|
|
||||||
custom := &config.CustomConfig{Enabled: customEnabled}
|
|
||||||
if customEnabled {
|
|
||||||
fmt.Println("Pre-commands (run BEFORE timer, one per line, empty line to end):")
|
|
||||||
custom.Pre = readLines(reader, "pre")
|
|
||||||
fmt.Println("Post-commands (run AFTER timer, one per line, empty line to end):")
|
|
||||||
custom.Post = readLines(reader, "post")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Script module
|
|
||||||
fmt.Println(strings.Repeat("-", 50))
|
|
||||||
fmt.Println("Script — run a script with timer context (args: pre/post, profile, duration)")
|
|
||||||
scriptEnabled := askYesNo(reader, "Enable script module?", false)
|
|
||||||
script := &config.ScriptConfig{Enabled: scriptEnabled}
|
|
||||||
if scriptEnabled {
|
|
||||||
fmt.Print("Script path: ")
|
|
||||||
script.Path = readLine(reader, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build final config
|
|
||||||
p.Modules = &config.Modules{
|
|
||||||
Workspace: ws,
|
|
||||||
Media: media,
|
|
||||||
Lock: lock,
|
|
||||||
Kill: kill,
|
|
||||||
Notify: notify,
|
|
||||||
Sound: sound,
|
|
||||||
Brightness: brightness,
|
|
||||||
Mute: mute,
|
|
||||||
Custom: custom,
|
|
||||||
Script: script,
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Profiles = map[string]config.Profile{
|
|
||||||
name: *p,
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
|
||||||
fmt.Println(" ✅ Setup complete!")
|
|
||||||
fmt.Printf(" Profile: %s\n", name)
|
|
||||||
fmt.Printf(" Duration: %s\n", dur)
|
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func readLine(reader *bufio.Reader, fallback string) string {
|
|
||||||
line, _ := reader.ReadString('\n')
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
|
|
||||||
func askYesNo(reader *bufio.Reader, prompt string, defaultYes bool) bool {
|
|
||||||
suffix := "[Y/n]"
|
|
||||||
if !defaultYes {
|
|
||||||
suffix = "[y/N]"
|
|
||||||
}
|
|
||||||
fmt.Printf("%s %s: ", prompt, suffix)
|
|
||||||
answer := strings.ToLower(strings.TrimSpace(readLine(reader, "")))
|
|
||||||
if answer == "" {
|
|
||||||
return defaultYes
|
|
||||||
}
|
|
||||||
return answer == "y" || answer == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
func readLines(reader *bufio.Reader, label string) []string {
|
|
||||||
var lines []string
|
|
||||||
for {
|
|
||||||
fmt.Printf(" %s> ", label)
|
|
||||||
line, _ := reader.ReadString('\n')
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"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 !errors.Is(err, os.ErrNotExist) {
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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
@@ -1,116 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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=
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
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",
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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{})
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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{})
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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{})
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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{})
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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{})
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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{})
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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{})
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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{})
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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{})
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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{})
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package profiles
|
|
||||||
|
|
||||||
type Info struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Modules []string // list of enabled module names
|
|
||||||
Duration string // optional default duration
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
|
components = ["clippy", "rustfmt"]
|
||||||
+1172
File diff suppressed because it is too large
Load Diff
@@ -1,162 +0,0 @@
|
|||||||
#!/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
@@ -1,675 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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
@@ -1,104 +0,0 @@
|
|||||||
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