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..46d061d --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,93 @@ +name: release + +"on": + push: + tags: + - "v*.*.*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + linux-amd64: + runs-on: [self-hosted, linux, arch, homelab] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build release binary + shell: bash + run: cargo build --release --locked + + - name: Package artifact + shell: bash + run: | + tag="${GITHUB_REF##*/}" + version="${tag#v}" + mkdir -p dist + cp target/release/gosleep-timer dist/gosleep-timer + tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer + sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256" + + - name: Create Gitea release + id: release + shell: bash + run: | + tag="${GITHUB_REF##*/}" + release_url="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases" + existing="$(curl -fsS \ + "${release_url}/tags/${tag}" \ + -H "Authorization: token ${GITEA_TOKEN}" || true)" + release_id="$(printf '%s' "$existing" | jq -r '.id // empty')" + + if [ -z "$release_id" ]; then + body="$(cat <> "$GITHUB_OUTPUT" + + - name: Upload Gitea release assets + shell: bash + run: | + release_id="${{ steps.release.outputs.release_id }}" + for asset in dist/*.tar.gz dist/*.sha256; do + name="$(basename "$asset")" + release="$(curl -fsS \ + "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}" \ + -H "Authorization: token ${GITEA_TOKEN}")" + asset_id="$(printf '%s' "$release" | jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' | head -n 1)" + if [ -n "$asset_id" ]; then + curl -fsS \ + -X DELETE "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}" \ + -H "Authorization: token ${GITEA_TOKEN}" + fi + curl -fsS \ + -X POST "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=${name}" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@${asset}" + done + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: gosleep-timer-linux-amd64 + path: dist/* 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..00382e6 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,100 @@ +# Releasing + +This repository uses semantic versioning and Gitea tag-based releases. + +## Repository Description + +Use this short description in Gitea: + +> Rust TUI sleep timer for Linux desktop actions. + +Optional longer description: + +> A single-binary Linux sleep timer with YAML config, command preview, progress display, and post-countdown desktop actions for niri, Hyprland, KDE, media, brightness, audio, lock, power, process kill, and custom shell commands. + +## Upstream + +```bash +git remote add origin ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git +git remote set-url --push origin ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git +``` + +Public clone URL: + +```text +https://gitea.forust.xyz/forust/gosleep.git +``` + +## Gitea Actions Setup + +1. Enable Actions for `forust/gosleep`. +2. Register or select a Linux runner with labels used by the workflows: + `self-hosted`, `linux`, `arch`, `homelab`. +3. Ensure the runner has: + - Rust stable toolchain + - `rustfmt` + - `clippy` + - `curl` + - `jq` + - `tar` + - `sha256sum` + - Docker, if using the YAML lint job as written +4. Confirm Actions job token permissions allow `contents: write`. + The release workflow uses Gitea Actions' built-in `GITEA_TOKEN`; no extra repository secret is required. + +## Version Bump Checklist + +For a patch bump, for example `1.1.1` to `1.1.2`: + +```bash +version=1.1.2 +``` + +Update: + +- `Cargo.toml`: `package.version` +- `Cargo.lock`: package version, regenerated by Cargo +- `VERSION` +- `CHANGELOG.md` + +Then verify: + +```bash +cargo fmt --check +cargo test --locked +cargo clippy --locked -- -D warnings +cargo build --release --locked +``` + +## Create Release + +```bash +git status --short +git commit -am "chore: release v1.1.2" +git tag -a v1.1.2 -m "gosleep-timer v1.1.2" +git push origin main +git push origin v1.1.2 +``` + +The `.gitea/workflows/release.yaml` workflow will: + +1. Build `target/release/gosleep-timer`. +2. Package `gosleep-timer--linux-amd64.tar.gz`. +3. Generate a SHA-256 checksum. +4. Create a Gitea release. +5. Upload the archive and checksum as release assets. + +The workflow is idempotent: if the release already exists, it reuses it and replaces assets with matching names. + +## Manual Asset Build + +If Actions are unavailable: + +```bash +version="$(cat VERSION)" +cargo build --release --locked +mkdir -p dist +cp target/release/gosleep-timer dist/gosleep-timer +tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer +sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256" +``` 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() -}