From 355e618c77844236bc23aa396dff699a94b37ed9 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 3 Jul 2026 15:45:22 +0200 Subject: [PATCH] chore: prepare production rust release --- .editorconfig | 15 + .gitea/workflows/ci.yaml | 46 ++ .gitea/workflows/release.yaml | 75 ++ .github/workflows/cicd.yaml | 141 ---- .gitignore | 13 +- .golangci.yml | 108 --- .yamllint | 8 +- CHANGELOG.md | 16 + CONTRIBUTING.md | 29 + Cargo.lock | 881 ++++++++++++++++++++++ Cargo.toml | 28 + Dockerfile | 12 - LICENSE | 21 + README.md | 315 ++++---- RELEASING.md | 98 +++ TODO.md | 266 ------- VERSION | 1 + cicd.example.yaml | 171 ----- cmd_init.go | 263 ------- config/config.go | 282 ------- config/defaults.go | 57 -- config/types.go | 74 -- engine/executor.go | 42 -- engine/stages.go | 74 -- engine/timer.go | 116 --- go.mod | 34 - go.sum | 62 -- history/history.go | 85 --- history/stats.go | 54 -- main.go | 348 --------- modules/brightness/brightness.go | 61 -- modules/custom/custom.go | 34 - modules/kill/kill.go | 43 -- modules/lock/lock.go | 36 - modules/media/media.go | 40 - modules/module.go | 39 - modules/mute/mute.go | 53 -- modules/notify/notify.go | 48 -- modules/registry.go | 53 -- modules/script/script.go | 40 - modules/sound/sound.go | 74 -- modules/workspace/workspace.go | 56 -- profiles/manager.go | 76 -- profiles/profile.go | 8 - rust-toolchain.toml | 3 + src/main.rs | 1172 ++++++++++++++++++++++++++++++ timer.py | 162 ----- tui/app.go | 675 ----------------- tui/keybinds.go | 42 -- tui/qr.go | 17 - tui/styles.go | 69 -- tui/widgets.go | 104 --- 52 files changed, 2563 insertions(+), 4077 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitea/workflows/release.yaml delete mode 100644 .github/workflows/cicd.yaml delete mode 100644 .golangci.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 RELEASING.md delete mode 100644 TODO.md create mode 100644 VERSION delete mode 100644 cicd.example.yaml delete mode 100644 cmd_init.go delete mode 100644 config/config.go delete mode 100644 config/defaults.go delete mode 100644 config/types.go delete mode 100644 engine/executor.go delete mode 100644 engine/stages.go delete mode 100644 engine/timer.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 history/history.go delete mode 100644 history/stats.go delete mode 100644 main.go delete mode 100644 modules/brightness/brightness.go delete mode 100644 modules/custom/custom.go delete mode 100644 modules/kill/kill.go delete mode 100644 modules/lock/lock.go delete mode 100644 modules/media/media.go delete mode 100644 modules/module.go delete mode 100644 modules/mute/mute.go delete mode 100644 modules/notify/notify.go delete mode 100644 modules/registry.go delete mode 100644 modules/script/script.go delete mode 100644 modules/sound/sound.go delete mode 100644 modules/workspace/workspace.go delete mode 100644 profiles/manager.go delete mode 100644 profiles/profile.go create mode 100644 rust-toolchain.toml create mode 100644 src/main.rs delete mode 100755 timer.py delete mode 100644 tui/app.go delete mode 100644 tui/keybinds.go delete mode 100644 tui/qr.go delete mode 100644 tui/styles.go delete mode 100644 tui/widgets.go diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0e0c523 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..024d3d9 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -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 . diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..9bda16c --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,75 @@ +name: release + +"on": + push: + tags: + - "v*.*.*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + linux-amd64: + runs-on: [self-hosted, linux, arch, homelab] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build release binary + shell: bash + run: cargo build --release --locked + + - name: Package artifact + shell: bash + run: | + tag="${GITHUB_REF##*/}" + version="${tag#v}" + mkdir -p dist + cp target/release/gosleep-timer dist/gosleep-timer + tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer + sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256" + + - name: Create Gitea release + id: release + shell: bash + run: | + tag="${GITHUB_REF##*/}" + body="$(cat <> "$GITHUB_OUTPUT" + + - name: Upload Gitea release assets + shell: bash + run: | + release_id="${{ steps.release.outputs.release_id }}" + for asset in dist/*.tar.gz dist/*.sha256; do + name="$(basename "$asset")" + curl -fsS \ + -X POST "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=${name}" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@${asset}" + done + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: gosleep-timer-linux-amd64 + path: dist/* diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml deleted file mode 100644 index fd325dd..0000000 --- a/.github/workflows/cicd.yaml +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 0815c4e..1768499 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ -gosleep-timer -*.test -*.out -*.exe +/target/ +/dist/ +/.env +/.env.* +*.log +*.tmp +*.bak .DS_Store -cover.out -vendor/ diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index 1e80ac3..0000000 --- a/.golangci.yml +++ /dev/null @@ -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 diff --git a/.yamllint b/.yamllint index cea87d0..763b282 100644 --- a/.yamllint +++ b/.yamllint @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad786fb --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..73e052d --- /dev/null +++ b/CONTRIBUTING.md @@ -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 +``` diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..79cd212 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..85e5374 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index afc2764..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f08a526 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index c19e441..7268bbd 100644 --- a/README.md +++ b/README.md @@ -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: +- 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--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: - workspace: - enabled: true - backend: auto # auto, niri, hyprland, kde - workspace: 12 - 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 - brightness: - enabled: false - value: 0 - mute: - enabled: false - custom: - enabled: false - pre: [] - post: [] - script: - enabled: false - path: "" - - work: - extends: default - modules: - workspace: - workspace: 5 +duration: 25m +actions: + workspace: + enabled: true + backend: auto + number: 3 + media: + enabled: true + action: stop + brightness: + enabled: false + value: 30 + mute: + enabled: false + lock: + enabled: false + command: loginctl lock-session + kill: + enabled: false + processes: [] + power: + mode: none + custom: + enabled: false + 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 +hyprctl dispatch workspace +qdbus org.kde.KWin /KWin org.kde.KWin.setCurrentDesktop ``` -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). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..c3058a7 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,98 @@ +# Releasing + +This repository uses semantic versioning and Gitea tag-based releases. + +## Repository Description + +Use this short description in Gitea: + +> Rust TUI sleep timer for Linux desktop actions. + +Optional longer description: + +> A single-binary Linux sleep timer with YAML config, command preview, progress display, and post-countdown desktop actions for niri, Hyprland, KDE, media, brightness, audio, lock, power, process kill, and custom shell commands. + +## Upstream + +```bash +git remote add origin ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git +git remote set-url --push origin ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git +``` + +Public clone URL: + +```text +https://gitea.forust.xyz/forust/gosleep.git +``` + +## Gitea Actions Setup + +1. Enable Actions for `forust/gosleep`. +2. Register or select a Linux runner with labels used by the workflows: + `self-hosted`, `linux`, `arch`, `homelab`. +3. Ensure the runner has: + - Rust stable toolchain + - `rustfmt` + - `clippy` + - `curl` + - `jq` + - `tar` + - `sha256sum` + - Docker, if using the YAML lint job as written +4. Confirm Actions job token permissions allow `contents: write`. + The release workflow uses Gitea Actions' built-in `GITEA_TOKEN`; no extra repository secret is required. + +## Version Bump Checklist + +For a patch bump, for example `1.1.1` to `1.1.2`: + +```bash +version=1.1.2 +``` + +Update: + +- `Cargo.toml`: `package.version` +- `Cargo.lock`: package version, regenerated by Cargo +- `VERSION` +- `CHANGELOG.md` + +Then verify: + +```bash +cargo fmt --check +cargo test --locked +cargo clippy --locked -- -D warnings +cargo build --release --locked +``` + +## Create Release + +```bash +git status --short +git commit -am "chore: release v1.1.2" +git tag -a v1.1.2 -m "gosleep-timer v1.1.2" +git push origin main +git push origin v1.1.2 +``` + +The `.gitea/workflows/release.yaml` workflow will: + +1. Build `target/release/gosleep-timer`. +2. Package `gosleep-timer--linux-amd64.tar.gz`. +3. Generate a SHA-256 checksum. +4. Create a Gitea release. +5. Upload the archive and checksum as release assets. + +## Manual Asset Build + +If Actions are unavailable: + +```bash +version="$(cat VERSION)" +cargo build --release --locked +mkdir -p dist +cp target/release/gosleep-timer dist/gosleep-timer +tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer +sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256" +``` diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 37a5f3e..0000000 --- a/TODO.md +++ /dev/null @@ -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 образ (опционально) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..524cb55 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.1.1 diff --git a/cicd.example.yaml b/cicd.example.yaml deleted file mode 100644 index 3238cac..0000000 --- a/cicd.example.yaml +++ /dev/null @@ -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 diff --git a/cmd_init.go b/cmd_init.go deleted file mode 100644 index 271672f..0000000 --- a/cmd_init.go +++ /dev/null @@ -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 -} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index c72e85c..0000000 --- a/config/config.go +++ /dev/null @@ -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, - } - } -} diff --git a/config/defaults.go b/config/defaults.go deleted file mode 100644 index 3b90e7c..0000000 --- a/config/defaults.go +++ /dev/null @@ -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: "", - }, - } -} diff --git a/config/types.go b/config/types.go deleted file mode 100644 index c37721b..0000000 --- a/config/types.go +++ /dev/null @@ -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"` -} diff --git a/engine/executor.go b/engine/executor.go deleted file mode 100644 index e14c378..0000000 --- a/engine/executor.go +++ /dev/null @@ -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 -} diff --git a/engine/stages.go b/engine/stages.go deleted file mode 100644 index ae0dece..0000000 --- a/engine/stages.go +++ /dev/null @@ -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() -} diff --git a/engine/timer.go b/engine/timer.go deleted file mode 100644 index 7f634d4..0000000 --- a/engine/timer.go +++ /dev/null @@ -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 -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 7da223c..0000000 --- a/go.mod +++ /dev/null @@ -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 -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 77f2cf9..0000000 --- a/go.sum +++ /dev/null @@ -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= diff --git a/history/history.go b/history/history.go deleted file mode 100644 index 19a7622..0000000 --- a/history/history.go +++ /dev/null @@ -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() -} diff --git a/history/stats.go b/history/stats.go deleted file mode 100644 index b924caf..0000000 --- a/history/stats.go +++ /dev/null @@ -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 -} diff --git a/main.go b/main.go deleted file mode 100644 index 74ab4be..0000000 --- a/main.go +++ /dev/null @@ -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) - } -} diff --git a/modules/brightness/brightness.go b/modules/brightness/brightness.go deleted file mode 100644 index a0f473b..0000000 --- a/modules/brightness/brightness.go +++ /dev/null @@ -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{}) -} diff --git a/modules/custom/custom.go b/modules/custom/custom.go deleted file mode 100644 index 0eca2b6..0000000 --- a/modules/custom/custom.go +++ /dev/null @@ -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{}) -} diff --git a/modules/kill/kill.go b/modules/kill/kill.go deleted file mode 100644 index 52ba8d2..0000000 --- a/modules/kill/kill.go +++ /dev/null @@ -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{}) -} diff --git a/modules/lock/lock.go b/modules/lock/lock.go deleted file mode 100644 index 9bcb618..0000000 --- a/modules/lock/lock.go +++ /dev/null @@ -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{}) -} diff --git a/modules/media/media.go b/modules/media/media.go deleted file mode 100644 index ffe5ee4..0000000 --- a/modules/media/media.go +++ /dev/null @@ -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{}) -} diff --git a/modules/module.go b/modules/module.go deleted file mode 100644 index bd35816..0000000 --- a/modules/module.go +++ /dev/null @@ -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 -} diff --git a/modules/mute/mute.go b/modules/mute/mute.go deleted file mode 100644 index 05ff36a..0000000 --- a/modules/mute/mute.go +++ /dev/null @@ -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{}) -} diff --git a/modules/notify/notify.go b/modules/notify/notify.go deleted file mode 100644 index 65313db..0000000 --- a/modules/notify/notify.go +++ /dev/null @@ -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{}) -} diff --git a/modules/registry.go b/modules/registry.go deleted file mode 100644 index ba0a5a4..0000000 --- a/modules/registry.go +++ /dev/null @@ -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() -} diff --git a/modules/script/script.go b/modules/script/script.go deleted file mode 100644 index a8093e1..0000000 --- a/modules/script/script.go +++ /dev/null @@ -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{}) -} diff --git a/modules/sound/sound.go b/modules/sound/sound.go deleted file mode 100644 index c438441..0000000 --- a/modules/sound/sound.go +++ /dev/null @@ -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{}) -} diff --git a/modules/workspace/workspace.go b/modules/workspace/workspace.go deleted file mode 100644 index 2385d67..0000000 --- a/modules/workspace/workspace.go +++ /dev/null @@ -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{}) -} diff --git a/profiles/manager.go b/profiles/manager.go deleted file mode 100644 index 89eba8d..0000000 --- a/profiles/manager.go +++ /dev/null @@ -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 -} diff --git a/profiles/profile.go b/profiles/profile.go deleted file mode 100644 index 69d91e5..0000000 --- a/profiles/profile.go +++ /dev/null @@ -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 -} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d0ead5e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["clippy", "rustfmt"] diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..bce2242 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1172 @@ +use std::{ + fs, + io::{self, Write}, + path::PathBuf, + process::Command, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{ + Frame, Terminal, + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Wrap}, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Parser)] +#[command( + name = "gosleep-timer", + about = "Configure and run post-countdown desktop actions" +)] +struct Cli { + #[arg(long)] + config: Option, + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + Run { duration: Option }, + Preview, + Init, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +struct Config { + duration: String, + actions: Actions, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +struct Actions { + workspace: WorkspaceAction, + media: MediaAction, + brightness: BrightnessAction, + mute: ToggleAction, + lock: LockAction, + kill: KillAction, + power: PowerAction, + custom: CustomAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +struct WorkspaceAction { + enabled: bool, + backend: String, + number: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +struct MediaAction { + enabled: bool, + action: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +struct BrightnessAction { + enabled: bool, + value: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +struct ToggleAction { + enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +struct LockAction { + enabled: bool, + command: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +struct KillAction { + enabled: bool, + processes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +struct PowerAction { + mode: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +struct CustomAction { + enabled: bool, + commands: Vec, +} + +#[derive(Debug, Clone)] +struct ActionCommand { + label: &'static str, + args: Vec, + shell: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + duration: "25m".to_string(), + actions: Actions::default(), + } + } +} + +impl Default for WorkspaceAction { + fn default() -> Self { + Self { + enabled: true, + backend: "auto".to_string(), + number: 3, + } + } +} + +impl Default for MediaAction { + fn default() -> Self { + Self { + enabled: true, + action: "stop".to_string(), + } + } +} + +impl Default for BrightnessAction { + fn default() -> Self { + Self { + enabled: false, + value: 30, + } + } +} + +impl Default for LockAction { + fn default() -> Self { + Self { + enabled: false, + command: "loginctl lock-session".to_string(), + } + } +} + +impl Default for PowerAction { + fn default() -> Self { + Self { + mode: "none".to_string(), + } + } +} + +impl ActionCommand { + fn display(&self) -> String { + self.shell.clone().unwrap_or_else(|| self.args.join(" ")) + } +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let path = cli.config.unwrap_or_else(default_config_path); + let mut config = ensure_config(&path)?; + + match cli.command { + Some(Commands::Run { duration }) => { + if let Some(duration) = duration { + config.duration = duration; + } + run_timer(&config, &Arc::new(AtomicBool::new(false))) + } + Some(Commands::Preview) => { + for command in preview_commands(&config) { + println!("{command}"); + } + Ok(()) + } + Some(Commands::Init) => { + println!("{}", path.display()); + Ok(()) + } + None => run_tui(path, config), + } +} + +fn default_config_path() -> PathBuf { + if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") { + return PathBuf::from(dir).join("gosleep-timer").join("config.yaml"); + } + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") + .join("gosleep-timer") + .join("config.yaml") +} + +fn ensure_config(path: &PathBuf) -> Result { + if !path.exists() { + let config = Config::default(); + save_config(path, &config)?; + return Ok(config); + } + let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let mut config: Config = serde_yaml::from_str(&data).context("parse config")?; + normalize_config(&mut config); + Ok(config) +} + +fn save_config(path: &PathBuf, config: &Config) -> Result<()> { + let mut config = config.clone(); + normalize_config(&mut config); + validate_config(&config)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let data = serde_yaml::to_string(&config).context("serialize config")?; + fs::write(path, data).with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + +fn normalize_config(config: &mut Config) { + if config.duration.is_empty() { + config.duration = "25m".to_string(); + } + if config.actions.workspace.backend.is_empty() { + config.actions.workspace.backend = "auto".to_string(); + } + if config.actions.workspace.number == 0 { + config.actions.workspace.number = 1; + } + if config.actions.media.action.is_empty() { + config.actions.media.action = "stop".to_string(); + } + if config.actions.brightness.value == 0 { + config.actions.brightness.value = 30; + } + if config.actions.brightness.value > 100 { + config.actions.brightness.value = 100; + } + if config.actions.lock.command.is_empty() { + config.actions.lock.command = "loginctl lock-session".to_string(); + } + if config.actions.power.mode.is_empty() { + config.actions.power.mode = "none".to_string(); + } +} + +fn validate_config(config: &Config) -> Result<()> { + parse_duration(&config.duration)?; + match config.actions.power.mode.as_str() { + "none" | "poweroff" | "reboot" => {} + mode => bail!("invalid power mode {mode:?}"), + } + match config.actions.workspace.backend.as_str() { + "auto" | "niri" | "hyprland" | "kde" => {} + backend => bail!("invalid workspace backend {backend:?}"), + } + match config.actions.media.action.as_str() { + "none" | "stop" | "pause" | "play-pause" | "next" | "previous" => {} + action => bail!("invalid media action {action:?}"), + } + Ok(()) +} + +fn parse_duration(value: &str) -> Result { + if value.is_empty() { + bail!("duration is required"); + } + + let mut total = 0.0_f64; + let chars: Vec = value.chars().collect(); + let mut index = 0; + while index < chars.len() { + let start = index; + while index < chars.len() && (chars[index].is_ascii_digit() || chars[index] == '.') { + index += 1; + } + if start == index { + bail!("invalid duration {value:?}"); + } + let number: f64 = chars[start..index].iter().collect::().parse()?; + + let unit_start = index; + while index < chars.len() && !chars[index].is_ascii_digit() && chars[index] != '.' { + index += 1; + } + let unit: String = chars[unit_start..index].iter().collect(); + let multiplier = match unit.as_str() { + "h" => 3600.0, + "m" => 60.0, + "s" => 1.0, + "ms" => 0.001, + "us" | "µs" => 0.000001, + "ns" => 0.000000001, + _ => bail!("invalid duration unit {unit:?}"), + }; + total += number * multiplier; + } + + if total <= 0.0 { + bail!("duration must be greater than zero"); + } + Ok(Duration::from_secs_f64(total)) +} + +fn build_action_commands(config: &Config) -> Vec { + let mut config = config.clone(); + normalize_config(&mut config); + let actions = &config.actions; + let mut commands = Vec::new(); + + if actions.workspace.enabled { + commands.push(workspace_command(&actions.workspace)); + } + if actions.media.enabled && actions.media.action != "none" { + commands.push(ActionCommand { + label: "media", + args: vec!["playerctl".to_string(), actions.media.action.clone()], + shell: None, + }); + } + if actions.brightness.enabled { + let value = actions.brightness.value; + commands.push(ActionCommand { + label: "brightness", + args: vec![], + shell: Some(format!("brightnessctl set {value}% || light -S {value}")), + }); + } + if actions.mute.enabled { + commands.push(ActionCommand { + label: "mute", + args: vec![], + shell: Some( + "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle || pactl set-sink-mute @DEFAULT_SINK@ toggle".to_string(), + ), + }); + } + if actions.lock.enabled { + commands.push(ActionCommand { + label: "lock", + args: vec![], + shell: Some(actions.lock.command.clone()), + }); + } + if actions.kill.enabled { + for process in &actions.kill.processes { + let process = process.trim(); + if !process.is_empty() { + commands.push(ActionCommand { + label: "kill", + args: vec!["pkill".to_string(), process.to_string()], + shell: None, + }); + } + } + } + match actions.power.mode.as_str() { + "poweroff" => commands.push(ActionCommand { + label: "power", + args: vec!["systemctl".to_string(), "poweroff".to_string()], + shell: None, + }), + "reboot" => commands.push(ActionCommand { + label: "power", + args: vec!["systemctl".to_string(), "reboot".to_string()], + shell: None, + }), + _ => {} + } + if actions.custom.enabled { + for command in &actions.custom.commands { + let command = command.trim(); + if !command.is_empty() { + commands.push(ActionCommand { + label: "custom", + args: vec![], + shell: Some(command.to_string()), + }); + } + } + } + commands +} + +fn workspace_command(action: &WorkspaceAction) -> ActionCommand { + let number = action.number.to_string(); + match action.backend.as_str() { + "niri" => ActionCommand { + label: "workspace", + args: vec!["niri", "msg", "action", "focus-workspace", &number] + .into_iter() + .map(str::to_string) + .collect(), + shell: None, + }, + "hyprland" => ActionCommand { + label: "workspace", + args: vec!["hyprctl", "dispatch", "workspace", &number] + .into_iter() + .map(str::to_string) + .collect(), + shell: None, + }, + "kde" => ActionCommand { + label: "workspace", + args: vec![ + "qdbus", + "org.kde.KWin", + "/KWin", + "org.kde.KWin.setCurrentDesktop", + &number, + ] + .into_iter() + .map(str::to_string) + .collect(), + shell: None, + }, + _ => ActionCommand { + label: "workspace", + args: vec![], + shell: Some(format!( + "if command -v niri >/dev/null 2>&1; then niri msg action focus-workspace {0}; elif command -v hyprctl >/dev/null 2>&1; then hyprctl dispatch workspace {0}; elif command -v qdbus >/dev/null 2>&1; then qdbus org.kde.KWin /KWin org.kde.KWin.setCurrentDesktop {0}; fi", + number + )), + }, + } +} + +fn preview_commands(config: &Config) -> Vec { + build_action_commands(config) + .iter() + .map(ActionCommand::display) + .collect() +} + +fn run_timer(config: &Config, stop: &Arc) -> Result<()> { + validate_config(config)?; + let duration = parse_duration(&config.duration)?; + let started = Instant::now(); + println!("timer started: {}", config.duration); + + loop { + if stop.load(Ordering::Relaxed) { + println!("\nstopped"); + bail!("timer stopped"); + } + let elapsed = started.elapsed(); + if elapsed >= duration { + break; + } + print!("\rremaining: {}", format_duration(duration - elapsed)); + io::stdout().flush().ok(); + thread::sleep(Duration::from_millis(250)); + } + + println!("\nfinished"); + for command in build_action_commands(config) { + println!("run: {}", command.display()); + run_command(&command).with_context(|| format!("{} failed", command.label))?; + } + Ok(()) +} + +fn run_command(command: &ActionCommand) -> Result<()> { + let status = if let Some(shell) = &command.shell { + Command::new("sh").arg("-c").arg(shell).status()? + } else { + let Some(program) = command.args.first() else { + return Ok(()); + }; + Command::new(program).args(&command.args[1..]).status()? + }; + if !status.success() { + bail!("command exited with {status}"); + } + Ok(()) +} + +fn run_tui(path: PathBuf, config: Config) -> Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + let mut app = App::new(path, config); + let result = app.run(&mut terminal); + disable_raw_mode().ok(); + execute!(terminal.backend_mut(), LeaveAlternateScreen).ok(); + terminal.show_cursor().ok(); + result +} + +struct App { + path: PathBuf, + config: Config, + focus: usize, + offset: usize, + status: String, + timer: Option, + edit: Option, +} + +struct TimerState { + started: Instant, + duration: Duration, + stop: Arc, + handle: thread::JoinHandle>, +} + +struct EditState { + field: Field, + value: String, +} + +impl App { + fn new(path: PathBuf, config: Config) -> Self { + Self { + status: format!("loaded {}", path.display()), + path, + config, + focus: 0, + offset: 0, + timer: None, + edit: None, + } + } + + fn run(&mut self, terminal: &mut Terminal>) -> Result<()> { + loop { + self.reap_timer(); + terminal.draw(|frame| self.draw(frame))?; + if event::poll(Duration::from_millis(250))? { + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + if self.edit.is_some() { + self.handle_edit_key(key.code); + } else { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.stop_timer(); + return Ok(()); + } + KeyCode::Char('q') | KeyCode::Esc => { + self.stop_timer(); + return Ok(()); + } + KeyCode::Up | KeyCode::Char('k') => { + self.focus = self.focus.saturating_sub(1) + } + KeyCode::Down | KeyCode::Char('j') => { + self.focus = (self.focus + 1).min(FIELDS.len() - 1); + } + KeyCode::Char(' ') | KeyCode::Enter => self.activate()?, + KeyCode::Char('s') => self.save(), + KeyCode::Char('r') => self.start_timer(), + KeyCode::Char('x') => self.stop_timer(), + _ => {} + } + } + } + } + } + + fn draw(&mut self, frame: &mut Frame) { + let area = frame.area(); + if area.width < 36 || area.height < 10 { + frame.render_widget(Paragraph::new("terminal too small"), area); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), + Constraint::Min(5), + Constraint::Length(1), + ]) + .split(area); + + self.draw_header(frame, chunks[0]); + self.draw_body(frame, chunks[1]); + let footer = if let Some(edit) = &self.edit { + format!( + "editing {}: {} | enter apply | esc cancel", + edit.field.label, edit.value + ) + } else { + "space/enter edit-toggle | r run | x stop | s save | q quit".to_string() + }; + frame.render_widget( + Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)), + chunks[2], + ); + } + + fn draw_header(&self, frame: &mut Frame, area: Rect) { + let (_, remaining, progress) = self.timer_snapshot(); + let label = if self.timer.is_some() { + format!("time left: {}", format_duration(remaining)) + } else { + "timer not started".to_string() + }; + let header = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + frame.render_widget( + Paragraph::new("gosleep-timer").style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + header[0], + ); + frame.render_widget( + Paragraph::new(format!("config: {}", self.path.display())) + .style(Style::default().fg(Color::DarkGray)), + header[1], + ); + frame.render_widget( + Paragraph::new(self.status.clone()).style(Style::default().fg(Color::Yellow)), + header[2], + ); + frame.render_widget( + Gauge::default() + .gauge_style(Style::default().fg(Color::Magenta)) + .label(format!("{label} {:>3}%", (progress * 100.0).round() as u16)) + .ratio(progress), + header[3], + ); + } + + fn draw_body(&mut self, frame: &mut Frame, area: Rect) { + if area.width >= 118 { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(48), Constraint::Percentage(52)]) + .split(area); + self.draw_fields(frame, chunks[0]); + self.draw_preview(frame, chunks[1]); + } else { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(8), Constraint::Length(8)]) + .split(area); + self.draw_fields(frame, chunks[0]); + self.draw_preview(frame, chunks[1]); + } + } + + fn draw_fields(&mut self, frame: &mut Frame, area: Rect) { + let visible = area.height.saturating_sub(2) as usize; + self.offset = self.offset.min(FIELDS.len().saturating_sub(visible)); + if self.focus < self.offset { + self.offset = self.focus; + } + if self.focus >= self.offset + visible { + self.offset = self.focus.saturating_sub(visible.saturating_sub(1)); + } + + let items = FIELDS + .iter() + .enumerate() + .skip(self.offset) + .take(visible) + .map(|(index, field)| { + let selected = index == self.focus; + let marker = if selected { ">" } else { " " }; + let line = format!("{marker} {:<20} {}", field.label, self.field_value(field)); + let style = if selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + ListItem::new(line).style(style) + }) + .collect::>(); + frame.render_widget( + List::new(items).block( + Block::default() + .title(" settings ") + .borders(Borders::ALL) + .border_style(Color::Blue), + ), + area, + ); + } + + fn draw_preview(&self, frame: &mut Frame, area: Rect) { + let lines = preview_commands(&self.config) + .into_iter() + .flat_map(|command| wrap_command(&command, area.width.saturating_sub(4) as usize)) + .map(|line| Line::from(Span::styled(line, Style::default().fg(Color::Green)))) + .collect::>(); + let lines = if lines.is_empty() { + vec![Line::from("no post-timer actions enabled")] + } else { + lines + }; + frame.render_widget( + Paragraph::new(lines) + .block( + Block::default() + .title(" command preview ") + .borders(Borders::ALL) + .border_style(Color::Blue), + ) + .wrap(Wrap { trim: false }), + area, + ); + } + + fn activate(&mut self) -> Result<()> { + match FIELDS[self.focus].kind { + FieldKind::Bool => self.toggle_bool(FIELDS[self.focus].key), + FieldKind::Cycle(options) => self.cycle(FIELDS[self.focus].key, options), + FieldKind::Edit | FieldKind::Int | FieldKind::Csv | FieldKind::Semi => { + let field = FIELDS[self.focus]; + self.edit = Some(EditState { + field, + value: self.field_value(&field), + }); + } + } + Ok(()) + } + + fn handle_edit_key(&mut self, key: KeyCode) { + match key { + KeyCode::Esc => self.edit = None, + KeyCode::Enter => { + if let Some(edit) = self.edit.take() { + if let Err(err) = self.apply_edit(edit.field, edit.value) { + self.status = format!("edit failed: {err}"); + } + } + } + KeyCode::Backspace => { + if let Some(edit) = &mut self.edit { + edit.value.pop(); + } + } + KeyCode::Char(value) => { + if let Some(edit) = &mut self.edit { + edit.value.push(value); + } + } + _ => {} + } + } + + fn apply_edit(&mut self, field: Field, value: String) -> Result<()> { + match field.kind { + FieldKind::Edit => match field.key { + FieldKey::Duration => self.config.duration = value, + FieldKey::LockCommand => self.config.actions.lock.command = value, + _ => {} + }, + FieldKind::Int => { + let number: u32 = value.trim().parse().context("expected integer")?; + match field.key { + FieldKey::WorkspaceNumber => self.config.actions.workspace.number = number, + FieldKey::BrightnessValue => { + self.config.actions.brightness.value = number.min(100) as u8 + } + _ => {} + } + } + FieldKind::Csv => { + let values = split_list(&value, ','); + if matches!(field.key, FieldKey::KillProcesses) { + self.config.actions.kill.processes = values; + } + } + FieldKind::Semi => { + let values = split_list(&value, ';'); + if matches!(field.key, FieldKey::CustomCommands) { + self.config.actions.custom.commands = values; + } + } + FieldKind::Bool | FieldKind::Cycle(_) => {} + } + normalize_config(&mut self.config); + self.status = format!("updated {}", field.label); + Ok(()) + } + + fn save(&mut self) { + match save_config(&self.path, &self.config) { + Ok(()) => self.status = format!("saved {}", self.path.display()), + Err(err) => self.status = format!("save failed: {err}"), + } + } + + fn start_timer(&mut self) { + self.stop_timer(); + let duration = match parse_duration(&self.config.duration) { + Ok(duration) => duration, + Err(err) => { + self.status = format!("timer failed: {err}"); + return; + } + }; + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + let config = self.config.clone(); + let handle = thread::spawn(move || run_timer(&config, &thread_stop)); + self.timer = Some(TimerState { + started: Instant::now(), + duration, + stop, + handle, + }); + self.status = "timer running".to_string(); + } + + fn stop_timer(&mut self) { + if let Some(timer) = self.timer.take() { + timer.stop.store(true, Ordering::Relaxed); + } + self.status = "timer stopped".to_string(); + } + + fn reap_timer(&mut self) { + if self + .timer + .as_ref() + .is_some_and(|timer| timer.handle.is_finished()) + { + let timer = self.timer.take().expect("timer exists"); + match timer.handle.join() { + Ok(Ok(())) => self.status = "timer finished".to_string(), + Ok(Err(err)) => self.status = format!("timer failed: {err}"), + Err(_) => self.status = "timer failed: thread panicked".to_string(), + } + } + } + + fn timer_snapshot(&self) -> (Duration, Duration, f64) { + let Some(timer) = &self.timer else { + return (Duration::ZERO, Duration::ZERO, 0.0); + }; + let elapsed = timer.started.elapsed().min(timer.duration); + let remaining = timer.duration.saturating_sub(elapsed); + let progress = elapsed.as_secs_f64() / timer.duration.as_secs_f64(); + (elapsed, remaining, progress.clamp(0.0, 1.0)) + } + + fn field_value(&self, field: &Field) -> String { + match field.key { + FieldKey::Duration => self.config.duration.clone(), + FieldKey::WorkspaceEnabled => on_off(self.config.actions.workspace.enabled), + FieldKey::WorkspaceBackend => self.config.actions.workspace.backend.clone(), + FieldKey::WorkspaceNumber => self.config.actions.workspace.number.to_string(), + FieldKey::MediaEnabled => on_off(self.config.actions.media.enabled), + FieldKey::MediaAction => self.config.actions.media.action.clone(), + FieldKey::BrightnessEnabled => on_off(self.config.actions.brightness.enabled), + FieldKey::BrightnessValue => self.config.actions.brightness.value.to_string(), + FieldKey::MuteEnabled => on_off(self.config.actions.mute.enabled), + FieldKey::LockEnabled => on_off(self.config.actions.lock.enabled), + FieldKey::LockCommand => self.config.actions.lock.command.clone(), + FieldKey::KillEnabled => on_off(self.config.actions.kill.enabled), + FieldKey::KillProcesses => self.config.actions.kill.processes.join(","), + FieldKey::PowerMode => self.config.actions.power.mode.clone(), + FieldKey::CustomEnabled => on_off(self.config.actions.custom.enabled), + FieldKey::CustomCommands => self.config.actions.custom.commands.join(";"), + } + } + + fn toggle_bool(&mut self, key: FieldKey) { + match key { + FieldKey::WorkspaceEnabled => { + self.config.actions.workspace.enabled = !self.config.actions.workspace.enabled + } + FieldKey::MediaEnabled => { + self.config.actions.media.enabled = !self.config.actions.media.enabled + } + FieldKey::BrightnessEnabled => { + self.config.actions.brightness.enabled = !self.config.actions.brightness.enabled + } + FieldKey::MuteEnabled => { + self.config.actions.mute.enabled = !self.config.actions.mute.enabled + } + FieldKey::LockEnabled => { + self.config.actions.lock.enabled = !self.config.actions.lock.enabled + } + FieldKey::KillEnabled => { + self.config.actions.kill.enabled = !self.config.actions.kill.enabled + } + FieldKey::CustomEnabled => { + self.config.actions.custom.enabled = !self.config.actions.custom.enabled + } + _ => {} + } + } + + fn cycle(&mut self, key: FieldKey, options: &'static [&'static str]) { + let current = match key { + FieldKey::WorkspaceBackend => &mut self.config.actions.workspace.backend, + FieldKey::MediaAction => &mut self.config.actions.media.action, + FieldKey::PowerMode => &mut self.config.actions.power.mode, + _ => return, + }; + let index = options + .iter() + .position(|option| *option == current) + .unwrap_or(0); + *current = options[(index + 1) % options.len()].to_string(); + } +} + +#[derive(Clone, Copy)] +struct Field { + label: &'static str, + key: FieldKey, + kind: FieldKind, +} + +#[derive(Clone, Copy)] +enum FieldKind { + Edit, + Bool, + Cycle(&'static [&'static str]), + Int, + Csv, + Semi, +} + +#[derive(Clone, Copy)] +enum FieldKey { + Duration, + WorkspaceEnabled, + WorkspaceBackend, + WorkspaceNumber, + MediaEnabled, + MediaAction, + BrightnessEnabled, + BrightnessValue, + MuteEnabled, + LockEnabled, + LockCommand, + KillEnabled, + KillProcesses, + PowerMode, + CustomEnabled, + CustomCommands, +} + +const FIELDS: &[Field] = &[ + Field { + label: "duration", + key: FieldKey::Duration, + kind: FieldKind::Edit, + }, + Field { + label: "workspace enabled", + key: FieldKey::WorkspaceEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "workspace backend", + key: FieldKey::WorkspaceBackend, + kind: FieldKind::Cycle(&["auto", "niri", "hyprland", "kde"]), + }, + Field { + label: "workspace number", + key: FieldKey::WorkspaceNumber, + kind: FieldKind::Int, + }, + Field { + label: "media enabled", + key: FieldKey::MediaEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "media action", + key: FieldKey::MediaAction, + kind: FieldKind::Cycle(&["stop", "pause", "play-pause", "next", "previous", "none"]), + }, + Field { + label: "brightness enabled", + key: FieldKey::BrightnessEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "brightness value", + key: FieldKey::BrightnessValue, + kind: FieldKind::Int, + }, + Field { + label: "mute enabled", + key: FieldKey::MuteEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "lock enabled", + key: FieldKey::LockEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "lock command", + key: FieldKey::LockCommand, + kind: FieldKind::Edit, + }, + Field { + label: "kill enabled", + key: FieldKey::KillEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "kill processes", + key: FieldKey::KillProcesses, + kind: FieldKind::Csv, + }, + Field { + label: "power mode", + key: FieldKey::PowerMode, + kind: FieldKind::Cycle(&["none", "poweroff", "reboot"]), + }, + Field { + label: "custom enabled", + key: FieldKey::CustomEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "custom commands", + key: FieldKey::CustomCommands, + kind: FieldKind::Semi, + }, +]; + +fn on_off(value: bool) -> String { + if value { "on" } else { "off" }.to_string() +} + +fn format_duration(duration: Duration) -> String { + let seconds = duration.as_secs(); + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let seconds = seconds % 60; + if hours > 0 { + format!("{hours}h{minutes}m{seconds}s") + } else if minutes > 0 { + format!("{minutes}m{seconds}s") + } else { + format!("{seconds}s") + } +} + +fn wrap_command(command: &str, width: usize) -> Vec { + let width = width.max(8); + let mut lines = Vec::new(); + let mut current = String::new(); + for word in command.split_whitespace() { + if current.is_empty() { + current.push_str("$ "); + current.push_str(word); + } else if current.len() + word.len() < width { + current.push(' '); + current.push_str(word); + } else { + lines.push(current); + current = format!(" {word}"); + } + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +fn split_list(value: &str, separator: char) -> Vec { + value + .split(separator) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_duration_accepts_go_style_values() { + assert!(parse_duration("25m").is_ok()); + assert!(parse_duration("1h30m").is_ok()); + assert!(parse_duration("1ns").is_ok()); + assert!(parse_duration("bad").is_err()); + assert!(parse_duration("0s").is_err()); + } + + #[test] + fn preview_matches_expected_commands() { + let mut config = Config::default(); + config.actions.lock.enabled = true; + config.actions.kill.enabled = true; + config.actions.kill.processes = vec!["firefox".to_string(), "telegram-desktop".to_string()]; + config.actions.custom.enabled = true; + config.actions.custom.commands = vec!["echo done".to_string()]; + + let preview = preview_commands(&config); + assert_eq!(preview.len(), 6); + assert_eq!(preview[1], "playerctl stop"); + assert_eq!(preview[2], "loginctl lock-session"); + assert_eq!(preview[3], "pkill firefox"); + assert_eq!(preview[4], "pkill telegram-desktop"); + assert_eq!(preview[5], "echo done"); + } + + #[test] + fn wrap_command_keeps_lines_under_width() { + let lines = wrap_command( + "if command -v niri >/dev/null 2>&1; then niri msg action focus-workspace 3", + 24, + ); + assert!(lines.len() > 1); + assert!(lines.iter().all(|line| line.len() <= 24)); + } + + #[test] + fn split_list_trims_empty_values() { + assert_eq!( + split_list("firefox, telegram-desktop, ", ','), + vec!["firefox", "telegram-desktop"] + ); + } +} diff --git a/timer.py b/timer.py deleted file mode 100755 index 44b4232..0000000 --- a/timer.py +++ /dev/null @@ -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() diff --git a/tui/app.go b/tui/app.go deleted file mode 100644 index 7b95d8f..0000000 --- a/tui/app.go +++ /dev/null @@ -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()) -} diff --git a/tui/keybinds.go b/tui/keybinds.go deleted file mode 100644 index 66c240c..0000000 --- a/tui/keybinds.go +++ /dev/null @@ -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 -} diff --git a/tui/qr.go b/tui/qr.go deleted file mode 100644 index 3e7d35d..0000000 --- a/tui/qr.go +++ /dev/null @@ -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 -} diff --git a/tui/styles.go b/tui/styles.go deleted file mode 100644 index 47dbf2d..0000000 --- a/tui/styles.go +++ /dev/null @@ -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")) -) diff --git a/tui/widgets.go b/tui/widgets.go deleted file mode 100644 index 6420ec2..0000000 --- a/tui/widgets.go +++ /dev/null @@ -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() -}