chore: prepare production rust release
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.rs]
|
||||
indent_size = 4
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -0,0 +1,46 @@
|
||||
name: ci
|
||||
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check formatting
|
||||
shell: bash
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
run: cargo test --locked
|
||||
|
||||
- name: Run clippy
|
||||
shell: bash
|
||||
run: cargo clippy --locked -- -D warnings
|
||||
|
||||
- name: Build release binary
|
||||
shell: bash
|
||||
run: cargo build --release --locked
|
||||
|
||||
lint-yaml:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint YAML
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
cytopia/yamllint:latest \
|
||||
-c .yamllint .
|
||||
@@ -0,0 +1,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/*
|
||||
@@ -1,141 +0,0 @@
|
||||
name: ci
|
||||
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: gcr.forust.xyz
|
||||
IMAGE_NAME: forust/gosleep-timer
|
||||
|
||||
jobs:
|
||||
lint-go:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Lint with golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
args: --config .golangci.yml --timeout 10m
|
||||
|
||||
- name: Run staticcheck
|
||||
shell: bash
|
||||
run: |
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
staticcheck ./...
|
||||
|
||||
- name: Run gosec
|
||||
shell: bash
|
||||
run: |
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||
gosec -no-fail -fmt=text ./...
|
||||
|
||||
- name: Run revive
|
||||
shell: bash
|
||||
run: |
|
||||
go install github.com/mgechev/revive@latest
|
||||
revive -config .revive.toml ./...
|
||||
|
||||
lint-yaml:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint YAML syntax
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
cytopia/yamllint:latest \
|
||||
-c .yamllint .
|
||||
|
||||
build:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: go build -ldflags="-s -w" -o gosleep-timer .
|
||||
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: go test -v -race -coverprofile=cover.out ./...
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gosleep-timer
|
||||
path: gosleep-timer
|
||||
|
||||
publish:
|
||||
needs: [lint-go, lint-yaml, build]
|
||||
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev')
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Read project version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(go version -m 2>/dev/null || date +%Y%m%d)"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to registry
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" \
|
||||
-u "${{ secrets.REGISTRY_USERNAME }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build and push image
|
||||
shell: bash
|
||||
run: |
|
||||
image="${REGISTRY}/${IMAGE_NAME}"
|
||||
tags=("latest" "${{ steps.version.outputs.version }}")
|
||||
|
||||
case "${GITHUB_REF_NAME}" in
|
||||
main)
|
||||
tags+=("main" "prod")
|
||||
;;
|
||||
dev)
|
||||
tags+=("dev")
|
||||
;;
|
||||
esac
|
||||
|
||||
build_args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
build_args+=(-t "${image}:${tag}")
|
||||
done
|
||||
|
||||
docker build "${build_args[@]}" .
|
||||
|
||||
for tag in "${tags[@]}"; do
|
||||
docker push "${image}:${tag}"
|
||||
done
|
||||
+7
-6
@@ -1,7 +1,8 @@
|
||||
gosleep-timer
|
||||
*.test
|
||||
*.out
|
||||
*.exe
|
||||
/target/
|
||||
/dist/
|
||||
/.env
|
||||
/.env.*
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
.DS_Store
|
||||
cover.out
|
||||
vendor/
|
||||
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
skip-dirs:
|
||||
- vendor/
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- typecheck
|
||||
- unused
|
||||
- asciicheck
|
||||
- bodyclose
|
||||
- cyclop
|
||||
- decorder
|
||||
- dupl
|
||||
- dupword
|
||||
- durationcheck
|
||||
- errname
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- forcetypeassert
|
||||
- gochecknoinits
|
||||
- gocognit
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- godot
|
||||
- goimports
|
||||
- gomoddirectives
|
||||
- gomodguard
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- grouper
|
||||
- ifshort
|
||||
- importas
|
||||
- lll
|
||||
- makezero
|
||||
- mirror
|
||||
- misspell
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilerr
|
||||
- nilnil
|
||||
- nlreturn
|
||||
- noctx
|
||||
- nolintlint
|
||||
- nosprintfhostport
|
||||
- prealloc
|
||||
- predeclared
|
||||
- promlinter
|
||||
- reassign
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- stylecheck
|
||||
- tagalign
|
||||
- tagliatelle
|
||||
- tenv
|
||||
- testableexamples
|
||||
- thelper
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unparam
|
||||
- usestdlibvars
|
||||
- wastedassign
|
||||
- whitespace
|
||||
- wsl
|
||||
- zerologlint
|
||||
|
||||
linters-settings:
|
||||
gocyclo:
|
||||
min-complexity: 20
|
||||
gocognit:
|
||||
min-complexity: 30
|
||||
lll:
|
||||
line-length: 140
|
||||
misspell:
|
||||
locale: US
|
||||
funlen:
|
||||
lines: 80
|
||||
statements: 60
|
||||
revive:
|
||||
rules:
|
||||
- name: exported
|
||||
severity: warning
|
||||
- name: unexported-return
|
||||
severity: warning
|
||||
gosec:
|
||||
excludes:
|
||||
- G204 # subprocess launched with variables
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
- gosec
|
||||
- path: main\.go
|
||||
linters:
|
||||
- gochecknoinits
|
||||
|
||||
output:
|
||||
formats:
|
||||
- format: colored-line-number
|
||||
@@ -1,12 +1,18 @@
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
comments:
|
||||
min-spaces-from-content: 1
|
||||
comments-indentation: false
|
||||
document-start: disable
|
||||
line-length:
|
||||
max: 140
|
||||
document-start: disable
|
||||
braces:
|
||||
min-spaces-inside: 0
|
||||
max-spaces-inside: 1
|
||||
brackets:
|
||||
min-spaces-inside: 0
|
||||
max-spaces-inside: 1
|
||||
indentation:
|
||||
spaces: 2
|
||||
indent-sequences: consistent
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here.
|
||||
|
||||
The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`.
|
||||
|
||||
## [1.1.1] - 2026-07-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Rebuilt the application as a Rust terminal binary.
|
||||
- Added a Ratatui/Crossterm TUI with command preview wrapping, timer progress, and time-left display.
|
||||
- Added YAML configuration, CLI `init`, `preview`, and `run` commands.
|
||||
- Added Gitea CI and tag-based release workflows.
|
||||
|
||||
[1.1.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.1
|
||||
@@ -0,0 +1,29 @@
|
||||
# Contributing
|
||||
|
||||
## Checks
|
||||
|
||||
Run these before pushing:
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo test --locked
|
||||
cargo clippy --locked -- -D warnings
|
||||
cargo build --release --locked
|
||||
```
|
||||
|
||||
## Style
|
||||
|
||||
- Keep the application single-binary and Rust-first.
|
||||
- Keep config backwards-compatible where possible.
|
||||
- Prefer explicit, small functions over hidden behavior in the TUI.
|
||||
- Do not add runtime dependencies on Python, Node, or external services.
|
||||
|
||||
## Commits
|
||||
|
||||
Use concise conventional commits where practical:
|
||||
|
||||
```text
|
||||
feat: add timer progress gauge
|
||||
fix: wrap preview commands on narrow terminals
|
||||
chore: release v1.1.1
|
||||
```
|
||||
Generated
+881
@@ -0,0 +1,881 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 1.1.4",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "gosleep-timer"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"crossterm 0.29.0",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"crossterm 0.28.1",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools",
|
||||
"lru",
|
||||
"paste",
|
||||
"strum",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
dependencies = [
|
||||
"itertools",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "gosleep-timer"
|
||||
version = "1.1.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI"
|
||||
license = "MIT"
|
||||
repository = "https://gitea.forust.xyz/forust/gosleep"
|
||||
readme = "README.md"
|
||||
keywords = ["timer", "tui", "linux", "desktop", "sleep"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[[bin]]
|
||||
name = "gosleep-timer"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
crossterm = "0.29"
|
||||
ratatui = "0.29"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
||||
strip = true
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /gosleep-timer .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=builder /gosleep-timer /gosleep-timer
|
||||
ENTRYPOINT ["/gosleep-timer"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Forust
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,200 +1,213 @@
|
||||
# ⏱ gosleep-timer
|
||||
# gosleep-timer
|
||||
|
||||
TUI sleep timer with modular WM/DE support. Go rewrite of `timer.py`.
|
||||
Rust TUI sleep timer for Linux desktop actions.
|
||||
|
||||
## Quick start
|
||||
`gosleep-timer` waits for a configured duration, then runs post-countdown desktop commands such as switching workspace, stopping media, dimming brightness, muting audio, locking the session, killing selected processes, powering off/rebooting, or running custom shell commands.
|
||||
|
||||
## Status
|
||||
|
||||
- Current version: `1.1.1`
|
||||
- Release tags: `vMAJOR.MINOR.PATCH`, for example `v1.1.1`
|
||||
- Primary repository: <https://gitea.forust.xyz/forust/gosleep>
|
||||
- SSH upstream: `ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git`
|
||||
|
||||
## Features
|
||||
|
||||
- Single Rust binary, independent of Python.
|
||||
- Ratatui/Crossterm terminal UI.
|
||||
- YAML config at `~/.config/gosleep-timer/config.yaml` or `$XDG_CONFIG_HOME/gosleep-timer/config.yaml`.
|
||||
- CLI commands for initialization, preview, and direct timer runs.
|
||||
- Time-left display and progress bar.
|
||||
- Wrapped command preview for smaller terminals.
|
||||
- Linux desktop action support for niri, Hyprland, KDE, playerctl, brightnessctl/light, PipeWire/PulseAudio, systemd, and custom shell commands.
|
||||
|
||||
## Install
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
# Run TUI
|
||||
gosleep-timer
|
||||
|
||||
# First time? Interactive setup wizard
|
||||
gosleep-timer init
|
||||
git clone ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git
|
||||
cd gosleep
|
||||
cargo build --release --locked
|
||||
install -Dm755 target/release/gosleep-timer ~/.local/bin/gosleep-timer
|
||||
```
|
||||
|
||||
## Installation
|
||||
### From release artifact
|
||||
|
||||
Download `gosleep-timer-<version>-linux-amd64.tar.gz` from the Gitea release page, then:
|
||||
|
||||
```bash
|
||||
go install github.com/forust/gosleep-timer@latest
|
||||
# or
|
||||
git clone https://github.com/forust/gosleep-timer
|
||||
cd gosleep-timer
|
||||
go build -o gosleep-timer .
|
||||
sudo cp gosleep-timer /usr/local/bin/
|
||||
tar -xzf gosleep-timer-1.1.1-linux-amd64.tar.gz
|
||||
install -Dm755 gosleep-timer ~/.local/bin/gosleep-timer
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### TUI mode (default)
|
||||
Open the TUI:
|
||||
|
||||
```bash
|
||||
gosleep-timer
|
||||
```
|
||||
|
||||
Keys:
|
||||
| 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
|
||||
Initialize the default config:
|
||||
|
||||
```bash
|
||||
# Run with defaults (25m)
|
||||
gosleep-timer run
|
||||
gosleep-timer init
|
||||
```
|
||||
|
||||
# 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 1h30m
|
||||
|
||||
# With profile
|
||||
gosleep-timer run 20m --profile work
|
||||
```
|
||||
|
||||
### Other commands
|
||||
Use a custom config path:
|
||||
|
||||
```bash
|
||||
# Interactive setup
|
||||
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
|
||||
gosleep-timer --config ./config.yaml preview
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
File: `~/.config/gosleep-timer/config.yaml`
|
||||
|
||||
### Profiles
|
||||
Default config:
|
||||
|
||||
```yaml
|
||||
profiles:
|
||||
default:
|
||||
modules:
|
||||
duration: 25m
|
||||
actions:
|
||||
workspace:
|
||||
enabled: true
|
||||
backend: auto # auto, niri, hyprland, kde
|
||||
workspace: 12
|
||||
backend: auto
|
||||
number: 3
|
||||
media:
|
||||
enabled: true
|
||||
action: stop # stop, pause, play-pause, next, previous
|
||||
lock:
|
||||
enabled: true
|
||||
command: "" # custom command or auto-detect
|
||||
kill:
|
||||
enabled: false
|
||||
processes: [firefox, telegram-desktop]
|
||||
notify:
|
||||
enabled: true
|
||||
sound: "" # path or empty
|
||||
sound:
|
||||
enabled: true
|
||||
file: /usr/share/sounds/freedesktop/stereo/complete.oga
|
||||
action: stop
|
||||
brightness:
|
||||
enabled: false
|
||||
value: 0
|
||||
value: 30
|
||||
mute:
|
||||
enabled: false
|
||||
lock:
|
||||
enabled: false
|
||||
command: loginctl lock-session
|
||||
kill:
|
||||
enabled: false
|
||||
processes: []
|
||||
power:
|
||||
mode: none
|
||||
custom:
|
||||
enabled: false
|
||||
pre: []
|
||||
post: []
|
||||
script:
|
||||
enabled: false
|
||||
path: ""
|
||||
|
||||
work:
|
||||
extends: default
|
||||
modules:
|
||||
workspace:
|
||||
workspace: 5
|
||||
commands: []
|
||||
```
|
||||
|
||||
### 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 |
|
||||
|--------|-------|-------------|
|
||||
| **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
|
||||
`auto` workspace backend tries:
|
||||
|
||||
```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
|
||||
├── cmd_init.go # Interactive setup wizard
|
||||
├── config/ # YAML config load/save/types
|
||||
├── engine/ # Timer core, executor, stages
|
||||
├── modules/ # Module interface + 10 implementations
|
||||
├── profiles/ # Profile manager
|
||||
├── history/ # JSONL log + statistics
|
||||
├── tui/ # Bubbletea TUI app
|
||||
├── cicd.yaml # CI/CD pipeline
|
||||
└── Dockerfile # Multi-stage build
|
||||
```
|
||||
|
||||
Other actions use common Linux desktop tools:
|
||||
|
||||
- `playerctl`
|
||||
- `brightnessctl` or `light`
|
||||
- `wpctl` or `pactl`
|
||||
- `loginctl`
|
||||
- `pkill`
|
||||
- `systemctl`
|
||||
|
||||
Install only the tools needed for the actions you enable.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build -o gosleep-timer .
|
||||
|
||||
# Test
|
||||
go test ./...
|
||||
|
||||
# Lint
|
||||
golangci-lint run --config .golangci.yml
|
||||
|
||||
# Static analysis
|
||||
staticcheck ./...
|
||||
gosec ./...
|
||||
revive -config .revive.toml ./...
|
||||
cargo fmt --check
|
||||
cargo test --locked
|
||||
cargo clippy --locked -- -D warnings
|
||||
cargo build --release --locked
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
Local smoke test:
|
||||
|
||||
Pipeline (`.github/workflows/cicd.yaml`):
|
||||
- **lint-go**: golangci-lint, staticcheck, gosec, revive
|
||||
- **lint-yaml**: yamllint
|
||||
- **build**: go build + test with race detector
|
||||
- **publish**: Docker image to registry (main/dev branches)
|
||||
```bash
|
||||
tmpdir="$(mktemp -d)"
|
||||
cargo run -- --config "$tmpdir/config.yaml" init
|
||||
cargo run -- --config "$tmpdir/config.yaml" preview
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
MIT
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
|
||||
+100
@@ -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"
|
||||
```
|
||||
@@ -1,266 +0,0 @@
|
||||
# gosleep-timer — Go TUI Timer
|
||||
|
||||
## Цель
|
||||
Переписать `timer.py` на Go с модульной архитектурой, поддержкой нескольких WM/DE (niri, Hyprland, KDE), пользовательскими командами, профилями, историей, CLI-режимом и QR-экспортом.
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
gosleep-timer/
|
||||
├── main.go # CLI entrypoint
|
||||
├── go.mod / go.sum
|
||||
├── .gitignore
|
||||
├── TODO.md
|
||||
├── cicd.yaml # CI/CD pipeline
|
||||
├── .golangci.yml # linter config
|
||||
├── config/
|
||||
│ ├── config.go # загрузка YAML
|
||||
│ ├── defaults.go # дефолтные значения
|
||||
│ └── types.go # структуры Config, Module, Profile, Stage
|
||||
├── engine/
|
||||
│ ├── timer.go # ядро отсчёта
|
||||
│ ├── executor.go # запуск команд, таймауты, kill
|
||||
│ └── stages.go # фазы: pre → timer → post
|
||||
├── modules/
|
||||
│ ├── module.go # интерфейс Module
|
||||
│ ├── registry.go # глобальный реестр модулей
|
||||
│ ├── workspace/
|
||||
│ │ └── workspace.go # переключение workspace (niri/hyprland/kde)
|
||||
│ ├── media/
|
||||
│ │ └── media.go # playerctl / MPRIS
|
||||
│ ├── lock/
|
||||
│ │ └── lock.go # блокировка экрана
|
||||
│ ├── kill/
|
||||
│ │ └── kill.go # pkill процессов
|
||||
│ ├── notify/
|
||||
│ │ └── notify.go # notify-send / dunst
|
||||
│ ├── sound/
|
||||
│ │ └── sound.go # звуковой сигнал
|
||||
│ ├── brightness/
|
||||
│ │ └── brightness.go # управление яркостью (brightnessctl/light)
|
||||
│ ├── mute/
|
||||
│ │ └── mute.go # mute/unmute (pactl/wpctl)
|
||||
│ ├── custom/
|
||||
│ │ └── custom.go # пользовательские команды pre/post
|
||||
│ └── script/
|
||||
│ └── script.go # запуск произвольного скрипта
|
||||
├── profiles/
|
||||
│ ├── profile.go # структура Profile
|
||||
│ └── manager.go # CRUD профилей
|
||||
├── history/
|
||||
│ ├── history.go # JSONL лог запусков
|
||||
│ └── stats.go # статистика
|
||||
├── tui/
|
||||
│ ├── app.go # bubbletea App
|
||||
│ ├── widgets.go # кастомные виджеты
|
||||
│ ├── styles.go # темы
|
||||
│ ├── keybinds.go # горячие клавиши
|
||||
│ └── qr.go # QR код
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Очередность (от важного к мелкому)
|
||||
|
||||
### [x] 1. TODO.md
|
||||
Этот файл.
|
||||
|
||||
### [ ] 2. Фундамент
|
||||
- Инициализация Go модуля
|
||||
- `.gitignore`
|
||||
- Структура папок
|
||||
- Первый коммит
|
||||
|
||||
### [ ] 3. Конфиг
|
||||
- `config/types.go` — типы данных
|
||||
- `config/defaults.go` — дефолты
|
||||
- `config/config.go` — парсинг YAML
|
||||
- Автоопределение WM (niri/hyprland/kde/none)
|
||||
|
||||
### [ ] 4. Ядро
|
||||
- `engine/timer.go` — обратный отсчёт, тики
|
||||
- `engine/executor.go` — exec.Command, таймауты, принудительное завершение
|
||||
- `engine/stages.go` — фазы с callback-ами
|
||||
|
||||
### [ ] 5. Модули
|
||||
- `modules/module.go` — интерфейс
|
||||
- `modules/registry.go` — реестр
|
||||
- workspace, media, lock, kill, notify, sound, brightness, mute, custom, script
|
||||
|
||||
### [ ] 6. Профили
|
||||
- `profiles/profile.go`
|
||||
- `profiles/manager.go`
|
||||
- Загрузка/сохранение профилей, список, apply
|
||||
|
||||
### [ ] 7. История
|
||||
- `history/history.go` — запись в JSONL
|
||||
- `history/stats.go` — агрегация
|
||||
|
||||
### [ ] 8. TUI
|
||||
- `tui/app.go`, `widgets.go`, `styles.go`, `keybinds.go`, `qr.go`
|
||||
|
||||
### [ ] 9. CLI
|
||||
- `main.go` — cobra/флаги
|
||||
- Режимы: `tui` (по умолчанию), `run`, `list-profiles`, `export`, `import`
|
||||
|
||||
### [ ] 10. Линтеры + CI/CD
|
||||
- `.golangci.yml` (golint, staticcheck, gosec, revive, errcheck)
|
||||
- `cicd.yaml` на основе `cicd.example.yaml`
|
||||
- Go-специфичные линтеры
|
||||
|
||||
---
|
||||
|
||||
## Модули (детально)
|
||||
|
||||
### workspace
|
||||
- Поле `Backend` (niri | hyprland | kde | auto)
|
||||
- Поле `Workspace` (int)
|
||||
- Автоопределение: `loginctl`, `$XDG_CURRENT_DESKTOP`, `hyprctl`, `niri msg`
|
||||
- Команды:
|
||||
- niri: `niri msg action focus-workspace {n}`
|
||||
- hyprland: `hyprctl dispatch workspace {n}`
|
||||
- kde: `qdbus org.kde.KWin /KWin setCurrentDesktop {n}`
|
||||
|
||||
### media
|
||||
- Поле `Action` (stop | pause | play-pause | next | previous | none)
|
||||
- fallback: `playerctl`
|
||||
|
||||
### lock
|
||||
- Поле `Command` (кастомная строка)
|
||||
- fallback: `loginctl lock-session`
|
||||
|
||||
### kill
|
||||
- Поле `Processes` ([]string)
|
||||
- `pkill {name}` для каждого
|
||||
|
||||
### notify
|
||||
- Поле `Sound` (путь к файлу или none)
|
||||
- `notify-send` + опционально звук
|
||||
|
||||
### sound
|
||||
- Поле `File` (путь к wav/oga)
|
||||
- `paplay`, `aplay`, `ffplay`
|
||||
|
||||
### brightness
|
||||
- Поле `Value` (int 0-100)
|
||||
- `brightnessctl set {n}%` или `light -S {n}`
|
||||
|
||||
### mute
|
||||
- Поле `Mute` (bool)
|
||||
- `pactl set-sink-mute @DEFAULT_SINK@ 1` / `0`
|
||||
- `wpctl set-mute @DEFAULT_AUDIO_SINK@ 1` / `0`
|
||||
|
||||
### custom
|
||||
- Поля `PreCommands`, `PostCommands` ([]string)
|
||||
|
||||
### script
|
||||
- Поле `Path` (путь к скрипту)
|
||||
- Аргументы из контекста таймера
|
||||
|
||||
---
|
||||
|
||||
## CLI флаги (`main.go`)
|
||||
|
||||
```
|
||||
gosleep-timer # TUI режим
|
||||
gosleep-timer run 25m # CLI режим
|
||||
gosleep-timer run --profile work 25m
|
||||
gosleep-timer list-profiles
|
||||
gosleep-timer export > config.yaml
|
||||
gosleep-timer import < config.yaml
|
||||
gosleep-timer qr # показать QR с конфигом
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TUI (bubbletea)
|
||||
|
||||
### Экраны
|
||||
1. **Main** — ввод времени, чекбоксы модулей, выбор профиля, Start/Stop
|
||||
2. **Running** — прогресс-бар, оставшееся время, текущий этап
|
||||
3. **History** — последние запуски
|
||||
4. **Stats** — статистика
|
||||
|
||||
### Горячие клавиши
|
||||
- `s` — Start
|
||||
- `S` — Stop
|
||||
- `q` / `Esc` — Quit
|
||||
- `h` — History
|
||||
- `Tab` — фокус следующий элемент
|
||||
- `Enter` — toggle/action
|
||||
|
||||
---
|
||||
|
||||
## Формат конфига (`~/.config/gosleep-timer/config.yaml`)
|
||||
|
||||
```yaml
|
||||
profiles:
|
||||
default:
|
||||
modules:
|
||||
workspace:
|
||||
enabled: true
|
||||
backend: auto
|
||||
workspace: 12
|
||||
media:
|
||||
enabled: true
|
||||
action: stop
|
||||
lock:
|
||||
enabled: true
|
||||
command: ""
|
||||
kill:
|
||||
enabled: false
|
||||
processes: []
|
||||
notify:
|
||||
enabled: true
|
||||
sound: ""
|
||||
sound:
|
||||
enabled: true
|
||||
file: /usr/share/sounds/freedesktop/stereo/complete.oga
|
||||
brightness:
|
||||
enabled: false
|
||||
value: 0
|
||||
mute:
|
||||
enabled: false
|
||||
custom:
|
||||
enabled: false
|
||||
pre: []
|
||||
post: []
|
||||
script:
|
||||
enabled: false
|
||||
path: ""
|
||||
work:
|
||||
extends: default
|
||||
modules:
|
||||
workspace:
|
||||
workspace: 5
|
||||
gaming:
|
||||
modules:
|
||||
kill:
|
||||
enabled: true
|
||||
processes: [firefox, slack]
|
||||
mute:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## История (`~/.local/share/gosleep-timer/history.jsonl`)
|
||||
|
||||
```jsonl
|
||||
{"ts":"2026-07-03T01:30:00Z","profile":"default","duration":"25m","status":"completed"}
|
||||
{"ts":"2026-07-03T02:00:00Z","profile":"work","duration":"1h","status":"killed"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
На основе `cicd.example.yaml`:
|
||||
- `lint-go` — golangci-lint (все линтеры)
|
||||
- `lint-yaml` — yamllint
|
||||
- `lint-dockerfile` — hadolint (если появится)
|
||||
- `build` — go build
|
||||
- `test` — go test ./...
|
||||
- `publish` — docker образ (опционально)
|
||||
@@ -1,171 +0,0 @@
|
||||
name: ci
|
||||
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Shared image coordinates for the publish job.
|
||||
REGISTRY: gcr.forust.xyz
|
||||
IMAGE_NAME: forust/telegram-scraper
|
||||
|
||||
jobs:
|
||||
lint-prettier:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check formatting with Prettier
|
||||
shell: bash
|
||||
run: |
|
||||
mapfile -t prettier_files < <(
|
||||
git ls-files \
|
||||
| grep -E '\.(md|json|ya?ml|html|css)$' \
|
||||
| grep -Ev '^(\.docs/|\.zed/|errorpages/html/|homepages/(forust_files|xdfnx_files)/)'
|
||||
)
|
||||
|
||||
if [ "${#prettier_files[@]}" -eq 0 ]; then
|
||||
echo "No Prettier-managed files found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
node:22-alpine \
|
||||
sh -lc 'npx --yes prettier@3 --check --ignore-unknown "$@"' sh "${prettier_files[@]}"
|
||||
|
||||
lint-ruff:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint Python with Ruff
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
ghcr.io/astral-sh/ruff:latest \
|
||||
check .
|
||||
|
||||
lint-yaml:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint YAML syntax
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
cytopia/yamllint:latest \
|
||||
-c .yamllint .
|
||||
|
||||
lint-dockerfiles:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint Dockerfiles
|
||||
shell: bash
|
||||
run: |
|
||||
mapfile -t dockerfiles < <(
|
||||
git ls-files ':(glob)**/Dockerfile' ':(glob)**/Dockerfile.*'
|
||||
)
|
||||
|
||||
if [ "${#dockerfiles[@]}" -eq 0 ]; then
|
||||
echo "No Dockerfiles found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
--entrypoint hadolint \
|
||||
hadolint/hadolint:latest-debian \
|
||||
-c .hadolint.yaml "${dockerfiles[@]}"
|
||||
|
||||
validate:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate Kubernetes manifests
|
||||
shell: bash
|
||||
run: |
|
||||
mapfile -t manifests < <(
|
||||
git ls-files ':(glob)**/k8s/**/*.yaml' ':(glob)**/k8s/**/*.yml' \
|
||||
| grep -Ev '(^|/)(kustomization\.ya?ml|.*\.example\.ya?ml|.*values\.ya?ml|patch-.*\.ya?ml)$'
|
||||
)
|
||||
|
||||
if [ "${#manifests[@]}" -eq 0 ]; then
|
||||
echo "No Kubernetes manifests found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
ghcr.io/yannh/kubeconform:latest \
|
||||
-strict \
|
||||
-ignore-missing-schemas \
|
||||
-summary \
|
||||
"${manifests[@]}"
|
||||
|
||||
publish:
|
||||
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
|
||||
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev')
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Read project version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(python3 -c 'from pathlib import Path; import tomllib; print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"])')"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to registry
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" \
|
||||
-u "${{ secrets.REGISTRY_USERNAME }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build and push image
|
||||
shell: bash
|
||||
run: |
|
||||
image="${REGISTRY}/${IMAGE_NAME}"
|
||||
tags=("latest" "${{ steps.version.outputs.version }}")
|
||||
|
||||
case "${GITHUB_REF_NAME}" in
|
||||
main)
|
||||
tags+=("main" "prod")
|
||||
;;
|
||||
dev)
|
||||
tags+=("dev")
|
||||
;;
|
||||
esac
|
||||
|
||||
build_args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
build_args+=(-t "${image}:${tag}")
|
||||
done
|
||||
|
||||
docker build "${build_args[@]}" .
|
||||
|
||||
for tag in "${tags[@]}"; do
|
||||
docker push "${image}:${tag}"
|
||||
done
|
||||
-263
@@ -1,263 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
)
|
||||
|
||||
var initCmd = &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Interactive config setup wizard",
|
||||
Long: `Creates a new config.yaml with interactive prompts.
|
||||
Walks through all modules and profiles step by step.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfgPath, err := getConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if config already exists
|
||||
if _, err := os.Stat(cfgPath); err == nil {
|
||||
fmt.Printf("Config already exists at %s\n", cfgPath)
|
||||
fmt.Print("Overwrite? [y/N]: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
if answer != "y" && answer != "yes" {
|
||||
fmt.Println("Aborted.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
cfg := runWizard()
|
||||
if cfg == nil {
|
||||
fmt.Println("\nSetup aborted.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := config.Save(cfgPath, cfg); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n✅ Config saved to %s\n", cfgPath)
|
||||
fmt.Println("Run 'gosleep-timer' to start the TUI.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(initCmd)
|
||||
}
|
||||
|
||||
func runWizard() *config.Config {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
cfg := config.DefaultConfig()
|
||||
p := &config.Profile{}
|
||||
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
fmt.Println(" ⏱ gosleep-timer Setup Wizard")
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
|
||||
// Profile name
|
||||
fmt.Print("\nProfile name [default]: ")
|
||||
name := readLine(reader, "default")
|
||||
|
||||
// Duration
|
||||
fmt.Print("Default duration (e.g. 25m, 1h, 90s) [25m]: ")
|
||||
dur := readLine(reader, "25m")
|
||||
|
||||
// Workspace module
|
||||
fmt.Println(strings.Repeat("-", 50))
|
||||
fmt.Println("Workspace — switch to a workspace on timer start")
|
||||
wsEnabled := askYesNo(reader, "Enable workspace switch?", true)
|
||||
ws := &config.WorkspaceConfig{Enabled: wsEnabled}
|
||||
if wsEnabled {
|
||||
fmt.Print("Workspace number [12]: ")
|
||||
wsStr := readLine(reader, "12")
|
||||
ws.Workspace, _ = strconv.Atoi(wsStr)
|
||||
if ws.Workspace == 0 {
|
||||
ws.Workspace = 12
|
||||
}
|
||||
|
||||
fmt.Println("Backend (auto-detect / niri / hyprland / kde):")
|
||||
fmt.Print(" [auto]: ")
|
||||
ws.Backend = readLine(reader, "auto")
|
||||
if ws.Backend == "auto" {
|
||||
ws.Backend = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Media module
|
||||
fmt.Println(strings.Repeat("-", 50))
|
||||
fmt.Println("Media — control music player on timer start")
|
||||
mediaEnabled := askYesNo(reader, "Enable media control?", true)
|
||||
media := &config.MediaConfig{Enabled: mediaEnabled}
|
||||
if mediaEnabled {
|
||||
fmt.Println("Player action (stop / pause / play-pause / next / previous / none):")
|
||||
fmt.Print(" [stop]: ")
|
||||
action := readLine(reader, "stop")
|
||||
media.Action = action
|
||||
}
|
||||
|
||||
// Lock module
|
||||
fmt.Println(strings.Repeat("-", 50))
|
||||
fmt.Println("Lock — lock screen when timer finishes")
|
||||
lockEnabled := askYesNo(reader, "Enable screen lock?", true)
|
||||
lock := &config.LockConfig{Enabled: lockEnabled}
|
||||
if lockEnabled {
|
||||
fmt.Print("Custom lock command (leave empty for auto): ")
|
||||
cmd := readLine(reader, "")
|
||||
lock.Command = cmd
|
||||
}
|
||||
|
||||
// Kill module
|
||||
fmt.Println(strings.Repeat("-", 50))
|
||||
fmt.Println("Kill — terminate apps when timer starts")
|
||||
killEnabled := askYesNo(reader, "Enable process killer?", false)
|
||||
kill := &config.KillConfig{Enabled: killEnabled}
|
||||
if killEnabled {
|
||||
fmt.Print("Processes to kill (comma separated, e.g. firefox,telegram): ")
|
||||
procs := readLine(reader, "")
|
||||
if procs != "" {
|
||||
for _, p := range strings.Split(procs, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
kill.Processes = append(kill.Processes, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify module
|
||||
fmt.Println(strings.Repeat("-", 50))
|
||||
fmt.Println("Notify — desktop notification when timer finishes")
|
||||
notifyEnabled := askYesNo(reader, "Enable notifications?", true)
|
||||
notify := &config.NotifyConfig{Enabled: notifyEnabled}
|
||||
if notifyEnabled {
|
||||
fmt.Print("Notification sound path (empty = no sound): ")
|
||||
notify.Sound = readLine(reader, "")
|
||||
}
|
||||
|
||||
// Sound module
|
||||
fmt.Println(strings.Repeat("-", 50))
|
||||
fmt.Println("Sound — play audio when timer finishes")
|
||||
soundEnabled := askYesNo(reader, "Enable sound?", true)
|
||||
sound := &config.SoundConfig{Enabled: soundEnabled}
|
||||
if soundEnabled {
|
||||
sound.File = "/usr/share/sounds/freedesktop/stereo/complete.oga"
|
||||
fmt.Printf(" Default: %s\n", sound.File)
|
||||
fmt.Print("Custom sound file (leave empty for default): ")
|
||||
custom := readLine(reader, "")
|
||||
if custom != "" {
|
||||
sound.File = custom
|
||||
}
|
||||
}
|
||||
|
||||
// Brightness module
|
||||
fmt.Println(strings.Repeat("-", 50))
|
||||
fmt.Println("Brightness — dim screen during timer")
|
||||
brightnessEnabled := askYesNo(reader, "Enable brightness control?", false)
|
||||
brightness := &config.BrightnessConfig{Enabled: brightnessEnabled}
|
||||
if brightnessEnabled {
|
||||
fmt.Print("Brightness level 0-100 [0]: ")
|
||||
valStr := readLine(reader, "0")
|
||||
val, _ := strconv.Atoi(valStr)
|
||||
brightness.Value = val
|
||||
}
|
||||
|
||||
// Mute module
|
||||
fmt.Println(strings.Repeat("-", 50))
|
||||
fmt.Println("Mute — mute audio during timer")
|
||||
muteEnabled := askYesNo(reader, "Enable mute?", false)
|
||||
mute := &config.MuteConfig{Enabled: muteEnabled}
|
||||
|
||||
// Custom commands
|
||||
fmt.Println(strings.Repeat("-", 50))
|
||||
fmt.Println("Custom — run your own commands")
|
||||
customEnabled := askYesNo(reader, "Enable custom commands?", false)
|
||||
custom := &config.CustomConfig{Enabled: customEnabled}
|
||||
if customEnabled {
|
||||
fmt.Println("Pre-commands (run BEFORE timer, one per line, empty line to end):")
|
||||
custom.Pre = readLines(reader, "pre")
|
||||
fmt.Println("Post-commands (run AFTER timer, one per line, empty line to end):")
|
||||
custom.Post = readLines(reader, "post")
|
||||
}
|
||||
|
||||
// Script module
|
||||
fmt.Println(strings.Repeat("-", 50))
|
||||
fmt.Println("Script — run a script with timer context (args: pre/post, profile, duration)")
|
||||
scriptEnabled := askYesNo(reader, "Enable script module?", false)
|
||||
script := &config.ScriptConfig{Enabled: scriptEnabled}
|
||||
if scriptEnabled {
|
||||
fmt.Print("Script path: ")
|
||||
script.Path = readLine(reader, "")
|
||||
}
|
||||
|
||||
// Build final config
|
||||
p.Modules = &config.Modules{
|
||||
Workspace: ws,
|
||||
Media: media,
|
||||
Lock: lock,
|
||||
Kill: kill,
|
||||
Notify: notify,
|
||||
Sound: sound,
|
||||
Brightness: brightness,
|
||||
Mute: mute,
|
||||
Custom: custom,
|
||||
Script: script,
|
||||
}
|
||||
|
||||
cfg.Profiles = map[string]config.Profile{
|
||||
name: *p,
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
fmt.Println(" ✅ Setup complete!")
|
||||
fmt.Printf(" Profile: %s\n", name)
|
||||
fmt.Printf(" Duration: %s\n", dur)
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func readLine(reader *bufio.Reader, fallback string) string {
|
||||
line, _ := reader.ReadString('\n')
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return fallback
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func askYesNo(reader *bufio.Reader, prompt string, defaultYes bool) bool {
|
||||
suffix := "[Y/n]"
|
||||
if !defaultYes {
|
||||
suffix = "[y/N]"
|
||||
}
|
||||
fmt.Printf("%s %s: ", prompt, suffix)
|
||||
answer := strings.ToLower(strings.TrimSpace(readLine(reader, "")))
|
||||
if answer == "" {
|
||||
return defaultYes
|
||||
}
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
func readLines(reader *bufio.Reader, label string) []string {
|
||||
var lines []string
|
||||
for {
|
||||
fmt.Printf(" %s> ", label)
|
||||
line, _ := reader.ReadString('\n')
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func DefaultConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("home dir: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".config", "gosleep-timer", "config.yaml"), nil
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = make(map[string]Profile)
|
||||
}
|
||||
|
||||
resolved := make(map[string]Profile, len(cfg.Profiles))
|
||||
for name := range cfg.Profiles {
|
||||
seen := make(map[string]bool)
|
||||
r := resolveExtends(cfg.Profiles, name, seen)
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("circular extends detected for profile %q", name)
|
||||
}
|
||||
resolved[name] = *r
|
||||
}
|
||||
cfg.Profiles = resolved
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func Save(path string, cfg *Config) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir config dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadOrCreate(path string) (*Config, error) {
|
||||
cfg, err := Load(path)
|
||||
if err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg = DefaultConfig()
|
||||
if err := Save(path, cfg); err != nil {
|
||||
return nil, fmt.Errorf("create default config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func SaveToString(cfg *Config) (string, error) {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func DetectDesktop() string {
|
||||
if de := os.Getenv("XDG_CURRENT_DESKTOP"); de != "" {
|
||||
lower := strings.ToLower(de)
|
||||
if strings.Contains(lower, "hyprland") {
|
||||
return "hyprland"
|
||||
}
|
||||
if strings.Contains(lower, "kde") || strings.Contains(lower, "plasma") {
|
||||
return "kde"
|
||||
}
|
||||
if strings.Contains(lower, "niri") {
|
||||
return "niri"
|
||||
}
|
||||
}
|
||||
|
||||
if err := exec.Command("hyprctl", "instances").Run(); err == nil {
|
||||
return "hyprland"
|
||||
}
|
||||
|
||||
if err := exec.Command("niri", "msg", "-h").Run(); err == nil {
|
||||
return "niri"
|
||||
}
|
||||
|
||||
return "generic"
|
||||
}
|
||||
|
||||
func resolveExtends(profiles map[string]Profile, name string, seen map[string]bool) *Profile {
|
||||
if seen[name] {
|
||||
return nil
|
||||
}
|
||||
seen[name] = true
|
||||
|
||||
p, ok := profiles[name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
base := &Profile{}
|
||||
if p.Extends != "" {
|
||||
base = resolveExtends(profiles, p.Extends, seen)
|
||||
if base == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
merged := &Profile{
|
||||
Extends: p.Extends,
|
||||
Modules: copyModules(base.Modules),
|
||||
}
|
||||
|
||||
if p.Modules != nil {
|
||||
if merged.Modules == nil {
|
||||
merged.Modules = &Modules{}
|
||||
}
|
||||
overlayModules(merged.Modules, p.Modules)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func copyModules(m *Modules) *Modules {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
c := &Modules{}
|
||||
if m.Workspace != nil {
|
||||
v := *m.Workspace
|
||||
c.Workspace = &v
|
||||
}
|
||||
if m.Media != nil {
|
||||
v := *m.Media
|
||||
c.Media = &v
|
||||
}
|
||||
if m.Lock != nil {
|
||||
v := *m.Lock
|
||||
c.Lock = &v
|
||||
}
|
||||
if m.Kill != nil {
|
||||
v := *m.Kill
|
||||
v.Processes = append([]string(nil), m.Kill.Processes...)
|
||||
c.Kill = &v
|
||||
}
|
||||
if m.Notify != nil {
|
||||
v := *m.Notify
|
||||
c.Notify = &v
|
||||
}
|
||||
if m.Sound != nil {
|
||||
v := *m.Sound
|
||||
c.Sound = &v
|
||||
}
|
||||
if m.Brightness != nil {
|
||||
v := *m.Brightness
|
||||
c.Brightness = &v
|
||||
}
|
||||
if m.Mute != nil {
|
||||
v := *m.Mute
|
||||
c.Mute = &v
|
||||
}
|
||||
if m.Custom != nil {
|
||||
v := *m.Custom
|
||||
v.Pre = append([]string(nil), m.Custom.Pre...)
|
||||
v.Post = append([]string(nil), m.Custom.Post...)
|
||||
c.Custom = &v
|
||||
}
|
||||
if m.Script != nil {
|
||||
v := *m.Script
|
||||
c.Script = &v
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func overlayModules(base, overlay *Modules) {
|
||||
if overlay.Workspace != nil {
|
||||
if base.Workspace == nil {
|
||||
base.Workspace = &WorkspaceConfig{}
|
||||
}
|
||||
base.Workspace.Enabled = overlay.Workspace.Enabled
|
||||
if overlay.Workspace.Backend != "" {
|
||||
base.Workspace.Backend = overlay.Workspace.Backend
|
||||
}
|
||||
if overlay.Workspace.Workspace != 0 {
|
||||
base.Workspace.Workspace = overlay.Workspace.Workspace
|
||||
}
|
||||
}
|
||||
if overlay.Media != nil {
|
||||
if base.Media == nil {
|
||||
base.Media = &MediaConfig{}
|
||||
}
|
||||
base.Media.Enabled = overlay.Media.Enabled
|
||||
if overlay.Media.Action != "" {
|
||||
base.Media.Action = overlay.Media.Action
|
||||
}
|
||||
}
|
||||
if overlay.Lock != nil {
|
||||
if base.Lock == nil {
|
||||
base.Lock = &LockConfig{}
|
||||
}
|
||||
base.Lock.Enabled = overlay.Lock.Enabled
|
||||
if overlay.Lock.Command != "" {
|
||||
base.Lock.Command = overlay.Lock.Command
|
||||
}
|
||||
}
|
||||
if overlay.Kill != nil {
|
||||
base.Kill = &KillConfig{
|
||||
Enabled: overlay.Kill.Enabled,
|
||||
Processes: append([]string(nil), overlay.Kill.Processes...),
|
||||
}
|
||||
}
|
||||
if overlay.Notify != nil {
|
||||
if base.Notify == nil {
|
||||
base.Notify = &NotifyConfig{}
|
||||
}
|
||||
base.Notify.Enabled = overlay.Notify.Enabled
|
||||
if overlay.Notify.Sound != "" {
|
||||
base.Notify.Sound = overlay.Notify.Sound
|
||||
}
|
||||
}
|
||||
if overlay.Sound != nil {
|
||||
if base.Sound == nil {
|
||||
base.Sound = &SoundConfig{}
|
||||
}
|
||||
base.Sound.Enabled = overlay.Sound.Enabled
|
||||
if overlay.Sound.File != "" {
|
||||
base.Sound.File = overlay.Sound.File
|
||||
}
|
||||
}
|
||||
if overlay.Brightness != nil {
|
||||
base.Brightness = &BrightnessConfig{
|
||||
Enabled: overlay.Brightness.Enabled,
|
||||
Value: overlay.Brightness.Value,
|
||||
}
|
||||
}
|
||||
if overlay.Mute != nil {
|
||||
base.Mute = &MuteConfig{
|
||||
Enabled: overlay.Mute.Enabled,
|
||||
}
|
||||
}
|
||||
if overlay.Custom != nil {
|
||||
base.Custom = &CustomConfig{
|
||||
Enabled: overlay.Custom.Enabled,
|
||||
Pre: append([]string(nil), overlay.Custom.Pre...),
|
||||
Post: append([]string(nil), overlay.Custom.Post...),
|
||||
}
|
||||
}
|
||||
if overlay.Script != nil {
|
||||
base.Script = &ScriptConfig{
|
||||
Enabled: overlay.Script.Enabled,
|
||||
Path: overlay.Script.Path,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package config
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Profiles: map[string]Profile{
|
||||
"default": {
|
||||
Modules: DefaultModules(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultModules() *Modules {
|
||||
return &Modules{
|
||||
Workspace: &WorkspaceConfig{
|
||||
Enabled: true,
|
||||
Backend: "",
|
||||
Workspace: 12,
|
||||
},
|
||||
Media: &MediaConfig{
|
||||
Enabled: true,
|
||||
Action: "stop",
|
||||
},
|
||||
Lock: &LockConfig{
|
||||
Enabled: true,
|
||||
Command: "",
|
||||
},
|
||||
Kill: &KillConfig{
|
||||
Enabled: false,
|
||||
Processes: nil,
|
||||
},
|
||||
Notify: &NotifyConfig{
|
||||
Enabled: false,
|
||||
Sound: "",
|
||||
},
|
||||
Sound: &SoundConfig{
|
||||
Enabled: true,
|
||||
File: "/usr/share/sounds/freedesktop/stereo/complete.oga",
|
||||
},
|
||||
Brightness: &BrightnessConfig{
|
||||
Enabled: false,
|
||||
Value: 0,
|
||||
},
|
||||
Mute: &MuteConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
Custom: &CustomConfig{
|
||||
Enabled: false,
|
||||
Pre: nil,
|
||||
Post: nil,
|
||||
},
|
||||
Script: &ScriptConfig{
|
||||
Enabled: false,
|
||||
Path: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package config
|
||||
|
||||
type Config struct {
|
||||
Profiles map[string]Profile `yaml:"profiles"`
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
Extends string `yaml:"extends,omitempty"`
|
||||
Modules *Modules `yaml:"modules,omitempty"`
|
||||
}
|
||||
|
||||
type Modules struct {
|
||||
Workspace *WorkspaceConfig `yaml:"workspace,omitempty"`
|
||||
Media *MediaConfig `yaml:"media,omitempty"`
|
||||
Lock *LockConfig `yaml:"lock,omitempty"`
|
||||
Kill *KillConfig `yaml:"kill,omitempty"`
|
||||
Notify *NotifyConfig `yaml:"notify,omitempty"`
|
||||
Sound *SoundConfig `yaml:"sound,omitempty"`
|
||||
Brightness *BrightnessConfig `yaml:"brightness,omitempty"`
|
||||
Mute *MuteConfig `yaml:"mute,omitempty"`
|
||||
Custom *CustomConfig `yaml:"custom,omitempty"`
|
||||
Script *ScriptConfig `yaml:"script,omitempty"`
|
||||
}
|
||||
|
||||
type WorkspaceConfig struct {
|
||||
Enabled bool `yaml:"enabled" default:"true"`
|
||||
Backend string `yaml:"backend"` // auto, niri, hyprland, kde
|
||||
Workspace int `yaml:"workspace"`
|
||||
}
|
||||
|
||||
type MediaConfig struct {
|
||||
Enabled bool `yaml:"enabled" default:"true"`
|
||||
Action string `yaml:"action"` // stop, pause, play-pause, next, previous, none
|
||||
}
|
||||
|
||||
type LockConfig struct {
|
||||
Enabled bool `yaml:"enabled" default:"true"`
|
||||
Command string `yaml:"command"` // custom lock cmd, empty = auto
|
||||
}
|
||||
|
||||
type KillConfig struct {
|
||||
Enabled bool `yaml:"enabled" default:"false"`
|
||||
Processes []string `yaml:"processes"`
|
||||
}
|
||||
|
||||
type NotifyConfig struct {
|
||||
Enabled bool `yaml:"enabled" default:"true"`
|
||||
Sound string `yaml:"sound"` // path or empty
|
||||
}
|
||||
|
||||
type SoundConfig struct {
|
||||
Enabled bool `yaml:"enabled" default:"true"`
|
||||
File string `yaml:"file"`
|
||||
}
|
||||
|
||||
type BrightnessConfig struct {
|
||||
Enabled bool `yaml:"enabled" default:"false"`
|
||||
Value int `yaml:"value"` // 0-100
|
||||
}
|
||||
|
||||
type MuteConfig struct {
|
||||
Enabled bool `yaml:"enabled" default:"false"`
|
||||
}
|
||||
|
||||
type CustomConfig struct {
|
||||
Enabled bool `yaml:"enabled" default:"false"`
|
||||
Pre []string `yaml:"pre"`
|
||||
Post []string `yaml:"post"`
|
||||
}
|
||||
|
||||
type ScriptConfig struct {
|
||||
Enabled bool `yaml:"enabled" default:"false"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ExecResult struct {
|
||||
Cmd string
|
||||
Output string
|
||||
Err error
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
func Execute(ctx context.Context, cmd string, timeout time.Duration) ExecResult {
|
||||
start := time.Now()
|
||||
|
||||
var cancel context.CancelFunc
|
||||
if timeout > 0 {
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
out, err := exec.CommandContext(ctx, "bash", "-c", cmd).CombinedOutput()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
return ExecResult{
|
||||
Cmd: cmd,
|
||||
Output: string(out),
|
||||
Err: err,
|
||||
Elapsed: elapsed,
|
||||
}
|
||||
}
|
||||
|
||||
func ExecuteAll(ctx context.Context, cmds []string, timeout time.Duration) []ExecResult {
|
||||
results := make([]ExecResult, len(cmds))
|
||||
for i, cmd := range cmds {
|
||||
results[i] = Execute(ctx, cmd, timeout)
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StageRunner struct {
|
||||
Timer *Timer
|
||||
PreCmds []string
|
||||
PostCmds []string
|
||||
Events chan TimerEvent
|
||||
ExecTimeout time.Duration
|
||||
}
|
||||
|
||||
func NewStageRunner(timer *Timer, pre, post []string) *StageRunner {
|
||||
return &StageRunner{
|
||||
Timer: timer,
|
||||
PreCmds: pre,
|
||||
PostCmds: post,
|
||||
Events: make(chan TimerEvent, 100),
|
||||
ExecTimeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func (sr *StageRunner) Run(ctx context.Context) {
|
||||
defer close(sr.Events)
|
||||
|
||||
// PreCmds
|
||||
for _, cmd := range sr.PreCmds {
|
||||
result := Execute(ctx, cmd, sr.ExecTimeout)
|
||||
if result.Err != nil {
|
||||
sr.Events <- TimerEvent{Stage: StagePre, Status: StatusError, Done: true}
|
||||
return
|
||||
}
|
||||
sr.Events <- TimerEvent{Stage: StagePre, Status: StatusRunning}
|
||||
}
|
||||
|
||||
// Timer
|
||||
timerEvents := make(chan TimerEvent, 100)
|
||||
go sr.Timer.Start(ctx, timerEvents)
|
||||
|
||||
timerDone := false
|
||||
timerLoop:
|
||||
for {
|
||||
select {
|
||||
case ev := <-timerEvents:
|
||||
sr.Events <- ev
|
||||
if ev.Done {
|
||||
timerDone = true
|
||||
break timerLoop
|
||||
}
|
||||
case <-ctx.Done():
|
||||
break timerLoop
|
||||
}
|
||||
}
|
||||
|
||||
// PostCmds (only on normal timer completion)
|
||||
if timerDone && sr.Timer.Status == StatusDone {
|
||||
for _, cmd := range sr.PostCmds {
|
||||
result := Execute(ctx, cmd, sr.ExecTimeout)
|
||||
if result.Err != nil {
|
||||
sr.Events <- TimerEvent{Stage: StagePost, Status: StatusError, Done: true}
|
||||
return
|
||||
}
|
||||
sr.Events <- TimerEvent{Stage: StagePost, Status: StatusRunning}
|
||||
}
|
||||
sr.Events <- TimerEvent{Status: StatusDone, Done: true}
|
||||
}
|
||||
}
|
||||
|
||||
func (sr *StageRunner) Stop() {
|
||||
sr.Timer.Stop()
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusIdle Status = iota
|
||||
StatusRunning
|
||||
StatusPaused
|
||||
StatusDone
|
||||
StatusKilled
|
||||
StatusError
|
||||
)
|
||||
|
||||
type StageType string
|
||||
|
||||
const (
|
||||
StagePre StageType = "pre"
|
||||
StageTimer StageType = "timer"
|
||||
StagePost StageType = "post"
|
||||
)
|
||||
|
||||
type TimerEvent struct {
|
||||
Remaining time.Duration
|
||||
Total time.Duration
|
||||
Stage StageType
|
||||
Status Status
|
||||
Done bool
|
||||
}
|
||||
|
||||
type Timer struct {
|
||||
Duration time.Duration
|
||||
Status Status
|
||||
StartedAt time.Time
|
||||
elapsed time.Duration
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if d, err := time.ParseDuration(s); err == nil {
|
||||
return d, nil
|
||||
}
|
||||
re := regexp.MustCompile(`(\d+)([hms])`)
|
||||
matches := re.FindAllStringSubmatch(s, -1)
|
||||
if len(matches) == 0 {
|
||||
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||
}
|
||||
var d time.Duration
|
||||
for _, m := range matches {
|
||||
v, _ := strconv.Atoi(m[1])
|
||||
switch m[2] {
|
||||
case "h":
|
||||
d += time.Duration(v) * time.Hour
|
||||
case "m":
|
||||
d += time.Duration(v) * time.Minute
|
||||
case "s":
|
||||
d += time.Duration(v) * time.Second
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func NewTimer(d time.Duration) *Timer {
|
||||
return &Timer{Duration: d, Status: StatusIdle}
|
||||
}
|
||||
|
||||
func (t *Timer) Start(ctx context.Context, events chan<- TimerEvent) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
t.cancel = cancel
|
||||
t.StartedAt = time.Now()
|
||||
t.Status = StatusRunning
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Status = StatusKilled
|
||||
events <- TimerEvent{Status: StatusKilled, Done: true}
|
||||
return
|
||||
case <-ticker.C:
|
||||
elapsed := time.Since(t.StartedAt)
|
||||
remaining := t.Duration - elapsed
|
||||
if remaining <= 0 {
|
||||
t.Status = StatusDone
|
||||
events <- TimerEvent{Remaining: 0, Total: t.Duration, Status: StatusDone, Done: true}
|
||||
return
|
||||
}
|
||||
events <- TimerEvent{
|
||||
Remaining: remaining,
|
||||
Total: t.Duration,
|
||||
Stage: StageTimer,
|
||||
Status: StatusRunning,
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (t *Timer) Stop() {
|
||||
if t.cancel != nil {
|
||||
t.cancel()
|
||||
}
|
||||
t.Status = StatusKilled
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
module github.com/forust/gosleep-timer
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,85 +0,0 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
Timestamp time.Time `json:"ts"`
|
||||
Profile string `json:"profile"`
|
||||
Duration string `json:"duration"`
|
||||
Status string `json:"status"` // completed, killed, error
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
path string
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func DefaultPath() (string, error) {
|
||||
// $XDG_DATA_HOME/gosleep-timer/history.jsonl
|
||||
// fallback ~/.local/share/gosleep-timer/history.jsonl
|
||||
dataHome := os.Getenv("XDG_DATA_HOME")
|
||||
if dataHome == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dataHome = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
dir := filepath.Join(dataHome, "gosleep-timer")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "history.jsonl"), nil
|
||||
}
|
||||
|
||||
func Open(path string) (*Log, error) {
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Log{path: path, file: f}, nil
|
||||
}
|
||||
|
||||
func (l *Log) Append(r Record) error {
|
||||
r.Timestamp = time.Now().UTC()
|
||||
data, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(l.file, string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (l *Log) Close() error {
|
||||
return l.file.Close()
|
||||
}
|
||||
|
||||
func ReadAll(path string) ([]Record, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Record{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var records []Record
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
var r Record
|
||||
if err := json.Unmarshal(scanner.Bytes(), &r); err != nil {
|
||||
continue // skip corrupt lines
|
||||
}
|
||||
records = append(records, r)
|
||||
}
|
||||
return records, scanner.Err()
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package history
|
||||
|
||||
import "time"
|
||||
|
||||
type Stats struct {
|
||||
TotalRuns int
|
||||
CompletedRuns int
|
||||
KilledRuns int
|
||||
ErrorRuns int
|
||||
TotalDuration time.Duration
|
||||
AvgDuration time.Duration
|
||||
MostUsedProfile string
|
||||
LastRun *Record
|
||||
}
|
||||
|
||||
func Calculate(records []Record) Stats {
|
||||
var s Stats
|
||||
profileCount := make(map[string]int)
|
||||
s.TotalRuns = len(records)
|
||||
|
||||
for _, r := range records {
|
||||
switch r.Status {
|
||||
case "completed":
|
||||
s.CompletedRuns++
|
||||
case "killed":
|
||||
s.KilledRuns++
|
||||
case "error":
|
||||
s.ErrorRuns++
|
||||
}
|
||||
|
||||
profileCount[r.Profile]++
|
||||
|
||||
d, err := time.ParseDuration(r.Duration)
|
||||
if err == nil {
|
||||
s.TotalDuration += d
|
||||
}
|
||||
|
||||
s.LastRun = &r
|
||||
}
|
||||
|
||||
if s.TotalRuns > 0 {
|
||||
s.AvgDuration = time.Duration(int64(s.TotalDuration) / int64(s.TotalRuns))
|
||||
}
|
||||
|
||||
maxCount := 0
|
||||
for name, count := range profileCount {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
s.MostUsedProfile = name
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/engine"
|
||||
"github.com/forust/gosleep-timer/history"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
"github.com/forust/gosleep-timer/tui"
|
||||
|
||||
// Register all modules
|
||||
_ "github.com/forust/gosleep-timer/modules/brightness"
|
||||
_ "github.com/forust/gosleep-timer/modules/custom"
|
||||
_ "github.com/forust/gosleep-timer/modules/kill"
|
||||
_ "github.com/forust/gosleep-timer/modules/lock"
|
||||
_ "github.com/forust/gosleep-timer/modules/media"
|
||||
_ "github.com/forust/gosleep-timer/modules/mute"
|
||||
_ "github.com/forust/gosleep-timer/modules/notify"
|
||||
_ "github.com/forust/gosleep-timer/modules/script"
|
||||
_ "github.com/forust/gosleep-timer/modules/sound"
|
||||
_ "github.com/forust/gosleep-timer/modules/workspace"
|
||||
)
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
profile string
|
||||
cliMode bool
|
||||
quiet bool
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "gosleep-timer",
|
||||
Short: "TUI sleep timer with modular WM/DE support",
|
||||
Long: `gosleep-timer — configurable timer with modules for workspace switching,
|
||||
media control, screen locking, process killing, notifications, and more.
|
||||
|
||||
Supports niri, Hyprland, KDE, and custom commands.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Init modules registry
|
||||
modules.Init()
|
||||
|
||||
// Load config
|
||||
cfgPath := cfgFile
|
||||
if cfgPath == "" {
|
||||
var err error
|
||||
cfgPath, err = config.DefaultConfigPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("config path: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := config.LoadOrCreate(cfgPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
|
||||
// CLI mode (run without TUI)
|
||||
if cliMode {
|
||||
return runCLI(cfg, args)
|
||||
}
|
||||
|
||||
// Validate modules
|
||||
if errs := modules.ValidateAll(); len(errs) > 0 {
|
||||
for _, e := range errs {
|
||||
fmt.Fprintf(os.Stderr, "⚠ module warning: %v\n", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Start TUI
|
||||
m := tui.NewModel(cfg)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
return fmt.Errorf("tui error: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var listProfilesCmd = &cobra.Command{
|
||||
Use: "list-profiles",
|
||||
Short: "List available profiles",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for name := range cfg.Profiles {
|
||||
p := cfg.Profiles[name]
|
||||
extends := ""
|
||||
if p.Extends != "" {
|
||||
extends = fmt.Sprintf(" (extends: %s)", p.Extends)
|
||||
}
|
||||
fmt.Printf(" %s%s\n", name, extends)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var exportCmd = &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "Export config to stdout",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := config.SaveToString(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Print(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var importCmd = &cobra.Command{
|
||||
Use: "import < file",
|
||||
Short: "Import config from stdin",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfgPath, err := getConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("/dev/stdin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read stdin: %w", err)
|
||||
}
|
||||
|
||||
var cfg config.Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
if err := config.Save(cfgPath, &cfg); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Config imported to %s\n", cfgPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var historyCmd = &cobra.Command{
|
||||
Use: "history",
|
||||
Short: "Show timer history",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path, err := history.DefaultPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records, err := history.ReadAll(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
fmt.Println("No history.")
|
||||
return nil
|
||||
}
|
||||
for _, r := range records {
|
||||
errStr := ""
|
||||
if r.Error != "" {
|
||||
errStr = fmt.Sprintf(" error=%s", r.Error)
|
||||
}
|
||||
fmt.Printf("%s | %-8s | %-10s | %s%s\n",
|
||||
r.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
r.Duration,
|
||||
r.Status,
|
||||
r.Profile,
|
||||
errStr,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var statsCmd = &cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "Show timer statistics",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path, err := history.DefaultPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records, err := history.ReadAll(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := history.Calculate(records)
|
||||
fmt.Printf("Total runs: %d\n", s.TotalRuns)
|
||||
fmt.Printf("Completed: %d\n", s.CompletedRuns)
|
||||
fmt.Printf("Killed: %d\n", s.KilledRuns)
|
||||
fmt.Printf("Errors: %d\n", s.ErrorRuns)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func runCLI(cfg *config.Config, args []string) error {
|
||||
duration := "25m"
|
||||
if len(args) > 0 {
|
||||
duration = args[0]
|
||||
}
|
||||
|
||||
d, err := engine.ParseDuration(duration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q: %w", duration, err)
|
||||
}
|
||||
|
||||
profileName := profile
|
||||
if profileName == "" {
|
||||
profileName = "default"
|
||||
}
|
||||
|
||||
p, ok := cfg.Profiles[profileName]
|
||||
if !ok {
|
||||
return fmt.Errorf("profile %q not found", profileName)
|
||||
}
|
||||
|
||||
modCfg := p.Modules
|
||||
if modCfg == nil {
|
||||
modCfg = config.DefaultModules()
|
||||
}
|
||||
|
||||
modCtx := &modules.ModuleContext{
|
||||
Context: context.Background(),
|
||||
Profile: profileName,
|
||||
Duration: duration,
|
||||
Config: modCfg,
|
||||
}
|
||||
|
||||
preCmds := modules.CollectPreCmds(modCtx)
|
||||
postCmds := modules.CollectPostCmds(modCtx)
|
||||
|
||||
if !quiet {
|
||||
fmt.Printf("Timer: %s | Profile: %s\n", duration, profileName)
|
||||
fmt.Printf("Pre: %s\n", strings.Join(preCmds, "; "))
|
||||
}
|
||||
|
||||
t := engine.NewTimer(d)
|
||||
sr := engine.NewStageRunner(t, preCmds, postCmds)
|
||||
|
||||
// Handle signals
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
<-sigCh
|
||||
sr.Stop()
|
||||
cancel()
|
||||
if !quiet {
|
||||
fmt.Println("\n⛔ Timer killed")
|
||||
}
|
||||
}()
|
||||
|
||||
go sr.Run(ctx)
|
||||
|
||||
for evt := range sr.Events {
|
||||
if evt.Done {
|
||||
break
|
||||
}
|
||||
if evt.Stage == engine.StageTimer && !quiet {
|
||||
rem := evt.Remaining.Round(time.Second)
|
||||
fmt.Printf("\r⏱ %s remaining...", rem)
|
||||
}
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
fmt.Println("\n✅ Timer completed!")
|
||||
}
|
||||
|
||||
// Record history
|
||||
histPath, err := history.DefaultPath()
|
||||
if err == nil {
|
||||
if l, err := history.Open(histPath); err == nil {
|
||||
l.Append(history.Record{
|
||||
Profile: profileName,
|
||||
Duration: duration,
|
||||
Status: "completed",
|
||||
})
|
||||
l.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadConfig() (*config.Config, error) {
|
||||
path, err := getConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config.LoadOrCreate(path)
|
||||
}
|
||||
|
||||
func getConfigPath() (string, error) {
|
||||
if cfgFile != "" {
|
||||
return cfgFile, nil
|
||||
}
|
||||
return config.DefaultConfigPath()
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path")
|
||||
rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "suppress output")
|
||||
|
||||
runCmd := &cobra.Command{
|
||||
Use: "run [duration]",
|
||||
Short: "Run timer in CLI mode",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cliMode = true
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCLI(cfg, args)
|
||||
},
|
||||
}
|
||||
runCmd.Flags().StringVarP(&profile, "profile", "p", "default", "profile name")
|
||||
rootCmd.AddCommand(runCmd)
|
||||
|
||||
rootCmd.AddCommand(listProfilesCmd)
|
||||
rootCmd.AddCommand(exportCmd)
|
||||
rootCmd.AddCommand(importCmd)
|
||||
rootCmd.AddCommand(historyCmd)
|
||||
rootCmd.AddCommand(statsCmd)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
)
|
||||
|
||||
type BrightnessModule struct {
|
||||
tool string
|
||||
}
|
||||
|
||||
func (m *BrightnessModule) Name() string {
|
||||
return "brightness"
|
||||
}
|
||||
|
||||
func (m *BrightnessModule) Enabled(cfg *config.Modules) bool {
|
||||
return cfg.Brightness != nil && cfg.Brightness.Enabled
|
||||
}
|
||||
|
||||
func (m *BrightnessModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
|
||||
val := ctx.Config.Brightness.Value
|
||||
if val < 0 {
|
||||
val = 0
|
||||
}
|
||||
if val > 100 {
|
||||
val = 100
|
||||
}
|
||||
|
||||
switch stage {
|
||||
case modules.StagePre:
|
||||
if m.tool == "light" {
|
||||
return []string{fmt.Sprintf("light -S %d", val)}
|
||||
}
|
||||
return []string{fmt.Sprintf("brightnessctl set %d%%", val)}
|
||||
case modules.StagePost:
|
||||
if m.tool == "light" {
|
||||
return []string{"light -S 100"}
|
||||
}
|
||||
return []string{"brightnessctl set 100%"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *BrightnessModule) Validate() error {
|
||||
if _, err := exec.LookPath("brightnessctl"); err == nil {
|
||||
m.tool = "brightnessctl"
|
||||
return nil
|
||||
}
|
||||
if _, err := exec.LookPath("light"); err == nil {
|
||||
m.tool = "light"
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("brightness: neither brightnessctl nor light found in PATH")
|
||||
}
|
||||
|
||||
func init() {
|
||||
modules.Register(&BrightnessModule{})
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package custom
|
||||
|
||||
import (
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
)
|
||||
|
||||
type CustomModule struct{}
|
||||
|
||||
func (m *CustomModule) Name() string {
|
||||
return "custom"
|
||||
}
|
||||
|
||||
func (m *CustomModule) Enabled(cfg *config.Modules) bool {
|
||||
return cfg.Custom != nil && cfg.Custom.Enabled
|
||||
}
|
||||
|
||||
func (m *CustomModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
|
||||
switch stage {
|
||||
case modules.StagePre:
|
||||
return ctx.Config.Custom.Pre
|
||||
case modules.StagePost:
|
||||
return ctx.Config.Custom.Post
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *CustomModule) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
modules.Register(&CustomModule{})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package kill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
)
|
||||
|
||||
type KillModule struct{}
|
||||
|
||||
func (m *KillModule) Name() string {
|
||||
return "kill"
|
||||
}
|
||||
|
||||
func (m *KillModule) Enabled(cfg *config.Modules) bool {
|
||||
return cfg.Kill != nil && cfg.Kill.Enabled && len(cfg.Kill.Processes) > 0
|
||||
}
|
||||
|
||||
func (m *KillModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
|
||||
if stage != modules.StagePre {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cmds []string
|
||||
for _, proc := range ctx.Config.Kill.Processes {
|
||||
if proc == "" {
|
||||
continue
|
||||
}
|
||||
cmds = append(cmds, fmt.Sprintf("pkill %s", proc))
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (m *KillModule) Validate() error {
|
||||
_, err := exec.LookPath("pkill")
|
||||
return err
|
||||
}
|
||||
|
||||
func init() {
|
||||
modules.Register(&KillModule{})
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package lock
|
||||
|
||||
import (
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
)
|
||||
|
||||
type LockModule struct{}
|
||||
|
||||
func (m *LockModule) Name() string {
|
||||
return "lock"
|
||||
}
|
||||
|
||||
func (m *LockModule) Enabled(cfg *config.Modules) bool {
|
||||
return cfg.Lock != nil && cfg.Lock.Enabled
|
||||
}
|
||||
|
||||
func (m *LockModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
|
||||
if stage != modules.StagePost {
|
||||
return nil
|
||||
}
|
||||
cmd := ctx.Config.Lock.Command
|
||||
if cmd == "" {
|
||||
cmd = "loginctl lock-session"
|
||||
}
|
||||
return []string{cmd}
|
||||
}
|
||||
|
||||
func (m *LockModule) Validate() error {
|
||||
// No strict validation — custom commands may be set
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
modules.Register(&LockModule{})
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
)
|
||||
|
||||
type MediaModule struct{}
|
||||
|
||||
func (m *MediaModule) Name() string {
|
||||
return "media"
|
||||
}
|
||||
|
||||
func (m *MediaModule) Enabled(cfg *config.Modules) bool {
|
||||
return cfg.Media != nil && cfg.Media.Enabled
|
||||
}
|
||||
|
||||
func (m *MediaModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
|
||||
if stage != modules.StagePre {
|
||||
return nil
|
||||
}
|
||||
if ctx.Config.Media.Action == "none" {
|
||||
return nil
|
||||
}
|
||||
return []string{fmt.Sprintf("playerctl %s", ctx.Config.Media.Action)}
|
||||
}
|
||||
|
||||
func (m *MediaModule) Validate() error {
|
||||
if _, err := exec.LookPath("playerctl"); err != nil {
|
||||
return fmt.Errorf("media: playerctl not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
modules.Register(&MediaModule{})
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package modules
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
)
|
||||
|
||||
type Stage string
|
||||
|
||||
const (
|
||||
StagePre Stage = "pre"
|
||||
StagePost Stage = "post"
|
||||
)
|
||||
|
||||
type ModuleContext struct {
|
||||
Context context.Context
|
||||
Profile string
|
||||
Duration string
|
||||
Config *config.Modules
|
||||
}
|
||||
|
||||
type ModuleResult struct {
|
||||
Module string
|
||||
Cmd string
|
||||
Output string
|
||||
Error error
|
||||
Stage Stage
|
||||
}
|
||||
|
||||
type Module interface {
|
||||
Name() string
|
||||
// Enabled returns true if this module should run for the given config
|
||||
Enabled(cfg *config.Modules) bool
|
||||
// BuildCmds returns shell commands for the given stage (pre/post)
|
||||
BuildCmds(ctx *ModuleContext, stage Stage) []string
|
||||
// Validate checks if required binaries exist
|
||||
Validate() error
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package mute
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
)
|
||||
|
||||
type MuteModule struct {
|
||||
tool string
|
||||
}
|
||||
|
||||
func (m *MuteModule) Name() string {
|
||||
return "mute"
|
||||
}
|
||||
|
||||
func (m *MuteModule) Enabled(cfg *config.Modules) bool {
|
||||
return cfg.Mute != nil && cfg.Mute.Enabled
|
||||
}
|
||||
|
||||
func (m *MuteModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
|
||||
switch stage {
|
||||
case modules.StagePre:
|
||||
if m.tool == "wpctl" {
|
||||
return []string{"wpctl set-mute @DEFAULT_AUDIO_SINK@ 1"}
|
||||
}
|
||||
return []string{"pactl set-sink-mute @DEFAULT_SINK@ 1"}
|
||||
case modules.StagePost:
|
||||
if m.tool == "wpctl" {
|
||||
return []string{"wpctl set-mute @DEFAULT_AUDIO_SINK@ 0"}
|
||||
}
|
||||
return []string{"pactl set-sink-mute @DEFAULT_SINK@ 0"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MuteModule) Validate() error {
|
||||
if _, err := exec.LookPath("pactl"); err == nil {
|
||||
m.tool = "pactl"
|
||||
return nil
|
||||
}
|
||||
if _, err := exec.LookPath("wpctl"); err == nil {
|
||||
m.tool = "wpctl"
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("mute: neither pactl nor wpctl found in PATH")
|
||||
}
|
||||
|
||||
func init() {
|
||||
modules.Register(&MuteModule{})
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
)
|
||||
|
||||
type NotifyModule struct{}
|
||||
|
||||
func (m *NotifyModule) Name() string {
|
||||
return "notify"
|
||||
}
|
||||
|
||||
func (m *NotifyModule) Enabled(cfg *config.Modules) bool {
|
||||
return cfg.Notify != nil && cfg.Notify.Enabled
|
||||
}
|
||||
|
||||
func (m *NotifyModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
|
||||
if stage != modules.StagePost {
|
||||
return nil
|
||||
}
|
||||
|
||||
title := "gosleep-timer"
|
||||
body := fmt.Sprintf("Timer %s finished", ctx.Duration)
|
||||
cmds := []string{fmt.Sprintf("notify-send %q %q", title, body)}
|
||||
|
||||
if ctx.Config.Notify.Sound != "" {
|
||||
if _, err := exec.LookPath("paplay"); err == nil {
|
||||
cmds = append(cmds, fmt.Sprintf("paplay %s", ctx.Config.Notify.Sound))
|
||||
} else if _, err := exec.LookPath("aplay"); err == nil {
|
||||
cmds = append(cmds, fmt.Sprintf("aplay %s", ctx.Config.Notify.Sound))
|
||||
}
|
||||
}
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (m *NotifyModule) Validate() error {
|
||||
_, err := exec.LookPath("notify-send")
|
||||
return err
|
||||
}
|
||||
|
||||
func init() {
|
||||
modules.Register(&NotifyModule{})
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package modules
|
||||
|
||||
import "github.com/forust/gosleep-timer/config"
|
||||
|
||||
var registry []Module
|
||||
|
||||
func Register(m Module) {
|
||||
registry = append(registry, m)
|
||||
}
|
||||
|
||||
func GetAll() []Module {
|
||||
return registry
|
||||
}
|
||||
|
||||
func GetEnabled(cfg *config.Modules) []Module {
|
||||
var enabled []Module
|
||||
for _, m := range registry {
|
||||
if m.Enabled(cfg) {
|
||||
enabled = append(enabled, m)
|
||||
}
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
func CollectPreCmds(ctx *ModuleContext) []string {
|
||||
var cmds []string
|
||||
for _, m := range GetEnabled(ctx.Config) {
|
||||
cmds = append(cmds, m.BuildCmds(ctx, StagePre)...)
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
func CollectPostCmds(ctx *ModuleContext) []string {
|
||||
var cmds []string
|
||||
for _, m := range GetEnabled(ctx.Config) {
|
||||
cmds = append(cmds, m.BuildCmds(ctx, StagePost)...)
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
func ValidateAll() []error {
|
||||
var errs []error
|
||||
for _, m := range registry {
|
||||
if err := m.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func Init() {
|
||||
// registry starts empty; modules register themselves via init() or Register()
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
)
|
||||
|
||||
type ScriptModule struct{}
|
||||
|
||||
func (m *ScriptModule) Name() string {
|
||||
return "script"
|
||||
}
|
||||
|
||||
func (m *ScriptModule) Enabled(cfg *config.Modules) bool {
|
||||
return cfg.Script != nil && cfg.Script.Enabled && cfg.Script.Path != ""
|
||||
}
|
||||
|
||||
func (m *ScriptModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
|
||||
scriptPath := ctx.Config.Script.Path
|
||||
switch stage {
|
||||
case modules.StagePre:
|
||||
return []string{fmt.Sprintf("%s pre %s %s", scriptPath, ctx.Profile, ctx.Duration)}
|
||||
case modules.StagePost:
|
||||
return []string{fmt.Sprintf("%s post %s %s", scriptPath, ctx.Profile, ctx.Duration)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ScriptModule) Validate() error {
|
||||
// Validation happens after config is loaded, so we check at registration time
|
||||
// that the module itself is valid, but path is per-profile.
|
||||
// Return nil here; path validation happens at runtime in BuildCmds.
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
modules.Register(&ScriptModule{})
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package sound
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
)
|
||||
|
||||
type SoundModule struct {
|
||||
player string
|
||||
}
|
||||
|
||||
func (m *SoundModule) Name() string {
|
||||
return "sound"
|
||||
}
|
||||
|
||||
func (m *SoundModule) Enabled(cfg *config.Modules) bool {
|
||||
return cfg.Sound != nil && cfg.Sound.Enabled && cfg.Sound.File != ""
|
||||
}
|
||||
|
||||
func (m *SoundModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
|
||||
if stage != modules.StagePost {
|
||||
return nil
|
||||
}
|
||||
|
||||
file := ctx.Config.Sound.File
|
||||
if file == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
player := m.player
|
||||
if player == "" {
|
||||
player = detectPlayer()
|
||||
}
|
||||
|
||||
switch player {
|
||||
case "paplay":
|
||||
return []string{fmt.Sprintf("paplay %s", file)}
|
||||
case "aplay":
|
||||
return []string{fmt.Sprintf("aplay %s", file)}
|
||||
case "ffplay":
|
||||
return []string{fmt.Sprintf("ffplay -nodisp -autoexit %s", file)}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SoundModule) Validate() error {
|
||||
player := detectPlayer()
|
||||
if player == "" {
|
||||
return fmt.Errorf("no audio player found (paplay, aplay, or ffplay)")
|
||||
}
|
||||
m.player = player
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectPlayer() string {
|
||||
if _, err := exec.LookPath("paplay"); err == nil {
|
||||
return "paplay"
|
||||
}
|
||||
if _, err := exec.LookPath("aplay"); err == nil {
|
||||
return "aplay"
|
||||
}
|
||||
if _, err := exec.LookPath("ffplay"); err == nil {
|
||||
return "ffplay"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
modules.Register(&SoundModule{})
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
)
|
||||
|
||||
type WorkspaceModule struct{}
|
||||
|
||||
func (m *WorkspaceModule) Name() string {
|
||||
return "workspace"
|
||||
}
|
||||
|
||||
func (m *WorkspaceModule) Enabled(cfg *config.Modules) bool {
|
||||
return cfg.Workspace != nil && cfg.Workspace.Enabled
|
||||
}
|
||||
|
||||
func (m *WorkspaceModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
|
||||
if stage != modules.StagePre {
|
||||
return nil
|
||||
}
|
||||
|
||||
backend := ctx.Config.Workspace.Backend
|
||||
if backend == "" || backend == "auto" {
|
||||
backend = config.DetectDesktop()
|
||||
}
|
||||
|
||||
n := ctx.Config.Workspace.Workspace
|
||||
switch backend {
|
||||
case "niri":
|
||||
return []string{fmt.Sprintf("niri msg action focus-workspace %d", n)}
|
||||
case "hyprland":
|
||||
return []string{fmt.Sprintf("hyprctl dispatch workspace %d", n)}
|
||||
case "kde":
|
||||
return []string{fmt.Sprintf("qdbus org.kde.KWin /KWin setCurrentDesktop %d", n)}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *WorkspaceModule) Validate() error {
|
||||
// Check common workspace binaries
|
||||
for _, bin := range []string{"niri", "hyprctl", "qdbus"} {
|
||||
if _, err := exec.LookPath(bin); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("workspace: no supported backend binary found (niri, hyprctl, qdbus)")
|
||||
}
|
||||
|
||||
func init() {
|
||||
modules.Register(&WorkspaceModule{})
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewManager(cfg *config.Config) *Manager {
|
||||
return &Manager{cfg: cfg}
|
||||
}
|
||||
|
||||
func (m *Manager) List() []string {
|
||||
// Return sorted list of profile names
|
||||
names := make([]string, 0, len(m.cfg.Profiles))
|
||||
for name := range m.cfg.Profiles {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func (m *Manager) Get(name string) (*config.Profile, error) {
|
||||
p, ok := m.cfg.Profiles[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("profile %q not found", name)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Info(name string) (*Info, error) {
|
||||
p, err := m.Get(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := &Info{Name: name}
|
||||
if p.Modules != nil {
|
||||
m := p.Modules
|
||||
if m.Workspace != nil && m.Workspace.Enabled {
|
||||
info.Modules = append(info.Modules, "workspace")
|
||||
}
|
||||
if m.Media != nil && m.Media.Enabled {
|
||||
info.Modules = append(info.Modules, "media")
|
||||
}
|
||||
if m.Lock != nil && m.Lock.Enabled {
|
||||
info.Modules = append(info.Modules, "lock")
|
||||
}
|
||||
if m.Kill != nil && m.Kill.Enabled {
|
||||
info.Modules = append(info.Modules, "kill")
|
||||
}
|
||||
if m.Notify != nil && m.Notify.Enabled {
|
||||
info.Modules = append(info.Modules, "notify")
|
||||
}
|
||||
if m.Sound != nil && m.Sound.Enabled {
|
||||
info.Modules = append(info.Modules, "sound")
|
||||
}
|
||||
if m.Brightness != nil && m.Brightness.Enabled {
|
||||
info.Modules = append(info.Modules, "brightness")
|
||||
}
|
||||
if m.Mute != nil && m.Mute.Enabled {
|
||||
info.Modules = append(info.Modules, "mute")
|
||||
}
|
||||
if m.Custom != nil && m.Custom.Enabled {
|
||||
info.Modules = append(info.Modules, "custom")
|
||||
}
|
||||
if m.Script != nil && m.Script.Enabled {
|
||||
info.Modules = append(info.Modules, "script")
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package profiles
|
||||
|
||||
type Info struct {
|
||||
Name string
|
||||
Description string
|
||||
Modules []string // list of enabled module names
|
||||
Duration string // optional default duration
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["clippy", "rustfmt"]
|
||||
+1172
File diff suppressed because it is too large
Load Diff
@@ -1,162 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import asyncio
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import (
|
||||
Static, Input, Checkbox, Button, Footer, Header, Select
|
||||
)
|
||||
from textual.containers import Vertical, Horizontal, Center
|
||||
|
||||
|
||||
class TimerTUI(App):
|
||||
CSS = """
|
||||
Screen {
|
||||
align: center middle;
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
#main {
|
||||
width: 50%;
|
||||
border: solid #33ccff;
|
||||
padding: 1 2;
|
||||
background: #181818;
|
||||
border-title-align: center;
|
||||
border-title-color: cyan;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #33ccff;
|
||||
text-align: center;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
Input, Select {
|
||||
width: 100%;
|
||||
border: round #444;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
Checkbox {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Button {
|
||||
width: 48%;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
#status {
|
||||
color: cyan;
|
||||
text-align: center;
|
||||
padding-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.timer_process = None
|
||||
self.message_label = Static("", id="status")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Center():
|
||||
with Vertical(id="main"):
|
||||
yield Static("⏱ TIMER CONFIGURATION ⏱", classes="label")
|
||||
|
||||
self.input_time = Input(value="", placeholder="Время (например 20m)")
|
||||
self.cb_workspace = Checkbox("Переключить воркспейс", value=True)
|
||||
self.input_workspace = Input(value="12", placeholder="Номер воркспейса")
|
||||
|
||||
self.cb_player = Checkbox("Управлять плеером", value=True)
|
||||
self.select_player_action = Select(
|
||||
options=[
|
||||
("Ничего", "none"),
|
||||
("stop", "stop"),
|
||||
("pause", "pause"),
|
||||
("next", "next"),
|
||||
("previous", "previous"),
|
||||
],
|
||||
value="stop",
|
||||
)
|
||||
|
||||
self.cb_lock = Checkbox("Заблокировать экран", value=True)
|
||||
self.cb_kill = Checkbox("Убить приложения", value=False)
|
||||
self.input_kill = Input(value="firefox, telegram-desktop", placeholder="через запятую")
|
||||
|
||||
yield self.input_time
|
||||
yield self.cb_workspace
|
||||
yield self.input_workspace
|
||||
yield self.cb_player
|
||||
yield self.select_player_action
|
||||
yield self.cb_lock
|
||||
yield self.cb_kill
|
||||
yield self.input_kill
|
||||
|
||||
with Horizontal():
|
||||
yield Button("▶ Start / Restart Timer", id="start", variant="success")
|
||||
yield Button("✖ Quit", id="quit", variant="error")
|
||||
|
||||
yield self.message_label
|
||||
|
||||
yield Footer()
|
||||
|
||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "quit":
|
||||
await self.stop_timer()
|
||||
await self.action_quit()
|
||||
elif event.button.id == "start":
|
||||
await self.restart_timer()
|
||||
|
||||
async def stop_timer(self):
|
||||
if self.timer_process and self.timer_process.poll() is None:
|
||||
self.timer_process.terminate()
|
||||
await asyncio.sleep(0.2)
|
||||
if self.timer_process.poll() is None:
|
||||
self.timer_process.kill()
|
||||
|
||||
async def restart_timer(self):
|
||||
await self.stop_timer()
|
||||
self.notify("⏳ Запуск таймера...")
|
||||
|
||||
time_str = self.input_time.value.strip()
|
||||
commands = []
|
||||
|
||||
# Workspace switch
|
||||
if self.cb_workspace.value:
|
||||
workspace = self.input_workspace.value.strip()
|
||||
commands.append(f"niri msg action focus-workspace {workspace}")
|
||||
|
||||
# Player action
|
||||
if self.cb_player.value:
|
||||
action = self.select_player_action.value
|
||||
if action != "none":
|
||||
commands.append(f"playerctl {action}")
|
||||
|
||||
# Lock screen
|
||||
if self.cb_lock.value:
|
||||
commands.append("qs -c noctalia-shell ipc call lockScreen lock")
|
||||
|
||||
# Kill apps
|
||||
if self.cb_kill.value and self.input_kill.value.strip():
|
||||
for proc in self.input_kill.value.split(","):
|
||||
proc = proc.strip()
|
||||
if proc:
|
||||
commands.append(f"pkill {proc}")
|
||||
|
||||
cmd_after = " ; ".join(commands)
|
||||
full_cmd = f"timer {time_str} && bash -c '{cmd_after}'"
|
||||
|
||||
self.timer_process = subprocess.Popen(
|
||||
full_cmd,
|
||||
shell=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self.notify(f"✅ Таймер запущен на {time_str}")
|
||||
|
||||
def notify(self, msg: str):
|
||||
self.message_label.update(msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
TimerTUI().run()
|
||||
-675
@@ -1,675 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/forust/gosleep-timer/config"
|
||||
"github.com/forust/gosleep-timer/engine"
|
||||
"github.com/forust/gosleep-timer/history"
|
||||
"github.com/forust/gosleep-timer/modules"
|
||||
"github.com/forust/gosleep-timer/profiles"
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
screenMain screen = iota
|
||||
screenRunning
|
||||
screenHistory
|
||||
screenStats
|
||||
screenQR
|
||||
screenHelp
|
||||
)
|
||||
|
||||
type focusField int
|
||||
|
||||
const (
|
||||
focusTimeInput focusField = iota
|
||||
focusProfile
|
||||
focusStartButton
|
||||
focusStopButton
|
||||
)
|
||||
|
||||
type model struct {
|
||||
cfg *config.Config
|
||||
pMgr *profiles.Manager
|
||||
|
||||
// UI state
|
||||
screen screen
|
||||
focus focusField
|
||||
err error
|
||||
|
||||
// Timer input
|
||||
timeInput string
|
||||
|
||||
// Profile
|
||||
profiles []string
|
||||
selProfile int
|
||||
|
||||
// Running timer
|
||||
timer *engine.Timer
|
||||
stageRunner *engine.StageRunner
|
||||
remaining time.Duration
|
||||
total time.Duration
|
||||
currentStage engine.StageType
|
||||
timerStatus engine.Status
|
||||
statusMsg string
|
||||
|
||||
// History
|
||||
records []history.Record
|
||||
histLog *history.Log
|
||||
|
||||
// Module toggles (map profileName -> map moduleName -> enabled)
|
||||
moduleToggles map[string]map[string]bool
|
||||
}
|
||||
|
||||
func NewModel(cfg *config.Config) *model {
|
||||
pMgr := profiles.NewManager(cfg)
|
||||
profilesList := pMgr.List()
|
||||
if len(profilesList) == 0 {
|
||||
profilesList = []string{"default"}
|
||||
}
|
||||
|
||||
// Init module toggles
|
||||
toggles := make(map[string]map[string]bool)
|
||||
for _, name := range profilesList {
|
||||
toggles[name] = initModuleToggles(cfg, name)
|
||||
}
|
||||
|
||||
// Open history log
|
||||
histPath, err := history.DefaultPath()
|
||||
histLog := &history.Log{}
|
||||
if err == nil {
|
||||
if l, err := history.Open(histPath); err == nil {
|
||||
histLog = l
|
||||
}
|
||||
}
|
||||
|
||||
return &model{
|
||||
cfg: cfg,
|
||||
pMgr: pMgr,
|
||||
screen: screenMain,
|
||||
profiles: profilesList,
|
||||
selProfile: 0,
|
||||
timeInput: "25m",
|
||||
timerStatus: engine.StatusIdle,
|
||||
moduleToggles: toggles,
|
||||
histLog: histLog,
|
||||
}
|
||||
}
|
||||
|
||||
func initModuleToggles(cfg *config.Config, profileName string) map[string]bool {
|
||||
t := map[string]bool{
|
||||
"workspace": true,
|
||||
"media": true,
|
||||
"lock": true,
|
||||
"kill": false,
|
||||
"notify": true,
|
||||
"sound": true,
|
||||
"brightness": false,
|
||||
"mute": false,
|
||||
"custom": false,
|
||||
"script": false,
|
||||
}
|
||||
p, ok := cfg.Profiles[profileName]
|
||||
if !ok || p.Modules == nil {
|
||||
return t
|
||||
}
|
||||
m := p.Modules
|
||||
if m.Workspace != nil { t["workspace"] = m.Workspace.Enabled }
|
||||
if m.Media != nil { t["media"] = m.Media.Enabled }
|
||||
if m.Lock != nil { t["lock"] = m.Lock.Enabled }
|
||||
if m.Kill != nil { t["kill"] = m.Kill.Enabled }
|
||||
if m.Notify != nil { t["notify"] = m.Notify.Enabled }
|
||||
if m.Sound != nil { t["sound"] = m.Sound.Enabled }
|
||||
if m.Brightness != nil { t["brightness"] = m.Brightness.Enabled }
|
||||
if m.Mute != nil { t["mute"] = m.Mute.Enabled }
|
||||
if m.Custom != nil { t["custom"] = m.Custom.Enabled }
|
||||
if m.Script != nil { t["script"] = m.Script.Enabled }
|
||||
return t
|
||||
}
|
||||
|
||||
func (m *model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Messages
|
||||
type timerTickMsg struct {
|
||||
remaining time.Duration
|
||||
total time.Duration
|
||||
status engine.Status
|
||||
stage engine.StageType
|
||||
done bool
|
||||
}
|
||||
|
||||
type timerDoneMsg struct {
|
||||
status engine.Status
|
||||
}
|
||||
|
||||
type errorMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
case timerTickMsg:
|
||||
m.remaining = msg.remaining
|
||||
m.total = msg.total
|
||||
m.timerStatus = msg.status
|
||||
m.currentStage = msg.stage
|
||||
if msg.done {
|
||||
m.screen = screenMain
|
||||
m.timerStatus = engine.StatusIdle
|
||||
m.statusMsg = "✅ Timer completed"
|
||||
m.recordRun("completed", "")
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
case timerDoneMsg:
|
||||
m.screen = screenMain
|
||||
m.timerStatus = engine.StatusIdle
|
||||
if msg.status == engine.StatusKilled {
|
||||
m.statusMsg = "⛔ Timer killed"
|
||||
}
|
||||
return m, nil
|
||||
case errorMsg:
|
||||
m.err = msg.err
|
||||
m.statusMsg = fmt.Sprintf("❌ %v", msg.err)
|
||||
m.screen = screenMain
|
||||
m.timerStatus = engine.StatusIdle
|
||||
return m, nil
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch m.screen {
|
||||
case screenMain:
|
||||
return m.handleMainKey(msg)
|
||||
case screenRunning:
|
||||
return m.handleRunningKey(msg)
|
||||
case screenHistory:
|
||||
return m.handleHistoryKey(msg)
|
||||
case screenStats:
|
||||
return m.handleStatsKey(msg)
|
||||
case screenQR:
|
||||
return m.handleQRKey(msg)
|
||||
case screenHelp:
|
||||
return m.handleHelpKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleMainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c", "esc":
|
||||
m.histLog.Close()
|
||||
return m, tea.Quit
|
||||
case "tab":
|
||||
m.focus++
|
||||
if m.focus > focusStopButton {
|
||||
m.focus = focusTimeInput
|
||||
}
|
||||
case "shift+tab":
|
||||
m.focus--
|
||||
if m.focus < focusTimeInput {
|
||||
m.focus = focusStopButton
|
||||
}
|
||||
case "enter", " ":
|
||||
switch m.focus {
|
||||
case focusTimeInput:
|
||||
m.focus = focusProfile
|
||||
case focusProfile:
|
||||
m.focus = focusStartButton
|
||||
case focusStartButton:
|
||||
return m, m.startTimer()
|
||||
case focusStopButton:
|
||||
m.stopTimer()
|
||||
}
|
||||
case "s":
|
||||
return m, m.startTimer()
|
||||
case "S":
|
||||
m.stopTimer()
|
||||
case "h":
|
||||
m.loadHistory()
|
||||
m.screen = screenHistory
|
||||
case "?":
|
||||
m.screen = screenHelp
|
||||
case "up", "k":
|
||||
m.selProfile--
|
||||
if m.selProfile < 0 {
|
||||
m.selProfile = len(m.profiles) - 1
|
||||
}
|
||||
m.loadModuleToggles()
|
||||
case "down", "j":
|
||||
m.selProfile++
|
||||
if m.selProfile >= len(m.profiles) {
|
||||
m.selProfile = 0
|
||||
}
|
||||
m.loadModuleToggles()
|
||||
case "1", "2", "3", "4", "5", "6", "7", "8", "9", "0":
|
||||
// Toggle modules by number (1-9,0=10)
|
||||
idx := int(msg.String()[0] - '1')
|
||||
if msg.String() == "0" {
|
||||
idx = 9
|
||||
}
|
||||
m.toggleModule(idx)
|
||||
default:
|
||||
// Handle time input when focused
|
||||
if m.focus == focusTimeInput {
|
||||
if msg.Type == tea.KeyBackspace || msg.Type == tea.KeyDelete {
|
||||
if len(m.timeInput) > 0 {
|
||||
m.timeInput = m.timeInput[:len(m.timeInput)-1]
|
||||
}
|
||||
} else if msg.Type == tea.KeyRunes {
|
||||
m.timeInput += msg.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) loadModuleToggles() {
|
||||
name := m.profiles[m.selProfile]
|
||||
if _, ok := m.moduleToggles[name]; !ok {
|
||||
m.moduleToggles[name] = initModuleToggles(m.cfg, name)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) toggleModule(idx int) {
|
||||
modules := []string{"workspace", "media", "lock", "kill", "notify", "sound", "brightness", "mute", "custom", "script"}
|
||||
if idx < 0 || idx >= len(modules) {
|
||||
return
|
||||
}
|
||||
name := m.profiles[m.selProfile]
|
||||
key := modules[idx]
|
||||
if toggles, ok := m.moduleToggles[name]; ok {
|
||||
toggles[key] = !toggles[key]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) handleRunningKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "S", "s":
|
||||
m.stopTimer()
|
||||
m.screen = screenMain
|
||||
m.statusMsg = "⛔ Timer stopped"
|
||||
return m, nil
|
||||
case "q", "esc", "ctrl+c":
|
||||
m.stopTimer()
|
||||
m.screen = screenMain
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "esc", "h":
|
||||
m.screen = screenMain
|
||||
case "s":
|
||||
m.screen = screenStats
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleStatsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "esc", "h":
|
||||
m.screen = screenMain
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleQRKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
m.screen = screenMain
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "esc", "?":
|
||||
m.screen = screenMain
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) startTimer() tea.Cmd {
|
||||
d, err := engine.ParseDuration(m.timeInput)
|
||||
if err != nil {
|
||||
m.statusMsg = fmt.Sprintf("❌ Invalid time: %s", m.timeInput)
|
||||
return nil
|
||||
}
|
||||
if d <= 0 {
|
||||
m.statusMsg = "❌ Duration must be positive"
|
||||
return nil
|
||||
}
|
||||
|
||||
profileName := m.profiles[m.selProfile]
|
||||
profile, err := m.pMgr.Get(profileName)
|
||||
if err != nil {
|
||||
m.statusMsg = fmt.Sprintf("❌ Profile error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build module config based on toggles
|
||||
modCfg := m.buildModuleConfig(profile)
|
||||
|
||||
// Collect commands from modules
|
||||
modCtx := &modules.ModuleContext{
|
||||
Context: context.Background(),
|
||||
Profile: profileName,
|
||||
Duration: m.timeInput,
|
||||
Config: modCfg,
|
||||
}
|
||||
|
||||
preCmds := modules.CollectPreCmds(modCtx)
|
||||
postCmds := modules.CollectPostCmds(modCtx)
|
||||
|
||||
m.timer = engine.NewTimer(d)
|
||||
m.stageRunner = engine.NewStageRunner(m.timer, preCmds, postCmds)
|
||||
m.screen = screenRunning
|
||||
|
||||
// Start runner in background
|
||||
go m.stageRunner.Run(context.Background())
|
||||
|
||||
// Start reading events
|
||||
go func() {
|
||||
for evt := range m.stageRunner.Events {
|
||||
m.handleTimerEvent(evt)
|
||||
}
|
||||
}()
|
||||
|
||||
m.statusMsg = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *model) buildModuleConfig(profile *config.Profile) *config.Modules {
|
||||
if profile.Modules == nil {
|
||||
return config.DefaultModules()
|
||||
}
|
||||
modCfg := *profile.Modules // shallow copy
|
||||
toggles := m.moduleToggles[m.profiles[m.selProfile]]
|
||||
setEnabled(&modCfg, toggles)
|
||||
return &modCfg
|
||||
}
|
||||
|
||||
func setEnabled(mod *config.Modules, toggles map[string]bool) {
|
||||
if mod.Workspace != nil { mod.Workspace.Enabled = toggles["workspace"] }
|
||||
if mod.Media != nil { mod.Media.Enabled = toggles["media"] }
|
||||
if mod.Lock != nil { mod.Lock.Enabled = toggles["lock"] }
|
||||
if mod.Kill != nil { mod.Kill.Enabled = toggles["kill"] }
|
||||
if mod.Notify != nil { mod.Notify.Enabled = toggles["notify"] }
|
||||
if mod.Sound != nil { mod.Sound.Enabled = toggles["sound"] }
|
||||
if mod.Brightness != nil { mod.Brightness.Enabled = toggles["brightness"] }
|
||||
if mod.Mute != nil { mod.Mute.Enabled = toggles["mute"] }
|
||||
if mod.Custom != nil { mod.Custom.Enabled = toggles["custom"] }
|
||||
if mod.Script != nil { mod.Script.Enabled = toggles["script"] }
|
||||
}
|
||||
|
||||
func (m *model) handleTimerEvent(evt engine.TimerEvent) {
|
||||
m.remaining = evt.Remaining
|
||||
m.total = evt.Total
|
||||
m.currentStage = evt.Stage
|
||||
m.timerStatus = evt.Status
|
||||
}
|
||||
|
||||
func (m *model) stopTimer() {
|
||||
if m.stageRunner != nil {
|
||||
m.stageRunner.Stop()
|
||||
}
|
||||
m.timerStatus = engine.StatusIdle
|
||||
m.screen = screenMain
|
||||
m.recordRun("killed", "")
|
||||
}
|
||||
|
||||
func (m *model) recordRun(status, errStr string) {
|
||||
if m.histLog == nil {
|
||||
return
|
||||
}
|
||||
m.histLog.Append(history.Record{
|
||||
Profile: m.profiles[m.selProfile],
|
||||
Duration: m.timeInput,
|
||||
Status: status,
|
||||
Error: errStr,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *model) loadHistory() {
|
||||
path, err := history.DefaultPath()
|
||||
if err != nil {
|
||||
m.statusMsg = fmt.Sprintf("❌ History path: %v", err)
|
||||
return
|
||||
}
|
||||
records, err := history.ReadAll(path)
|
||||
if err != nil {
|
||||
m.statusMsg = fmt.Sprintf("❌ History read: %v", err)
|
||||
return
|
||||
}
|
||||
m.records = records
|
||||
}
|
||||
|
||||
func (m *model) View() string {
|
||||
switch m.screen {
|
||||
case screenMain:
|
||||
return m.mainView()
|
||||
case screenRunning:
|
||||
return m.runningView()
|
||||
case screenHistory:
|
||||
return m.historyView()
|
||||
case screenStats:
|
||||
return m.statsView()
|
||||
case screenQR:
|
||||
return m.qrView()
|
||||
case screenHelp:
|
||||
return m.helpView()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *model) mainView() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(TitleStyle.Render("⏱ gosleep-timer"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Profile selector
|
||||
b.WriteString(SelectWidget("Profile:", m.profiles, m.selProfile))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Time input
|
||||
timeFocused := m.focus == focusTimeInput
|
||||
b.WriteString(InputField("Duration:", m.timeInput, "e.g. 25m, 1h30m, 90s", timeFocused))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Module toggles
|
||||
b.WriteString(LabelStyle.Render("Modules:"))
|
||||
b.WriteString("\n")
|
||||
moduleNames := []string{"workspace", "media", "lock", "kill", "notify", "sound", "brightness", "mute", "custom", "script"}
|
||||
toggles := m.moduleToggles[m.profiles[m.selProfile]]
|
||||
for i, name := range moduleNames {
|
||||
enabled := toggles[name]
|
||||
line := fmt.Sprintf(" %d. ", i+1)
|
||||
if i == 9 {
|
||||
line = " 0. "
|
||||
}
|
||||
line += ModuleCheckbox(name, enabled, enabled)
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Buttons
|
||||
startStyle := SuccessButtonStyle
|
||||
stopStyle := DangerButtonStyle
|
||||
if m.focus == focusStartButton {
|
||||
startStyle = startStyle.Bold(true).Background(lipgloss.Color("#00cc55"))
|
||||
}
|
||||
if m.focus == focusStopButton {
|
||||
stopStyle = stopStyle.Bold(true).Background(lipgloss.Color("#dd4444"))
|
||||
}
|
||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Center,
|
||||
startStyle.Render("[ s ] Start"),
|
||||
" ",
|
||||
stopStyle.Render("[ S ] Stop"),
|
||||
))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Status
|
||||
if m.statusMsg != "" {
|
||||
isErr := strings.HasPrefix(m.statusMsg, "❌")
|
||||
b.WriteString(StatusLine(m.statusMsg, isErr))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Help bar
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpBar("s:start", "S:stop", "h:history", "?:help", "q:quit"))
|
||||
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func (m *model) runningView() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(TitleStyle.Render("⏱ Timer Running"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Big timer display
|
||||
b.WriteString(TimerBlock(m.remaining))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Progress bar
|
||||
totalSec := m.total.Seconds()
|
||||
remainingSec := m.remaining.Seconds()
|
||||
var pct float64
|
||||
if totalSec > 0 {
|
||||
pct = 1.0 - (remainingSec / totalSec)
|
||||
}
|
||||
b.WriteString(ProgressBar(40, pct, ""))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Stage info
|
||||
stageStr := string(m.currentStage)
|
||||
if stageStr == "" {
|
||||
stageStr = "timer"
|
||||
}
|
||||
b.WriteString(StatusStyle.Render(fmt.Sprintf("Stage: %s", stageStr)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Profile info
|
||||
profileName := m.profiles[m.selProfile]
|
||||
b.WriteString(StatusStyle.Render(fmt.Sprintf("Profile: %s", profileName)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Stop hint
|
||||
b.WriteString(HelpStyle.Render("Press S to stop · q to quit"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func (m *model) historyView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(TitleStyle.Render("📋 History"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if len(m.records) == 0 {
|
||||
b.WriteString(StatusStyle.Render("No history yet."))
|
||||
} else {
|
||||
start := 0
|
||||
if len(m.records) > 20 {
|
||||
start = len(m.records) - 20
|
||||
}
|
||||
for i := len(m.records) - 1; i >= start; i-- {
|
||||
r := m.records[i]
|
||||
line := fmt.Sprintf("%s | %-11s | %-6s | %s",
|
||||
r.Timestamp.Format("15:04 02-Jan"),
|
||||
r.Duration,
|
||||
r.Status,
|
||||
r.Profile,
|
||||
)
|
||||
style := ActiveModuleStyle
|
||||
if r.Status == "killed" {
|
||||
style = ErrorStyle
|
||||
}
|
||||
b.WriteString(style.Render(line))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpBar("s:stats", "h/esc:back", "q:quit"))
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func (m *model) statsView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(TitleStyle.Render("📊 Statistics"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
stats := history.Calculate(m.records)
|
||||
b.WriteString(fmt.Sprintf("Total runs: %d\n", stats.TotalRuns))
|
||||
b.WriteString(fmt.Sprintf("Completed: %d\n", stats.CompletedRuns))
|
||||
b.WriteString(fmt.Sprintf("Killed: %d\n", stats.KilledRuns))
|
||||
b.WriteString(fmt.Sprintf("Errors: %d\n", stats.ErrorRuns))
|
||||
b.WriteString(fmt.Sprintf("Total time: %s\n", stats.TotalDuration.Round(time.Second)))
|
||||
if stats.TotalRuns > 0 {
|
||||
b.WriteString(fmt.Sprintf("Avg duration: %s\n", stats.AvgDuration.Round(time.Second)))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Most used: %s\n", stats.MostUsedProfile))
|
||||
if stats.LastRun != nil {
|
||||
b.WriteString(fmt.Sprintf("Last run: %s (%s — %s)\n",
|
||||
stats.LastRun.Timestamp.Format("02 Jan 15:04"),
|
||||
stats.LastRun.Duration,
|
||||
stats.LastRun.Status,
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpBar("h:back", "q:quit"))
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func (m *model) qrView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(TitleStyle.Render("📱 QR Code"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Generate config YAML
|
||||
data, err := config.SaveToString(m.cfg)
|
||||
if err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Config error: %v", err)))
|
||||
} else {
|
||||
qr, err := GenerateQR(data)
|
||||
if err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("QR error: %v", err)))
|
||||
} else {
|
||||
b.WriteString(qr)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpBar("q/esc:back"))
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func (m *model) helpView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(TitleStyle.Render("⌨ Help"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpView())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(HelpBar("q/esc:back"))
|
||||
return BorderStyle.Render(b.String())
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package tui
|
||||
|
||||
type KeyAction int
|
||||
|
||||
const (
|
||||
KeyQuit KeyAction = iota
|
||||
KeyStart
|
||||
KeyStop
|
||||
KeyToggleModule
|
||||
KeyNextProfile
|
||||
KeyPrevProfile
|
||||
KeyFocusNext
|
||||
KeyFocusPrev
|
||||
KeyHistory
|
||||
KeyStats
|
||||
KeyHelp
|
||||
)
|
||||
|
||||
type KeyBinding struct {
|
||||
Key string
|
||||
Action KeyAction
|
||||
Label string
|
||||
}
|
||||
|
||||
var DefaultKeyBindings = []KeyBinding{
|
||||
{Key: "s", Action: KeyStart, Label: "start"},
|
||||
{Key: "S", Action: KeyStop, Label: "stop"},
|
||||
{Key: "tab", Action: KeyFocusNext, Label: "next"},
|
||||
{Key: "shift+tab", Action: KeyFocusPrev, Label: "prev"},
|
||||
{Key: "h", Action: KeyHistory, Label: "history"},
|
||||
{Key: "?", Action: KeyHelp, Label: "help"},
|
||||
{Key: "q", Action: KeyQuit, Label: "quit"},
|
||||
{Key: "esc", Action: KeyQuit, Label: "quit"},
|
||||
}
|
||||
|
||||
func HelpView() string {
|
||||
s := "\n Keys:\n"
|
||||
for _, kb := range DefaultKeyBindings {
|
||||
s += " " + kb.Key + " — " + kb.Label + "\n"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// GenerateQR returns a string of the QR code for the given data
|
||||
func GenerateQR(data string) (string, error) {
|
||||
qr, err := qrcode.New(data, qrcode.Medium)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qr generate: %w", err)
|
||||
}
|
||||
// Convert to ASCII art
|
||||
art := qr.ToString(true)
|
||||
return art, nil
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
AppStyle = lipgloss.NewStyle().
|
||||
Padding(1, 2).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
TitleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#33ccff")).
|
||||
Align(lipgloss.Center).
|
||||
Padding(0, 1)
|
||||
|
||||
LabelStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#33ccff")).
|
||||
Padding(0, 1)
|
||||
|
||||
StatusStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#00ff88")).
|
||||
Align(lipgloss.Center).
|
||||
PaddingTop(1)
|
||||
|
||||
ErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#ff4444")).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
TimerStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#ffffff")).
|
||||
Background(lipgloss.Color("#33ccff")).
|
||||
Align(lipgloss.Center).
|
||||
Padding(0, 2)
|
||||
|
||||
ProgressBarStyle = lipgloss.NewStyle().
|
||||
Height(1)
|
||||
|
||||
HelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#888888"))
|
||||
|
||||
ActiveModuleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#00ff88"))
|
||||
|
||||
InactiveModuleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666"))
|
||||
|
||||
BorderStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("#33ccff")).
|
||||
Padding(1, 2)
|
||||
|
||||
ButtonStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Padding(0, 2)
|
||||
|
||||
SuccessButtonStyle = ButtonStyle.Copy().
|
||||
Foreground(lipgloss.Color("#ffffff")).
|
||||
Background(lipgloss.Color("#00aa44"))
|
||||
|
||||
DangerButtonStyle = ButtonStyle.Copy().
|
||||
Foreground(lipgloss.Color("#ffffff")).
|
||||
Background(lipgloss.Color("#cc3333"))
|
||||
|
||||
DimmedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#444444"))
|
||||
)
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ProgressBar renders a progress bar with given width, percent (0.0-1.0)
|
||||
func ProgressBar(width int, percent float64, label string) string {
|
||||
if width < 10 {
|
||||
width = 10
|
||||
}
|
||||
filled := int(float64(width) * percent)
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
empty := width - filled
|
||||
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", empty)
|
||||
bar = lipgloss.NewStyle().Foreground(lipgloss.Color("#33ccff")).Render(bar)
|
||||
|
||||
pct := fmt.Sprintf("%3d%%", int(percent*100))
|
||||
return fmt.Sprintf("%s %s %s", pct, bar, label)
|
||||
}
|
||||
|
||||
// TimerBlock renders the main timer display (HH:MM:SS)
|
||||
func TimerBlock(remaining time.Duration) string {
|
||||
totalSec := int(remaining.Seconds())
|
||||
h := totalSec / 3600
|
||||
m := (totalSec % 3600) / 60
|
||||
s := totalSec % 60
|
||||
|
||||
timeStr := fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
||||
return TimerStyle.Render(timeStr)
|
||||
}
|
||||
|
||||
// ModuleCheckbox renders a module toggle line
|
||||
func ModuleCheckbox(name string, enabled bool, checked bool) string {
|
||||
checkmark := " "
|
||||
if checked {
|
||||
checkmark = "●"
|
||||
}
|
||||
style := InactiveModuleStyle
|
||||
if enabled {
|
||||
style = ActiveModuleStyle
|
||||
}
|
||||
return style.Render(fmt.Sprintf("[ %s ] %s", checkmark, name))
|
||||
}
|
||||
|
||||
// StatusLine renders a status message
|
||||
func StatusLine(msg string, isError bool) string {
|
||||
if isError {
|
||||
return ErrorStyle.Render(msg)
|
||||
}
|
||||
return StatusStyle.Render(msg)
|
||||
}
|
||||
|
||||
// HelpBar renders the bottom help bar with keybindings
|
||||
func HelpBar(items ...string) string {
|
||||
return HelpStyle.Render(strings.Join(items, " · "))
|
||||
}
|
||||
|
||||
// InputField renders a labeled input field
|
||||
func InputField(label, value, placeholder string, focused bool) string {
|
||||
style := LabelStyle
|
||||
borderColor := "#444"
|
||||
if focused {
|
||||
borderColor = "#33ccff"
|
||||
}
|
||||
val := value
|
||||
if val == "" {
|
||||
val = placeholder
|
||||
style = DimmedStyle
|
||||
}
|
||||
inputBox := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(borderColor)).
|
||||
Width(30).
|
||||
Render(val)
|
||||
|
||||
return fmt.Sprintf("%s\n%s", style.Render(label), inputBox)
|
||||
}
|
||||
|
||||
// SelectWidget renders a simple select list
|
||||
func SelectWidget(label string, options []string, selected int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(LabelStyle.Render(label))
|
||||
b.WriteString("\n")
|
||||
for i, opt := range options {
|
||||
if i == selected {
|
||||
b.WriteString(lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#33ccff")).
|
||||
Bold(true).
|
||||
Render("▸ " + opt))
|
||||
} else {
|
||||
b.WriteString(" " + opt)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user