chore: prepare production rust release
ci / rust (push) Successful in 53s
ci / lint-yaml (push) Successful in 5s
release / linux-amd64 (push) Failing after 31s

This commit is contained in:
2026-07-03 15:45:22 +02:00
parent d1cc207ce4
commit edd77bdcf1
52 changed files with 2583 additions and 4077 deletions
+15
View File
@@ -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
+46
View File
@@ -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 .
+93
View File
@@ -0,0 +1,93 @@
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##*/}"
release_url="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases"
existing="$(curl -fsS \
"${release_url}/tags/${tag}" \
-H "Authorization: token ${GITEA_TOKEN}" || true)"
release_id="$(printf '%s' "$existing" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then
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 "${release_url}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$payload")"
release_id="$(printf '%s' "$response" | jq -r '.id')"
fi
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")"
release="$(curl -fsS \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}" \
-H "Authorization: token ${GITEA_TOKEN}")"
asset_id="$(printf '%s' "$release" | jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' | head -n 1)"
if [ -n "$asset_id" ]; then
curl -fsS \
-X DELETE "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}" \
-H "Authorization: token ${GITEA_TOKEN}"
fi
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/*
-141
View File
@@ -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
View File
@@ -1,7 +1,8 @@
gosleep-timer /target/
*.test /dist/
*.out /.env
*.exe /.env.*
*.log
*.tmp
*.bak
.DS_Store .DS_Store
cover.out
vendor/
-108
View File
@@ -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
+7 -1
View File
@@ -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
+16
View File
@@ -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
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+21
View File
@@ -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.
+164 -151
View File
@@ -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).
+100
View File
@@ -0,0 +1,100 @@
# 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.
The workflow is idempotent: if the release already exists, it reuses it and replaces assets with matching names.
## 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"
```
-266
View File
@@ -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
View File
@@ -0,0 +1 @@
1.1.1
-171
View File
@@ -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
View File
@@ -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
}
-282
View File
@@ -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,
}
}
}
-57
View File
@@ -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: "",
},
}
}
-74
View File
@@ -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"`
}
-42
View File
@@ -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
}
-74
View File
@@ -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
View File
@@ -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
}
-34
View File
@@ -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
)
-62
View File
@@ -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=
-85
View File
@@ -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()
}
-54
View File
@@ -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
}
-348
View File
@@ -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)
}
}
-61
View File
@@ -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{})
}
-34
View File
@@ -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{})
}
-43
View File
@@ -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{})
}
-36
View File
@@ -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{})
}
-40
View File
@@ -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{})
}
-39
View File
@@ -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
}
-53
View File
@@ -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{})
}
-48
View File
@@ -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{})
}
-53
View File
@@ -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()
}
-40
View File
@@ -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{})
}
-74
View File
@@ -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{})
}
-56
View File
@@ -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{})
}
-76
View File
@@ -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
}
-8
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["clippy", "rustfmt"]
+1172
View File
File diff suppressed because it is too large Load Diff
-162
View File
@@ -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
View File
@@ -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())
}
-42
View File
@@ -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
}
-17
View File
@@ -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
}
-69
View File
@@ -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
View File
@@ -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()
}