From 59b31e5ca32595d1afcc44a3ef52712b5ceb519a Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:58:37 +0200 Subject: [PATCH] init Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- .envrc | 1 + .github/.goreleaser.yaml | 34 ++ .github/CONTRIBUTING.md | 10 + .github/FUNDING.yml | 1 + .github/ISSUE_TEMPLATE/bug_report.yml | 57 ++++ .github/ISSUE_TEMPLATE/feature_request.yml | 37 +++ .github/assets/banner.png | Bin 0 -> 42163 bytes .github/assets/logo.png | Bin 0 -> 7414 bytes .github/scripts/inject-exec.py | 33 ++ .github/workflows/release.yml | 28 ++ .gitignore | 4 + LICENSE | 21 ++ README.md | 128 ++++++++ cmd/settuings/main.go | 94 ++++++ docs/about.md | 25 ++ flake.lock | 120 +++++++ flake.nix | 42 +++ go.mod | 55 ++++ go.sum | 129 ++++++++ internal/audio/devices.go | 99 ++++++ internal/audio/level.go | 87 +++++ internal/audio/play.go | 97 ++++++ internal/audio/status.go | 97 ++++++ internal/audio/streams.go | 113 +++++++ internal/audio/wpctl.go | 54 ++++ internal/bluetooth/bluetoothctl.go | 71 +++++ internal/bluetooth/controller.go | 61 ++++ internal/bluetooth/device.go | 225 +++++++++++++ internal/config/config.go | 96 ++++++ internal/config/default_config.yaml | 56 ++++ internal/config/keybindings.go | 63 ++++ internal/icons/icons.go | 53 +++ internal/keys/audio.go | 26 ++ internal/keys/bluetooth.go | 32 ++ internal/keys/docs.go | 26 ++ internal/keys/global.go | 61 ++++ internal/keys/keys.go | 66 ++++ internal/keys/network.go | 39 +++ internal/network/device.go | 122 +++++++ internal/network/ethernet.go | 45 +++ internal/network/known.go | 62 ++++ internal/network/nmcli.go | 120 +++++++ internal/network/vpn.go | 44 +++ internal/network/wifi.go | 118 +++++++ internal/style/highlight.go | 10 + internal/style/style.go | 20 ++ internal/ui/about/model.go | 282 ++++++++++++++++ internal/ui/about/update.go | 66 ++++ internal/ui/about/view.go | 85 +++++ internal/ui/app/model.go | 101 ++++++ internal/ui/app/pages.go | 127 ++++++++ internal/ui/app/sidebar.go | 96 ++++++ internal/ui/app/update.go | 116 +++++++ internal/ui/app/view.go | 44 +++ internal/ui/audio/apps.go | 173 ++++++++++ internal/ui/audio/commands.go | 96 ++++++ internal/ui/audio/input.go | 220 +++++++++++++ internal/ui/audio/keymap.go | 51 +++ internal/ui/audio/messages.go | 46 +++ internal/ui/audio/model.go | 106 ++++++ internal/ui/audio/output.go | 165 ++++++++++ internal/ui/audio/pagination.go | 44 +++ internal/ui/audio/rows.go | 62 ++++ internal/ui/audio/slider.go | 61 ++++ internal/ui/audio/style.go | 22 ++ internal/ui/audio/tabbar.go | 49 +++ internal/ui/audio/update.go | 79 +++++ internal/ui/audio/view.go | 85 +++++ internal/ui/bluetooth/commands.go | 77 +++++ internal/ui/bluetooth/detail.go | 206 ++++++++++++ internal/ui/bluetooth/keymap.go | 44 +++ internal/ui/bluetooth/list.go | 301 ++++++++++++++++++ internal/ui/bluetooth/messages.go | 31 ++ internal/ui/bluetooth/model.go | 54 ++++ internal/ui/bluetooth/pagination.go | 44 +++ internal/ui/bluetooth/style.go | 22 ++ internal/ui/bluetooth/tabbar.go | 30 ++ internal/ui/bluetooth/update.go | 70 ++++ internal/ui/bluetooth/view.go | 93 ++++++ internal/ui/components/notifications/model.go | 154 +++++++++ internal/ui/network/commands.go | 118 +++++++ internal/ui/network/detail.go | 273 ++++++++++++++++ internal/ui/network/ethernet.go | 165 ++++++++++ internal/ui/network/keymap.go | 63 ++++ internal/ui/network/known.go | 286 +++++++++++++++++ internal/ui/network/messages.go | 64 ++++ internal/ui/network/model.go | 109 +++++++ internal/ui/network/pagination.go | 44 +++ internal/ui/network/style.go | 22 ++ internal/ui/network/tabbar.go | 63 ++++ internal/ui/network/update.go | 115 +++++++ internal/ui/network/view.go | 82 +++++ internal/ui/network/vpn.go | 139 ++++++++ internal/ui/network/wifi.go | 296 +++++++++++++++++ internal/util/cursor.go | 30 ++ internal/util/editor.go | 65 ++++ internal/util/os.go | 48 +++ internal/util/util.go | 58 ++++ internal/util/viewport.go | 107 +++++++ nix/gomod2nix.toml | 254 +++++++++++++++ nix/package.nix | 29 ++ nix/shell.nix | 57 ++++ settuings.go | 6 + 103 files changed, 8577 insertions(+) create mode 100644 .envrc create mode 100644 .github/.goreleaser.yaml create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/assets/banner.png create mode 100644 .github/assets/logo.png create mode 100644 .github/scripts/inject-exec.py create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/settuings/main.go create mode 100644 docs/about.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/audio/devices.go create mode 100644 internal/audio/level.go create mode 100644 internal/audio/play.go create mode 100644 internal/audio/status.go create mode 100644 internal/audio/streams.go create mode 100644 internal/audio/wpctl.go create mode 100644 internal/bluetooth/bluetoothctl.go create mode 100644 internal/bluetooth/controller.go create mode 100644 internal/bluetooth/device.go create mode 100644 internal/config/config.go create mode 100644 internal/config/default_config.yaml create mode 100644 internal/config/keybindings.go create mode 100644 internal/icons/icons.go create mode 100644 internal/keys/audio.go create mode 100644 internal/keys/bluetooth.go create mode 100644 internal/keys/docs.go create mode 100644 internal/keys/global.go create mode 100644 internal/keys/keys.go create mode 100644 internal/keys/network.go create mode 100644 internal/network/device.go create mode 100644 internal/network/ethernet.go create mode 100644 internal/network/known.go create mode 100644 internal/network/nmcli.go create mode 100644 internal/network/vpn.go create mode 100644 internal/network/wifi.go create mode 100644 internal/style/highlight.go create mode 100644 internal/style/style.go create mode 100644 internal/ui/about/model.go create mode 100644 internal/ui/about/update.go create mode 100644 internal/ui/about/view.go create mode 100644 internal/ui/app/model.go create mode 100644 internal/ui/app/pages.go create mode 100644 internal/ui/app/sidebar.go create mode 100644 internal/ui/app/update.go create mode 100644 internal/ui/app/view.go create mode 100644 internal/ui/audio/apps.go create mode 100644 internal/ui/audio/commands.go create mode 100644 internal/ui/audio/input.go create mode 100644 internal/ui/audio/keymap.go create mode 100644 internal/ui/audio/messages.go create mode 100644 internal/ui/audio/model.go create mode 100644 internal/ui/audio/output.go create mode 100644 internal/ui/audio/pagination.go create mode 100644 internal/ui/audio/rows.go create mode 100644 internal/ui/audio/slider.go create mode 100644 internal/ui/audio/style.go create mode 100644 internal/ui/audio/tabbar.go create mode 100644 internal/ui/audio/update.go create mode 100644 internal/ui/audio/view.go create mode 100644 internal/ui/bluetooth/commands.go create mode 100644 internal/ui/bluetooth/detail.go create mode 100644 internal/ui/bluetooth/keymap.go create mode 100644 internal/ui/bluetooth/list.go create mode 100644 internal/ui/bluetooth/messages.go create mode 100644 internal/ui/bluetooth/model.go create mode 100644 internal/ui/bluetooth/pagination.go create mode 100644 internal/ui/bluetooth/style.go create mode 100644 internal/ui/bluetooth/tabbar.go create mode 100644 internal/ui/bluetooth/update.go create mode 100644 internal/ui/bluetooth/view.go create mode 100644 internal/ui/components/notifications/model.go create mode 100644 internal/ui/network/commands.go create mode 100644 internal/ui/network/detail.go create mode 100644 internal/ui/network/ethernet.go create mode 100644 internal/ui/network/keymap.go create mode 100644 internal/ui/network/known.go create mode 100644 internal/ui/network/messages.go create mode 100644 internal/ui/network/model.go create mode 100644 internal/ui/network/pagination.go create mode 100644 internal/ui/network/style.go create mode 100644 internal/ui/network/tabbar.go create mode 100644 internal/ui/network/update.go create mode 100644 internal/ui/network/view.go create mode 100644 internal/ui/network/vpn.go create mode 100644 internal/ui/network/wifi.go create mode 100644 internal/util/cursor.go create mode 100644 internal/util/editor.go create mode 100644 internal/util/os.go create mode 100644 internal/util/util.go create mode 100644 internal/util/viewport.go create mode 100644 nix/gomod2nix.toml create mode 100644 nix/package.nix create mode 100644 nix/shell.nix create mode 100644 settuings.go diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/.goreleaser.yaml b/.github/.goreleaser.yaml new file mode 100644 index 0000000..e800187 --- /dev/null +++ b/.github/.goreleaser.yaml @@ -0,0 +1,34 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - binary: settuings + main: ./cmd/settuings + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - formats: + - tar.gz + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..7b8ae86 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing + +Everybody is invited and welcome to contribute. There is a lot to do... Check the issues! + +The process is straight-forward. + +- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1) +- Fork this git repository +- Write your changes (bug fixes, new features, ...). +- Create a Pull Request against the main branch. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..b1c5749 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: anotherhadi diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..28c03c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,57 @@ +name: "🐞 Bug Report" +description: Report a reproducible error +title: "[BUG] " +labels: ["bug"] +body: + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of the issue. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Step-by-step instructions to trigger the bug. + placeholder: | + 1. ... + 2. ... + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened instead. + validations: + required: true + + - type: input + id: os + attributes: + label: Operating system + placeholder: "e.g. Ubuntu 24.04, macOS 15" + validations: + required: true + + - type: input + id: version + attributes: + label: settuings version + description: Output of `settuings -v` + placeholder: "e.g. v0.3.1" + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional context + description: Logs, screenshots, or anything else relevant. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..b469597 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,37 @@ +name: "🚀 Feature Request" +description: Suggest an idea for this project +title: "[FEATURE] " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: Is your feature request related to a problem? Describe it. + placeholder: "I'm always frustrated when..." + validations: + required: false + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: A clear description of what you want to happen. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any alternative solutions or features you've thought about. + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional context + description: Mockups, related issues, or anything else relevant. + validations: + required: false diff --git a/.github/assets/banner.png b/.github/assets/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..58f432bcb4be03cb58673103208731ad6a500fc9 GIT binary patch literal 42163 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 WvJT%|qXU}YV(@hJb6Mw<&;$U64}TE= literal 0 HcmV?d00001 diff --git a/.github/assets/logo.png b/.github/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d123e876746cbed3f3f3652e69288e674be4b23b GIT binary patch literal 7414 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 NJYD@<);T3K0RY0JfKUJc literal 0 HcmV?d00001 diff --git a/.github/scripts/inject-exec.py b/.github/scripts/inject-exec.py new file mode 100644 index 0000000..3dd1ad4 --- /dev/null +++ b/.github/scripts/inject-exec.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +import re +import subprocess +import sys +from pathlib import Path + +PATTERN = re.compile(r".*?", re.DOTALL) + + +def replace(match): + cmd = match.group(1).strip() + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + output = result.stdout + if result.returncode != 0: + print(f"[inject-exec] command failed ({result.returncode}): {cmd}", file=sys.stderr) + print(result.stderr, file=sys.stderr) + sys.exit(1) + output = re.sub(r"\n?|\n?", "", output) + if output and not output.endswith("\n"): + output += "\n" + return f"\n{output}" + + +def process(path): + content = Path(path).read_text() + new_content = PATTERN.sub(replace, content) + if new_content != content: + Path(path).write_text(new_content) + print(f"[inject-exec] updated {path}") + + +for p in sys.argv[1:]: + process(p) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bf265c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean --config .github/.goreleaser.yaml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03ab393 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.claude/ +CLAUDE.md +result/ +.pre-commit-config.yaml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ce7478 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hadi + +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 new file mode 100644 index 0000000..7e3e60a --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +

+ logo +
+ +
+ +# SetTUIngs + +> A TUI to manage your Linux system settings like wifi, bluetooth, and more, without leaving the terminal. + +![GitHub Stars](https://www.shieldcn.dev/github/stars/anotherhadi/settuings.svg?variant=outline&theme=violet) +![Release](https://www.shieldcn.dev/github/release/anotherhadi/settuings.svg?variant=outline&theme=violet) +![CI](https://www.shieldcn.dev/github/ci/anotherhadi/settuings.svg?variant=outline&theme=violet) +[![Ko-fi](https://www.shieldcn.dev/badge/Ko--fi-sponsor-FF5E5B.svg?logo=kofi&variant=secondary&theme=violet)](https://ko-fi.com/anotherhadi) + + + + +- [What is Spilltea?](#what-is-settuings) +- [Legal Disclaimer](#legal-disclaimer) +- [Features](#features) +- [Installation](#installation) +- [Project Management](#project-management) +- [Configuration](#configuration) + - [Per-project configuration](#per-project-configuration) +- [CLI Flags](#cli-flags) +- [Plugin System](#plugin-system) +- [Vim / Neovim Integration](#vim--neovim-integration) +- [Deployment](#deployment) +- [Tech Stack](#tech-stack) + + + +## What is SetTUIngs? + +SetTUIngs is a keyboard-driven TUI for the everyday Linux system settings you'd otherwise dig through a GUI panel for: WiFi, Ethernet, VPN, Bluetooth, and audio. Every page is a thin, readable wrapper around the CLI tools you already have installed (`nmcli`, `bluetoothctl`, and `wpctl`), so there's no background daemon or D-Bus client library involved, and it always reflects whatever changed things elsewhere (a NetworkManager applet, `pavucontrol`, another terminal). + +demo + +## Features + +- **WiFi**: Scan, connect, and manage networks, including signal strength, security, saved passwords, and known networks, with the radio toggled on/off in one keystroke. +- **Ethernet & VPN**: Bring interfaces up or down and connect to VPN profiles from the same Network page. +- **Bluetooth**: Discover, pair, connect, trust, and forget devices, and power the adapter on/off. +- **Audio**: Adjust output/input volume, mute, and pick the default speaker or mic; preview a device with a test tone or a live input-level meter; control the volume of individual apps (browser tab, Spotify, etc.) independently. +- **Vim-like Navigation**: The entire interface is keyboard-driven with Vim-inspired shortcuts. Use `h/j/k/l` to move, `gg`/`G` to jump to the top/bottom, `/` to search, `q` to close panels, and more. All keybindings are fully customizable via the config file. + +## Installation + +
+Go install + +```sh +go install github.com/anotherhadi/settuings/cmd/settuings@latest +``` + +Requires Go 1.22+. The binary will be placed in `$GOPATH/bin` (or `~/go/bin`). + +
+ +
+Nix (temporary run, no install) + +```sh +nix run github:anotherhadi/settuings +``` + +
+ +
+NixOS (flake) + +Add settuings to your flake inputs: + +```nix +inputs.settuings.url = "github:anotherhadi/settuings"; +``` + +Then add the package to your system or home-manager packages: + +```nix +environment.systemPackages = [ inputs.settuings.packages.${pkgs.system}.default ]; +``` + +
+ +## CLI Flags + + + +``` +A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players. + +Usage: + spilltea [flags] + +Flags: + --add-default-config copy the default config file to the config path and exit + --add-default-plugins copy built-in example plugins into the plugins dir and exit + -c, --config string path to config file + -h, --help help for spilltea + --host string proxy host (overrides config) + --plugins-dir string path to plugins dir (overrides config) + -p, --port int proxy port (overrides config) + -P, --project string project name to open directly, or "tmp" for a temporary session + --ssl-insecure skip TLS certificate verification (overrides config) + --upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config) + -v, --version version for spilltea +``` + + + +## Tech Stack + +| Component | Library | +| --------- | ------------------------------------------------------- | +| TUI | [bubbletea](https://github.com/charmbracelet/bubbletea) | +| Styles | [lipgloss](https://github.com/charmbracelet/lipgloss) | +| Config | [viper](https://github.com/spf13/viper) | +| Plugins | [gopher-lua](https://github.com/yuin/gopher-lua) | + +--- + + 100 { + pct = 100 + } + _, err := run("set-volume", id, strconv.Itoa(pct)+"%") + return err +} + +// SetMute sets id's mute state explicitly. +func SetMute(id string, mute bool) error { + state := "0" + if mute { + state = "1" + } + _, err := run("set-mute", id, state) + return err +} + +// ToggleMute flips id's current mute state. +func ToggleMute(id string) error { + _, err := run("set-mute", id, "toggle") + return err +} + +// SetDefault makes id the default sink or source for its kind. +func SetDefault(id string) error { + _, err := run("set-default", id) + return err +} diff --git a/internal/audio/level.go b/internal/audio/level.go new file mode 100644 index 0000000..de44b8f --- /dev/null +++ b/internal/audio/level.go @@ -0,0 +1,87 @@ +package audio + +import ( + "encoding/binary" + "io" + "math" + "os/exec" + "strconv" +) + +// levelSampleRate and levelFrameSamples pick a small chunk size (~100ms) so +// the UI can poll for a fresh peak reading at a responsive rate without +// reading much audio data. +const ( + levelSampleRate = 16000 + levelFrameFrames = levelSampleRate / 10 // 100ms + levelFrameBytes = levelFrameFrames * 4 // 4 bytes per float32 sample +) + +// LevelMonitor streams raw audio from a source and reports its peak +// amplitude in short chunks, for a live input-level meter. Callers must call +// Stop when done to release the underlying pw-cat process. +type LevelMonitor struct { + cmd *exec.Cmd + stdout io.ReadCloser +} + +// StartLevelMonitor begins capturing raw audio from sourceID. +func StartLevelMonitor(sourceID string) (*LevelMonitor, error) { + target, err := nodeTarget(sourceID) + if err != nil { + return nil, err + } + + cmd := exec.Command("pw-cat", // #nosec G204 -- target is a node.name read back from `wpctl inspect`, never shell-interpreted + "--record", + "--target", target, + "--format", "f32", + "--rate", strconv.Itoa(levelSampleRate), + "--channels", "1", + "--raw", + "-", + ) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + return &LevelMonitor{cmd: cmd, stdout: stdout}, nil +} + +// Next blocks until one ~100ms chunk of audio has been captured and returns +// its peak amplitude as a value in [0, 1]. It returns an error once the +// monitor is stopped or the source disappears. +func (l *LevelMonitor) Next() (float64, error) { + buf := make([]byte, levelFrameBytes) + n, err := io.ReadFull(l.stdout, buf) + if n == 0 { + return 0, err + } + + var peak float32 + for i := 0; i+4 <= n; i += 4 { + v := math.Float32frombits(binary.LittleEndian.Uint32(buf[i : i+4])) + if v < 0 { + v = -v + } + if v > peak { + peak = v + } + } + if peak > 1 { + peak = 1 + } + return float64(peak), nil +} + +// Stop terminates the capture process. Safe to call more than once. +func (l *LevelMonitor) Stop() { + if l.cmd.Process != nil { + _ = l.cmd.Process.Kill() + } + _ = l.cmd.Wait() +} diff --git a/internal/audio/play.go b/internal/audio/play.go new file mode 100644 index 0000000..c419307 --- /dev/null +++ b/internal/audio/play.go @@ -0,0 +1,97 @@ +package audio + +import ( + "bytes" + "context" + "encoding/binary" + "math" + "os/exec" +) + +const sampleRate = 44100 + +// toneStep is one segment of the test chime: a tone at freqHz lasting dur, +// with a short fade in/out to avoid clicks. +type toneStep struct { + freqHz float64 + dur float64 // seconds +} + +// testChime is a short two-tone "ding-dong" beep, the same shape used by +// most desktop sound settings panels to preview an output device. +var testChime = []toneStep{ + {freqHz: 880, dur: 0.15}, + {freqHz: 660, dur: 0.22}, +} + +// PlayTestTone plays a short chime through the given sink, without changing +// the system default output. +func PlayTestTone(sinkID string) error { + target, err := nodeTarget(sinkID) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "pw-play", "--target", target, "-") // #nosec G204 -- target is a node.name read back from `wpctl inspect`, never shell-interpreted + cmd.Stdin = bytes.NewReader(synthChime(testChime)) + return cmd.Run() +} + +// synthChime renders steps as a 16-bit PCM mono WAV file in memory. +func synthChime(steps []toneStep) []byte { + const amplitude = 0.35 * 32767 + const fade = 0.015 // seconds of fade in/out per step, to avoid clicks + + var samples []int16 + for _, step := range steps { + n := int(step.dur * sampleRate) + for i := 0; i < n; i++ { + t := float64(i) / sampleRate + env := 1.0 + if t < fade { + env = t / fade + } else if remaining := step.dur - t; remaining < fade { + env = remaining / fade + } + v := amplitude * env * math.Sin(2*math.Pi*step.freqHz*t) + samples = append(samples, int16(v)) + } + } + + pcm := make([]byte, len(samples)*2) + for i, s := range samples { + binary.LittleEndian.PutUint16(pcm[i*2:], uint16(s)) + } + + var buf bytes.Buffer + buf.WriteString("RIFF") + writeU32(&buf, uint32(36+len(pcm))) + buf.WriteString("WAVE") + buf.WriteString("fmt ") + writeU32(&buf, 16) + writeU16(&buf, 1) // PCM + writeU16(&buf, 1) // mono + writeU32(&buf, sampleRate) + writeU32(&buf, sampleRate*2) + writeU16(&buf, 2) // block align + writeU16(&buf, 16) // bits per sample + buf.WriteString("data") + writeU32(&buf, uint32(len(pcm))) + buf.Write(pcm) + return buf.Bytes() +} + +func writeU32(buf *bytes.Buffer, v uint32) { + var b [4]byte + binary.LittleEndian.PutUint32(b[:], v) + buf.Write(b[:]) +} + +func writeU16(buf *bytes.Buffer, v uint16) { + var b [2]byte + binary.LittleEndian.PutUint16(b[:], v) + buf.Write(b[:]) +} diff --git a/internal/audio/status.go b/internal/audio/status.go new file mode 100644 index 0000000..275cca7 --- /dev/null +++ b/internal/audio/status.go @@ -0,0 +1,97 @@ +package audio + +import ( + "regexp" + "strconv" + "strings" +) + +// statusEntry is one line parsed out of a `wpctl status` section (Sinks, +// Sources, or Streams). +type statusEntry struct { + ID string + Default bool + Name string + Volume int // 0-100, only meaningful when HasVolume is true + Muted bool + HasVolume bool +} + +// entryLineRe matches one tree entry, e.g.: +// +// " │ * 57. Ryzen HD Audio Controller Speaker [vol: 0.90 MUTED]" +// " 110. pw-play" +// +// Leading tree-drawing characters and the optional "*" (default marker) are +// stripped; the remainder is the id and the free-form rest of the line. +var entryLineRe = regexp.MustCompile(`^[\s│├└─]*(\*)?\s*(\d+)\.\s+(.+)$`) + +var volBracketRe = regexp.MustCompile(`^(.*?)\s*\[vol:\s*([0-9.]+)(\s+MUTED)?\]$`) + +// status returns the raw output of `wpctl status`. +func status() (string, error) { + return run("status") +} + +// parseSection extracts the entries listed under headerLine (e.g. "Sinks:", +// "Sources:", "Streams:") within the "Audio" domain block of `wpctl status` +// output, stopping at the first blank line. Port/link sub-lines (which +// contain " > ") are tree children of a stream entry, not entries of their +// own, and are skipped. +func parseSection(out, headerLine string) []statusEntry { + lines := strings.Split(out, "\n") + + audioStart := -1 + for i, l := range lines { + if strings.TrimSpace(l) == "Audio" { + audioStart = i + break + } + } + if audioStart == -1 { + return nil + } + + sectionStart := -1 + for i := audioStart; i < len(lines); i++ { + if strings.HasSuffix(strings.TrimSpace(lines[i]), headerLine) { + sectionStart = i + 1 + break + } + } + if sectionStart == -1 { + return nil + } + + var entries []statusEntry + for i := sectionStart; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + // A blank line ends the whole Audio block (e.g. after Streams:); a + // line ending in ":" is the next section header (e.g. "Sources:"). + // Either marks the end of this section. + if trimmed == "" || strings.HasSuffix(trimmed, ":") { + break + } + m := entryLineRe.FindStringSubmatch(line) + if m == nil { + continue + } + id, isDefault, rest := m[2], m[1] == "*", strings.TrimSpace(m[3]) + if strings.Contains(rest, " > ") { + continue // a port/link sub-line, not an entry + } + + entry := statusEntry{ID: id, Default: isDefault, Name: rest} + if vm := volBracketRe.FindStringSubmatch(rest); vm != nil { + entry.Name = strings.TrimSpace(vm[1]) + if v, err := strconv.ParseFloat(vm[2], 64); err == nil { + entry.Volume = int(v*100 + 0.5) + entry.HasVolume = true + } + entry.Muted = vm[3] != "" + } + entries = append(entries, entry) + } + return entries +} diff --git a/internal/audio/streams.go b/internal/audio/streams.go new file mode 100644 index 0000000..d75ce93 --- /dev/null +++ b/internal/audio/streams.go @@ -0,0 +1,113 @@ +package audio + +import ( + "fmt" + "strings" +) + +type StreamKind int + +const ( + StreamOutput StreamKind = iota // a Stream/Output/Audio node, e.g. a browser tab playing sound + StreamInput // a Stream/Input/Audio node, e.g. an app recording from the mic +) + +// Stream is one running application audio stream (playback or capture), as +// listed under wpctl status's Streams section. +type Stream struct { + ID string + App string // human-readable application name + Kind StreamKind + Volume int // 0-100 + Muted bool +} + +// ListStreams returns every currently running application audio stream, +// both playback and capture, output streams first. +func ListStreams() ([]Stream, error) { + out, err := status() + if err != nil { + return nil, err + } + + var streams []Stream + for _, e := range parseSection(out, "Streams:") { + props, err := inspect(e.ID) + if err != nil { + continue // the stream may have exited between `status` and `inspect` + } + var kind StreamKind + switch props["media.class"] { + case "Stream/Output/Audio": + kind = StreamOutput + case "Stream/Input/Audio": + kind = StreamInput + default: + continue + } + + app := props["application.name"] + if app == "" { + app = e.Name + } + + vol, muted, err := GetVolume(e.ID) + if err != nil { + continue + } + + streams = append(streams, Stream{ID: e.ID, App: app, Kind: kind, Volume: vol, Muted: muted}) + } + + // Output streams first, stable within each kind. + out2 := make([]Stream, 0, len(streams)) + for _, s := range streams { + if s.Kind == StreamOutput { + out2 = append(out2, s) + } + } + for _, s := range streams { + if s.Kind == StreamInput { + out2 = append(out2, s) + } + } + return out2, nil +} + +// inspect runs `wpctl inspect ` and parses its "key = value" property +// dump into a map. Values are unquoted; boolean/marker prefixes ("* ") are +// stripped from keys. +func inspect(id string) (map[string]string, error) { + out, err := run("inspect", id) + if err != nil { + return nil, err + } + props := make(map[string]string) + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "* ") + key, value, ok := strings.Cut(line, " = ") + if !ok { + continue + } + props[key] = strings.Trim(value, `"`) + } + return props, nil +} + +// nodeTarget resolves a wpctl global id (e.g. "57", as used by +// get-volume/set-volume/set-default/...) to the PipeWire node.name pw-play +// and pw-cat expect for their --target flag. The two tools use different id +// spaces: passing a wpctl id straight to --target is silently ignored and +// falls back to the default node instead of erroring. +func nodeTarget(id string) (string, error) { + props, err := inspect(id) + if err != nil { + return "", err + } + name := props["node.name"] + if name == "" { + return "", fmt.Errorf("no node.name found for id %s", id) + } + return name, nil +} diff --git a/internal/audio/wpctl.go b/internal/audio/wpctl.go new file mode 100644 index 0000000..e0ceaf5 --- /dev/null +++ b/internal/audio/wpctl.go @@ -0,0 +1,54 @@ +// Package audio wraps wpctl (and the pw-play/pw-cat helper tools that ship +// alongside it) to read and control PipeWire audio state: output/input +// devices, their volume and mute, the default device, and per-application +// streams. Every exported function shells out to a CLI tool; there is no +// direct PipeWire/WirePlumber library integration. +package audio + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +var ErrNotFound = errors.New("wpctl not found in PATH") + +const defaultTimeout = 15 * time.Second + +// CheckAvailable reports whether wpctl is installed and PipeWire is +// reachable. Call it before using the rest of the package. +func CheckAvailable() error { + if _, err := exec.LookPath("wpctl"); err != nil { + return ErrNotFound + } + if _, err := run("status"); err != nil { + return err + } + return nil +} + +func run(args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "wpctl", args...) // #nosec G204 -- args are fixed wpctl subcommands or IDs read back from `wpctl status`, never shell-interpreted + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return "", ErrNotFound + } + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("wpctl %s: %s", strings.Join(args, " "), msg) + } + return stdout.String(), nil +} diff --git a/internal/bluetooth/bluetoothctl.go b/internal/bluetooth/bluetoothctl.go new file mode 100644 index 0000000..fa28d82 --- /dev/null +++ b/internal/bluetooth/bluetoothctl.go @@ -0,0 +1,71 @@ +// Package bluetooth wraps bluetoothctl to read and control Bluetooth state +// (controller power, device pairing/connection). Every exported function +// shells out to bluetoothctl in its non-interactive single-command mode; +// there is no direct D-Bus/BlueZ integration. +package bluetooth + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/charmbracelet/x/ansi" +) + +var ( + ErrNotFound = errors.New("bluetoothctl not found in PATH") + ErrNoController = errors.New("no Bluetooth controller found") +) + +const defaultTimeout = 15 * time.Second + +// CheckAvailable reports whether bluetoothctl is installed and a Bluetooth +// controller is reachable. Call it before using the rest of the package. +func CheckAvailable() error { + if _, err := exec.LookPath("bluetoothctl"); err != nil { + return ErrNotFound + } + out, err := run(defaultTimeout, "list") + if err != nil { + return fmt.Errorf("%w: %v", ErrNoController, err) + } + if strings.TrimSpace(out) == "" { + return ErrNoController + } + return nil +} + +// run executes bluetoothctl in single-command (non-interactive) mode. +// bluetoothctl writes both normal output and error messages to stdout +// (colored with ANSI escapes), so errors are extracted from there rather +// than stderr. +func run(timeout time.Duration, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "bluetoothctl", args...) // #nosec G204 -- args are fixed bluetoothctl subcommands or user-supplied device addresses, never shell-interpreted + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + out := ansi.Strip(stdout.String()) + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + return "", ErrNotFound + } + msg := strings.TrimSpace(out) + if msg == "" { + msg = strings.TrimSpace(stderr.String()) + } + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("bluetoothctl %s: %s", strings.Join(args, " "), msg) + } + return out, nil +} diff --git a/internal/bluetooth/controller.go b/internal/bluetooth/controller.go new file mode 100644 index 0000000..3fe60a5 --- /dev/null +++ b/internal/bluetooth/controller.go @@ -0,0 +1,61 @@ +package bluetooth + +import "strings" + +// ControllerInfo is the state of the default Bluetooth adapter, as reported +// by `bluetoothctl show`. +type ControllerInfo struct { + Address string + Name string + Powered bool + Discoverable bool + Pairable bool + Discovering bool +} + +// ShowController returns the state of the default Bluetooth controller. +func ShowController() (ControllerInfo, error) { + out, err := run(defaultTimeout, "show") + if err != nil { + return ControllerInfo{}, err + } + + var c ControllerInfo + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if rest, ok := strings.CutPrefix(line, "Controller "); ok { + if fields := strings.Fields(rest); len(fields) > 0 { + c.Address = fields[0] + } + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + value = strings.TrimSpace(value) + switch strings.TrimSpace(key) { + case "Name": + c.Name = value + case "Powered": + c.Powered = value == "yes" + case "Discoverable": + c.Discoverable = value == "yes" + case "Pairable": + c.Pairable = value == "yes" + case "Discovering": + c.Discovering = value == "yes" + } + } + return c, nil +} + +// SetPower turns the Bluetooth controller on or off entirely. +func SetPower(on bool) error { + state := "off" + if on { + state = "on" + } + _, err := run(defaultTimeout, "power", state) + return err +} diff --git a/internal/bluetooth/device.go b/internal/bluetooth/device.go new file mode 100644 index 0000000..cd1bd28 --- /dev/null +++ b/internal/bluetooth/device.go @@ -0,0 +1,225 @@ +package bluetooth + +import ( + "strconv" + "strings" + "time" +) + +const ( + // actionTimeout bounds connect/pair calls, which can take longer than a + // plain query while BlueZ negotiates with the remote device. + actionTimeout = 30 * time.Second + scanBuffer = 10 * time.Second +) + +// Device is a Bluetooth device known to BlueZ: either paired, or seen +// during a recent scan. +type Device struct { + Address string + Name string + Paired bool + Bonded bool + Trusted bool + Connected bool + Blocked bool + Icon string // freedesktop icon name, e.g. "input-mouse"; empty if unknown + Battery int // 0-100, -1 if unknown +} + +// DeviceDetail extends Device with the extra fields shown on the "device +// info" screen. +type DeviceDetail struct { + Device + Modalias string + UUIDs []string // human-readable service labels +} + +// parseDeviceLines parses the "Device
" lines printed by +// `bluetoothctl devices` into address -> name. +func parseDeviceLines(out string) map[string]string { + names := make(map[string]string) + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "Device ") + if !ok { + continue + } + addr, name, ok := strings.Cut(rest, " ") + if !ok { + continue + } + names[addr] = name + } + return names +} + +func deviceSet(filter string) (map[string]bool, error) { + args := []string{"devices"} + if filter != "" { + args = append(args, filter) + } + out, err := run(defaultTimeout, args...) + if err != nil { + return nil, err + } + set := make(map[string]bool) + for addr := range parseDeviceLines(out) { + set[addr] = true + } + return set, nil +} + +// ListDevices returns every device BlueZ currently knows about: paired +// devices plus anything seen during a recent scan. Paired devices get an +// extra `info` lookup for icon/battery/blocked state; unpaired ones don't, +// to keep a large scan result snappy. +func ListDevices() ([]Device, error) { + out, err := run(defaultTimeout, "devices") + if err != nil { + return nil, err + } + names := parseDeviceLines(out) + + paired, err := deviceSet("Paired") + if err != nil { + return nil, err + } + connected, err := deviceSet("Connected") + if err != nil { + return nil, err + } + trusted, err := deviceSet("Trusted") + if err != nil { + return nil, err + } + + devices := make([]Device, 0, len(names)) + for addr, name := range names { + d := Device{ + Address: addr, + Name: name, + Paired: paired[addr], + Connected: connected[addr], + Trusted: trusted[addr], + Battery: -1, + } + if d.Paired { + if detail, err := Info(addr); err == nil { + d.Icon = detail.Icon + d.Battery = detail.Battery + d.Bonded = detail.Bonded + d.Blocked = detail.Blocked + if detail.Name != "" { + d.Name = detail.Name + } + } + } + if d.Name == "" { + d.Name = d.Address + } + devices = append(devices, d) + } + return devices, nil +} + +// Info fetches full detail for one device via `bluetoothctl info`. +func Info(address string) (DeviceDetail, error) { + out, err := run(defaultTimeout, "info", address) + if err != nil { + return DeviceDetail{}, err + } + + d := DeviceDetail{Device: Device{Address: address, Battery: -1}} + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Device ") { + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + switch key { + case "Name": + d.Name = value + case "Icon": + d.Icon = value + case "Paired": + d.Paired = value == "yes" + case "Bonded": + d.Bonded = value == "yes" + case "Trusted": + d.Trusted = value == "yes" + case "Blocked": + d.Blocked = value == "yes" + case "Connected": + d.Connected = value == "yes" + case "Modalias": + d.Modalias = value + case "UUID": + if i := strings.Index(value, "("); i >= 0 { + value = strings.TrimSpace(value[:i]) + } + if value != "" { + d.UUIDs = append(d.UUIDs, value) + } + case "Battery Percentage": + if i, j := strings.Index(value, "("), strings.Index(value, ")"); i >= 0 && j > i { + if n, err := strconv.Atoi(value[i+1 : j]); err == nil { + d.Battery = n + } + } + } + } + return d, nil +} + +// Scan puts the controller into discovery mode for the given number of +// seconds, then returns. Newly seen devices show up in a subsequent +// ListDevices call. +func Scan(seconds int) error { + _, err := run(time.Duration(seconds)*time.Second+scanBuffer, "--timeout", strconv.Itoa(seconds), "scan", "on") + return err +} + +// Pair initiates pairing with a device. Because non-interactive +// bluetoothctl has no agent to answer PIN/confirmation prompts, this only +// succeeds for "just works" devices (most mice, keyboards, headsets). +func Pair(address string) error { + _, err := run(actionTimeout, "pair", address) + return err +} + +// Connect brings up all profiles for an already-paired (or just-paired) +// device. +func Connect(address string) error { + _, err := run(actionTimeout, "connect", address) + return err +} + +// Disconnect disconnects a device without forgetting it. +func Disconnect(address string) error { + _, err := run(defaultTimeout, "disconnect", address) + return err +} + +// SetTrusted marks a device as trusted (allowed to reconnect without +// confirmation) or not. +func SetTrusted(address string, trust bool) error { + verb := "untrust" + if trust { + verb = "trust" + } + _, err := run(defaultTimeout, verb, address) + return err +} + +// Remove deletes a paired device's profile, or drops an unpaired device +// from BlueZ's scan cache. +func Remove(address string) error { + _, err := run(defaultTimeout, "remove", address) + return err +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..778784a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,96 @@ +package config + +import ( + _ "embed" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +//go:embed default_config.yaml +var defaultConfig []byte + +type Config struct { + Version string `mapstructure:"-"` + + App struct { + ExternalEditor string `mapstructure:"external_editor"` + } `mapstructure:"app"` + + TUI struct { + UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"` + DefaultSidebarState string `mapstructure:"default_sidebar_state"` + } `mapstructure:"tui"` + + Keybindings Keybindings `mapstructure:"keybindings"` +} + +var Global *Config + +func Load(path string) error { + var defaults map[string]any + if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil { + return fmt.Errorf("default config: %w", err) + } + for k, v := range flatten("", defaults) { + viper.SetDefault(k, v) + } + + viper.SetConfigType("yaml") + viper.SetConfigFile(path) + if err := viper.ReadInConfig(); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + } + + Global = &Config{} + if err := viper.Unmarshal(Global); err != nil { + return err + } + return nil +} + +func WriteDefaultConfig(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + if err := os.WriteFile(path, defaultConfig, 0o600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil +} + +func ExpandPath(p string) string { + if strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return p + } + return filepath.Join(home, p[2:]) + } + return p +} + +func flatten(prefix string, m map[string]any) map[string]any { + out := make(map[string]any) + for k, v := range m { + key := k + if prefix != "" { + key = prefix + "." + k + } + if nested, ok := v.(map[string]any); ok { + for nk, nv := range flatten(key, nested) { + out[nk] = nv + } + } else { + out[key] = v + } + } + return out +} diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml new file mode 100644 index 0000000..309dd55 --- /dev/null +++ b/internal/config/default_config.yaml @@ -0,0 +1,56 @@ +app: + external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait) + +tui: + use_nerdfont_icons: true + default_sidebar_state: "expanded" # hidden, collapsed, expanded + +keybindings: + global: + quit: "q,ctrl+c" + escape: "esc,ctrl+c" + help: "?" + open_logs: "ctrl+g" + toggle_sidebar: "ctrl+b" + cycle_focus: "tab" + up: "up,k" + down: "down,j" + left: "left,h" + right: "right,l" + goto_top: "g" + goto_bottom: "G,end" + scroll_up: "pgup,ctrl+u" + scroll_down: "pgdown,ctrl+d" + prev_page: "[,ctrl+i" + next_page: "],ctrl+o" + + docs: + search: "/" + search_reset: "r" + search_next: "n" + search_prev: "N" + + network: + select: "enter" + refresh: "r" + forget: "d" + toggle_auto: "a" + reveal_secret: "p" + known: "K" + info: "i" + disconnect: "d" + toggle_radio: "w" + + bluetooth: + select: "enter" + refresh: "r" + forget: "d" + toggle_trust: "t" + info: "i" + toggle_power: "w" + + audio: + select: "enter" + refresh: "r" + mute: "m" + test: "t" diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go new file mode 100644 index 0000000..31f7a88 --- /dev/null +++ b/internal/config/keybindings.go @@ -0,0 +1,63 @@ +package config + +type GlobalKeys struct { + Quit string `mapstructure:"quit"` + Escape string `mapstructure:"escape"` + OpenLogs string `mapstructure:"open_logs"` + ToggleSidebar string `mapstructure:"toggle_sidebar"` + Help string `mapstructure:"help"` + Up string `mapstructure:"up"` + Down string `mapstructure:"down"` + Left string `mapstructure:"left"` + Right string `mapstructure:"right"` + CycleFocus string `mapstructure:"cycle_focus"` + ScrollUp string `mapstructure:"scroll_up"` + ScrollDown string `mapstructure:"scroll_down"` + GotoTop string `mapstructure:"goto_top"` + GotoBottom string `mapstructure:"goto_bottom"` + PrevPage string `mapstructure:"prev_page"` + NextPage string `mapstructure:"next_page"` +} + +type DocsKeys struct { + Search string `mapstructure:"search"` + SearchReset string `mapstructure:"search_reset"` + SearchNext string `mapstructure:"search_next"` + SearchPrev string `mapstructure:"search_prev"` +} + +type NetworkKeys struct { + Select string `mapstructure:"select"` + Refresh string `mapstructure:"refresh"` + Forget string `mapstructure:"forget"` + ToggleAuto string `mapstructure:"toggle_auto"` + RevealSecret string `mapstructure:"reveal_secret"` + Known string `mapstructure:"known"` + Info string `mapstructure:"info"` + Disconnect string `mapstructure:"disconnect"` + ToggleRadio string `mapstructure:"toggle_radio"` +} + +type BluetoothKeys struct { + Select string `mapstructure:"select"` + Refresh string `mapstructure:"refresh"` + Forget string `mapstructure:"forget"` + ToggleTrust string `mapstructure:"toggle_trust"` + Info string `mapstructure:"info"` + TogglePower string `mapstructure:"toggle_power"` +} + +type AudioKeys struct { + Select string `mapstructure:"select"` + Refresh string `mapstructure:"refresh"` + Mute string `mapstructure:"mute"` + Test string `mapstructure:"test"` +} + +type Keybindings struct { + Global GlobalKeys `mapstructure:"global"` + Docs DocsKeys `mapstructure:"docs"` + Network NetworkKeys `mapstructure:"network"` + Bluetooth BluetoothKeys `mapstructure:"bluetooth"` + Audio AudioKeys `mapstructure:"audio"` +} diff --git a/internal/icons/icons.go b/internal/icons/icons.go new file mode 100644 index 0000000..c54ee96 --- /dev/null +++ b/internal/icons/icons.go @@ -0,0 +1,53 @@ +package icons + +import "github.com/anotherhadi/settuings/internal/config" + +type Icons struct { + Edit string + Reload string + Add string + + About string + Network string + Bluetooth string + Audio string + + Wifi string + Ethernet string + VPN string + Check string + Lock string + + Output string + Input string + Apps string +} + +var I *Icons + +func Init(cfg *config.Config) { + if cfg.TUI.UseNerdfontIcons { + I = &Icons{ + Edit: "󰏫 ", + Reload: "󰑙 ", + Add: "󰐕 ", + + About: " ", + Network: "󰛳 ", + Bluetooth: "󰂯 ", + Audio: "󰓃 ", + + Wifi: "󰤨 ", + Ethernet: "󰈀 ", + VPN: "󰖂 ", + Check: "󰄬 ", + Lock: "󰌾 ", + + Output: "󰕾 ", + Input: "󰍬 ", + Apps: "󰀻 ", + } + } else { + I = &Icons{} + } +} diff --git a/internal/keys/audio.go b/internal/keys/audio.go new file mode 100644 index 0000000..e4742f1 --- /dev/null +++ b/internal/keys/audio.go @@ -0,0 +1,26 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/settuings/internal/config" +) + +type AudioKeyMap struct { + Select key.Binding + Refresh key.Binding + Mute key.Binding + Test key.Binding +} + +func newAudioKeyMap(cfg config.AudioKeys) AudioKeyMap { + return AudioKeyMap{ + Select: binding(cfg.Select, "set as default"), + Refresh: binding(cfg.Refresh, "refresh"), + Mute: binding(cfg.Mute, "mute/unmute"), + Test: binding(cfg.Test, "test"), + } +} + +func (a AudioKeyMap) Bindings() []key.Binding { + return []key.Binding{a.Select, a.Refresh, a.Mute, a.Test} +} diff --git a/internal/keys/bluetooth.go b/internal/keys/bluetooth.go new file mode 100644 index 0000000..910dd79 --- /dev/null +++ b/internal/keys/bluetooth.go @@ -0,0 +1,32 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/settuings/internal/config" +) + +type BluetoothKeyMap struct { + Select key.Binding + Refresh key.Binding + Forget key.Binding + ToggleTrust key.Binding + Info key.Binding + TogglePower key.Binding +} + +func newBluetoothKeyMap(cfg config.BluetoothKeys) BluetoothKeyMap { + return BluetoothKeyMap{ + Select: binding(cfg.Select, "connect/pair"), + Refresh: binding(cfg.Refresh, "scan"), + Forget: binding(cfg.Forget, "forget"), + ToggleTrust: binding(cfg.ToggleTrust, "toggle trust"), + Info: binding(cfg.Info, "device info"), + TogglePower: binding(cfg.TogglePower, "toggle bluetooth"), + } +} + +func (b BluetoothKeyMap) Bindings() []key.Binding { + return []key.Binding{ + b.Select, b.Refresh, b.Info, b.ToggleTrust, b.Forget, b.TogglePower, + } +} diff --git a/internal/keys/docs.go b/internal/keys/docs.go new file mode 100644 index 0000000..d002ede --- /dev/null +++ b/internal/keys/docs.go @@ -0,0 +1,26 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/settuings/internal/config" +) + +type DocsKeyMap struct { + Search key.Binding + SearchReset key.Binding + SearchNext key.Binding + SearchPrev key.Binding +} + +func newDocsKeyMap(cfg config.DocsKeys) DocsKeyMap { + return DocsKeyMap{ + Search: binding(cfg.Search, "search"), + SearchReset: binding(cfg.SearchReset, "reset search"), + SearchNext: binding(cfg.SearchNext, "next match"), + SearchPrev: binding(cfg.SearchPrev, "prev match"), + } +} + +func (d DocsKeyMap) Bindings() []key.Binding { + return []key.Binding{d.Search, d.SearchReset, d.SearchNext, d.SearchPrev} +} diff --git a/internal/keys/global.go b/internal/keys/global.go new file mode 100644 index 0000000..161fd52 --- /dev/null +++ b/internal/keys/global.go @@ -0,0 +1,61 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/settuings/internal/config" +) + +type GlobalKeyMap struct { + Quit key.Binding + OpenLogs key.Binding + ToggleSidebar key.Binding + Help key.Binding + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + CycleFocus key.Binding + Escape key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + GotoTop key.Binding + GotoBottom key.Binding + PrevPage key.Binding + NextPage key.Binding +} + +func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap { + return GlobalKeyMap{ + Quit: binding(cfg.Quit, "quit"), + OpenLogs: binding(cfg.OpenLogs, "open logs"), + ToggleSidebar: binding(cfg.ToggleSidebar, "toggle sidebar"), + Help: binding(cfg.Help, "help"), + Up: binding(cfg.Up, "up"), + Down: binding(cfg.Down, "down"), + Left: binding(cfg.Left, "scroll left"), + Right: binding(cfg.Right, "scroll right"), + CycleFocus: binding(cfg.CycleFocus, "cycle focus"), + Escape: binding(cfg.Escape, "cancel"), + ScrollUp: binding(cfg.ScrollUp, "scroll up"), + ScrollDown: binding(cfg.ScrollDown, "scroll down"), + GotoTop: binding(cfg.GotoTop, "go to top"), + GotoBottom: binding(cfg.GotoBottom, "go to bottom"), + PrevPage: binding(cfg.PrevPage, "prev page"), + NextPage: binding(cfg.NextPage, "next page"), + } +} + +func (g GlobalKeyMap) Bindings() []key.Binding { + return []key.Binding{ + g.Up, g.Down, g.Left, g.Right, g.CycleFocus, + g.Quit, g.Escape, g.Help, + g.OpenLogs, g.ToggleSidebar, + g.ScrollUp, g.ScrollDown, + g.GotoTop, g.GotoBottom, g.PrevPage, g.NextPage, + } +} + +// CommonBindings returns keys available on every page. +func (g GlobalKeyMap) CommonBindings() []key.Binding { + return []key.Binding{g.Quit, g.Help, g.OpenLogs, g.ToggleSidebar} +} diff --git a/internal/keys/keys.go b/internal/keys/keys.go new file mode 100644 index 0000000..60ae1a0 --- /dev/null +++ b/internal/keys/keys.go @@ -0,0 +1,66 @@ +package keys + +import ( + "strings" + + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/settuings/internal/config" +) + +type KeyMap struct { + Global GlobalKeyMap + Docs DocsKeyMap + Network NetworkKeyMap + Bluetooth BluetoothKeyMap + Audio AudioKeyMap +} + +var Keys *KeyMap + +func Init(cfg *config.Config) { + kb := cfg.Keybindings + Keys = &KeyMap{ + Global: newGlobalKeyMap(kb.Global), + Docs: newDocsKeyMap(kb.Docs), + Network: newNetworkKeyMap(kb.Network), + Bluetooth: newBluetoothKeyMap(kb.Bluetooth), + Audio: newAudioKeyMap(kb.Audio), + } +} + +func parseKeys(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if k := strings.TrimSpace(p); k != "" { + out = append(out, k) + } + } + return out +} + +func binding(s, help string) key.Binding { + keys := parseKeys(s) + display := strings.Join(keys, "/") + return key.NewBinding(key.WithKeys(keys...), key.WithHelp(display, help)) +} + +// ChunkByWidth splits bindings into columns sized to fit the terminal width. +func ChunkByWidth(bindings []key.Binding, termWidth int) [][]key.Binding { + cols := termWidth / 26 + if cols < 2 { + cols = 2 + } else if cols > 7 { + cols = 7 + } + perCol := (len(bindings) + cols - 1) / cols + var out [][]key.Binding + for i := 0; i < len(bindings); i += perCol { + end := i + perCol + if end > len(bindings) { + end = len(bindings) + } + out = append(out, bindings[i:end]) + } + return out +} diff --git a/internal/keys/network.go b/internal/keys/network.go new file mode 100644 index 0000000..1a29790 --- /dev/null +++ b/internal/keys/network.go @@ -0,0 +1,39 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/settuings/internal/config" +) + +type NetworkKeyMap struct { + Select key.Binding + Refresh key.Binding + Forget key.Binding + ToggleAuto key.Binding + RevealSecret key.Binding + Known key.Binding + Info key.Binding + Disconnect key.Binding + ToggleRadio key.Binding +} + +func newNetworkKeyMap(cfg config.NetworkKeys) NetworkKeyMap { + return NetworkKeyMap{ + Select: binding(cfg.Select, "select"), + Refresh: binding(cfg.Refresh, "refresh"), + Forget: binding(cfg.Forget, "forget"), + ToggleAuto: binding(cfg.ToggleAuto, "toggle autoconnect"), + RevealSecret: binding(cfg.RevealSecret, "show/hide password"), + Known: binding(cfg.Known, "known networks"), + Info: binding(cfg.Info, "network info"), + Disconnect: binding(cfg.Disconnect, "disconnect"), + ToggleRadio: binding(cfg.ToggleRadio, "toggle wifi"), + } +} + +func (n NetworkKeyMap) Bindings() []key.Binding { + return []key.Binding{ + n.Select, n.Refresh, n.Known, n.Info, n.Disconnect, n.ToggleRadio, + n.ToggleAuto, n.RevealSecret, n.Forget, + } +} diff --git a/internal/network/device.go b/internal/network/device.go new file mode 100644 index 0000000..df940c4 --- /dev/null +++ b/internal/network/device.go @@ -0,0 +1,122 @@ +package network + +import "strings" + +// Device is one row of `nmcli device status`. +type Device struct { + Name string + Type string // e.g. "wifi", "ethernet", "wireguard", "loopback" + State string // e.g. "connected", "disconnected", "unavailable" + Connection string // active connection profile name, empty if none +} + +func (d Device) Connected() bool { + return strings.HasPrefix(d.State, "connected") +} + +// ListDevices returns every network device known to NetworkManager. +func ListDevices() ([]Device, error) { + rows, err := runTerse("DEVICE,TYPE,STATE,CONNECTION", "device", "status") + if err != nil { + return nil, err + } + devices := make([]Device, 0, len(rows)) + for _, row := range rows { + devices = append(devices, Device{ + Name: field(row, 0), + Type: field(row, 1), + State: field(row, 2), + Connection: field(row, 3), + }) + } + return devices, nil +} + +// DeviceConnect asks NetworkManager to bring a device up using whichever +// saved connection profile fits it best. Unlike VPNUp (which reactivates a +// specific, already-known connection by name), this works even right after +// a disconnect, when `device status` no longer reports which connection +// profile was last active on that device. +func DeviceConnect(device string) error { + _, err := run("device", "connect", device) + return err +} + +// DeviceIP4 returns the device's primary private IPv4 address without its +// CIDR suffix (e.g. "192.168.1.54"), or "" if the device has none. +func DeviceIP4(device string) (string, error) { + out, err := getField("IP4.ADDRESS", "device", "show", device) + if err != nil { + return "", err + } + if out == "" { + return "", nil + } + // nmcli -g can return multiple addresses newline-separated; keep the first. + if i := strings.IndexByte(out, '\n'); i >= 0 { + out = out[:i] + } + if i := strings.IndexByte(out, '/'); i >= 0 { + out = out[:i] + } + return out, nil +} + +// DeviceDetail holds everything settuings shows on the "network info" screen +// for a connected device: addressing, gateway, DNS and hardware details. +type DeviceDetail struct { + HWAddr string + MTU string + IPv4 string // address + CIDR, e.g. "192.168.1.54/24" + Gateway4 string + DNS4 []string + IPv6 []string + Gateway6 string + DNS6 []string +} + +// DeviceDetails fetches full addressing/hardware info for device via +// `nmcli device show`. Unlike the tabular nmcli commands elsewhere in this +// package, `device show` prints one "KEY:VALUE" line per property (with +// "KEY[n]" for multi-valued ones) and does not escape colons inside values, +// so it's parsed differently from runTerse. +func DeviceDetails(device string) (DeviceDetail, error) { + out, err := run("-t", "-f", + "GENERAL.HWADDR,GENERAL.MTU,IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,IP6.ADDRESS,IP6.GATEWAY,IP6.DNS", + "device", "show", device) + if err != nil { + return DeviceDetail{}, err + } + + var d DeviceDetail + for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + if i := strings.IndexByte(key, '['); i >= 0 { + key = key[:i] // strip the "[n]" multi-value index + } + switch key { + case "GENERAL.HWADDR": + d.HWAddr = value + case "GENERAL.MTU": + d.MTU = value + case "IP4.ADDRESS": + if d.IPv4 == "" { + d.IPv4 = value + } + case "IP4.GATEWAY": + d.Gateway4 = value + case "IP4.DNS": + d.DNS4 = append(d.DNS4, value) + case "IP6.ADDRESS": + d.IPv6 = append(d.IPv6, value) + case "IP6.GATEWAY": + d.Gateway6 = value + case "IP6.DNS": + d.DNS6 = append(d.DNS6, value) + } + } + return d, nil +} diff --git a/internal/network/ethernet.go b/internal/network/ethernet.go new file mode 100644 index 0000000..914fb28 --- /dev/null +++ b/internal/network/ethernet.go @@ -0,0 +1,45 @@ +package network + +// EthernetDevice is a wired network interface and its active profile, if any. +type EthernetDevice struct { + Device string + Connection string + State string + IP4 string +} + +func (e EthernetDevice) Connected() bool { + return e.State == "connected" +} + +// ListEthernet returns every ethernet device, with its private IPv4 address +// filled in when connected. +func ListEthernet() ([]EthernetDevice, error) { + devices, err := ListDevices() + if err != nil { + return nil, err + } + + eth := make([]EthernetDevice, 0) + for _, d := range devices { + if d.Type != "ethernet" { + continue + } + e := EthernetDevice{ + Device: d.Name, + Connection: d.Connection, + State: d.State, + } + if e.Connected() { + e.IP4, _ = DeviceIP4(d.Name) + } + eth = append(eth, e) + } + return eth, nil +} + +// EthernetDown deactivates an ethernet device. +func EthernetDown(device string) error { + _, err := run("device", "disconnect", device) + return err +} diff --git a/internal/network/known.go b/internal/network/known.go new file mode 100644 index 0000000..944185f --- /dev/null +++ b/internal/network/known.go @@ -0,0 +1,62 @@ +package network + +import "strings" + +// KnownNetwork is a saved Wi-Fi connection profile ("known network" in the +// iOS sense), independent of whether its AP is currently in range. +type KnownNetwork struct { + Name string + Security string + AutoConnect bool + Active bool +} + +// ListKnownWifi returns every saved Wi-Fi connection profile. +func ListKnownWifi() ([]KnownNetwork, error) { + rows, err := runTerse("NAME,TYPE,AUTOCONNECT,ACTIVE", "connection", "show") + if err != nil { + return nil, err + } + + known := make([]KnownNetwork, 0) + for _, row := range rows { + if field(row, 1) != "802-11-wireless" { + continue + } + name := field(row, 0) + security, _ := getField("802-11-wireless-security.key-mgmt", "connection", "show", "id", name) + known = append(known, KnownNetwork{ + Name: name, + Security: security, + AutoConnect: field(row, 2) == "yes", + Active: field(row, 3) == "yes", + }) + } + return known, nil +} + +// Secret returns the saved PSK/passphrase for a Wi-Fi connection profile. +// It is empty for open networks or profiles using a non-PSK auth method. +func Secret(name string) (string, error) { + out, err := run("--show-secrets", "-g", "802-11-wireless-security.psk", "connection", "show", "id", name) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// SetAutoConnect toggles whether a saved connection is joined automatically. +func SetAutoConnect(name string, enabled bool) error { + value := "no" + if enabled { + value = "yes" + } + _, err := run("connection", "modify", "id", name, "connection.autoconnect", value) + return err +} + +// Forget deletes a saved connection profile entirely. +func Forget(name string) error { + _, err := run("connection", "delete", "id", name) + return err +} diff --git a/internal/network/nmcli.go b/internal/network/nmcli.go new file mode 100644 index 0000000..dcd2304 --- /dev/null +++ b/internal/network/nmcli.go @@ -0,0 +1,120 @@ +// Package network wraps nmcli to read and control network state +// (Wi-Fi, Ethernet, VPN). Every exported function shells out to nmcli; +// there is no direct D-Bus/NetworkManager integration. +package network + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +var ( + ErrNotFound = errors.New("nmcli not found in PATH") + ErrNotRunning = errors.New("NetworkManager is not running") +) + +const defaultTimeout = 15 * time.Second + +// CheckAvailable reports whether nmcli is installed and NetworkManager is +// reachable. Call it before using the rest of the package. +func CheckAvailable() error { + if _, err := exec.LookPath("nmcli"); err != nil { + return ErrNotFound + } + out, err := run("-t", "-f", "RUNNING", "general") + if err != nil { + return fmt.Errorf("%w: %v", ErrNotRunning, err) + } + if strings.TrimSpace(out) != "running" { + return ErrNotRunning + } + return nil +} + +func run(args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "nmcli", args...) // #nosec G204 -- args are fixed nmcli subcommands or user-supplied SSID/password, never shell-interpreted + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return "", ErrNotFound + } + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("nmcli %s: %s", strings.Join(args, " "), msg) + } + return stdout.String(), nil +} + +// getField runs `nmcli -g ...` and returns the single trimmed value. +func getField(field string, args ...string) (string, error) { + out, err := run(append([]string{"-g", field}, args...)...) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// runTerse runs nmcli with -t -f and parses each output line into +// its colon-separated columns, unescaping nmcli's `\:` and `\\`. +func runTerse(fields string, args ...string) ([][]string, error) { + out, err := run(append([]string{"-t", "-f", fields}, args...)...) + if err != nil { + return nil, err + } + out = strings.TrimRight(out, "\n") + if out == "" { + return nil, nil + } + + lines := strings.Split(out, "\n") + rows := make([][]string, 0, len(lines)) + for _, line := range lines { + rows = append(rows, parseTerseLine(line)) + } + return rows, nil +} + +func parseTerseLine(line string) []string { + var fields []string + var cur strings.Builder + escaped := false + for _, r := range line { + if escaped { + cur.WriteRune(r) + escaped = false + continue + } + switch r { + case '\\': + escaped = true + case ':': + fields = append(fields, cur.String()) + cur.Reset() + default: + cur.WriteRune(r) + } + } + fields = append(fields, cur.String()) + return fields +} + +// field is a small helper to safely index a parsed terse row. +func field(row []string, i int) string { + if i < 0 || i >= len(row) { + return "" + } + return row[i] +} diff --git a/internal/network/vpn.go b/internal/network/vpn.go new file mode 100644 index 0000000..a3ec6f6 --- /dev/null +++ b/internal/network/vpn.go @@ -0,0 +1,44 @@ +package network + +// VPNConnection is a saved VPN or WireGuard connection profile. +type VPNConnection struct { + Name string + Type string // "vpn" or "wireguard" + Active bool + Device string +} + +// ListVPN returns every saved connection whose type is "vpn" or "wireguard". +func ListVPN() ([]VPNConnection, error) { + rows, err := runTerse("NAME,TYPE,ACTIVE,DEVICE", "connection", "show") + if err != nil { + return nil, err + } + + vpns := make([]VPNConnection, 0) + for _, row := range rows { + typ := field(row, 1) + if typ != "vpn" && typ != "wireguard" { + continue + } + vpns = append(vpns, VPNConnection{ + Name: field(row, 0), + Type: typ, + Active: field(row, 2) == "yes", + Device: field(row, 3), + }) + } + return vpns, nil +} + +// VPNUp activates a VPN/WireGuard connection by name. +func VPNUp(name string) error { + _, err := run("connection", "up", "id", name) + return err +} + +// VPNDown deactivates a VPN/WireGuard connection by name. +func VPNDown(name string) error { + _, err := run("connection", "down", "id", name) + return err +} diff --git a/internal/network/wifi.go b/internal/network/wifi.go new file mode 100644 index 0000000..255913c --- /dev/null +++ b/internal/network/wifi.go @@ -0,0 +1,118 @@ +package network + +import ( + "errors" + "strconv" +) + +var ErrNoWifiDevice = errors.New("no Wi-Fi device found") + +// WifiDevice returns the first Wi-Fi radio device, if any. +func WifiDevice() (Device, error) { + devices, err := ListDevices() + if err != nil { + return Device{}, err + } + for _, d := range devices { + if d.Type == "wifi" { + return d, nil + } + } + return Device{}, ErrNoWifiDevice +} + +// WifiNetwork is one access point seen by `nmcli device wifi list`. +type WifiNetwork struct { + Active bool // this is the AP we're currently associated with + SSID string + BSSID string + Signal int // 0-100 + Security string +} + +func (w WifiNetwork) Secured() bool { + return w.Security != "" && w.Security != "--" +} + +// ListWifi returns visible access points, deduplicated by SSID (keeping the +// strongest signal seen for each network), sorted strongest-first. +func ListWifi() ([]WifiNetwork, error) { + rows, err := runTerse("IN-USE,SSID,BSSID,SIGNAL,SECURITY", "device", "wifi", "list") + if err != nil { + return nil, err + } + + bySSID := make(map[string]WifiNetwork) + order := make([]string, 0, len(rows)) + for _, row := range rows { + ssid := field(row, 1) + if ssid == "" { + continue // hidden network with no broadcast SSID + } + signal, _ := strconv.Atoi(field(row, 3)) + net := WifiNetwork{ + Active: field(row, 0) == "*", + SSID: ssid, + BSSID: field(row, 2), + Signal: signal, + Security: field(row, 4), + } + existing, ok := bySSID[ssid] + if !ok || net.Active || net.Signal > existing.Signal { + bySSID[ssid] = net + } + if !ok { + order = append(order, ssid) + } + } + + networks := make([]WifiNetwork, 0, len(order)) + for _, ssid := range order { + networks = append(networks, bySSID[ssid]) + } + return networks, nil +} + +// Rescan asks NetworkManager to trigger a fresh Wi-Fi scan. It returns once +// the scan is requested, not once it completes; call ListWifi shortly after. +func Rescan() error { + _, err := run("device", "wifi", "rescan") + return err +} + +// ConnectWifi joins ssid, creating a new saved profile if none exists yet. +// Pass an empty password for open networks or when a saved profile already +// has one. +func ConnectWifi(ssid, password string) error { + args := []string{"device", "wifi", "connect", ssid} + if password != "" { + args = append(args, "password", password) + } + _, err := run(args...) + return err +} + +// DisconnectWifi disconnects the given Wi-Fi device without forgetting it. +func DisconnectWifi(device string) error { + _, err := run("device", "disconnect", device) + return err +} + +// SetWifiRadio turns the Wi-Fi radio on or off entirely. +func SetWifiRadio(on bool) error { + state := "off" + if on { + state = "on" + } + _, err := run("radio", "wifi", state) + return err +} + +// RadioEnabled reports whether the Wi-Fi radio is currently switched on. +func RadioEnabled() (bool, error) { + out, err := getField("WIFI", "general") + if err != nil { + return false, err + } + return out == "enabled", nil +} diff --git a/internal/style/highlight.go b/internal/style/highlight.go new file mode 100644 index 0000000..20e9101 --- /dev/null +++ b/internal/style/highlight.go @@ -0,0 +1,10 @@ +package style + +import ( + "charm.land/lipgloss/v2" + "image/color" +) + +func Paint(c color.Color, s string) string { + return lipgloss.NewStyle().Foreground(c).Render(s) +} diff --git a/internal/style/style.go b/internal/style/style.go new file mode 100644 index 0000000..0236447 --- /dev/null +++ b/internal/style/style.go @@ -0,0 +1,20 @@ +package style + +import ( + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +type Styles struct { + PanelEditing lipgloss.Style +} + +var S *Styles + +func Init() { + S = &Styles{ + PanelEditing: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ilovetui.S.Base0E), + } +} diff --git a/internal/ui/about/model.go b/internal/ui/about/model.go new file mode 100644 index 0000000..8c223ef --- /dev/null +++ b/internal/ui/about/model.go @@ -0,0 +1,282 @@ +package about + +import ( + "regexp" + "sort" + "strings" + "unicode/utf8" + + settuings "github.com/anotherhadi/settuings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/keys" + "github.com/anotherhadi/settuings/internal/util" +) + +func readDoc(name string) string { + b, _ := settuings.DocsFS.ReadFile("docs/" + name) + return string(b) +} + +var contentMarkdown = readDoc("about.md") + +type matchEntry struct { + line int + start int + end int +} + +type Model struct { + viewport viewport.Model + help help.Model + searchInput textinput.Model + searching bool + + matches []matchEntry + matchIndex int + + renderedLines []string + strippedLines []string + + width int + height int +} + +func New() Model { + ti := textinput.New() + ti.Prompt = "/" + s := ti.Styles() + s.Focused.Prompt = lipgloss.NewStyle().Foreground(ilovetui.S.Primary) + ti.SetStyles(s) + + return Model{ + viewport: viewport.New(), + help: ilovetui.NewHelp(), + searchInput: ti, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) IsEditing() bool { return m.searching } + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.help.SetWidth(w - 2) + m.searchInput.SetWidth(w - 4) + + statusH := strings.Count(m.renderStatusBar(), "\n") + 1 + frameW := windowStyle().GetHorizontalFrameSize() + frameH := windowStyle().GetVerticalFrameSize() + + m.viewport.SetWidth(w - frameW) + m.viewport.SetHeight(h - frameH - statusH) + m.renderMarkdown() +} + +func (m *Model) applySearch() { + query := m.searchInput.Value() + m.matches = nil + m.matchIndex = 0 + + if query != "" { + re, err := regexp.Compile("(?i)" + regexp.QuoteMeta(query)) + if err == nil { + for lineIdx, stripped := range m.strippedLines { + for _, match := range re.FindAllStringIndex(stripped, -1) { + m.matches = append(m.matches, matchEntry{ + line: lineIdx, + start: match[0], + end: match[1], + }) + } + } + } + } + + m.rebuildViewportContent() + if len(m.matches) > 0 { + util.SetYOffset(&m.viewport, m.matches[0].line) + } +} + +func (m *Model) searchNext() { + if len(m.matches) == 0 { + return + } + m.matchIndex = (m.matchIndex + 1) % len(m.matches) + m.rebuildViewportContent() + util.SetYOffset(&m.viewport, m.matches[m.matchIndex].line) +} + +func (m *Model) searchPrev() { + if len(m.matches) == 0 { + return + } + m.matchIndex = (m.matchIndex - 1 + len(m.matches)) % len(m.matches) + m.rebuildViewportContent() + util.SetYOffset(&m.viewport, m.matches[m.matchIndex].line) +} + +func (m *Model) rebuildViewportContent() { + if len(m.matches) == 0 || m.searchInput.Value() == "" { + m.viewport.SetContent(strings.Join(m.renderedLines, "\n")) + return + } + + type lineInfo struct { + intervals [][]int + currentIdx int + } + byLine := make(map[int]*lineInfo) + for i, match := range m.matches { + li := byLine[match.line] + if li == nil { + li = &lineInfo{currentIdx: -1} + byLine[match.line] = li + } + li.intervals = append(li.intervals, []int{match.start, match.end}) + if i == m.matchIndex { + li.currentIdx = len(li.intervals) - 1 + } + } + + lines := make([]string, len(m.renderedLines)) + for i, ansiLine := range m.renderedLines { + if li, ok := byLine[i]; ok { + lines[i] = injectHighlightsInLine(ansiLine, li.intervals, li.currentIdx) + } else { + lines[i] = ansiLine + } + } + m.viewport.SetContent(strings.Join(lines, "\n")) +} + +func lipglossAnsiCodes(s lipgloss.Style) (open, close string) { + const sentinel = "X" + rendered := s.Render(sentinel) + idx := strings.Index(rendered, sentinel) + if idx < 0 { + return "", "" + } + return rendered[:idx], rendered[idx+len(sentinel):] +} + +func injectHighlightsInLine(ansiLine string, intervals [][]int, currentIdx int) string { + if len(intervals) == 0 { + return ansiLine + } + + normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.SubtleBg)) + currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.Primary).Foreground(ilovetui.S.Text)) + + type injection struct { + visPos int + code string + priority int // 0 = close (emit before opens at same pos), 1 = open + } + var injections []injection + for i, iv := range intervals { + open, close := normalOpen, normalClose + if i == currentIdx { + open, close = currentOpen, currentClose + } + injections = append(injections, injection{visPos: iv[0], code: open, priority: 1}) + injections = append(injections, injection{visPos: iv[1], code: close, priority: 0}) + } + sort.SliceStable(injections, func(a, b int) bool { + if injections[a].visPos != injections[b].visPos { + return injections[a].visPos < injections[b].visPos + } + return injections[a].priority < injections[b].priority + }) + + var sb strings.Builder + visPos := 0 + injIdx := 0 + i := 0 + for i < len(ansiLine) { + for injIdx < len(injections) && injections[injIdx].visPos == visPos { + sb.WriteString(injections[injIdx].code) + injIdx++ + } + if ansiLine[i] == '\x1b' { + j := i + 1 + if j < len(ansiLine) { + switch ansiLine[j] { + case '[': + j++ + for j < len(ansiLine) && (ansiLine[j] < '@' || ansiLine[j] > '~') { + j++ + } + if j < len(ansiLine) { + j++ + } + case ']': + j++ + for j < len(ansiLine) { + if ansiLine[j] == '\a' { + j++ + break + } + if ansiLine[j] == '\x1b' && j+1 < len(ansiLine) && ansiLine[j+1] == '\\' { + j += 2 + break + } + j++ + } + default: + j++ + } + } + sb.WriteString(ansiLine[i:j]) + i = j + } else { + _, size := utf8.DecodeRuneInString(ansiLine[i:]) + if size == 0 { + size = 1 + } + sb.WriteString(ansiLine[i : i+size]) + i += size + visPos += size + } + } + for injIdx < len(injections) { + sb.WriteString(injections[injIdx].code) + injIdx++ + } + return sb.String() +} + +func (m *Model) renderStatusBar() string { + if m.searching { + return lipgloss.NewStyle().Padding(0, 1).Render(m.searchInput.View()) + } + return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(aboutKeyMap{width: m.width})) +} + +type aboutKeyMap struct{ width int } + +func (aboutKeyMap) ShortHelp() []key.Binding { + g := keys.Keys.Global + d := keys.Keys.Docs + return []key.Binding{g.Up, g.Down, d.Search, g.Help} +} + +func (m aboutKeyMap) FullHelp() [][]key.Binding { + g := keys.Keys.Global + d := keys.Keys.Docs + pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown} + all := append(d.Bindings(), pageGlobals...) + all = append(all, g.CommonBindings()...) + return keys.ChunkByWidth(all, m.width) +} diff --git a/internal/ui/about/update.go b/internal/ui/about/update.go new file mode 100644 index 0000000..7349937 --- /dev/null +++ b/internal/ui/about/update.go @@ -0,0 +1,66 @@ +package about + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/settuings/internal/keys" + "github.com/anotherhadi/settuings/internal/util" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + g := keys.Keys.Global + d := keys.Keys.Docs + + switch msg := msg.(type) { + case tea.MouseWheelMsg: + util.HandleMouseWheel(msg, &m.viewport) + + case tea.KeyPressMsg: + if m.searching { + switch { + case key.Matches(msg, g.Escape): + m.searching = false + m.searchInput.Blur() + m.SetSize(m.width, m.height) + case msg.String() == "enter": + m.searching = false + m.searchInput.Blur() + m.SetSize(m.width, m.height) + default: + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + m.applySearch() + return m, cmd + } + return m, nil + } + + switch { + case key.Matches(msg, d.Search): + m.searching = true + m.searchInput.SetValue("") + m.searchInput.Focus() + m.SetSize(m.width, m.height) + case key.Matches(msg, d.SearchReset): + m.matches = nil + m.matchIndex = 0 + m.rebuildViewportContent() + case key.Matches(msg, d.SearchNext): + m.searchNext() + case key.Matches(msg, d.SearchPrev): + m.searchPrev() + case key.Matches(msg, g.Up): + util.ScrollLines(&m.viewport, -1) + case key.Matches(msg, g.Down): + util.ScrollLines(&m.viewport, 1) + case key.Matches(msg, g.ScrollUp): + util.ScrollViewport(&m.viewport, -1) + case key.Matches(msg, g.ScrollDown): + util.ScrollViewport(&m.viewport, 1) + case key.Matches(msg, g.Help): + m.help.ShowAll = !m.help.ShowAll + m.SetSize(m.width, m.height) + } + } + return m, nil +} diff --git a/internal/ui/about/view.go b/internal/ui/about/view.go new file mode 100644 index 0000000..2dd9441 --- /dev/null +++ b/internal/ui/about/view.go @@ -0,0 +1,85 @@ +package about + +import ( + "bytes" + "fmt" + "strings" + "text/template" + + tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/config" + "github.com/anotherhadi/settuings/internal/util" + "github.com/charmbracelet/x/ansi" +) + +func windowStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ilovetui.S.Subtle). + Padding(0, 0) +} + +func (m Model) View() tea.View { + statusBar := m.renderStatusBar() + if len(m.matches) > 0 { + var countText string + if m.searching { + countText = fmt.Sprintf("%d matches", len(m.matches)) + } else { + countText = fmt.Sprintf("%d/%d", m.matchIndex+1, len(m.matches)) + } + count := lipgloss.NewStyle().Padding(0, 1). + Foreground(ilovetui.S.Muted). + Render(countText) + statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, count) + } + return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, + windowStyle().Render(m.viewport.View()), + statusBar, + )) +} + +func (m *Model) renderMarkdown() { + cfg := config.Global + data := struct { + Cfg *config.Config + OS string + Kernel string + Hostname string + Arch string + }{ + Cfg: cfg, + OS: util.OSName(), + Kernel: util.KernelVersion(), + Hostname: util.Hostname(), + Arch: util.Arch(), + } + + tmpl, err := template.New("about").Parse(contentMarkdown) + if err != nil { + return + } + + var processed bytes.Buffer + if err := tmpl.Execute(&processed, data); err != nil { + return + } + + width := m.viewport.Width() - util.ScrollbarGutterWidth + renderer, _ := glamour.NewTermRenderer( + glamour.WithStyles(ilovetui.GlamourStyleConfig()), + glamour.WithWordWrap(width), + ) + + str, _ := renderer.Render(processed.String()) + m.renderedLines = strings.Split(str, "\n") + m.strippedLines = make([]string, len(m.renderedLines)) + for i, l := range m.renderedLines { + m.strippedLines[i] = ansi.Strip(l) + } + m.applySearch() + util.RefreshScrollbar(&m.viewport) +} diff --git a/internal/ui/app/model.go b/internal/ui/app/model.go new file mode 100644 index 0000000..578b99e --- /dev/null +++ b/internal/ui/app/model.go @@ -0,0 +1,101 @@ +package app + +import ( + "fmt" + "log" + "os" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/settuings/internal/config" + aboutUI "github.com/anotherhadi/settuings/internal/ui/about" + audioUI "github.com/anotherhadi/settuings/internal/ui/audio" + bluetoothUI "github.com/anotherhadi/settuings/internal/ui/bluetooth" + notificationsUI "github.com/anotherhadi/settuings/internal/ui/components/notifications" + networkUI "github.com/anotherhadi/settuings/internal/ui/network" + "github.com/sirupsen/logrus" +) + +var pageShortcuts = func() map[string]page { + m := make(map[string]page, len(pageRegistry)) + for i, e := range pageRegistry { + m[strconv.Itoa(i+1)] = e.id + } + return m +}() + +type Model struct { + page page + logPath string + fatalErr error + logFileErr error + + width int + height int + sidebarState sidebarState + about aboutUI.Model + network networkUI.Model + bluetooth bluetoothUI.Model + audio audioUI.Model + notifications notificationsUI.Model +} + +const logPath = "/tmp/settuings.log" + +// New creates the app's root model. initialPage selects the page shown on +// launch (matched case-insensitively against PageNames); an empty string +// falls back to the default page. It returns an error if initialPage is +// non-empty and does not match a known page. +func New(initialPage string) (Model, error) { + startPage := pageAbout + if initialPage != "" { + p, ok := lookupPage(initialPage) + if !ok { + return Model{}, fmt.Errorf("unknown page %q (valid: %s)", initialPage, strings.Join(PageNames(), ", ")) + } + startPage = p + } + + cfg := config.Global + + m := Model{ + page: startPage, + about: aboutUI.New(), + network: networkUI.New(), + bluetooth: bluetoothUI.New(), + audio: audioUI.New(), + notifications: notificationsUI.New(), + sidebarState: sidebarState(cfg.TUI.DefaultSidebarState), + logPath: logPath, + } + + if lf, err := os.OpenFile(m.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil { + log.SetOutput(lf) + logrus.SetOutput(lf) + } else { + m.logFileErr = err + } + + return m, nil +} + +func (m Model) FatalErr() error { return m.fatalErr } + +func (m Model) Init() tea.Cmd { + cmds := []tea.Cmd{m.about.Init(), m.network.Init(), m.bluetooth.Init(), m.audio.Init()} + if m.page != pageAbout { + cmds = append(cmds, m.activatePage(m.page)) + } + if m.logFileErr != nil { + err := m.logFileErr + cmds = append(cmds, func() tea.Msg { + return notificationsUI.NotificationMsg{ + Title: "Warning", + Body: "Could not open log file: " + err.Error(), + Kind: notificationsUI.KindWarning, + } + }) + } + return tea.Batch(cmds...) +} diff --git a/internal/ui/app/pages.go b/internal/ui/app/pages.go new file mode 100644 index 0000000..dbd61d1 --- /dev/null +++ b/internal/ui/app/pages.go @@ -0,0 +1,127 @@ +package app + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/settuings/internal/icons" + aboutUI "github.com/anotherhadi/settuings/internal/ui/about" + audioUI "github.com/anotherhadi/settuings/internal/ui/audio" + bluetoothUI "github.com/anotherhadi/settuings/internal/ui/bluetooth" + networkUI "github.com/anotherhadi/settuings/internal/ui/network" +) + +type page string + +const ( + pageAbout page = "About" + pageNetwork page = "Network" + pageBluetooth page = "Bluetooth" + pageAudio page = "Audio" +) + +// pageEntry describes a page and all its integration hooks. +type pageEntry struct { + id page + icon func() string + + // render returns the page's view content. nil = show "empty". + render func(m *Model) string + // update is called when this page is active. nil = no-op. + update func(m *Model, msg tea.Msg) tea.Cmd + // isEditing reports whether the page is in text-editing mode. + isEditing func(m *Model) bool + // resize propagates a new (w, h) to the page model. + resize func(m *Model, w, h int) + // hasUpdate reports whether the page has unseen updates. + hasUpdate func(m *Model) bool + // activate is called once when the user navigates onto this page. + // nil = no-op. + activate func(m *Model) tea.Cmd + // deactivate is called synchronously once when the user navigates away + // from this page (or quits the app). nil = no-op. Unlike activate, this + // runs synchronously rather than as a tea.Cmd: pages that hold a + // background OS process (e.g. Audio's live input-level meter) need it + // torn down immediately, not racing an async command against exit. + deactivate func(m *Model) +} + +var pageRegistry = []pageEntry{ + { + id: pageAbout, + icon: func() string { return icons.I.About }, + + render: func(m *Model) string { return m.about.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.about.Update(msg) + m.about = updated.(aboutUI.Model) + return cmd + }, + isEditing: func(m *Model) bool { return m.about.IsEditing() }, + resize: func(m *Model, w, h int) { m.about.SetSize(w, h) }, + }, + { + id: pageNetwork, + icon: func() string { return icons.I.Network }, + + render: func(m *Model) string { return m.network.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.network.Update(msg) + m.network = updated.(networkUI.Model) + return cmd + }, + isEditing: func(m *Model) bool { return m.network.IsEditing() }, + resize: func(m *Model, w, h int) { m.network.SetSize(w, h) }, + activate: func(m *Model) tea.Cmd { return m.network.Activate() }, + }, + { + id: pageBluetooth, + icon: func() string { return icons.I.Bluetooth }, + + render: func(m *Model) string { return m.bluetooth.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.bluetooth.Update(msg) + m.bluetooth = updated.(bluetoothUI.Model) + return cmd + }, + isEditing: func(m *Model) bool { return m.bluetooth.IsEditing() }, + resize: func(m *Model, w, h int) { m.bluetooth.SetSize(w, h) }, + activate: func(m *Model) tea.Cmd { return m.bluetooth.Activate() }, + }, + { + id: pageAudio, + icon: func() string { return icons.I.Audio }, + + render: func(m *Model) string { return m.audio.View().Content }, + update: func(m *Model, msg tea.Msg) tea.Cmd { + updated, cmd := m.audio.Update(msg) + m.audio = updated.(audioUI.Model) + return cmd + }, + isEditing: func(m *Model) bool { return m.audio.IsEditing() }, + resize: func(m *Model, w, h int) { m.audio.SetSize(w, h) }, + activate: func(m *Model) tea.Cmd { return m.audio.Activate() }, + deactivate: func(m *Model) { m.audio.Deactivate() }, + }, +} + +// PageNames returns the valid page identifiers, in sidebar order, usable +// with the --page flag. +func PageNames() []string { + names := make([]string, len(pageRegistry)) + for i, e := range pageRegistry { + names[i] = string(e.id) + } + return names +} + +// lookupPage resolves a case-insensitive page name (as passed on the +// command line) to its registry id. +func lookupPage(name string) (page, bool) { + for _, e := range pageRegistry { + if strings.EqualFold(string(e.id), name) { + return e.id, true + } + } + return "", false +} diff --git a/internal/ui/app/sidebar.go b/internal/ui/app/sidebar.go new file mode 100644 index 0000000..71d8e3b --- /dev/null +++ b/internal/ui/app/sidebar.go @@ -0,0 +1,96 @@ +package app + +import ( + "strconv" + "strings" + + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +type sidebarState string + +const ( + sidebarHidden sidebarState = "hidden" + sidebarCollapsed sidebarState = "collapsed" + sidebarExpanded sidebarState = "expanded" +) + +func (m *Model) cycleSidebarState() { + switch m.sidebarState { + case sidebarHidden: + m.sidebarState = sidebarCollapsed + case sidebarCollapsed: + m.sidebarState = sidebarExpanded + default: + m.sidebarState = sidebarHidden + } +} + +func (m Model) getSidebarWidth() int { + switch m.sidebarState { + case sidebarHidden: + return 0 + case sidebarCollapsed: + return 8 + default: + return 18 + } +} + +func (m *Model) renderSidebar() string { + if m.sidebarState == sidebarHidden { + return "" + } + // content width inside bordered panel + inner := m.getSidebarWidth() - 2 + + titleText := "SETTUINGS" + if m.sidebarState == sidebarCollapsed { + titleText = "STUI" + } + title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 1).Render(titleText) + + divider := strings.Repeat("─", inner) + + badgeSelected := lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Bold(true) + badgeNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle) + textSelected := lipgloss.NewStyle().Foreground(ilovetui.S.Primary) + textNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Text) + lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1) + + var items strings.Builder + badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true) + + for i, entry := range pageRegistry { + selected := entry.id == m.page + badgeStyle, textStyle := badgeNormal, textNormal + if selected { + badgeStyle, textStyle = badgeSelected, textSelected + } else if entry.hasUpdate != nil && entry.hasUpdate(m) { + badgeStyle = badgeUnread + } + icon := "" + if entry.icon != nil { + icon = entry.icon() + } + label := " " + icon + if m.sidebarState != sidebarCollapsed { + label += string(entry.id) + } + line := lineStyle.Render(badgeStyle.Render(strconv.Itoa(i+1)) + textStyle.Render(label)) + if m.sidebarState == sidebarCollapsed && icon == "" { + line = " " + line + } + items.WriteString(line + "\n") + } + + parts := []string{ + title, + lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render(divider), + items.String(), + } + body := lipgloss.JoinVertical(lipgloss.Left, parts...) + + return ilovetui.S.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body) +} diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go new file mode 100644 index 0000000..2db0048 --- /dev/null +++ b/internal/ui/app/update.go @@ -0,0 +1,116 @@ +package app + +import ( + "os/exec" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/settuings/internal/keys" + notificationsUI "github.com/anotherhadi/settuings/internal/ui/components/notifications" + "github.com/anotherhadi/settuings/internal/util" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case notificationsUI.NotificationMsg: + var cmd tea.Cmd + m.notifications, cmd = m.notifications.Update(msg) + return m, cmd + case notificationsUI.DismissMsg: + var cmd tea.Cmd + m.notifications, cmd = m.notifications.Update(msg) + return m, cmd + } + + var activateCmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeChildren() + + case tea.KeyPressMsg: + if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() { + m.deactivatePage(m.page) + return m, tea.Quit + } + + if key.Matches(msg, keys.Keys.Global.OpenLogs) { + return m, tea.ExecProcess(exec.Command(util.ResolveEditor(), m.logPath), nil) // #nosec G204 -- editor from trusted config/$EDITOR, logPath is a fixed path + } + + if !m.activeIsEditing() { + switch { + case key.Matches(msg, keys.Keys.Global.ToggleSidebar): + m.cycleSidebarState() + m.resizeChildren() + + default: + if p, ok := pageShortcuts[msg.String()]; ok { + prev := m.page + m.page = p + if prev != p { + m.deactivatePage(prev) + activateCmd = m.activatePage(p) + } + } + } + } + } + + var cmd tea.Cmd + m, cmd = m.updateActivePage(msg) + return m, tea.Batch(activateCmd, cmd) +} + +func (m *Model) activatePage(p page) tea.Cmd { + for _, e := range pageRegistry { + if e.id == p && e.activate != nil { + return e.activate(m) + } + } + return nil +} + +// deactivatePage synchronously tears down page p's background state, if it +// has any (see pageEntry.deactivate). +func (m *Model) deactivatePage(p page) { + for _, e := range pageRegistry { + if e.id == p && e.deactivate != nil { + e.deactivate(m) + return + } + } +} + +func (m Model) activeIsEditing() bool { + for _, e := range pageRegistry { + if e.id == m.page && e.isEditing != nil { + return e.isEditing(&m) + } + } + return false +} + +func (m Model) updateActivePage(msg tea.Msg) (Model, tea.Cmd) { + for _, e := range pageRegistry { + if e.id == m.page && e.update != nil { + cmd := e.update(&m, msg) + return m, cmd + } + } + return m, nil +} + +func (m *Model) resizeChildren() { + sidebarW := m.getSidebarWidth() + h := m.height + for _, e := range pageRegistry { + if e.resize == nil { + continue + } + e.resize(m, m.width-sidebarW, h) + } + m.notifications.SetSize(m.width, m.height) +} diff --git a/internal/ui/app/view.go b/internal/ui/app/view.go new file mode 100644 index 0000000..d6fff57 --- /dev/null +++ b/internal/ui/app/view.go @@ -0,0 +1,44 @@ +package app + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +func (m Model) newView(content string) tea.View { + v := tea.NewView(content) + v.AltScreen = true + v.WindowTitle = "Settuings" + return v +} + +func (m Model) View() tea.View { + if m.width == 0 { + return m.newView("") + } + + rendered := m.renderNormal() + if m.notifications.HasNotifications() { + rendered = m.notifications.View(rendered) + } + + v := m.newView(rendered) + v.MouseMode = tea.MouseModeCellMotion + return v +} + +func (m Model) renderNormal() string { + sidebar := m.renderSidebar() + content := m.renderActivePage() + return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content) +} + +func (m *Model) renderActivePage() string { + for _, e := range pageRegistry { + if e.id == m.page && e.render != nil { + return e.render(m) + } + } + return ilovetui.S.Faint.Render("Work in progress") +} diff --git a/internal/ui/audio/apps.go b/internal/ui/audio/apps.go new file mode 100644 index 0000000..a32a67b --- /dev/null +++ b/internal/ui/audio/apps.go @@ -0,0 +1,173 @@ +package audio + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/audio" + "github.com/anotherhadi/settuings/internal/keys" +) + +type appsState struct { + loading bool + err error + streams []audio.Stream + cursor int + pending bool + feedback string +} + +func newAppsState() appsState { + return appsState{loading: true} +} + +func (m *Model) handleAppsList(msg appsListMsg) { + m.apps.loading = false + m.apps.err = msg.err + if msg.err != nil { + return + } + m.apps.streams = msg.streams + if m.apps.cursor >= len(m.apps.streams) { + m.apps.cursor = max(0, len(m.apps.streams)-1) + } +} + +func (m *Model) handleAppsAction(msg appsActionMsg) tea.Cmd { + m.apps.pending = false + if msg.err != nil { + m.apps.feedback = msg.err.Error() + return nil + } + return fetchAppsCmd() +} + +func (m Model) selectedStream() *audio.Stream { + if m.apps.cursor < 0 || m.apps.cursor >= len(m.apps.streams) { + return nil + } + return &m.apps.streams[m.apps.cursor] +} + +func (m *Model) updateApps(msg tea.Msg) tea.Cmd { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return nil + } + g := keys.Keys.Global + a := keys.Keys.Audio + + switch { + case key.Matches(keyMsg, g.Up): + if m.apps.cursor > 0 { + m.apps.cursor-- + } + case key.Matches(keyMsg, g.Down): + if m.apps.cursor < len(m.apps.streams)-1 { + m.apps.cursor++ + } + case key.Matches(keyMsg, g.Left): + return m.adjustStreamVolume(-volumeStep) + case key.Matches(keyMsg, g.Right): + return m.adjustStreamVolume(volumeStep) + case key.Matches(keyMsg, a.Refresh): + m.apps.pending = true + return fetchAppsCmd() + case key.Matches(keyMsg, a.Mute): + s := m.selectedStream() + if s == nil { + return nil + } + m.apps.feedback = "" + m.apps.pending = true + return appsToggleMuteCmd(s.ID) + } + return nil +} + +func (m *Model) adjustStreamVolume(delta int) tea.Cmd { + s := m.selectedStream() + if s == nil { + return nil + } + pct := s.Volume + delta + if pct < 0 { + pct = 0 + } else if pct > 100 { + pct = 100 + } + s.Volume = pct + m.apps.feedback = "" + return appsSetVolumeCmd(s.ID, pct) +} + +func sectionTitle(k audio.StreamKind) string { + if k == audio.StreamInput { + return "INPUT STREAMS (recording)" + } + return "OUTPUT STREAMS (playback)" +} + +// windowAroundCursor picks a [start, end) slice of size items (or fewer, if +// total is smaller) that keeps cursor in view. +func windowAroundCursor(cursor, total, size int) (start, end int) { + if size >= total { + return 0, total + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + if start+size > total { + start = total - size + } + return start, start + size +} + +func (m Model) renderApps(width, height int) string { + if m.apps.loading { + return ilovetui.S.Faint.Render("Loading application streams…") + } + + header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("APPLICATIONS") + if m.apps.pending { + header += mutedStyle().Render(" refreshing…") + } + + if m.apps.err != nil { + return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.apps.err.Error())) + } + if len(m.apps.streams) == 0 { + return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No application is currently playing or recording audio.")) + } + + maxItems := (height - 3) / streamRowHeight + if maxItems < 1 { + maxItems = 1 + } + start, end := windowAroundCursor(m.apps.cursor, len(m.apps.streams), maxItems) + + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle) + var b strings.Builder + lastKind := audio.StreamKind(-1) + for i := start; i < end; i++ { + s := m.apps.streams[i] + if s.Kind != lastKind { + b.WriteString(sectionStyle.Render(sectionTitle(s.Kind)) + "\n\n") + lastKind = s.Kind + } + b.WriteString(renderStreamRow(s, i == m.apps.cursor, width)) + b.WriteString("\n\n") + } + list := strings.TrimRight(b.String(), "\n") + + footer := "" + if m.apps.feedback != "" { + footer = subtleStyle().Render(m.apps.feedback) + } + + return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer) +} diff --git a/internal/ui/audio/commands.go b/internal/ui/audio/commands.go new file mode 100644 index 0000000..d6c3b09 --- /dev/null +++ b/internal/ui/audio/commands.go @@ -0,0 +1,96 @@ +package audio + +import ( + "time" + + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/settuings/internal/audio" + notificationsUI "github.com/anotherhadi/settuings/internal/ui/components/notifications" +) + +const tickInterval = 5 * time.Second + +func tickCmd() tea.Cmd { + return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} }) +} + +func checkCmd() tea.Cmd { + return func() tea.Msg { return checkMsg{err: audio.CheckAvailable()} } +} + +func fetchOutputCmd() tea.Cmd { + return func() tea.Msg { + devices, err := audio.ListSinks() + return outputListMsg{devices: devices, err: err} + } +} + +func fetchInputCmd() tea.Cmd { + return func() tea.Msg { + devices, err := audio.ListSources() + return inputListMsg{devices: devices, err: err} + } +} + +func fetchAppsCmd() tea.Cmd { + return func() tea.Msg { + streams, err := audio.ListStreams() + return appsListMsg{streams: streams, err: err} + } +} + +func outputSetVolumeCmd(id string, pct int) tea.Cmd { + return func() tea.Msg { return outputActionMsg{err: audio.SetVolume(id, pct)} } +} + +func outputToggleMuteCmd(id string) tea.Cmd { + return func() tea.Msg { return outputActionMsg{err: audio.ToggleMute(id)} } +} + +func outputSetDefaultCmd(id string) tea.Cmd { + return func() tea.Msg { return outputActionMsg{err: audio.SetDefault(id)} } +} + +func outputTestToneCmd(sinkID string) tea.Cmd { + return func() tea.Msg { return outputActionMsg{err: audio.PlayTestTone(sinkID)} } +} + +func inputSetVolumeCmd(id string, pct int) tea.Cmd { + return func() tea.Msg { return inputActionMsg{err: audio.SetVolume(id, pct)} } +} + +func inputToggleMuteCmd(id string) tea.Cmd { + return func() tea.Msg { return inputActionMsg{err: audio.ToggleMute(id)} } +} + +func inputSetDefaultCmd(id string) tea.Cmd { + return func() tea.Msg { return inputActionMsg{err: audio.SetDefault(id)} } +} + +func appsSetVolumeCmd(id string, pct int) tea.Cmd { + return func() tea.Msg { return appsActionMsg{err: audio.SetVolume(id, pct)} } +} + +func appsToggleMuteCmd(id string) tea.Cmd { + return func() tea.Msg { return appsActionMsg{err: audio.ToggleMute(id)} } +} + +func notifyCmd(title, body string) tea.Cmd { + return func() tea.Msg { + return notificationsUI.NotificationMsg{Title: title, Body: body, Kind: notificationsUI.KindInfo} + } +} + +func startLevelMonitorCmd(sourceID string) tea.Cmd { + return func() tea.Msg { + mon, err := audio.StartLevelMonitor(sourceID) + return levelStartedMsg{monitor: mon, err: err} + } +} + +func waitLevelCmd(mon *audio.LevelMonitor) tea.Cmd { + return func() tea.Msg { + peak, err := mon.Next() + return levelMsg{peak: peak, err: err} + } +} diff --git a/internal/ui/audio/input.go b/internal/ui/audio/input.go new file mode 100644 index 0000000..65e0473 --- /dev/null +++ b/internal/ui/audio/input.go @@ -0,0 +1,220 @@ +package audio + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/audio" + "github.com/anotherhadi/settuings/internal/keys" +) + +type inputState struct { + loading bool + err error + devices []audio.Device + cursor int + pending bool + feedback string + + testing bool + monitor *audio.LevelMonitor + level float64 // last peak reading in [0, 1], only meaningful while testing +} + +func newInputState() inputState { + return inputState{loading: true} +} + +// stopLevelMonitor tears down a running live input-level test, if any. Safe +// to call unconditionally. +func (s *inputState) stopLevelMonitor() { + if s.monitor != nil { + s.monitor.Stop() + s.monitor = nil + } + s.testing = false + s.level = 0 +} + +func (m *Model) handleInputList(msg inputListMsg) { + m.input.loading = false + m.input.err = msg.err + if msg.err != nil { + return + } + m.input.devices = msg.devices + if m.input.cursor >= len(m.input.devices) { + m.input.cursor = max(0, len(m.input.devices)-1) + } +} + +func (m *Model) handleInputAction(msg inputActionMsg) tea.Cmd { + m.input.pending = false + if msg.err != nil { + m.input.feedback = msg.err.Error() + return nil + } + return fetchInputCmd() +} + +// handleLevelStarted reacts to a StartLevelMonitor result. If the user +// toggled testing off again before this arrived, the freshly-started monitor +// is stopped immediately instead of being adopted. +func (m *Model) handleLevelStarted(msg levelStartedMsg) tea.Cmd { + if msg.err != nil { + m.input.testing = false + m.input.feedback = msg.err.Error() + return nil + } + if !m.input.testing { + msg.monitor.Stop() + return nil + } + m.input.monitor = msg.monitor + return waitLevelCmd(msg.monitor) +} + +// handleLevel reacts to one peak reading, re-arming the wait for the next +// one as long as the test is still active. +func (m *Model) handleLevel(msg levelMsg) tea.Cmd { + if !m.input.testing || m.input.monitor == nil { + return nil + } + if msg.err != nil { + m.input.feedback = "Input test stopped: " + msg.err.Error() + m.input.stopLevelMonitor() + return nil + } + m.input.level = msg.peak + return waitLevelCmd(m.input.monitor) +} + +func (m Model) selectedInput() *audio.Device { + if m.input.cursor < 0 || m.input.cursor >= len(m.input.devices) { + return nil + } + return &m.input.devices[m.input.cursor] +} + +func (m *Model) updateInput(msg tea.Msg) tea.Cmd { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return nil + } + g := keys.Keys.Global + a := keys.Keys.Audio + + switch { + case key.Matches(keyMsg, g.Up): + if m.input.cursor > 0 { + m.input.stopLevelMonitor() + m.input.cursor-- + } + case key.Matches(keyMsg, g.Down): + if m.input.cursor < len(m.input.devices)-1 { + m.input.stopLevelMonitor() + m.input.cursor++ + } + case key.Matches(keyMsg, g.Left): + return m.adjustInputVolume(-volumeStep) + case key.Matches(keyMsg, g.Right): + return m.adjustInputVolume(volumeStep) + case key.Matches(keyMsg, a.Refresh): + m.input.pending = true + return fetchInputCmd() + case key.Matches(keyMsg, a.Mute): + dev := m.selectedInput() + if dev == nil { + return nil + } + m.input.feedback = "" + m.input.pending = true + return inputToggleMuteCmd(dev.ID) + case key.Matches(keyMsg, a.Select): + dev := m.selectedInput() + if dev == nil { + return nil + } + if dev.Default { + m.input.feedback = "Already the default input." + return nil + } + m.input.feedback = "" + m.input.pending = true + return inputSetDefaultCmd(dev.ID) + case key.Matches(keyMsg, a.Test): + dev := m.selectedInput() + if dev == nil { + return nil + } + if m.input.testing { + m.input.stopLevelMonitor() + return nil + } + m.input.testing = true + m.input.feedback = "" + return startLevelMonitorCmd(dev.ID) + } + return nil +} + +func (m *Model) adjustInputVolume(delta int) tea.Cmd { + dev := m.selectedInput() + if dev == nil { + return nil + } + pct := dev.Volume + delta + if pct < 0 { + pct = 0 + } else if pct > 100 { + pct = 100 + } + dev.Volume = pct + m.input.feedback = "" + return inputSetVolumeCmd(dev.ID, pct) +} + +func (m Model) renderInput(width, height int) string { + if m.input.loading { + return ilovetui.S.Faint.Render("Loading input devices…") + } + + header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("INPUT DEVICES") + if m.input.pending { + header += mutedStyle().Render(" refreshing…") + } + + if m.input.err != nil { + return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.input.err.Error())) + } + if len(m.input.devices) == 0 { + return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No input devices found.")) + } + + available := (height - 3) / deviceRowHeight + if available < 1 { + available = 1 + } + p, start, end := paginate(m.input.cursor, len(m.input.devices), available) + + var rows []string + for i := start; i < end; i++ { + selected := i == m.input.cursor + row := renderDeviceRow(m.input.devices[i], selected, width) + if selected && m.input.testing { + row += "\n " + renderLevelMeter(m.input.level, barWidth(width)) + } + rows = append(rows, row) + } + list := strings.Join(rows, "\n\n") + + footer := renderPaginatorDots(p) + if m.input.feedback != "" { + footer = subtleStyle().Render(m.input.feedback) + } + + return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer) +} diff --git a/internal/ui/audio/keymap.go b/internal/ui/audio/keymap.go new file mode 100644 index 0000000..13bf1ff --- /dev/null +++ b/internal/ui/audio/keymap.go @@ -0,0 +1,51 @@ +package audio + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/settuings/internal/keys" +) + +// audioKeyMap renders the bottom help bar, adapting to the active tab. +type audioKeyMap struct { + m Model + width int +} + +// relabel reuses an existing binding's keys under a different help +// description. Volume adjustment reuses the global Left/Right binding (h/l, +// arrow keys), which is normally labelled "scroll left/right" — here it +// means something else, so the help bar needs its own wording. +func relabel(b key.Binding, desc string) key.Binding { + return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(b.Help().Key, desc)) +} + +func (k audioKeyMap) ShortHelp() []key.Binding { + g := keys.Keys.Global + a := keys.Keys.Audio + volDown, volUp := relabel(g.Left, "vol down"), relabel(g.Right, "vol up") + + switch k.m.active { + case tabApps: + return []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, g.CycleFocus, g.Help} + default: + return []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, a.Test, g.Help} + } +} + +func (k audioKeyMap) FullHelp() [][]key.Binding { + g := keys.Keys.Global + a := keys.Keys.Audio + volDown, volUp := relabel(g.Left, "volume down"), relabel(g.Right, "volume up") + + var bindings []key.Binding + switch k.m.active { + case tabOutput: + bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Select, a.Mute, a.Test, a.Refresh, g.CycleFocus} + case tabInput: + bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Select, a.Mute, relabel(a.Test, "test mic"), a.Refresh, g.CycleFocus} + case tabApps: + bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, a.Refresh, g.CycleFocus} + } + bindings = append(bindings, g.CommonBindings()...) + return keys.ChunkByWidth(bindings, k.width) +} diff --git a/internal/ui/audio/messages.go b/internal/ui/audio/messages.go new file mode 100644 index 0000000..d5b1fba --- /dev/null +++ b/internal/ui/audio/messages.go @@ -0,0 +1,46 @@ +package audio + +import "github.com/anotherhadi/settuings/internal/audio" + +// checkMsg carries the result of audio.CheckAvailable. +type checkMsg struct{ err error } + +// tickMsg triggers a periodic refresh of every tab while this page is active. +type tickMsg struct{} + +type outputListMsg struct { + devices []audio.Device + err error +} + +type inputListMsg struct { + devices []audio.Device + err error +} + +type appsListMsg struct { + streams []audio.Stream + err error +} + +// outputActionMsg carries the result of a control action (set volume, mute, +// set default, play test tone) performed on the Output tab. +type outputActionMsg struct{ err error } + +// inputActionMsg is the Input tab's equivalent of outputActionMsg. +type inputActionMsg struct{ err error } + +// appsActionMsg is the Apps tab's equivalent of outputActionMsg. +type appsActionMsg struct{ err error } + +// levelStartedMsg carries the result of starting a live input-level meter. +type levelStartedMsg struct { + monitor *audio.LevelMonitor + err error +} + +// levelMsg carries one peak reading from the live input-level meter. +type levelMsg struct { + peak float64 + err error +} diff --git a/internal/ui/audio/model.go b/internal/ui/audio/model.go new file mode 100644 index 0000000..d6a63c3 --- /dev/null +++ b/internal/ui/audio/model.go @@ -0,0 +1,106 @@ +// Package audio provides the "Audio" TUI page: a thin, tabbed wrapper around +// wpctl (and the pw-play/pw-cat helper tools that ship with it) covering +// output devices, input devices, and per-application streams, in the same +// spirit as the network page's tabs. +package audio + +import ( + "charm.land/bubbles/v2/help" + tea "charm.land/bubbletea/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/icons" +) + +type tab int + +const ( + tabOutput tab = iota + tabInput + tabApps +) + +var visibleTabs = []tab{tabOutput, tabInput, tabApps} + +func (t tab) label() string { + switch t { + case tabOutput: + return "Output" + case tabInput: + return "Input" + case tabApps: + return "Apps" + default: + return "" + } +} + +func (t tab) icon() string { + switch t { + case tabOutput: + return icons.I.Output + case tabInput: + return icons.I.Input + case tabApps: + return icons.I.Apps + default: + return "" + } +} + +type Model struct { + active tab + width int + height int + help help.Model + + checkErr error // reason wpctl is unusable; nil once confirmed OK + checked bool + + output outputState + input inputState + apps appsState +} + +func New() Model { + return Model{ + active: tabOutput, + help: ilovetui.NewHelp(), + output: newOutputState(), + input: newInputState(), + apps: newAppsState(), + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +// Activate is called every time the user navigates onto this page. It +// re-checks wpctl availability, kicks off a fetch for every tab, and starts +// the periodic auto-refresh loop. +func (m *Model) Activate() tea.Cmd { + return tea.Batch( + checkCmd(), + fetchOutputCmd(), + fetchInputCmd(), + fetchAppsCmd(), + tickCmd(), + ) +} + +// Deactivate is called synchronously when the user navigates away from this +// page (or quits) so the live input-level meter, if running, never leaks a +// background pw-cat process. +func (m *Model) Deactivate() { + m.input.stopLevelMonitor() +} + +func (m Model) IsEditing() bool { + return false +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.help.SetWidth(w - 2) +} diff --git a/internal/ui/audio/output.go b/internal/ui/audio/output.go new file mode 100644 index 0000000..ced3c53 --- /dev/null +++ b/internal/ui/audio/output.go @@ -0,0 +1,165 @@ +package audio + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/audio" + "github.com/anotherhadi/settuings/internal/keys" +) + +const volumeStep = 5 + +type outputState struct { + loading bool + err error + devices []audio.Device + cursor int + pending bool + feedback string +} + +func newOutputState() outputState { + return outputState{loading: true} +} + +func (m *Model) handleOutputList(msg outputListMsg) { + m.output.loading = false + m.output.err = msg.err + if msg.err != nil { + return + } + m.output.devices = msg.devices + if m.output.cursor >= len(m.output.devices) { + m.output.cursor = max(0, len(m.output.devices)-1) + } +} + +func (m *Model) handleOutputAction(msg outputActionMsg) tea.Cmd { + m.output.pending = false + if msg.err != nil { + m.output.feedback = msg.err.Error() + return nil + } + return fetchOutputCmd() +} + +func (m Model) selectedOutput() *audio.Device { + if m.output.cursor < 0 || m.output.cursor >= len(m.output.devices) { + return nil + } + return &m.output.devices[m.output.cursor] +} + +func (m *Model) updateOutput(msg tea.Msg) tea.Cmd { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return nil + } + g := keys.Keys.Global + a := keys.Keys.Audio + + switch { + case key.Matches(keyMsg, g.Up): + if m.output.cursor > 0 { + m.output.cursor-- + } + case key.Matches(keyMsg, g.Down): + if m.output.cursor < len(m.output.devices)-1 { + m.output.cursor++ + } + case key.Matches(keyMsg, g.Left): + return m.adjustOutputVolume(-volumeStep) + case key.Matches(keyMsg, g.Right): + return m.adjustOutputVolume(volumeStep) + case key.Matches(keyMsg, a.Refresh): + m.output.pending = true + return fetchOutputCmd() + case key.Matches(keyMsg, a.Mute): + dev := m.selectedOutput() + if dev == nil { + return nil + } + m.output.feedback = "" + m.output.pending = true + return outputToggleMuteCmd(dev.ID) + case key.Matches(keyMsg, a.Select): + dev := m.selectedOutput() + if dev == nil { + return nil + } + if dev.Default { + m.output.feedback = "Already the default output." + return nil + } + m.output.feedback = "" + m.output.pending = true + return outputSetDefaultCmd(dev.ID) + case key.Matches(keyMsg, a.Test): + dev := m.selectedOutput() + if dev == nil { + return nil + } + return tea.Batch(outputTestToneCmd(dev.ID), notifyCmd("Audio", "Playing test sound on "+dev.Name+"…")) + } + return nil +} + +// adjustOutputVolume nudges the selected device's volume by delta, updating +// it locally right away so the bar responds instantly instead of waiting on +// the round trip to wpctl and back. +func (m *Model) adjustOutputVolume(delta int) tea.Cmd { + dev := m.selectedOutput() + if dev == nil { + return nil + } + pct := dev.Volume + delta + if pct < 0 { + pct = 0 + } else if pct > 100 { + pct = 100 + } + dev.Volume = pct + m.output.feedback = "" + return outputSetVolumeCmd(dev.ID, pct) +} + +func (m Model) renderOutput(width, height int) string { + if m.output.loading { + return ilovetui.S.Faint.Render("Loading output devices…") + } + + header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("OUTPUT DEVICES") + if m.output.pending { + header += mutedStyle().Render(" refreshing…") + } + + if m.output.err != nil { + return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.output.err.Error())) + } + if len(m.output.devices) == 0 { + return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No output devices found.")) + } + + available := (height - 3) / deviceRowHeight + if available < 1 { + available = 1 + } + p, start, end := paginate(m.output.cursor, len(m.output.devices), available) + + var rows []string + for i := start; i < end; i++ { + rows = append(rows, renderDeviceRow(m.output.devices[i], i == m.output.cursor, width)) + } + list := strings.Join(rows, "\n\n") + + footer := renderPaginatorDots(p) + if m.output.feedback != "" { + footer = subtleStyle().Render(m.output.feedback) + } + + return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer) +} diff --git a/internal/ui/audio/pagination.go b/internal/ui/audio/pagination.go new file mode 100644 index 0000000..5c8f7ab --- /dev/null +++ b/internal/ui/audio/pagination.go @@ -0,0 +1,44 @@ +package audio + +import ( + "charm.land/bubbles/v2/paginator" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +func newPaginator() paginator.Model { + p := paginator.New() + p.Type = paginator.Dots + p.ActiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render("•") + p.InactiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render("•") + return p +} + +// paginate slices a list of length total into pages of availableRows items, +// deriving the current page from cursor (the globally-indexed selection) +// rather than tracking page state separately. It returns the configured +// paginator and the [start, end) bounds of the visible slice. +func paginate(cursor, total, availableRows int) (p paginator.Model, start, end int) { + p = newPaginator() + perPage := availableRows + if perPage < 1 { + perPage = 1 + } + p.PerPage = perPage + p.SetTotalPages(total) + if p.TotalPages > 0 { + p.Page = cursor / perPage + if p.Page >= p.TotalPages { + p.Page = p.TotalPages - 1 + } + } + start, end = p.GetSliceBounds(total) + return p, start, end +} + +func renderPaginatorDots(p paginator.Model) string { + if p.TotalPages <= 1 { + return "" + } + return mutedStyle().Render(p.View()) +} diff --git a/internal/ui/audio/rows.go b/internal/ui/audio/rows.go new file mode 100644 index 0000000..f1295bb --- /dev/null +++ b/internal/ui/audio/rows.go @@ -0,0 +1,62 @@ +package audio + +import ( + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/audio" + "github.com/anotherhadi/settuings/internal/icons" +) + +// deviceRowHeight and streamRowHeight are the number of lines each list row +// occupies (name line + bar line + one blank spacer line), used to size how +// many rows fit in the available height. +const ( + deviceRowHeight = 3 + streamRowHeight = 3 +) + +// barWidth derives a volume-bar width from the row's available width, +// leaving room for the name line's cursor/default marker on one side and the +// percentage/MUTED label on the other. +func barWidth(rowWidth int) int { + w := rowWidth - 24 + switch { + case w > 30: + w = 30 + case w < 10: + w = 10 + } + return w +} + +func renderDeviceRow(d audio.Device, selected bool, width int) string { + textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text) + if selected { + textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary) + } + cursor := " " + if selected { + cursor = "> " + } + name := textStyle.Render(d.Name) + if d.Default { + name += " " + successStyle().Render(icons.I.Check+"default") + } + nameLine := cursor + name + barLine := " " + renderVolumeBar(d.Volume, barWidth(width), d.Muted) + return nameLine + "\n" + barLine +} + +func renderStreamRow(s audio.Stream, selected bool, width int) string { + textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text) + if selected { + textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary) + } + cursor := " " + if selected { + cursor = "> " + } + nameLine := cursor + textStyle.Render(s.App) + barLine := " " + renderVolumeBar(s.Volume, barWidth(width), s.Muted) + return nameLine + "\n" + barLine +} diff --git a/internal/ui/audio/slider.go b/internal/ui/audio/slider.go new file mode 100644 index 0000000..2fa548f --- /dev/null +++ b/internal/ui/audio/slider.go @@ -0,0 +1,61 @@ +package audio + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +// renderVolumeBar draws a static block-bar slider. bubbles has no dedicated +// slider widget, only an animated progress bar meant for async progress, not +// an immediately-adjustable value — a static bar reflects every keypress +// instantly without depending on progress.Model's frame-tick animation. +func renderVolumeBar(pct, barWidth int, muted bool) string { + if barWidth < 4 { + barWidth = 4 + } + filled := pct * barWidth / 100 + switch { + case filled > barWidth: + filled = barWidth + case filled < 0: + filled = 0 + } + bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) + pctText := fmt.Sprintf("%3d%%", pct) + + barColor := ilovetui.S.Primary + if muted { + barColor = ilovetui.S.Subtle + } + line := lipgloss.NewStyle().Foreground(barColor).Render("["+bar+"]") + " " + subtleStyle().Render(pctText) + if muted { + line += " " + errorStyle().Bold(true).Render("MUTED") + } + return line +} + +// renderLevelMeter draws a live input-level meter from a peak value in +// [0, 1], used to test a microphone. +func renderLevelMeter(peak float64, barWidth int) string { + if barWidth < 4 { + barWidth = 4 + } + filled := int(peak*float64(barWidth) + 0.5) + switch { + case filled > barWidth: + filled = barWidth + case filled < 0: + filled = 0 + } + color := ilovetui.S.Success + if peak > 0.9 { + color = ilovetui.S.Error + } else if peak > 0.65 { + color = ilovetui.S.Warning + } + bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) + return "Level: " + lipgloss.NewStyle().Foreground(color).Render("["+bar+"]") +} diff --git a/internal/ui/audio/style.go b/internal/ui/audio/style.go new file mode 100644 index 0000000..b154dff --- /dev/null +++ b/internal/ui/audio/style.go @@ -0,0 +1,22 @@ +package audio + +import ( + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +func errorStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Error) +} + +func successStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Success) +} + +func subtleStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Subtle) +} + +func mutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Muted) +} diff --git a/internal/ui/audio/tabbar.go b/internal/ui/audio/tabbar.go new file mode 100644 index 0000000..38ee3ee --- /dev/null +++ b/internal/ui/audio/tabbar.go @@ -0,0 +1,49 @@ +package audio + +import ( + "strings" + + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +// cycleTab moves to the next tab, stopping any live input-level test running +// on the tab being left. +func (m *Model) cycleTab() { + m.input.stopLevelMonitor() + for i, t := range visibleTabs { + if t == m.active { + m.active = visibleTabs[(i+1)%len(visibleTabs)] + return + } + } +} + +func (m Model) renderTabBar(width int) string { + active := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 2). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(ilovetui.S.Primary) + inactive := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Padding(0, 2). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(ilovetui.S.Subtle) + + var tabs []string + for _, t := range visibleTabs { + style := inactive + if t == m.active { + style = active + } + tabs = append(tabs, style.Render(t.icon()+t.label())) + } + row := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...) + + filler := width - lipgloss.Width(row) + if filler > 0 { + row = lipgloss.JoinHorizontal(lipgloss.Bottom, row, + lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(ilovetui.S.Subtle). + Render(strings.Repeat(" ", filler))) + } + return row +} diff --git a/internal/ui/audio/update.go b/internal/ui/audio/update.go new file mode 100644 index 0000000..92812f5 --- /dev/null +++ b/internal/ui/audio/update.go @@ -0,0 +1,79 @@ +package audio + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/settuings/internal/keys" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case checkMsg: + wasUnavailable := m.checkErr != nil + m.checked = true + m.checkErr = msg.err + if msg.err == nil && wasUnavailable { + // recovered: resume fetching + the auto-refresh loop + return m, tea.Batch(fetchOutputCmd(), fetchInputCmd(), fetchAppsCmd(), tickCmd()) + } + return m, nil + + case tickMsg: + if m.checkErr != nil { + return m, nil + } + return m, tea.Batch(fetchOutputCmd(), fetchInputCmd(), fetchAppsCmd(), tickCmd()) + + case outputListMsg: + m.handleOutputList(msg) + return m, nil + case outputActionMsg: + return m, m.handleOutputAction(msg) + + case inputListMsg: + m.handleInputList(msg) + return m, nil + case inputActionMsg: + return m, m.handleInputAction(msg) + case levelStartedMsg: + return m, m.handleLevelStarted(msg) + case levelMsg: + return m, m.handleLevel(msg) + + case appsListMsg: + m.handleAppsList(msg) + return m, nil + case appsActionMsg: + return m, m.handleAppsAction(msg) + + case tea.KeyPressMsg: + if m.checkErr != nil { + if key.Matches(msg, keys.Keys.Audio.Refresh) { + return m, checkCmd() + } + return m, nil + } + + g := keys.Keys.Global + switch { + case key.Matches(msg, g.CycleFocus): + m.cycleTab() + return m, nil + case key.Matches(msg, g.Help): + m.help.ShowAll = !m.help.ShowAll + return m, nil + } + + var cmd tea.Cmd + switch m.active { + case tabOutput: + cmd = m.updateOutput(msg) + case tabInput: + cmd = m.updateInput(msg) + case tabApps: + cmd = m.updateApps(msg) + } + return m, cmd + } + return m, nil +} diff --git a/internal/ui/audio/view.go b/internal/ui/audio/view.go new file mode 100644 index 0000000..84b5eda --- /dev/null +++ b/internal/ui/audio/view.go @@ -0,0 +1,85 @@ +package audio + +import ( + "errors" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/audio" +) + +func windowStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ilovetui.S.Subtle). + Padding(1, 2) +} + +func (m Model) View() tea.View { + statusBar := m.renderStatusBar() + statusH := strings.Count(statusBar, "\n") + 1 + + frameW := windowStyle().GetHorizontalFrameSize() + frameH := windowStyle().GetVerticalFrameSize() + innerW := m.width - frameW + + tabBar := m.renderTabBar(innerW) + tabBarH := lipgloss.Height(tabBar) + + innerH := m.height - tabBarH - frameH - statusH + + var body string + switch { + case !m.checked: + body = ilovetui.S.Faint.Render("Checking wpctl…") + case m.checkErr != nil: + body = m.renderUnavailable() + default: + body = m.renderActiveTab(innerW, innerH) + } + + box := lipgloss.NewStyle().Width(innerW).Height(innerH).Render(body) + content := windowStyle().Render(lipgloss.JoinVertical(lipgloss.Left, tabBar, box)) + + return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, content, statusBar)) +} + +func (m Model) renderStatusBar() string { + km := audioKeyMap{m: m, width: m.width} + return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km)) +} + +func (m Model) renderActiveTab(width, height int) string { + switch m.active { + case tabOutput: + return m.renderOutput(width, height) + case tabInput: + return m.renderInput(width, height) + case tabApps: + return m.renderApps(width, height) + default: + return "" + } +} + +func (m Model) renderUnavailable() string { + title := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Error).Render("Audio page unavailable") + + var hint string + switch { + case errors.Is(m.checkErr, audio.ErrNotFound): + hint = "wpctl was not found in PATH. Install PipeWire/WirePlumber to use this page." + default: + hint = m.checkErr.Error() + } + + return lipgloss.JoinVertical(lipgloss.Left, + title, + "", + subtleStyle().Render(hint), + "", + mutedStyle().Render("Press r to try again once fixed."), + ) +} diff --git a/internal/ui/bluetooth/commands.go b/internal/ui/bluetooth/commands.go new file mode 100644 index 0000000..059b6a4 --- /dev/null +++ b/internal/ui/bluetooth/commands.go @@ -0,0 +1,77 @@ +package bluetooth + +import ( + "time" + + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/settuings/internal/bluetooth" +) + +const ( + tickInterval = 5 * time.Second + scanSeconds = 5 +) + +func tickCmd() tea.Cmd { + return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} }) +} + +func checkCmd() tea.Cmd { + return func() tea.Msg { return checkMsg{err: bluetooth.CheckAvailable()} } +} + +func fetchControllerCmd() tea.Cmd { + return func() tea.Msg { + info, err := bluetooth.ShowController() + return controllerMsg{info: info, err: err} + } +} + +func fetchDevicesCmd() tea.Cmd { + return func() tea.Msg { + devices, err := bluetooth.ListDevices() + return deviceListMsg{devices: devices, err: err} + } +} + +func togglePowerCmd(on bool) tea.Cmd { + return func() tea.Msg { return powerActionMsg{err: bluetooth.SetPower(on)} } +} + +func scanCmd() tea.Cmd { + return func() tea.Msg { return scanDoneMsg{err: bluetooth.Scan(scanSeconds)} } +} + +func connectCmd(address string) tea.Cmd { + return func() tea.Msg { return deviceActionMsg{err: bluetooth.Connect(address)} } +} + +func disconnectCmd(address string) tea.Cmd { + return func() tea.Msg { return deviceActionMsg{err: bluetooth.Disconnect(address)} } +} + +// pairCmd pairs with a device and, on success, immediately connects it — +// most devices don't auto-connect right after pairing. +func pairCmd(address string) tea.Cmd { + return func() tea.Msg { + if err := bluetooth.Pair(address); err != nil { + return deviceActionMsg{err: err} + } + return deviceActionMsg{err: bluetooth.Connect(address)} + } +} + +func trustCmd(address string, trust bool) tea.Cmd { + return func() tea.Msg { return deviceActionMsg{err: bluetooth.SetTrusted(address, trust)} } +} + +func removeCmd(address string) tea.Cmd { + return func() tea.Msg { return deviceActionMsg{err: bluetooth.Remove(address)} } +} + +func fetchDetailCmd(address string) tea.Cmd { + return func() tea.Msg { + detail, err := bluetooth.Info(address) + return detailMsg{detail: detail, err: err} + } +} diff --git a/internal/ui/bluetooth/detail.go b/internal/ui/bluetooth/detail.go new file mode 100644 index 0000000..c41f460 --- /dev/null +++ b/internal/ui/bluetooth/detail.go @@ -0,0 +1,206 @@ +package bluetooth + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/bluetooth" + "github.com/anotherhadi/settuings/internal/keys" + "github.com/anotherhadi/settuings/internal/util" +) + +// deviceDetailState is the "device info" screen for whichever device (paired +// or not) the user asked about. It overlays the main list. Content can +// exceed a short terminal, so it's shown in a viewport. +type deviceDetailState struct { + open bool + address string + title string + + loading bool + err error + data bluetooth.DeviceDetail + + pending bool + feedback string + + viewport viewport.Model +} + +func (m *Model) openDetail(address, title string) tea.Cmd { + w, h := m.contentBudget() + vp := viewport.New() + vp.SetWidth(w) + vp.SetHeight(h) + + m.detail = deviceDetailState{ + open: true, + address: address, + title: title, + loading: true, + viewport: vp, + } + m.refreshDetailContent() + return fetchDetailCmd(address) +} + +// contentBudget returns the exact space available inside the bordered page +// box, below the tab bar and above the status bar. View() renders the box +// at this same size, so the detail viewport fills it exactly instead of +// leaving it short. Since the status bar's height depends on live state +// (e.g. whether help is expanded), callers must call resizeDetail whenever +// that state changes. +func (m Model) contentBudget() (w, h int) { + frameW := windowStyle().GetHorizontalFrameSize() + frameH := windowStyle().GetVerticalFrameSize() + w = m.width - frameW + + statusH := strings.Count(m.renderStatusBar(), "\n") + 1 + tabBarH := lipgloss.Height(renderTabBar(w)) + + h = m.height - tabBarH - frameH - statusH + if h < 1 { + h = 1 + } + return w, h +} + +func (m *Model) resizeDetail() { + if !m.detail.open { + return + } + w, h := m.contentBudget() + m.detail.viewport.SetWidth(w) + m.detail.viewport.SetHeight(h) + m.refreshDetailContent() +} + +func (m *Model) handleDetail(msg detailMsg) { + if !m.detail.open { + return + } + m.detail.loading = false + m.detail.data = msg.detail + m.detail.err = msg.err + m.refreshDetailContent() +} + +// closeDetailIfPending closes the info screen once the connect/disconnect +// it triggered has completed, but only if it's still the one waiting. +func (m *Model) closeDetailIfPending() { + if m.detail.open && m.detail.pending { + m.detail = deviceDetailState{} + } +} + +func (m *Model) updateDetail(msg tea.Msg) tea.Cmd { + if wheel, ok := msg.(tea.MouseWheelMsg); ok { + util.HandleMouseWheel(wheel, &m.detail.viewport) + return nil + } + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return nil + } + g := keys.Keys.Global + b := keys.Keys.Bluetooth + + switch { + case key.Matches(keyMsg, g.Escape), key.Matches(keyMsg, g.CycleFocus): + m.detail = deviceDetailState{} + case key.Matches(keyMsg, g.Help): + m.help.ShowAll = !m.help.ShowAll + m.resizeDetail() + case key.Matches(keyMsg, b.Select): + m.detail.pending = true + m.refreshDetailContent() + switch { + case m.detail.data.Connected: + return disconnectCmd(m.detail.address) + case m.detail.data.Paired: + return connectCmd(m.detail.address) + default: + return pairCmd(m.detail.address) + } + case key.Matches(keyMsg, b.ToggleTrust): + m.detail.pending = true + m.refreshDetailContent() + return trustCmd(m.detail.address, !m.detail.data.Trusted) + case key.Matches(keyMsg, g.Up): + util.ScrollLines(&m.detail.viewport, -1) + case key.Matches(keyMsg, g.Down): + util.ScrollLines(&m.detail.viewport, 1) + case key.Matches(keyMsg, g.ScrollUp): + util.ScrollViewport(&m.detail.viewport, -1) + case key.Matches(keyMsg, g.ScrollDown): + util.ScrollViewport(&m.detail.viewport, 1) + } + return nil +} + +func (m Model) renderDetail() string { + return m.detail.viewport.View() +} + +func (m *Model) refreshDetailContent() { + m.detail.viewport.SetContent(m.detailContent()) + util.RefreshScrollbar(&m.detail.viewport) +} + +func (m Model) detailContent() string { + d := m.detail + if d.loading { + return ilovetui.S.Faint.Render("Loading device info…") + } + + title := lipgloss.NewStyle().Bold(true).Render(d.title) + if d.pending { + title += mutedStyle().Render(" working…") + } + rows := []string{title, subtleStyle().Render("Address: ") + d.address} + + if d.err != nil { + rows = append(rows, "", errorStyle().Render(d.err.Error())) + return lipgloss.JoinVertical(lipgloss.Left, rows...) + } + + rows = append(rows, + subtleStyle().Render("Paired: ")+yesNo(d.data.Paired), + subtleStyle().Render("Trusted: ")+yesNo(d.data.Trusted), + subtleStyle().Render("Connected: ")+yesNo(d.data.Connected), + subtleStyle().Render("Blocked: ")+yesNo(d.data.Blocked), + ) + if d.data.Icon != "" { + rows = append(rows, subtleStyle().Render("Type: ")+d.data.Icon) + } + if d.data.Battery >= 0 { + rows = append(rows, subtleStyle().Render("Battery: ")+fmt.Sprintf("%d%%", d.data.Battery)) + } + if d.data.Modalias != "" { + rows = append(rows, subtleStyle().Render("Modalias: ")+d.data.Modalias) + } + if len(d.data.UUIDs) > 0 { + rows = append(rows, "", subtleStyle().Render("Services:")) + for _, u := range d.data.UUIDs { + rows = append(rows, " "+u) + } + } + + if d.feedback != "" { + rows = append(rows, "", errorStyle().Render(d.feedback)) + } + + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} + +func yesNo(b bool) string { + if b { + return successStyle().Render("yes") + } + return subtleStyle().Render("no") +} diff --git a/internal/ui/bluetooth/keymap.go b/internal/ui/bluetooth/keymap.go new file mode 100644 index 0000000..632f0a5 --- /dev/null +++ b/internal/ui/bluetooth/keymap.go @@ -0,0 +1,44 @@ +package bluetooth + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/settuings/internal/keys" +) + +// bluetoothKeyMap renders the bottom help bar, adapting to whether the +// confirm-forget prompt or the device info screen is open. +type bluetoothKeyMap struct { + m Model + width int +} + +func (k bluetoothKeyMap) ShortHelp() []key.Binding { + g := keys.Keys.Global + b := keys.Keys.Bluetooth + + switch { + case k.m.list.confirmRemove: + return []key.Binding{b.Forget, g.Escape} + case k.m.detail.open: + return []key.Binding{g.Up, g.Down, b.Select, b.ToggleTrust, g.Escape, g.Help} + default: + return []key.Binding{g.Up, g.Down, b.Select, b.Refresh, b.Info, g.Help} + } +} + +func (k bluetoothKeyMap) FullHelp() [][]key.Binding { + g := keys.Keys.Global + b := keys.Keys.Bluetooth + + var bindings []key.Binding + switch { + case k.m.list.confirmRemove: + bindings = []key.Binding{b.Forget, g.Escape} + case k.m.detail.open: + bindings = []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, b.Select, b.ToggleTrust, g.Escape} + default: + bindings = []key.Binding{g.Up, g.Down, b.Select, b.Refresh, b.Info, b.ToggleTrust, b.Forget, b.TogglePower} + } + bindings = append(bindings, g.CommonBindings()...) + return keys.ChunkByWidth(bindings, k.width) +} diff --git a/internal/ui/bluetooth/list.go b/internal/ui/bluetooth/list.go new file mode 100644 index 0000000..8e0f465 --- /dev/null +++ b/internal/ui/bluetooth/list.go @@ -0,0 +1,301 @@ +package bluetooth + +import ( + "fmt" + "sort" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/bluetooth" + "github.com/anotherhadi/settuings/internal/icons" + "github.com/anotherhadi/settuings/internal/keys" +) + +// deviceListState is the main screen: paired devices (fixed profiles) +// followed by other devices seen during a scan but not yet paired. Both +// live in one cursor-navigable, height-budgeted, paginated list. +type deviceListState struct { + loading bool + err error + paired []bluetooth.Device + others []bluetooth.Device + cursor int + pending bool + scanning bool + feedback string + + confirmRemove bool +} + +func newDeviceListState() deviceListState { + return deviceListState{loading: true} +} + +func (s deviceListState) total() int { return len(s.paired) + len(s.others) } + +func (s deviceListState) selected() *bluetooth.Device { + if s.cursor < 0 || s.cursor >= s.total() { + return nil + } + if s.cursor < len(s.paired) { + return &s.paired[s.cursor] + } + return &s.others[s.cursor-len(s.paired)] +} + +func (m *Model) handleDeviceList(msg deviceListMsg) { + m.list.loading = false + m.list.err = msg.err + if msg.err != nil { + return + } + + paired := make([]bluetooth.Device, 0, len(msg.devices)) + others := make([]bluetooth.Device, 0, len(msg.devices)) + for _, d := range msg.devices { + if d.Paired { + paired = append(paired, d) + } else { + others = append(others, d) + } + } + sort.Slice(paired, func(i, j int) bool { + if paired[i].Connected != paired[j].Connected { + return paired[i].Connected + } + return strings.ToLower(paired[i].Name) < strings.ToLower(paired[j].Name) + }) + sort.Slice(others, func(i, j int) bool { + return strings.ToLower(others[i].Name) < strings.ToLower(others[j].Name) + }) + m.list.paired = paired + m.list.others = others + if m.list.cursor >= m.list.total() { + m.list.cursor = max(0, m.list.total()-1) + } +} + +func (m *Model) handleDeviceAction(msg deviceActionMsg) tea.Cmd { + m.list.pending = false + if msg.err != nil { + m.list.feedback = msg.err.Error() + m.detail.pending = false + m.detail.feedback = msg.err.Error() + m.refreshDetailContent() + return nil + } + m.list.feedback = "" + m.closeDetailIfPending() + return fetchDevicesCmd() +} + +func (m *Model) handlePowerAction(msg powerActionMsg) tea.Cmd { + m.list.pending = false + if msg.err != nil { + m.list.feedback = msg.err.Error() + return nil + } + m.list.feedback = "" + return tea.Batch(fetchControllerCmd(), fetchDevicesCmd()) +} + +func (m *Model) handleScanDone(msg scanDoneMsg) tea.Cmd { + m.list.scanning = false + if msg.err != nil { + m.list.feedback = msg.err.Error() + return nil + } + m.list.feedback = "" + return fetchDevicesCmd() +} + +func (m *Model) updateList(msg tea.Msg) tea.Cmd { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return nil + } + + if m.list.confirmRemove { + return m.updateConfirmRemove(keyMsg) + } + + g := keys.Keys.Global + b := keys.Keys.Bluetooth + + switch { + case key.Matches(keyMsg, g.Up): + if m.list.cursor > 0 { + m.list.cursor-- + } + case key.Matches(keyMsg, g.Down): + if m.list.cursor < m.list.total()-1 { + m.list.cursor++ + } + case key.Matches(keyMsg, b.Refresh): + m.list.pending = true + m.list.scanning = true + return scanCmd() + case key.Matches(keyMsg, b.Select): + return m.selectDevice() + case key.Matches(keyMsg, b.Info): + return m.openInfo() + case key.Matches(keyMsg, b.ToggleTrust): + return m.toggleTrust() + case key.Matches(keyMsg, b.Forget): + if m.list.selected() != nil { + m.list.confirmRemove = true + } + case key.Matches(keyMsg, b.TogglePower): + m.list.pending = true + return togglePowerCmd(!m.controller.Powered) + } + return nil +} + +func (m *Model) updateConfirmRemove(keyMsg tea.KeyPressMsg) tea.Cmd { + switch keyMsg.String() { + case "y", "enter": + sel := m.list.selected() + m.list.confirmRemove = false + if sel == nil { + return nil + } + m.list.pending = true + return removeCmd(sel.Address) + default: + m.list.confirmRemove = false + } + return nil +} + +func (m *Model) selectDevice() tea.Cmd { + sel := m.list.selected() + if sel == nil { + return nil + } + m.list.pending = true + if sel.Paired { + if sel.Connected { + return disconnectCmd(sel.Address) + } + return connectCmd(sel.Address) + } + return pairCmd(sel.Address) +} + +func (m *Model) toggleTrust() tea.Cmd { + sel := m.list.selected() + if sel == nil { + return nil + } + m.list.pending = true + return trustCmd(sel.Address, !sel.Trusted) +} + +func (m *Model) openInfo() tea.Cmd { + sel := m.list.selected() + if sel == nil { + return nil + } + return m.openDetail(sel.Address, sel.Name) +} + +func (m Model) renderList(width, height int) string { + if m.list.loading { + return ilovetui.S.Faint.Render("Loading Bluetooth devices…") + } + if m.list.err != nil { + return errorStyle().Render(m.list.err.Error()) + } + if m.list.total() == 0 { + return ilovetui.S.Faint.Render("No devices found. Press r to scan for nearby devices.") + } + + // Reserve a line for each section header that could appear, so the + // paginated device rows never overflow the available height even on + // the one page where a section boundary falls. + headerReserve := 0 + if len(m.list.paired) > 0 { + headerReserve++ + } + if len(m.list.others) > 0 { + headerReserve++ + } + + available := height - headerReserve - 1 // trailing line for paginator/feedback + if available < 1 { + available = 1 + } + p, start, end := paginate(m.list.cursor, m.list.total(), available) + + var lines []string + for i := start; i < end; i++ { + if i == 0 && len(m.list.paired) > 0 { + lines = append(lines, sectionHeader("PAIRED DEVICES")) + } + if i == len(m.list.paired) && len(m.list.others) > 0 { + lines = append(lines, sectionHeader("OTHER DEVICES")) + } + if i < len(m.list.paired) { + lines = append(lines, m.renderDeviceRow(m.list.paired[i], i == m.list.cursor, width)) + } else { + lines = append(lines, m.renderDeviceRow(m.list.others[i-len(m.list.paired)], i == m.list.cursor, width)) + } + } + list := strings.Join(lines, "\n") + + footer := renderPaginatorDots(p) + switch { + case m.list.confirmRemove: + name := "" + if sel := m.list.selected(); sel != nil { + name = sel.Name + } + footer = errorStyle().Render(fmt.Sprintf("Forget %q? press y to confirm, any other key to cancel", name)) + case m.list.feedback != "": + footer = errorStyle().Render(m.list.feedback) + } + + return lipgloss.JoinVertical(lipgloss.Left, list, footer) +} + +func sectionHeader(text string) string { + return lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render(text) +} + +func (m Model) renderDeviceRow(d bluetooth.Device, selected bool, width int) string { + textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text) + if selected { + textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary) + } + cursor := " " + if selected { + cursor = "> " + } + check := " " + if d.Connected { + check = successStyle().Render(icons.I.Check) + } + + var tags []string + if d.Trusted { + tags = append(tags, "trusted") + } + if d.Battery >= 0 { + tags = append(tags, fmt.Sprintf("%d%%", d.Battery)) + } + tag := strings.Join(tags, " · ") + + row := fmt.Sprintf("%s%s%s", cursor, check, textStyle.Render(d.Name)) + if tag == "" { + return row + } + pad := width - lipgloss.Width(row) - lipgloss.Width(tag) + if pad < 1 { + pad = 1 + } + return row + strings.Repeat(" ", pad) + subtleStyle().Render(tag) +} diff --git a/internal/ui/bluetooth/messages.go b/internal/ui/bluetooth/messages.go new file mode 100644 index 0000000..9e9c459 --- /dev/null +++ b/internal/ui/bluetooth/messages.go @@ -0,0 +1,31 @@ +package bluetooth + +import "github.com/anotherhadi/settuings/internal/bluetooth" + +// checkMsg carries the result of bluetooth.CheckAvailable. +type checkMsg struct{ err error } + +// tickMsg triggers a periodic refresh of the controller + device list while +// this page is active. +type tickMsg struct{} + +type controllerMsg struct { + info bluetooth.ControllerInfo + err error +} + +type deviceListMsg struct { + devices []bluetooth.Device + err error +} + +type deviceActionMsg struct{ err error } + +type powerActionMsg struct{ err error } + +type scanDoneMsg struct{ err error } + +type detailMsg struct { + detail bluetooth.DeviceDetail + err error +} diff --git a/internal/ui/bluetooth/model.go b/internal/ui/bluetooth/model.go new file mode 100644 index 0000000..9f19fb8 --- /dev/null +++ b/internal/ui/bluetooth/model.go @@ -0,0 +1,54 @@ +// Package bluetooth provides the "Bluetooth" TUI page: a thin wrapper +// around bluetoothctl covering controller power and device +// pairing/connection, in the same spirit as the network page's Wi-Fi tab. +package bluetooth + +import ( + "charm.land/bubbles/v2/help" + tea "charm.land/bubbletea/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/bluetooth" +) + +type Model struct { + width int + height int + help help.Model + + checkErr error // reason bluetoothctl is unusable; nil once confirmed OK + checked bool + + controller bluetooth.ControllerInfo + + list deviceListState + detail deviceDetailState +} + +func New() Model { + return Model{ + help: ilovetui.NewHelp(), + list: newDeviceListState(), + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +// Activate is called every time the user navigates onto this page. It +// re-checks bluetoothctl availability, fetches controller + device state, +// and starts the periodic auto-refresh loop. +func (m *Model) Activate() tea.Cmd { + return tea.Batch(checkCmd(), fetchControllerCmd(), fetchDevicesCmd(), tickCmd()) +} + +func (m Model) IsEditing() bool { + return m.list.confirmRemove +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.help.SetWidth(w - 2) + m.resizeDetail() +} diff --git a/internal/ui/bluetooth/pagination.go b/internal/ui/bluetooth/pagination.go new file mode 100644 index 0000000..10acec8 --- /dev/null +++ b/internal/ui/bluetooth/pagination.go @@ -0,0 +1,44 @@ +package bluetooth + +import ( + "charm.land/bubbles/v2/paginator" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +func newPaginator() paginator.Model { + p := paginator.New() + p.Type = paginator.Dots + p.ActiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render("•") + p.InactiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render("•") + return p +} + +// paginate slices a list of length total into pages of availableRows items, +// deriving the current page from cursor (the globally-indexed selection) +// rather than tracking page state separately. It returns the configured +// paginator and the [start, end) bounds of the visible slice. +func paginate(cursor, total, availableRows int) (p paginator.Model, start, end int) { + p = newPaginator() + perPage := availableRows + if perPage < 1 { + perPage = 1 + } + p.PerPage = perPage + p.SetTotalPages(total) + if p.TotalPages > 0 { + p.Page = cursor / perPage + if p.Page >= p.TotalPages { + p.Page = p.TotalPages - 1 + } + } + start, end = p.GetSliceBounds(total) + return p, start, end +} + +func renderPaginatorDots(p paginator.Model) string { + if p.TotalPages <= 1 { + return "" + } + return mutedStyle().Render(p.View()) +} diff --git a/internal/ui/bluetooth/style.go b/internal/ui/bluetooth/style.go new file mode 100644 index 0000000..b82222b --- /dev/null +++ b/internal/ui/bluetooth/style.go @@ -0,0 +1,22 @@ +package bluetooth + +import ( + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +func errorStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Error) +} + +func successStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Success) +} + +func subtleStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Subtle) +} + +func mutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Muted) +} diff --git a/internal/ui/bluetooth/tabbar.go b/internal/ui/bluetooth/tabbar.go new file mode 100644 index 0000000..60ad2ca --- /dev/null +++ b/internal/ui/bluetooth/tabbar.go @@ -0,0 +1,30 @@ +package bluetooth + +import ( + "strings" + + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/icons" +) + +// renderTabBar draws a header matching the network page's tab bar, but with +// a single always-active entry: this page has no tabs of its own, just a +// visual label for consistency. +func renderTabBar(width int) string { + style := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 2). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(ilovetui.S.Primary) + + label := style.Render(icons.I.Bluetooth + "Bluetooth") + + filler := width - lipgloss.Width(label) + if filler > 0 { + label = lipgloss.JoinHorizontal(lipgloss.Bottom, label, + lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(ilovetui.S.Primary). + Render(strings.Repeat(" ", filler))) + } + return label +} diff --git a/internal/ui/bluetooth/update.go b/internal/ui/bluetooth/update.go new file mode 100644 index 0000000..2ec99e5 --- /dev/null +++ b/internal/ui/bluetooth/update.go @@ -0,0 +1,70 @@ +package bluetooth + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/settuings/internal/keys" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case checkMsg: + wasUnavailable := m.checkErr != nil + m.checked = true + m.checkErr = msg.err + if msg.err == nil && wasUnavailable { + // recovered: resume fetching + the auto-refresh loop + return m, tea.Batch(fetchControllerCmd(), fetchDevicesCmd(), tickCmd()) + } + return m, nil + + case tickMsg: + if m.checkErr != nil { + return m, nil + } + return m, tea.Batch(fetchControllerCmd(), fetchDevicesCmd(), tickCmd()) + + case controllerMsg: + m.controller = msg.info + return m, nil + + case deviceListMsg: + m.handleDeviceList(msg) + return m, nil + case deviceActionMsg: + return m, m.handleDeviceAction(msg) + case powerActionMsg: + return m, m.handlePowerAction(msg) + case scanDoneMsg: + return m, m.handleScanDone(msg) + case detailMsg: + m.handleDetail(msg) + return m, nil + + case tea.MouseWheelMsg: + if m.detail.open { + return m, m.updateDetail(msg) + } + return m, nil + + case tea.KeyPressMsg: + if m.checkErr != nil { + if key.Matches(msg, keys.Keys.Bluetooth.Refresh) { + return m, checkCmd() + } + return m, nil + } + + if m.detail.open { + return m, m.updateDetail(msg) + } + + if !m.IsEditing() && key.Matches(msg, keys.Keys.Global.Help) { + m.help.ShowAll = !m.help.ShowAll + return m, nil + } + + return m, m.updateList(msg) + } + return m, nil +} diff --git a/internal/ui/bluetooth/view.go b/internal/ui/bluetooth/view.go new file mode 100644 index 0000000..5d4f74d --- /dev/null +++ b/internal/ui/bluetooth/view.go @@ -0,0 +1,93 @@ +package bluetooth + +import ( + "errors" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/bluetooth" +) + +func windowStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ilovetui.S.Subtle). + Padding(1, 2) +} + +func (m Model) View() tea.View { + statusBar := m.renderStatusBar() + innerW, innerH := m.contentBudget() + tabBar := renderTabBar(innerW) + + var body string + switch { + case !m.checked: + body = ilovetui.S.Faint.Render("Checking bluetoothctl…") + case m.checkErr != nil: + body = m.renderUnavailable() + case m.detail.open: + body = m.renderDetail() + default: + body = m.renderMain(innerW, innerH) + } + + box := lipgloss.NewStyle().Width(innerW).Height(innerH).Render(body) + content := windowStyle().Render(lipgloss.JoinVertical(lipgloss.Left, tabBar, box)) + + return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, content, statusBar)) +} + +func (m Model) renderStatusBar() string { + km := bluetoothKeyMap{m: m, width: m.width} + return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km)) +} + +func (m Model) renderMain(width, height int) string { + header := m.renderPowerStatus() + headerH := lipgloss.Height(header) + 1 // blank line after + listH := height - headerH + if listH < 1 { + listH = 1 + } + return lipgloss.JoinVertical(lipgloss.Left, header, "", m.renderList(width, listH)) +} + +func (m Model) renderPowerStatus() string { + label := "Bluetooth: " + status := errorStyle().Bold(true).Render("Off") + if m.controller.Powered { + status = successStyle().Bold(true).Render("On") + } + line := label + status + switch { + case m.list.scanning: + line += mutedStyle().Render(" scanning…") + case m.list.pending: + line += mutedStyle().Render(" working…") + } + return line +} + +func (m Model) renderUnavailable() string { + title := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Error).Render("Bluetooth page unavailable") + + var hint string + switch { + case errors.Is(m.checkErr, bluetooth.ErrNotFound): + hint = "bluetoothctl was not found in PATH. Install BlueZ to use this page." + case errors.Is(m.checkErr, bluetooth.ErrNoController): + hint = "No Bluetooth controller was found. Try: sudo systemctl start bluetooth" + default: + hint = m.checkErr.Error() + } + + return lipgloss.JoinVertical(lipgloss.Left, + title, + "", + subtleStyle().Render(hint), + "", + mutedStyle().Render("Press r to try again once fixed."), + ) +} diff --git a/internal/ui/components/notifications/model.go b/internal/ui/components/notifications/model.go new file mode 100644 index 0000000..f00b565 --- /dev/null +++ b/internal/ui/components/notifications/model.go @@ -0,0 +1,154 @@ +package notifications + +import ( + "image/color" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/charmbracelet/x/ansi" +) + +type Kind string + +const ( + KindInfo Kind = "info" + KindSuccess Kind = "success" + KindWarning Kind = "warning" + KindError Kind = "error" +) + +type NotificationMsg struct { + Title string + Body string + Kind Kind +} + +type DismissMsg struct{ ID int } + +type notification struct { + id int + title string + body string + kind Kind +} + +type Model struct { + queue []notification + nextID int + width int + height int +} + +func New() Model { return Model{} } + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h +} + +func (m Model) HasNotifications() bool { + return len(m.queue) > 0 +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case NotificationMsg: + n := notification{id: m.nextID, title: msg.Title, body: msg.Body, kind: msg.Kind} + m.nextID++ + m.queue = append(m.queue, n) + return m, tea.Tick(4*time.Second, func(time.Time) tea.Msg { return DismissMsg{ID: n.id} }) + case DismissMsg: + for i, n := range m.queue { + if n.id == msg.ID { + m.queue = append(m.queue[:i], m.queue[i+1:]...) + break + } + } + } + return m, nil +} + +func (m Model) View(background string) string { + if len(m.queue) == 0 { + return background + } + + const popupW = 34 + + var popups []string + start := len(m.queue) - 3 + if start < 0 { + start = 0 + } + for i := start; i < len(m.queue); i++ { + n := m.queue[i] + var accent color.Color + switch n.kind { + case KindSuccess: + accent = ilovetui.S.Success + case KindWarning: + accent = ilovetui.S.Warning + case KindError: + accent = ilovetui.S.Error + default: + accent = ilovetui.S.Primary + } + + titleStr := lipgloss.NewStyle().Foreground(accent).Bold(true).Render(n.title) + bodyStr := lipgloss.NewStyle().Foreground(ilovetui.S.Text).Width(popupW).Render(n.body) + + inner := lipgloss.JoinVertical(lipgloss.Left, titleStr, bodyStr) + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(accent). + Padding(0, 1). + Render(inner) + popups = append(popups, box) + } + + popup := strings.Join(popups, "\n") + return overlayTopRight(background, popup, m.width, m.height) +} + +func overlayTopRight(bg, popup string, w, h int) string { + bgLines := strings.Split(bg, "\n") + + popupLines := strings.Split(popup, "\n") + popupH := len(popupLines) + popupW := 0 + for _, l := range popupLines { + if vw := lipgloss.Width(l); vw > popupW { + popupW = vw + } + } + + const marginTop = 1 + const marginRight = 2 + startY := marginTop + startX := w - popupW - marginRight + if startX < 0 { + startX = 0 + } + + result := make([]string, h) + for y := 0; y < h; y++ { + bgLine := "" + if y < len(bgLines) { + bgLine = bgLines[y] + } + + popupY := y - startY + if popupY >= 0 && popupY < popupH { + prefix := ansi.Truncate(bgLine, startX, "") + suffix := ansi.TruncateLeft(bgLine, startX+popupW, "") + result[y] = prefix + popupLines[popupY] + suffix + } else { + result[y] = bgLine + } + } + + return strings.Join(result, "\n") +} diff --git a/internal/ui/network/commands.go b/internal/ui/network/commands.go new file mode 100644 index 0000000..8a6e648 --- /dev/null +++ b/internal/ui/network/commands.go @@ -0,0 +1,118 @@ +package network + +import ( + "time" + + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/settuings/internal/network" +) + +const tickInterval = 5 * time.Second + +func tickCmd() tea.Cmd { + return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} }) +} + +func checkCmd() tea.Cmd { + return func() tea.Msg { return checkMsg{err: network.CheckAvailable()} } +} + +func fetchWifiCmd() tea.Cmd { + return func() tea.Msg { + radioOn, _ := network.RadioEnabled() + networks, err := network.ListWifi() + if err != nil { + return wifiListMsg{radioOn: radioOn, err: err} + } + var ip, device, connectionName string + if dev, err := network.WifiDevice(); err == nil { + device = dev.Name + if dev.Connected() { + ip, _ = network.DeviceIP4(dev.Name) + connectionName = dev.Connection + } + } + return wifiListMsg{networks: networks, ip: ip, device: device, connectionName: connectionName, radioOn: radioOn} + } +} + +func toggleWifiRadioCmd(on bool) tea.Cmd { + return func() tea.Msg { return radioActionMsg{err: network.SetWifiRadio(on)} } +} + +func rescanWifiCmd() tea.Cmd { + return func() tea.Msg { return wifiActionMsg{err: network.Rescan()} } +} + +func connectWifiCmd(ssid, password string) tea.Cmd { + return func() tea.Msg { return wifiActionMsg{err: network.ConnectWifi(ssid, password)} } +} + +func disconnectWifiCmd() tea.Cmd { + return func() tea.Msg { + dev, err := network.WifiDevice() + if err != nil { + return wifiActionMsg{err: err} + } + return wifiActionMsg{err: network.DisconnectWifi(dev.Name)} + } +} + +func fetchEthernetCmd() tea.Cmd { + return func() tea.Msg { + devices, err := network.ListEthernet() + return ethernetListMsg{devices: devices, err: err} + } +} + +func ethernetConnectCmd(device string) tea.Cmd { + return func() tea.Msg { return ethernetActionMsg{err: network.DeviceConnect(device)} } +} + +func ethernetDownCmd(device string) tea.Cmd { + return func() tea.Msg { return ethernetActionMsg{err: network.EthernetDown(device)} } +} + +func fetchVPNCmd() tea.Cmd { + return func() tea.Msg { + conns, err := network.ListVPN() + return vpnListMsg{conns: conns, err: err} + } +} + +func vpnUpCmd(name string) tea.Cmd { + return func() tea.Msg { return vpnActionMsg{err: network.VPNUp(name)} } +} + +func vpnDownCmd(name string) tea.Cmd { + return func() tea.Msg { return vpnActionMsg{err: network.VPNDown(name)} } +} + +func fetchKnownCmd() tea.Cmd { + return func() tea.Msg { + networks, err := network.ListKnownWifi() + return knownListMsg{networks: networks, err: err} + } +} + +func fetchSecretCmd(name string) tea.Cmd { + return func() tea.Msg { + secret, err := network.Secret(name) + return knownSecretMsg{name: name, secret: secret, err: err} + } +} + +func toggleAutoConnectCmd(name string, enabled bool) tea.Cmd { + return func() tea.Msg { return knownActionMsg{err: network.SetAutoConnect(name, enabled)} } +} + +func forgetCmd(name string) tea.Cmd { + return func() tea.Msg { return knownActionMsg{err: network.Forget(name)} } +} + +func fetchDeviceDetailCmd(device string) tea.Cmd { + return func() tea.Msg { + detail, err := network.DeviceDetails(device) + return deviceDetailMsg{detail: detail, err: err} + } +} diff --git a/internal/ui/network/detail.go b/internal/ui/network/detail.go new file mode 100644 index 0000000..2bdcc5d --- /dev/null +++ b/internal/ui/network/detail.go @@ -0,0 +1,273 @@ +package network + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/keys" + "github.com/anotherhadi/settuings/internal/network" + "github.com/anotherhadi/settuings/internal/util" +) + +// deviceDetailState is the "network info" screen: full addressing/hardware +// details for whichever connected device (wifi or ethernet) the user asked +// about, plus a way to disconnect it. It overlays whichever tab opened it. +// Content can exceed a short terminal, so it's shown in a viewport. +type deviceDetailState struct { + open bool + kind tab // tabWifi or tabEthernet: decides which disconnect action to run + device string + title string + security string // wifi only + signal int // wifi only, <=0 means n/a + connectionName string // wifi only: profile name, for secret lookup + + loading bool + err error + data network.DeviceDetail + + secretLoading bool + secretValue string + secretRevealed bool + secretErr error + + pending bool + feedback string + + viewport viewport.Model +} + +func (m *Model) openDeviceDetail(kind tab, device, title, security string, signal int) tea.Cmd { + w, h := m.contentBudget() + vp := viewport.New() + vp.SetWidth(w) + vp.SetHeight(h) + + m.deviceDetail = deviceDetailState{ + open: true, + kind: kind, + device: device, + title: title, + security: security, + signal: signal, + connectionName: m.wifi.connectionName, + loading: true, + secretLoading: kind == tabWifi, + viewport: vp, + } + m.refreshDeviceDetailContent() + + cmds := []tea.Cmd{fetchDeviceDetailCmd(device)} + if kind == tabWifi && m.wifi.connectionName != "" { + cmds = append(cmds, fetchSecretCmd(m.wifi.connectionName)) + } + return tea.Batch(cmds...) +} + +// contentBudget returns the exact space available inside the bordered tab +// box, below the tab bar and above the status bar. View() renders the box +// at this same size, so anything sized against contentBudget (e.g. a detail +// viewport) fills the box exactly instead of leaving it short. Since the +// status bar's height depends on live state (e.g. whether help is +// expanded), callers must re-size whenever that state changes — see +// resizeDeviceDetail/resizeKnownDetail. +func (m Model) contentBudget() (w, h int) { + frameW := windowStyle().GetHorizontalFrameSize() + frameH := windowStyle().GetVerticalFrameSize() + w = m.width - frameW + + statusH := strings.Count(m.renderStatusBar(), "\n") + 1 + tabBarH := lipgloss.Height(m.renderTabBar(w)) + + h = m.height - tabBarH - frameH - statusH + if h < 1 { + h = 1 + } + return w, h +} + +// resizeDetails re-sizes every open detail viewport to match the current +// content budget. Call it whenever that budget could have changed: on +// window resize, or when the status bar's height changes (e.g. toggling +// help). +func (m *Model) resizeDetails() { + m.resizeDeviceDetail() + m.resizeKnownDetail() +} + +func (m *Model) resizeDeviceDetail() { + if !m.deviceDetail.open { + return + } + w, h := m.contentBudget() + m.deviceDetail.viewport.SetWidth(w) + m.deviceDetail.viewport.SetHeight(h) + m.refreshDeviceDetailContent() +} + +func (m *Model) handleDeviceDetail(msg deviceDetailMsg) { + if !m.deviceDetail.open { + return + } + m.deviceDetail.loading = false + m.deviceDetail.data = msg.detail + m.deviceDetail.err = msg.err + m.refreshDeviceDetailContent() +} + +// handleDeviceDetailSecret feeds a knownSecretMsg to the info screen if it's +// the one that asked for it. The known-networks tab checks the same message +// for its own detail view; both can be true at once only in theory (they're +// on different tabs), so this is safe to call unconditionally. +func (m *Model) handleDeviceDetailSecret(msg knownSecretMsg) { + if !m.deviceDetail.open || m.deviceDetail.kind != tabWifi || m.deviceDetail.connectionName != msg.name { + return + } + m.deviceDetail.secretLoading = false + m.deviceDetail.secretValue = msg.secret + m.deviceDetail.secretErr = msg.err + m.refreshDeviceDetailContent() +} + +// closeDeviceDetailIfPending closes the info screen once the disconnect it +// triggered has completed, but only if it's still the one waiting on kind. +func (m *Model) closeDeviceDetailIfPending(kind tab) { + if m.deviceDetail.open && m.deviceDetail.kind == kind && m.deviceDetail.pending { + m.deviceDetail = deviceDetailState{} + } +} + +func (m *Model) updateDeviceDetail(msg tea.Msg) tea.Cmd { + if wheel, ok := msg.(tea.MouseWheelMsg); ok { + util.HandleMouseWheel(wheel, &m.deviceDetail.viewport) + return nil + } + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return nil + } + g := keys.Keys.Global + n := keys.Keys.Network + + switch { + case key.Matches(keyMsg, g.Escape), key.Matches(keyMsg, g.CycleFocus): + m.deviceDetail = deviceDetailState{} + case key.Matches(keyMsg, g.Help): + m.help.ShowAll = !m.help.ShowAll + m.resizeDetails() + case key.Matches(keyMsg, n.Disconnect): + m.deviceDetail.pending = true + m.refreshDeviceDetailContent() + switch m.deviceDetail.kind { + case tabWifi: + return disconnectWifiCmd() + case tabEthernet: + return ethernetDownCmd(m.deviceDetail.device) + } + case key.Matches(keyMsg, n.RevealSecret): + if m.deviceDetail.kind == tabWifi { + m.deviceDetail.secretRevealed = !m.deviceDetail.secretRevealed + m.refreshDeviceDetailContent() + } + case key.Matches(keyMsg, g.Up): + util.ScrollLines(&m.deviceDetail.viewport, -1) + case key.Matches(keyMsg, g.Down): + util.ScrollLines(&m.deviceDetail.viewport, 1) + case key.Matches(keyMsg, g.ScrollUp): + util.ScrollViewport(&m.deviceDetail.viewport, -1) + case key.Matches(keyMsg, g.ScrollDown): + util.ScrollViewport(&m.deviceDetail.viewport, 1) + } + return nil +} + +func (m Model) renderDeviceDetail() string { + return m.deviceDetail.viewport.View() +} + +func (m *Model) refreshDeviceDetailContent() { + m.deviceDetail.viewport.SetContent(m.deviceDetailContent()) + util.RefreshScrollbar(&m.deviceDetail.viewport) +} + +func (m Model) deviceDetailContent() string { + d := m.deviceDetail + if d.loading { + return ilovetui.S.Faint.Render("Loading network info…") + } + + title := lipgloss.NewStyle().Bold(true).Render(d.title) + if d.pending { + title += mutedStyle().Render(" disconnecting…") + } + rows := []string{title} + if d.kind == tabWifi { + rows = append(rows, subtleStyle().Render("Security: ")+securityLabel(d.security)) + if d.signal > 0 { + rows = append(rows, subtleStyle().Render("Signal: ")+fmt.Sprintf("%d%%", d.signal)) + } + rows = append(rows, subtleStyle().Render("Password: ")+m.renderDeviceSecret()) + } + rows = append(rows, subtleStyle().Render("Interface: ")+d.device) + + if d.err != nil { + rows = append(rows, "", errorStyle().Render(d.err.Error())) + return lipgloss.JoinVertical(lipgloss.Left, rows...) + } + + rows = append(rows, + subtleStyle().Render("IPv4 Address: ")+valueOrDash(d.data.IPv4), + subtleStyle().Render("Gateway: ")+valueOrDash(d.data.Gateway4), + subtleStyle().Render("DNS: ")+valueOrDash(strings.Join(d.data.DNS4, ", ")), + subtleStyle().Render("MAC Address: ")+valueOrDash(d.data.HWAddr), + subtleStyle().Render("MTU: ")+valueOrDash(d.data.MTU), + ) + + if len(d.data.IPv6) > 0 { + rows = append(rows, "", subtleStyle().Render("IPv6 Address: ")+d.data.IPv6[0]) + for _, addr := range d.data.IPv6[1:] { + rows = append(rows, " "+addr) + } + } + if d.data.Gateway6 != "" { + rows = append(rows, subtleStyle().Render("IPv6 Gateway: ")+d.data.Gateway6) + } + if len(d.data.DNS6) > 0 { + rows = append(rows, subtleStyle().Render("IPv6 DNS: ")+strings.Join(d.data.DNS6, ", ")) + } + + if d.feedback != "" { + rows = append(rows, "", errorStyle().Render(d.feedback)) + } + + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} + +func (m Model) renderDeviceSecret() string { + d := m.deviceDetail + if d.secretLoading { + return mutedStyle().Render("loading…") + } + if d.secretErr != nil { + return errorStyle().Render(d.secretErr.Error()) + } + if d.secretValue == "" { + return subtleStyle().Render("(none)") + } + if !d.secretRevealed { + return subtleStyle().Render(strings.Repeat("•", len(d.secretValue))) + } + return d.secretValue +} + +func valueOrDash(s string) string { + if s == "" { + return "—" + } + return s +} diff --git a/internal/ui/network/ethernet.go b/internal/ui/network/ethernet.go new file mode 100644 index 0000000..ba3ecb5 --- /dev/null +++ b/internal/ui/network/ethernet.go @@ -0,0 +1,165 @@ +package network + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/icons" + "github.com/anotherhadi/settuings/internal/keys" + "github.com/anotherhadi/settuings/internal/network" +) + +type ethernetState struct { + loading bool + err error + devices []network.EthernetDevice + cursor int + pending bool + feedback string +} + +func newEthernetState() ethernetState { + return ethernetState{loading: true} +} + +func (m *Model) handleEthernetList(msg ethernetListMsg) { + m.ethernet.loading = false + m.ethernet.err = msg.err + if msg.err != nil { + return + } + m.ethernet.devices = msg.devices + if m.ethernet.cursor >= len(m.ethernet.devices) { + m.ethernet.cursor = max(0, len(m.ethernet.devices)-1) + } +} + +func (m *Model) handleEthernetAction(msg ethernetActionMsg) tea.Cmd { + m.ethernet.pending = false + if msg.err != nil { + m.ethernet.feedback = msg.err.Error() + m.deviceDetail.pending = false + m.deviceDetail.feedback = msg.err.Error() + m.refreshDeviceDetailContent() + return nil + } + m.ethernet.feedback = "" + m.closeDeviceDetailIfPending(tabEthernet) + return fetchEthernetCmd() +} + +func (m *Model) updateEthernet(msg tea.Msg) tea.Cmd { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return nil + } + g := keys.Keys.Global + n := keys.Keys.Network + + switch { + case key.Matches(keyMsg, g.Up): + if m.ethernet.cursor > 0 { + m.ethernet.cursor-- + } + case key.Matches(keyMsg, g.Down): + if m.ethernet.cursor < len(m.ethernet.devices)-1 { + m.ethernet.cursor++ + } + case key.Matches(keyMsg, n.Refresh): + return fetchEthernetCmd() + case key.Matches(keyMsg, n.Select): + return m.selectEthernet() + case key.Matches(keyMsg, n.Info): + return m.infoEthernet() + } + return nil +} + +func (m *Model) infoEthernet() tea.Cmd { + if m.ethernet.cursor < 0 || m.ethernet.cursor >= len(m.ethernet.devices) { + return nil + } + dev := m.ethernet.devices[m.ethernet.cursor] + if !dev.Connected() { + m.ethernet.feedback = "Not connected." + return nil + } + return m.openDeviceDetail(tabEthernet, dev.Device, dev.Device, "", 0) +} + +func (m *Model) selectEthernet() tea.Cmd { + if m.ethernet.cursor < 0 || m.ethernet.cursor >= len(m.ethernet.devices) { + return nil + } + dev := m.ethernet.devices[m.ethernet.cursor] + m.ethernet.pending = true + if dev.Connected() { + return ethernetDownCmd(dev.Device) + } + return ethernetConnectCmd(dev.Device) +} + +func (m Model) renderEthernet(height int) string { + if m.ethernet.loading { + return ilovetui.S.Faint.Render("Loading ethernet devices…") + } + + header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("ETHERNET DEVICES") + if m.ethernet.pending { + header += mutedStyle().Render(" working…") + } + + if m.ethernet.err != nil { + return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.ethernet.err.Error())) + } + if len(m.ethernet.devices) == 0 { + return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No ethernet devices found.")) + } + + available := height - 3 // header + blank line + a trailing line for the paginator/feedback + if available < 1 { + available = 1 + } + p, start, end := paginate(m.ethernet.cursor, len(m.ethernet.devices), available) + + var rows []string + for i := start; i < end; i++ { + rows = append(rows, m.renderEthernetRow(m.ethernet.devices[i], i == m.ethernet.cursor)) + } + list := strings.Join(rows, "\n") + + footer := renderPaginatorDots(p) + if m.ethernet.feedback != "" { + footer = errorStyle().Render(m.ethernet.feedback) + } + + return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer) +} + +func (m Model) renderEthernetRow(d network.EthernetDevice, selected bool) string { + textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text) + if selected { + textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary) + } + cursor := " " + if selected { + cursor = "> " + } + check := " " + if d.Connected() { + check = successStyle().Render(icons.I.Check) + } + name := d.Device + if d.Connection != "" { + name = d.Connection + } + details := d.State + if d.IP4 != "" { + details += " · " + d.IP4 + } + return fmt.Sprintf("%s%s%s %s %s", cursor, check, textStyle.Render(name), subtleStyle().Render("("+d.Device+")"), subtleStyle().Render(details)) +} diff --git a/internal/ui/network/keymap.go b/internal/ui/network/keymap.go new file mode 100644 index 0000000..38f7b98 --- /dev/null +++ b/internal/ui/network/keymap.go @@ -0,0 +1,63 @@ +package network + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/settuings/internal/keys" +) + +// networkKeyMap renders the bottom help bar, adapting to the active tab and +// whether a sub-view (password prompt, device info, known-network detail) +// is open. +type networkKeyMap struct { + m Model + width int +} + +func (k networkKeyMap) ShortHelp() []key.Binding { + g := keys.Keys.Global + n := keys.Keys.Network + + switch { + case k.m.wifi.connecting: + return []key.Binding{n.Select, g.Escape, g.Help} + case k.m.deviceDetail.open && k.m.deviceDetail.kind == tabWifi: + return []key.Binding{g.Up, g.Down, n.RevealSecret, n.Disconnect, g.Escape, g.Help} + case k.m.deviceDetail.open: + return []key.Binding{g.Up, g.Down, n.Disconnect, g.Escape, g.Help} + case k.m.active == tabKnown && k.m.known.detail: + return []key.Binding{n.RevealSecret, n.ToggleAuto, n.Forget, g.Escape, g.Help} + case k.m.active == tabKnown: + return []key.Binding{g.Up, g.Down, n.Select, n.Known, g.Help} + case k.m.active == tabWifi: + return []key.Binding{g.Up, g.Down, n.Select, n.Disconnect, n.ToggleRadio, g.Help} + default: + return []key.Binding{g.Up, g.Down, n.Select, g.CycleFocus, n.Known, g.Help} + } +} + +func (k networkKeyMap) FullHelp() [][]key.Binding { + g := keys.Keys.Global + n := keys.Keys.Network + + var bindings []key.Binding + switch { + case k.m.wifi.connecting: + bindings = []key.Binding{n.Select, g.Escape} + case k.m.deviceDetail.open && k.m.deviceDetail.kind == tabWifi: + bindings = []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, n.RevealSecret, n.Disconnect, g.Escape} + case k.m.deviceDetail.open: + bindings = []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, n.Disconnect, g.Escape} + case k.m.active == tabKnown && k.m.known.detail: + bindings = []key.Binding{n.RevealSecret, n.ToggleAuto, n.Forget, g.Escape} + case k.m.active == tabKnown: + bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, n.Known} + case k.m.active == tabWifi: + bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, n.Info, n.Disconnect, n.ToggleRadio, g.CycleFocus, n.Known} + case k.m.active == tabEthernet: + bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, n.Info, g.CycleFocus, n.Known} + default: + bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, g.CycleFocus, n.Known} + } + bindings = append(bindings, g.CommonBindings()...) + return keys.ChunkByWidth(bindings, k.width) +} diff --git a/internal/ui/network/known.go b/internal/ui/network/known.go new file mode 100644 index 0000000..1b18287 --- /dev/null +++ b/internal/ui/network/known.go @@ -0,0 +1,286 @@ +package network + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/icons" + "github.com/anotherhadi/settuings/internal/keys" + "github.com/anotherhadi/settuings/internal/network" + "github.com/anotherhadi/settuings/internal/util" +) + +type knownState struct { + loading bool + err error + networks []network.KnownNetwork + cursor int + pending bool + feedback string + + detail bool + secretLoading bool + secretRevealed bool + secretValue string + secretErr error + + confirmForget bool + + // Content can exceed a short terminal, so the detail screen is shown in + // a viewport. + viewport viewport.Model +} + +func newKnownState() knownState { + return knownState{loading: true} +} + +func (m *Model) handleKnownList(msg knownListMsg) { + m.known.loading = false + m.known.err = msg.err + if msg.err != nil { + return + } + m.known.networks = msg.networks + if m.known.cursor >= len(m.known.networks) { + m.known.cursor = max(0, len(m.known.networks)-1) + } +} + +func (m *Model) handleKnownSecret(msg knownSecretMsg) { + if m.known.selected() == nil || m.known.selected().Name != msg.name { + return // stale response for a network we've since navigated away from + } + m.known.secretLoading = false + m.known.secretValue = msg.secret + m.known.secretErr = msg.err + m.refreshKnownDetailContent() +} + +func (m *Model) handleKnownAction(msg knownActionMsg) tea.Cmd { + m.known.pending = false + if msg.err != nil { + m.known.feedback = msg.err.Error() + m.refreshKnownDetailContent() + return nil + } + m.known.feedback = "" + m.known.detail = false + return fetchKnownCmd() +} + +func (s knownState) selected() *network.KnownNetwork { + if s.cursor < 0 || s.cursor >= len(s.networks) { + return nil + } + return &s.networks[s.cursor] +} + +func (m *Model) updateKnown(msg tea.Msg) tea.Cmd { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return nil + } + if m.known.detail { + return m.updateKnownDetail(keyMsg) + } + + g := keys.Keys.Global + n := keys.Keys.Network + switch { + case key.Matches(keyMsg, g.Up): + if m.known.cursor > 0 { + m.known.cursor-- + } + case key.Matches(keyMsg, g.Down): + if m.known.cursor < len(m.known.networks)-1 { + m.known.cursor++ + } + case key.Matches(keyMsg, n.Refresh): + return fetchKnownCmd() + case key.Matches(keyMsg, n.Select): + if sel := m.known.selected(); sel != nil { + m.known.detail = true + m.known.secretRevealed = false + m.known.secretLoading = true + m.known.secretValue = "" + m.known.secretErr = nil + m.openKnownDetail() + return fetchSecretCmd(sel.Name) + } + } + return nil +} + +func (m *Model) updateKnownDetail(keyMsg tea.KeyPressMsg) tea.Cmd { + g := keys.Keys.Global + n := keys.Keys.Network + + if m.known.confirmForget { + m.known.confirmForget = false + defer m.refreshKnownDetailContent() + if keyMsg.String() == "y" || keyMsg.String() == "enter" { + if sel := m.known.selected(); sel != nil { + m.known.pending = true + return forgetCmd(sel.Name) + } + } + return nil + } + + switch { + case key.Matches(keyMsg, g.Escape): + m.known.detail = false + case key.Matches(keyMsg, n.RevealSecret): + m.known.secretRevealed = !m.known.secretRevealed + m.refreshKnownDetailContent() + case key.Matches(keyMsg, n.ToggleAuto): + if sel := m.known.selected(); sel != nil { + m.known.pending = true + return toggleAutoConnectCmd(sel.Name, !sel.AutoConnect) + } + case key.Matches(keyMsg, n.Forget): + m.known.confirmForget = true + m.refreshKnownDetailContent() + case key.Matches(keyMsg, g.Up): + util.ScrollLines(&m.known.viewport, -1) + case key.Matches(keyMsg, g.Down): + util.ScrollLines(&m.known.viewport, 1) + case key.Matches(keyMsg, g.ScrollUp): + util.ScrollViewport(&m.known.viewport, -1) + case key.Matches(keyMsg, g.ScrollDown): + util.ScrollViewport(&m.known.viewport, 1) + } + return nil +} + +// openKnownDetail sizes and populates the detail viewport for the currently +// selected network. Call it whenever the detail screen is opened. +func (m *Model) openKnownDetail() { + w, h := m.contentBudget() + vp := viewport.New() + vp.SetWidth(w) + vp.SetHeight(h) + m.known.viewport = vp + m.refreshKnownDetailContent() +} + +// resizeKnownDetail keeps the detail viewport sized to the available space +// whenever the terminal is resized. +func (m *Model) resizeKnownDetail() { + if !m.known.detail { + return + } + w, h := m.contentBudget() + m.known.viewport.SetWidth(w) + m.known.viewport.SetHeight(h) + m.refreshKnownDetailContent() +} + +func (m *Model) refreshKnownDetailContent() { + m.known.viewport.SetContent(m.knownDetailContent()) + util.RefreshScrollbar(&m.known.viewport) +} + +func (m Model) renderKnown(height int) string { + if m.known.detail { + return m.renderKnownDetail() + } + if m.known.loading { + return ilovetui.S.Faint.Render("Loading known networks…") + } + + header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("KNOWN NETWORKS") + + if m.known.err != nil { + return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.known.err.Error())) + } + if len(m.known.networks) == 0 { + return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No saved Wi-Fi networks.")) + } + + available := height - 3 // header + blank line + a trailing line for the paginator + if available < 1 { + available = 1 + } + p, start, end := paginate(m.known.cursor, len(m.known.networks), available) + + var rows []string + for i := start; i < end; i++ { + rows = append(rows, m.renderKnownRow(m.known.networks[i], i == m.known.cursor)) + } + list := strings.Join(rows, "\n") + + return lipgloss.JoinVertical(lipgloss.Left, header, "", list, renderPaginatorDots(p)) +} + +func (m Model) renderKnownRow(k network.KnownNetwork, selected bool) string { + textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text) + if selected { + textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary) + } + cursor := " " + if selected { + cursor = "> " + } + check := " " + if k.Active { + check = successStyle().Render(icons.I.Check) + } + auto := "manual" + if k.AutoConnect { + auto = "auto-join" + } + return fmt.Sprintf("%s%s%s %s", cursor, check, textStyle.Render(k.Name), subtleStyle().Render(auto)) +} + +func (m Model) renderKnownDetail() string { + return m.known.viewport.View() +} + +func (m Model) knownDetailContent() string { + sel := m.known.selected() + if sel == nil { + return ilovetui.S.Faint.Render("No network selected.") + } + + rows := []string{ + lipgloss.NewStyle().Bold(true).Render(sel.Name), + "", + subtleStyle().Render("Security: ") + securityLabel(sel.Security), + subtleStyle().Render("Auto-join: ") + fmt.Sprintf("%v", sel.AutoConnect), + subtleStyle().Render("Active: ") + fmt.Sprintf("%v", sel.Active), + subtleStyle().Render("Password: ") + m.renderKnownSecret(), + } + + if m.known.feedback != "" { + rows = append(rows, "", errorStyle().Render(m.known.feedback)) + } + + if m.known.confirmForget { + rows = append(rows, "", errorStyle().Render(fmt.Sprintf("Forget %q? press y to confirm, any other key to cancel", sel.Name))) + } + + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} + +func (m Model) renderKnownSecret() string { + if m.known.secretLoading { + return mutedStyle().Render("loading…") + } + if m.known.secretErr != nil { + return errorStyle().Render(m.known.secretErr.Error()) + } + if m.known.secretValue == "" { + return subtleStyle().Render("(none)") + } + if !m.known.secretRevealed { + return subtleStyle().Render(strings.Repeat("•", len(m.known.secretValue))) + } + return m.known.secretValue +} diff --git a/internal/ui/network/messages.go b/internal/ui/network/messages.go new file mode 100644 index 0000000..01ec95b --- /dev/null +++ b/internal/ui/network/messages.go @@ -0,0 +1,64 @@ +package network + +import "github.com/anotherhadi/settuings/internal/network" + +// checkMsg carries the result of network.CheckAvailable. +type checkMsg struct{ err error } + +// tickMsg triggers a periodic refresh of every tab while this page is active. +type tickMsg struct{} + +type wifiListMsg struct { + networks []network.WifiNetwork + ip string + device string // wifi interface name, e.g. "wlo1" + connectionName string // active connection profile name, for secret lookup + radioOn bool + err error +} + +type wifiActionMsg struct { + err error +} + +type ethernetListMsg struct { + devices []network.EthernetDevice + err error +} + +type ethernetActionMsg struct { + err error +} + +type vpnListMsg struct { + conns []network.VPNConnection + err error +} + +type vpnActionMsg struct { + err error +} + +type knownListMsg struct { + networks []network.KnownNetwork + err error +} + +type knownSecretMsg struct { + name string + secret string + err error +} + +type knownActionMsg struct { + err error +} + +type deviceDetailMsg struct { + detail network.DeviceDetail + err error +} + +type radioActionMsg struct { + err error +} diff --git a/internal/ui/network/model.go b/internal/ui/network/model.go new file mode 100644 index 0000000..00661cc --- /dev/null +++ b/internal/ui/network/model.go @@ -0,0 +1,109 @@ +// Package network provides the "Network" TUI page: a thin, tabbed wrapper +// around nmcli covering Wi-Fi, Ethernet, VPN, and known Wi-Fi networks. +package network + +import ( + "charm.land/bubbles/v2/help" + tea "charm.land/bubbletea/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/icons" +) + +type tab int + +const ( + tabWifi tab = iota + tabEthernet + tabVPN + tabKnown // hidden from the tab bar; reachable only via its own shortcut +) + +// visibleTabs are the tabs shown in the tab bar and reachable by cycling. +var visibleTabs = []tab{tabWifi, tabEthernet, tabVPN} + +func (t tab) label() string { + switch t { + case tabWifi: + return "Wifi" + case tabEthernet: + return "Ethernet" + case tabVPN: + return "VPN" + case tabKnown: + return "Known" + default: + return "" + } +} + +func (t tab) icon() string { + switch t { + case tabWifi: + return icons.I.Wifi + case tabEthernet: + return icons.I.Ethernet + case tabVPN: + return icons.I.VPN + case tabKnown: + return icons.I.Lock + default: + return "" + } +} + +type Model struct { + active tab + previousTab tab // where to return to when leaving the hidden Known tab + width int + height int + help help.Model + + checkErr error // reason nmcli/NetworkManager is unusable; nil once confirmed OK + checked bool + + wifi wifiState + ethernet ethernetState + vpn vpnState + known knownState + deviceDetail deviceDetailState +} + +func New() Model { + return Model{ + active: tabWifi, + help: ilovetui.NewHelp(), + wifi: newWifiState(), + ethernet: newEthernetState(), + vpn: newVPNState(), + known: newKnownState(), + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +// Activate is called every time the user navigates onto this page. It +// re-checks nmcli availability, kicks off a fetch for every tab, and starts +// the periodic auto-refresh loop. +func (m *Model) Activate() tea.Cmd { + return tea.Batch( + checkCmd(), + fetchWifiCmd(), + fetchEthernetCmd(), + fetchVPNCmd(), + fetchKnownCmd(), + tickCmd(), + ) +} + +func (m Model) IsEditing() bool { + return m.wifi.connecting || m.known.confirmForget +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h + m.help.SetWidth(w - 2) + m.resizeDetails() +} diff --git a/internal/ui/network/pagination.go b/internal/ui/network/pagination.go new file mode 100644 index 0000000..5b8e3b6 --- /dev/null +++ b/internal/ui/network/pagination.go @@ -0,0 +1,44 @@ +package network + +import ( + "charm.land/bubbles/v2/paginator" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +func newPaginator() paginator.Model { + p := paginator.New() + p.Type = paginator.Dots + p.ActiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render("•") + p.InactiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render("•") + return p +} + +// paginate slices a list of length total into pages of availableRows items, +// deriving the current page from cursor (the globally-indexed selection) +// rather than tracking page state separately. It returns the configured +// paginator and the [start, end) bounds of the visible slice. +func paginate(cursor, total, availableRows int) (p paginator.Model, start, end int) { + p = newPaginator() + perPage := availableRows + if perPage < 1 { + perPage = 1 + } + p.PerPage = perPage + p.SetTotalPages(total) + if p.TotalPages > 0 { + p.Page = cursor / perPage + if p.Page >= p.TotalPages { + p.Page = p.TotalPages - 1 + } + } + start, end = p.GetSliceBounds(total) + return p, start, end +} + +func renderPaginatorDots(p paginator.Model) string { + if p.TotalPages <= 1 { + return "" + } + return mutedStyle().Render(p.View()) +} diff --git a/internal/ui/network/style.go b/internal/ui/network/style.go new file mode 100644 index 0000000..2e43d7c --- /dev/null +++ b/internal/ui/network/style.go @@ -0,0 +1,22 @@ +package network + +import ( + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +func errorStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Error) +} + +func successStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Success) +} + +func subtleStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Subtle) +} + +func mutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ilovetui.S.Muted) +} diff --git a/internal/ui/network/tabbar.go b/internal/ui/network/tabbar.go new file mode 100644 index 0000000..a3377f3 --- /dev/null +++ b/internal/ui/network/tabbar.go @@ -0,0 +1,63 @@ +package network + +import ( + "strings" + + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +// cycleTab moves to the next visible tab. From the hidden Known tab it +// returns to whichever tab was active before Known was opened. +func (m *Model) cycleTab() { + if m.active == tabKnown { + m.active = m.previousTab + return + } + for i, t := range visibleTabs { + if t == m.active { + m.active = visibleTabs[(i+1)%len(visibleTabs)] + return + } + } +} + +// toggleKnown opens the hidden Known tab, or leaves it back to the +// previously active tab if it's already open. +func (m *Model) toggleKnown() { + if m.active == tabKnown { + m.active = m.previousTab + return + } + m.previousTab = m.active + m.active = tabKnown +} + +func (m Model) renderTabBar(width int) string { + active := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 2). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(ilovetui.S.Primary) + inactive := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Padding(0, 2). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(ilovetui.S.Subtle) + + var tabs []string + for _, t := range visibleTabs { + style := inactive + if t == m.active { + style = active + } + tabs = append(tabs, style.Render(t.icon()+t.label())) + } + row := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...) + + filler := width - lipgloss.Width(row) + if filler > 0 { + row = lipgloss.JoinHorizontal(lipgloss.Bottom, row, + lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(ilovetui.S.Subtle). + Render(strings.Repeat(" ", filler))) + } + return row +} diff --git a/internal/ui/network/update.go b/internal/ui/network/update.go new file mode 100644 index 0000000..72fbce2 --- /dev/null +++ b/internal/ui/network/update.go @@ -0,0 +1,115 @@ +package network + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/settuings/internal/keys" + "github.com/anotherhadi/settuings/internal/util" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case checkMsg: + wasUnavailable := m.checkErr != nil + m.checked = true + m.checkErr = msg.err + if msg.err == nil && wasUnavailable { + // recovered: resume fetching + the auto-refresh loop + return m, tea.Batch(fetchWifiCmd(), fetchEthernetCmd(), fetchVPNCmd(), fetchKnownCmd(), tickCmd()) + } + return m, nil + + case tickMsg: + if m.checkErr != nil { + return m, nil + } + return m, tea.Batch(fetchWifiCmd(), fetchEthernetCmd(), fetchVPNCmd(), fetchKnownCmd(), tickCmd()) + + case wifiListMsg: + m.handleWifiList(msg) + return m, nil + case wifiActionMsg: + return m, m.handleWifiAction(msg) + case radioActionMsg: + return m, m.handleRadioAction(msg) + + case ethernetListMsg: + m.handleEthernetList(msg) + return m, nil + case ethernetActionMsg: + return m, m.handleEthernetAction(msg) + + case vpnListMsg: + m.handleVPNList(msg) + return m, nil + case vpnActionMsg: + return m, m.handleVPNAction(msg) + + case knownListMsg: + m.handleKnownList(msg) + return m, nil + case knownSecretMsg: + m.handleKnownSecret(msg) + m.handleDeviceDetailSecret(msg) + return m, nil + case knownActionMsg: + return m, m.handleKnownAction(msg) + + case deviceDetailMsg: + m.handleDeviceDetail(msg) + return m, nil + + case tea.MouseWheelMsg: + if m.deviceDetail.open { + return m, m.updateDeviceDetail(msg) + } + if m.active == tabKnown && m.known.detail { + util.HandleMouseWheel(msg, &m.known.viewport) + } + return m, nil + + case tea.KeyPressMsg: + if m.checkErr != nil { + if key.Matches(msg, keys.Keys.Network.Refresh) { + return m, checkCmd() + } + return m, nil + } + + if m.deviceDetail.open { + return m, m.updateDeviceDetail(msg) + } + + g := keys.Keys.Global + n := keys.Keys.Network + + if !m.IsEditing() { + switch { + case key.Matches(msg, g.CycleFocus): + m.cycleTab() + return m, nil + case key.Matches(msg, n.Known): + m.toggleKnown() + return m, nil + case key.Matches(msg, g.Help): + m.help.ShowAll = !m.help.ShowAll + m.resizeDetails() + return m, nil + } + } + + var cmd tea.Cmd + switch m.active { + case tabWifi: + cmd = m.updateWifi(msg) + case tabEthernet: + cmd = m.updateEthernet(msg) + case tabVPN: + cmd = m.updateVPN(msg) + case tabKnown: + cmd = m.updateKnown(msg) + } + return m, cmd + } + return m, nil +} diff --git a/internal/ui/network/view.go b/internal/ui/network/view.go new file mode 100644 index 0000000..a7ca0a3 --- /dev/null +++ b/internal/ui/network/view.go @@ -0,0 +1,82 @@ +package network + +import ( + "errors" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/network" +) + +func windowStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ilovetui.S.Subtle). + Padding(1, 2) +} + +func (m Model) View() tea.View { + statusBar := m.renderStatusBar() + innerW, innerH := m.contentBudget() + tabBar := m.renderTabBar(innerW) + + var body string + switch { + case !m.checked: + body = ilovetui.S.Faint.Render("Checking nmcli…") + case m.checkErr != nil: + body = m.renderUnavailable() + case m.deviceDetail.open: + body = m.renderDeviceDetail() + default: + body = m.renderActiveTab(innerW, innerH) + } + + box := lipgloss.NewStyle().Width(innerW).Height(innerH).Render(body) + content := windowStyle().Render(lipgloss.JoinVertical(lipgloss.Left, tabBar, box)) + + return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, content, statusBar)) +} + +func (m Model) renderStatusBar() string { + km := networkKeyMap{m: m, width: m.width} + return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km)) +} + +func (m Model) renderActiveTab(width, height int) string { + switch m.active { + case tabWifi: + return m.renderWifi(width, height) + case tabEthernet: + return m.renderEthernet(height) + case tabVPN: + return m.renderVPN(height) + case tabKnown: + return m.renderKnown(height) + default: + return "" + } +} + +func (m Model) renderUnavailable() string { + title := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Error).Render("Network page unavailable") + + var hint string + switch { + case errors.Is(m.checkErr, network.ErrNotFound): + hint = "nmcli was not found in PATH. Install NetworkManager to use this page." + case errors.Is(m.checkErr, network.ErrNotRunning): + hint = "NetworkManager doesn't seem to be running. Try: sudo systemctl start NetworkManager" + default: + hint = m.checkErr.Error() + } + + return lipgloss.JoinVertical(lipgloss.Left, + title, + "", + subtleStyle().Render(hint), + "", + mutedStyle().Render("Press r to try again once fixed."), + ) +} diff --git a/internal/ui/network/vpn.go b/internal/ui/network/vpn.go new file mode 100644 index 0000000..9d49405 --- /dev/null +++ b/internal/ui/network/vpn.go @@ -0,0 +1,139 @@ +package network + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/icons" + "github.com/anotherhadi/settuings/internal/keys" + "github.com/anotherhadi/settuings/internal/network" +) + +type vpnState struct { + loading bool + err error + conns []network.VPNConnection + cursor int + pending bool + feedback string +} + +func newVPNState() vpnState { + return vpnState{loading: true} +} + +func (m *Model) handleVPNList(msg vpnListMsg) { + m.vpn.loading = false + m.vpn.err = msg.err + if msg.err != nil { + return + } + m.vpn.conns = msg.conns + if m.vpn.cursor >= len(m.vpn.conns) { + m.vpn.cursor = max(0, len(m.vpn.conns)-1) + } +} + +func (m *Model) handleVPNAction(msg vpnActionMsg) tea.Cmd { + m.vpn.pending = false + if msg.err != nil { + m.vpn.feedback = msg.err.Error() + return nil + } + m.vpn.feedback = "" + return fetchVPNCmd() +} + +func (m *Model) updateVPN(msg tea.Msg) tea.Cmd { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return nil + } + g := keys.Keys.Global + n := keys.Keys.Network + + switch { + case key.Matches(keyMsg, g.Up): + if m.vpn.cursor > 0 { + m.vpn.cursor-- + } + case key.Matches(keyMsg, g.Down): + if m.vpn.cursor < len(m.vpn.conns)-1 { + m.vpn.cursor++ + } + case key.Matches(keyMsg, n.Refresh): + return fetchVPNCmd() + case key.Matches(keyMsg, n.Select): + return m.selectVPN() + } + return nil +} + +func (m *Model) selectVPN() tea.Cmd { + if m.vpn.cursor < 0 || m.vpn.cursor >= len(m.vpn.conns) { + return nil + } + conn := m.vpn.conns[m.vpn.cursor] + m.vpn.pending = true + if conn.Active { + return vpnDownCmd(conn.Name) + } + return vpnUpCmd(conn.Name) +} + +func (m Model) renderVPN(height int) string { + if m.vpn.loading { + return ilovetui.S.Faint.Render("Loading VPN connections…") + } + + header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("VPN CONNECTIONS") + if m.vpn.pending { + header += mutedStyle().Render(" working…") + } + + if m.vpn.err != nil { + return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.vpn.err.Error())) + } + if len(m.vpn.conns) == 0 { + return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No VPN connections configured.")) + } + + available := height - 3 // header + blank line + a trailing line for the paginator/feedback + if available < 1 { + available = 1 + } + p, start, end := paginate(m.vpn.cursor, len(m.vpn.conns), available) + + var rows []string + for i := start; i < end; i++ { + rows = append(rows, m.renderVPNRow(m.vpn.conns[i], i == m.vpn.cursor)) + } + list := strings.Join(rows, "\n") + + footer := renderPaginatorDots(p) + if m.vpn.feedback != "" { + footer = errorStyle().Render(m.vpn.feedback) + } + + return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer) +} + +func (m Model) renderVPNRow(c network.VPNConnection, selected bool) string { + textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text) + if selected { + textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary) + } + cursor := " " + if selected { + cursor = "> " + } + check := " " + if c.Active { + check = successStyle().Render(icons.I.Check) + } + return fmt.Sprintf("%s%s%s %s", cursor, check, textStyle.Render(c.Name), subtleStyle().Render(c.Type)) +} diff --git a/internal/ui/network/wifi.go b/internal/ui/network/wifi.go new file mode 100644 index 0000000..cd7b00d --- /dev/null +++ b/internal/ui/network/wifi.go @@ -0,0 +1,296 @@ +package network + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/settuings/internal/icons" + "github.com/anotherhadi/settuings/internal/keys" + "github.com/anotherhadi/settuings/internal/network" +) + +type wifiState struct { + loading bool + err error + ip string + device string // wifi interface name, e.g. "wlo1" + connectionName string // active connection profile name, e.g. "Djoon 1" + radioOn bool + current *network.WifiNetwork // the AP we're associated with, if any + others []network.WifiNetwork + cursor int + pending bool // a connect/disconnect/rescan/radio-toggle is in flight + feedback string + + connecting bool + connectSSID string + password textinput.Model +} + +func newWifiState() wifiState { + ti := textinput.New() + ti.Prompt = "Password: " + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '•' + s := ti.Styles() + s.Focused.Prompt = lipgloss.NewStyle().Foreground(ilovetui.S.Primary) + ti.SetStyles(s) + return wifiState{password: ti, loading: true} +} + +func (m Model) isKnownSSID(ssid string) bool { + for _, k := range m.known.networks { + if k.Name == ssid { + return true + } + } + return false +} + +func (m *Model) handleWifiList(msg wifiListMsg) { + m.wifi.loading = false + m.wifi.err = msg.err + m.wifi.radioOn = msg.radioOn + if msg.err != nil { + return + } + m.wifi.ip = msg.ip + m.wifi.device = msg.device + m.wifi.connectionName = msg.connectionName + m.wifi.current = nil + m.wifi.others = m.wifi.others[:0] + for _, n := range msg.networks { + n := n + if n.Active { + m.wifi.current = &n + continue + } + m.wifi.others = append(m.wifi.others, n) + } + if m.wifi.cursor >= len(m.wifi.others) { + m.wifi.cursor = max(0, len(m.wifi.others)-1) + } +} + +func (m *Model) handleWifiAction(msg wifiActionMsg) tea.Cmd { + m.wifi.pending = false + if msg.err != nil { + m.wifi.feedback = msg.err.Error() + m.deviceDetail.pending = false + m.deviceDetail.feedback = msg.err.Error() + m.refreshDeviceDetailContent() + return nil + } + m.wifi.feedback = "" + m.closeDeviceDetailIfPending(tabWifi) + return tea.Batch(fetchWifiCmd(), fetchKnownCmd()) +} + +func (m *Model) handleRadioAction(msg radioActionMsg) tea.Cmd { + m.wifi.pending = false + if msg.err != nil { + m.wifi.feedback = msg.err.Error() + return nil + } + m.wifi.feedback = "" + return fetchWifiCmd() +} + +func (m *Model) updateWifi(msg tea.Msg) tea.Cmd { + if m.wifi.connecting { + return m.updateWifiPasswordPrompt(msg) + } + + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return nil + } + g := keys.Keys.Global + n := keys.Keys.Network + + switch { + case key.Matches(keyMsg, g.Up): + if m.wifi.cursor > 0 { + m.wifi.cursor-- + } + case key.Matches(keyMsg, g.Down): + if m.wifi.cursor < len(m.wifi.others)-1 { + m.wifi.cursor++ + } + case key.Matches(keyMsg, n.Refresh): + m.wifi.pending = true + return tea.Batch(rescanWifiCmd(), fetchWifiCmd()) + case key.Matches(keyMsg, n.Select): + return m.selectWifi() + case key.Matches(keyMsg, n.ToggleRadio): + m.wifi.pending = true + return toggleWifiRadioCmd(!m.wifi.radioOn) + case key.Matches(keyMsg, n.Disconnect): + if m.wifi.current == nil { + m.wifi.feedback = "Not connected to a network." + return nil + } + m.wifi.pending = true + return disconnectWifiCmd() + case key.Matches(keyMsg, n.Info): + if m.wifi.current == nil { + m.wifi.feedback = "Not connected to a network." + return nil + } + cur := m.wifi.current + return m.openDeviceDetail(tabWifi, m.wifi.device, cur.SSID, cur.Security, cur.Signal) + } + return nil +} + +func (m *Model) selectWifi() tea.Cmd { + if len(m.wifi.others) == 0 { + return nil + } + target := m.wifi.others[m.wifi.cursor] + if target.Secured() && !m.isKnownSSID(target.SSID) { + m.wifi.connecting = true + m.wifi.connectSSID = target.SSID + m.wifi.password.SetValue("") + m.wifi.password.Focus() + return nil + } + m.wifi.pending = true + return connectWifiCmd(target.SSID, "") +} + +func (m *Model) updateWifiPasswordPrompt(msg tea.Msg) tea.Cmd { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return nil + } + g := keys.Keys.Global + switch { + case key.Matches(keyMsg, g.Escape): + m.wifi.connecting = false + m.wifi.password.Blur() + return nil + case keyMsg.String() == "enter": + ssid := m.wifi.connectSSID + password := m.wifi.password.Value() + m.wifi.connecting = false + m.wifi.password.Blur() + m.wifi.pending = true + return connectWifiCmd(ssid, password) + default: + var cmd tea.Cmd + m.wifi.password, cmd = m.wifi.password.Update(keyMsg) + return cmd + } +} + +func (m Model) renderWifi(width, height int) string { + if m.wifi.connecting { + return m.renderWifiPasswordPrompt() + } + + if m.wifi.loading { + return ilovetui.S.Faint.Render("Loading Wi-Fi networks…") + } + + othersHeader := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("OTHER NETWORKS") + if m.wifi.pending { + othersHeader += mutedStyle().Render(" refreshing…") + } + + fixed := lipgloss.JoinVertical(lipgloss.Left, + m.renderWifiRadioStatus(), + "", + lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("CURRENT NETWORK"), + "", + m.renderCurrentWifiCard(width), + "", + othersHeader, + "", + ) + fixedH := lipgloss.Height(fixed) + + if m.wifi.err != nil { + return lipgloss.JoinVertical(lipgloss.Left, fixed, errorStyle().Render(m.wifi.err.Error())) + } + if len(m.wifi.others) == 0 { + return lipgloss.JoinVertical(lipgloss.Left, fixed, ilovetui.S.Faint.Render("No other networks in range.")) + } + + available := height - fixedH - 1 // reserve a trailing line for the paginator/feedback + if available < 1 { + available = 1 + } + p, start, end := paginate(m.wifi.cursor, len(m.wifi.others), available) + + var rows []string + for i := start; i < end; i++ { + rows = append(rows, m.renderWifiRow(m.wifi.others[i], i == m.wifi.cursor)) + } + list := strings.Join(rows, "\n") + + footer := renderPaginatorDots(p) + if m.wifi.feedback != "" { + footer = errorStyle().Render(m.wifi.feedback) + } + + return lipgloss.JoinVertical(lipgloss.Left, fixed, list, footer) +} + +func (m Model) renderWifiRadioStatus() string { + label := "Wi-Fi: " + if m.wifi.radioOn { + return label + successStyle().Bold(true).Render("On") + } + return label + errorStyle().Bold(true).Render("Off") +} + +func (m Model) renderCurrentWifiCard(width int) string { + if m.wifi.current == nil { + return ilovetui.S.Faint.Render("Not connected") + } + cur := m.wifi.current + check := lipgloss.NewStyle().Foreground(ilovetui.S.Success).Render(icons.I.Check) + title := lipgloss.NewStyle().Bold(true).Render(cur.SSID) + line1 := check + title + line2 := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render( + fmt.Sprintf("%s · %d%%", securityLabel(cur.Security), cur.Signal)) + if m.wifi.ip != "" { + line2 += lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render(" · " + m.wifi.ip) + } + return lipgloss.NewStyle().Width(width).Render(lipgloss.JoinVertical(lipgloss.Left, line1, line2)) +} + +func (m Model) renderWifiRow(n network.WifiNetwork, selected bool) string { + textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text) + subtleStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle) + if selected { + textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary) + } + lock := "" + if n.Secured() { + lock = " " + icons.I.Lock + } + cursor := " " + if selected { + cursor = "> " + } + return fmt.Sprintf("%s%s%s %s", cursor, textStyle.Render(n.SSID), lock, subtleStyle.Render(fmt.Sprintf("%d%%", n.Signal))) +} + +func (m Model) renderWifiPasswordPrompt() string { + title := lipgloss.NewStyle().Bold(true).Render("Connect to " + m.wifi.connectSSID) + return lipgloss.JoinVertical(lipgloss.Left, title, "", m.wifi.password.View()) +} + +func securityLabel(security string) string { + if security == "" || security == "--" { + return "Open" + } + return security +} diff --git a/internal/util/cursor.go b/internal/util/cursor.go new file mode 100644 index 0000000..8ad74f3 --- /dev/null +++ b/internal/util/cursor.go @@ -0,0 +1,30 @@ +package util + +// CursorMovePage moves cursor forward or backward by one page (perPage items), +// clamped to [0, total-1]. +func CursorMovePage(cursor, total, perPage int, forward bool) int { + step := perPage + if step < 1 { + step = 1 + } + if forward { + cursor += step + } else { + cursor -= step + } + if cursor < 0 || total <= 0 { + return 0 + } + if cursor >= total { + return total - 1 + } + return cursor +} + +// CursorGotoBottom returns the last valid cursor index for a list of total items. +func CursorGotoBottom(total int) int { + if total <= 0 { + return 0 + } + return total - 1 +} diff --git a/internal/util/editor.go b/internal/util/editor.go new file mode 100644 index 0000000..e3215ba --- /dev/null +++ b/internal/util/editor.go @@ -0,0 +1,65 @@ +package util + +import ( + "log" + "os" + "os/exec" + + tea "charm.land/bubbletea/v2" + + "github.com/anotherhadi/settuings/internal/config" +) + +type EditorFinishedMsg struct { + Content string + Err error +} + +func ResolveEditor() string { + editor := config.Global.App.ExternalEditor + if editor == "" { + editor = os.Getenv("EDITOR") + } + if editor == "" { + editor = "vi" + } + return editor +} + +func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd { + f, err := os.CreateTemp("", "settuings-*.http") + if err != nil { + log.Printf("editor: create temp file: %v", err) + return nil + } + tmpPath := f.Name() + if _, err := f.WriteString(content); err != nil { + log.Printf("editor: writing temp file: %v", err) + } + if err := f.Close(); err != nil { + log.Printf("editor: closing temp file: %v", err) + } + return tea.ExecProcess(exec.Command(ResolveEditor(), tmpPath), func(err error) tea.Msg { // #nosec G204 -- editor from trusted config/$EDITOR, tmpPath from os.CreateTemp + defer os.Remove(tmpPath) + return callback(tmpPath, err) + }) +} + +func OpenExternalEditor(content string) tea.Cmd { + return openWithEditor(content, func(tmpPath string, err error) tea.Msg { + if err != nil { + return EditorFinishedMsg{Err: err} + } + data, readErr := os.ReadFile(tmpPath) // #nosec G304 -- tmpPath is from os.CreateTemp, controlled by this process + if readErr != nil { + return EditorFinishedMsg{Err: readErr} + } + return EditorFinishedMsg{Content: string(data)} + }) +} + +func OpenExternalEditorView(content string) tea.Cmd { + return openWithEditor(content, func(_ string, _ error) tea.Msg { + return nil + }) +} diff --git a/internal/util/os.go b/internal/util/os.go new file mode 100644 index 0000000..4d49915 --- /dev/null +++ b/internal/util/os.go @@ -0,0 +1,48 @@ +package util + +import ( + "os" + "runtime" + "strings" +) + +// OSName returns a human-readable OS name, e.g. "NixOS 25.05" or +// "Ubuntu 24.04.1 LTS", read from /etc/os-release. Falls back to +// runtime.GOOS if the file is missing or has no PRETTY_NAME. +func OSName() string { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return runtime.GOOS + } + for _, line := range strings.Split(string(data), "\n") { + name, ok := strings.CutPrefix(line, "PRETTY_NAME=") + if !ok { + continue + } + return strings.Trim(strings.TrimSpace(name), `"`) + } + return runtime.GOOS +} + +// KernelVersion returns the running kernel release, e.g. "6.12.9-arch1-1". +func KernelVersion() string { + data, err := os.ReadFile("/proc/sys/kernel/osrelease") + if err != nil { + return "unknown" + } + return strings.TrimSpace(string(data)) +} + +// Hostname returns the machine's hostname. +func Hostname() string { + name, err := os.Hostname() + if err != nil { + return "unknown" + } + return name +} + +// Arch returns the CPU architecture, e.g. "amd64". +func Arch() string { + return runtime.GOARCH +} diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 0000000..5f43989 --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,58 @@ +package util + +import ( + "strings" + + "charm.land/bubbles/v2/paginator" + "charm.land/lipgloss/v2" +) + +func Truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-1] + "…" +} + +// EmptyState renders centered placeholder lines, word-wrapping to fit maxW. +func EmptyState(maxW int, lines ...string) string { + return lipgloss.NewStyle(). + Width(maxW). + AlignHorizontal(lipgloss.Center). + Render(strings.Join(lines, "\n")) +} + +// CenterLines centers each line horizontally relative to the longest one. +func CenterLines(lines ...string) string { + maxWidth := 0 + for _, l := range lines { + if w := lipgloss.Width(l); w > maxWidth { + maxWidth = w + } + } + centered := make([]string, len(lines)) + for i, l := range lines { + centered[i] = lipgloss.PlaceHorizontal(maxWidth, lipgloss.Center, l) + } + return strings.Join(centered, "\n") +} + +// PageBounds returns clamped start/end indices for rendering a paginated list. +func PageBounds(p paginator.Model, total int) (start, end int) { + start, end = p.GetSliceBounds(total) + if start < 0 { + start = 0 + } + if end < start { + end = start + } + return +} + +// InferScheme returns "http" for port 80, "https" otherwise. +func InferScheme(host string) string { + if strings.HasSuffix(host, ":80") { + return "http" + } + return "https" +} diff --git a/internal/util/viewport.go b/internal/util/viewport.go new file mode 100644 index 0000000..bf9fa12 --- /dev/null +++ b/internal/util/viewport.go @@ -0,0 +1,107 @@ +package util + +import ( + "strings" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +// ScrollbarGutterWidth is the fixed width, in terminal columns, that +// RefreshScrollbar reserves for its gutter — whether or not a scrollbar is +// actually needed right now. Content that pre-wraps itself to a viewport's +// width (e.g. glamour markdown) must subtract this, otherwise it overflows +// the moment the scrollbar's thumb/track appear and the gutter eats into +// the width the content assumed it had. +const ScrollbarGutterWidth = 2 + +// SetYOffset sets vp's Y offset and refreshes its scrollbar gutter to match. +// Every scroll site should go through this (or [ScrollLines]/[ScrollViewport]) +// instead of calling vp.SetYOffset directly, so the scrollbar never goes +// stale. +func SetYOffset(vp *viewport.Model, y int) { + vp.SetYOffset(y) + RefreshScrollbar(vp) +} + +// ScrollLines scrolls vp by n lines (negative n scrolls up) and refreshes +// its scrollbar gutter. +func ScrollLines(vp *viewport.Model, n int) { + SetYOffset(vp, vp.YOffset()+n) +} + +// ScrollViewport scrolls vp vertically by half its height, then refreshes +// its scrollbar gutter. delta should be -1 for up, +1 for down. +func ScrollViewport(vp *viewport.Model, delta int) { + step := vp.Height() / 2 + if step < 1 { + step = 1 + } + ScrollLines(vp, delta*step) +} + +// HandleMouseWheel applies standard mouse wheel scrolling to vp, refreshing +// its scrollbar gutter on vertical scrolls. +// Vertical: one line at a time. Shift+vertical or horizontal: scroll 6 columns. +func HandleMouseWheel(msg tea.MouseWheelMsg, vp *viewport.Model) { + switch msg.Button { + case tea.MouseWheelUp: + if msg.Mod.Contains(tea.ModShift) { + vp.ScrollLeft(6) + } else { + ScrollLines(vp, -1) + } + case tea.MouseWheelDown: + if msg.Mod.Contains(tea.ModShift) { + vp.ScrollRight(6) + } else { + ScrollLines(vp, 1) + } + case tea.MouseWheelLeft: + vp.ScrollLeft(6) + case tea.MouseWheelRight: + vp.ScrollRight(6) + } +} + +// RefreshScrollbar rebuilds vp's left-gutter scrollbar to reflect its +// current size, content and scroll position. Call it after anything that +// changes vp's width, height or content; YOffset changes are already +// covered if they go through [SetYOffset], [ScrollLines], [ScrollViewport] +// or [HandleMouseWheel]. +// +// The gutter is always [ScrollbarGutterWidth] columns wide, even when +// there's nothing to scroll (it's just blank then). That keeps vp's usable +// content width constant regardless of scroll state, so content sized +// against vp.Width() doesn't need to know whether a scrollbar happens to be +// showing right now. +func RefreshScrollbar(vp *viewport.Model) { + height := vp.Height() + total := vp.TotalLineCount() + blank := strings.Repeat(" ", ScrollbarGutterWidth) + + if height <= 0 || total <= height { + vp.LeftGutterFunc = func(viewport.GutterContext) string { return blank } + return + } + + yOffset := vp.YOffset() + thumbSize := max(1, height*height/total) + thumbStart := 0 + if maxOffset := total - height; maxOffset > 0 { + thumbStart = yOffset * (height - thumbSize) / maxOffset + } + + trackStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle) + thumbStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Primary) + + vp.LeftGutterFunc = func(ctx viewport.GutterContext) string { + pos := ctx.Index - yOffset + if pos >= thumbStart && pos < thumbStart+thumbSize { + return thumbStyle.Render("█") + " " + } + return trackStyle.Render("│") + " " + } +} diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml new file mode 100644 index 0000000..4b331a5 --- /dev/null +++ b/nix/gomod2nix.toml @@ -0,0 +1,254 @@ +schema = 3 + +[mod] + [mod.'charm.land/bubbles/v2'] + version = 'v2.1.0' + hash = 'sha256-2OmqpBrl+taOJzAhVM6OReLmoYRxZOXx9JqFNjQjsPA=' + + [mod.'charm.land/bubbletea/v2'] + version = 'v2.0.6' + hash = 'sha256-1jxXmcnI4peUE0Xs3HGe57pIhRONx235aAaeqm2r434=' + + [mod.'charm.land/glamour/v2'] + version = 'v2.0.0' + hash = 'sha256-CZYlNGw2MihqnSHf1Xxqz55NnqW9fVpLxyvLItryIw4=' + + [mod.'charm.land/lipgloss/v2'] + version = 'v2.0.3' + hash = 'sha256-/RFkSUscU3NwymzT+PfizGf3XyQIdVGQlX7vxktCUGk=' + + [mod.'github.com/alecthomas/chroma/v2'] + version = 'v2.24.1' + hash = 'sha256-DufsljWRKireFuLFcnPozuF0N3UoRYGlEfNFMD+z0ng=' + + [mod.'github.com/andybalholm/brotli'] + version = 'v1.0.4' + hash = 'sha256-gAnPRdGP4yna4hiRIEDyBtDOVJqd7RU27wlPu96Rdf8=' + + [mod.'github.com/anotherhadi/ilovetui'] + version = 'v0.1.6' + hash = 'sha256-7E+7UFks5vM3XWCvX2joFmHGcW7qqnoox6ZPFglaLO4=' + + [mod.'github.com/atotto/clipboard'] + version = 'v0.1.4' + hash = 'sha256-ZZ7U5X0gWOu8zcjZcWbcpzGOGdycwq0TjTFh/eZHjXk=' + + [mod.'github.com/aymerick/douceur'] + version = 'v0.2.0' + hash = 'sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=' + + [mod.'github.com/charmbracelet/colorprofile'] + version = 'v0.4.3' + hash = 'sha256-y+QDUxGOKhugEMQLRUTZYT2C+wKqYHnMLJ44jbh7+JA=' + + [mod.'github.com/charmbracelet/ultraviolet'] + version = 'v0.0.0-20260511121909-c840852527f3' + hash = 'sha256-oWEEaaetCmXvflD03MWy8qD105Kd62iI62+lxofE9Ns=' + + [mod.'github.com/charmbracelet/x/ansi'] + version = 'v0.11.7' + hash = 'sha256-q8BZJq4K7NE5ETocN9/G/EoV0dUyD703ONSfHiUYzWQ=' + + [mod.'github.com/charmbracelet/x/exp/slice'] + version = 'v0.0.0-20260517005351-920740d613be' + hash = 'sha256-bMsbEzP1gHA2OJx4zMYZUI3UFhcTG+mcFm8rRY+Khh8=' + + [mod.'github.com/charmbracelet/x/term'] + version = 'v0.2.2' + hash = 'sha256-KF7IU1Luxl/sZP6XjomWB2e3lxSUS4/5AahhapGir/4=' + + [mod.'github.com/charmbracelet/x/termios'] + version = 'v0.1.1' + hash = 'sha256-sri3LpHCBhGvnJldDzBxwbbZpeSGZVCJFOUL45uBFds=' + + [mod.'github.com/charmbracelet/x/windows'] + version = 'v0.2.2' + hash = 'sha256-CvmE8kAC5wlPSeWjl2hc5xizvGS2FeOLHw84froldkk=' + + [mod.'github.com/clipperhouse/displaywidth'] + version = 'v0.11.0' + hash = 'sha256-WokyTaofEy95xlshqK5YDzpemhXV5oaQifxS9YyfCXo=' + + [mod.'github.com/clipperhouse/uax29/v2'] + version = 'v2.7.0' + hash = 'sha256-GO3az7WiGcwU0OvmocwdfR5ohGRL8NbjscIaMyhAdxE=' + + [mod.'github.com/dlclark/regexp2'] + version = 'v1.12.0' + hash = 'sha256-PVX2rDCkiG0vyA1CbDi3bzLeZ2T8hcqJv3pZ5YwGzMI=' + + [mod.'github.com/dustin/go-humanize'] + version = 'v1.0.1' + hash = 'sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=' + + [mod.'github.com/fsnotify/fsnotify'] + version = 'v1.10.1' + hash = 'sha256-6LBLgsh4nKkMpgRKVsYFEaGDSU1fncBcWVSjKBdfgjU=' + + [mod.'github.com/go-viper/mapstructure/v2'] + version = 'v2.5.0' + hash = 'sha256-LbrCBANBprVI84M0CWrXc7rriJL5ac5VKbh58LBTw7U=' + + [mod.'github.com/golang/groupcache'] + version = 'v0.0.0-20210331224755-41bb18bfe9da' + hash = 'sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0=' + + [mod.'github.com/google/uuid'] + version = 'v1.6.0' + hash = 'sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=' + + [mod.'github.com/gorilla/css'] + version = 'v1.0.1' + hash = 'sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=' + + [mod.'github.com/gorilla/websocket'] + version = 'v1.5.0' + hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc=' + + [mod.'github.com/inconshreveable/mousetrap'] + version = 'v1.1.0' + hash = 'sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE=' + + [mod.'github.com/klauspost/compress'] + version = 'v1.17.8' + hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs=' + + [mod.'github.com/lqqyt2423/go-mitmproxy'] + version = 'v1.8.11' + hash = 'sha256-PoCL6TOt99JsenH4XFx+7MqIzzYnnLHYwUpEQ7kxxyg=' + + [mod.'github.com/lucasb-eyer/go-colorful'] + version = 'v1.4.0' + hash = 'sha256-i/3GDHKEMLCy0kc3mtyk58UWYOPmKoUVaq6QCAWXKP0=' + + [mod.'github.com/mattn/go-isatty'] + version = 'v0.0.22' + hash = 'sha256-6O/0jc33pKUzlzUGpH8Ekk54XgJvx6Qe7kJtbcNJAV4=' + + [mod.'github.com/mattn/go-runewidth'] + version = 'v0.0.23' + hash = 'sha256-SmChZ2U1aR8pW3LPhdM7KcVF5TO6VcHgRzBtUXbBWJA=' + + [mod.'github.com/microcosm-cc/bluemonday'] + version = 'v1.0.27' + hash = 'sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es=' + + [mod.'github.com/muesli/cancelreader'] + version = 'v0.2.2' + hash = 'sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ=' + + [mod.'github.com/ncruces/go-strftime'] + version = 'v1.0.0' + hash = 'sha256-GYIwYDONuv/yTE0AEugCHQbtV3oiBaco93xUNYFcVBQ=' + + [mod.'github.com/pelletier/go-toml/v2'] + version = 'v2.3.1' + hash = 'sha256-5H8+UOtPOs+Yc+8oVT/3bugCCdbq3jFMH6eOW8dadyg=' + + [mod.'github.com/remyoudompheng/bigfft'] + version = 'v0.0.0-20230129092748-24d4a6f8daec' + hash = 'sha256-vYmpyCE37eBYP/navhaLV4oX4/nu0Z/StAocLIFqrmM=' + + [mod.'github.com/rivo/uniseg'] + version = 'v0.4.7' + hash = 'sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=' + + [mod.'github.com/sagikazarmark/locafero'] + version = 'v0.12.0' + hash = 'sha256-EXk9S5Z5sYyApAzCgHIugsGMbt/pHWRfHYFZH5D+5Ws=' + + [mod.'github.com/sahilm/fuzzy'] + version = 'v0.1.1' + hash = 'sha256-f2VsDI6G+V2w31tSDzbZPi9EI2E7jRV6Aq8yeOorSZY=' + + [mod.'github.com/satori/go.uuid'] + version = 'v1.2.0' + hash = 'sha256-y/lSGbnZa7mYJCs30a3LTyjfCFQSaYp8GbVR8dwtmsg=' + + [mod.'github.com/sirupsen/logrus'] + version = 'v1.9.4' + hash = 'sha256-ltRvmtM3XTCAFwY0IesfRqYIivyXPPuvkFjL4ARh1wg=' + + [mod.'github.com/spf13/afero'] + version = 'v1.15.0' + hash = 'sha256-LhcezbOqfuBzacytbqck0hNUxi6NbWNhifUc5/9uHQ8=' + + [mod.'github.com/spf13/cast'] + version = 'v1.10.0' + hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk=' + + [mod.'github.com/spf13/cobra'] + version = 'v1.10.2' + hash = 'sha256-nbRCTFiDCC2jKK7AHi79n7urYCMP5yDZnWtNVJrDi+k=' + + [mod.'github.com/spf13/pflag'] + version = 'v1.0.10' + hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU=' + + [mod.'github.com/spf13/viper'] + version = 'v1.21.0' + hash = 'sha256-A9A8i7HH/ge4j3hw7G++HNj8BjhhpZKvxHhfY+QAxkI=' + + [mod.'github.com/subosito/gotenv'] + version = 'v1.6.0' + hash = 'sha256-LspbjTniiq2xAICSXmgqP7carwlNaLqnCTQfw2pa80A=' + + [mod.'github.com/xo/terminfo'] + version = 'v0.0.0-20220910002029-abceb7e1c41e' + hash = 'sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=' + + [mod.'github.com/yuin/goldmark'] + version = 'v1.8.2' + hash = 'sha256-LoWfW1Tb6mNuMR7SoA/4SJv4pTKfsVXqeXEVm4uEQ7Q=' + + [mod.'github.com/yuin/goldmark-emoji'] + version = 'v1.0.6' + hash = 'sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY=' + + [mod.'github.com/yuin/gopher-lua'] + version = 'v1.1.2' + hash = 'sha256-2YMCxv7RO3uqq6OTjvE0kR9nTt+n2cF+MrLtGEW68po=' + + [mod.'go.uber.org/atomic'] + version = 'v1.11.0' + hash = 'sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY=' + + [mod.'go.yaml.in/yaml/v3'] + version = 'v3.0.4' + hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4=' + + [mod.'golang.org/x/net'] + version = 'v0.55.0' + hash = 'sha256-Phi2mSmBGOJcvqPPAit3uqF3UP8SKRI9dHj6yTM3s5s=' + + [mod.'golang.org/x/sync'] + version = 'v0.20.0' + hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y=' + + [mod.'golang.org/x/sys'] + version = 'v0.45.0' + hash = 'sha256-hkBoNazrDA67ER6sWhb+EKxx9nJ24+nz3zGy+zT5Hvw=' + + [mod.'golang.org/x/text'] + version = 'v0.37.0' + hash = 'sha256-8XDOnlPIybcDRy89fkjG5VqtIt5Ku+LmaqYhgKl7i1E=' + + [mod.'gopkg.in/yaml.v3'] + version = 'v3.0.1' + hash = 'sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=' + + [mod.'modernc.org/libc'] + version = 'v1.72.3' + hash = 'sha256-eOCoqSnX/VzpG63nh1j3JpXRndnZZcn2cDTeutexXAI=' + + [mod.'modernc.org/mathutil'] + version = 'v1.7.1' + hash = 'sha256-COZ5rF2GhQVR1r6a0DanJ8qwQ94JSKdQxTMWrDzE0Cc=' + + [mod.'modernc.org/memory'] + version = 'v1.11.0' + hash = 'sha256-MkybF8vvrxXS5j7O8w3skwTo0aMo1yjWS0K440rYcHM=' + + [mod.'modernc.org/sqlite'] + version = 'v1.50.1' + hash = 'sha256-JbGP3eerH/6mgzI0aq9SpLLwe6ld6PV/zJReG8mLQN0=' diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..d343633 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,29 @@ +{ + pkgs, + buildGoApplication, +}: let + pname = "settuings"; + version = "0.0.1"; + ldflags = ["-s" "-w" "-X main.version=${version}"]; + pkg = buildGoApplication { + inherit pname version ldflags; + src = ../.; + modules = ./gomod2nix.toml; + nativeBuildInputs = [pkgs.installShellFiles]; + env.GOTOOLCHAIN = "local"; + postInstall = '' + installShellCompletion --cmd settuings \ + --bash <($out/bin/settuings completion bash) \ + --zsh <($out/bin/settuings completion zsh) \ + --fish <($out/bin/settuings completion fish) + ''; + meta = with pkgs.lib; { + description = "A TUI to manage your Linux system settings like wifi, bluetooth, and more, without leaving the terminal."; + homepage = "https://github.com/anotherhadi/settuings"; + platforms = platforms.unix; + }; + }; +in { + "${pname}" = pkg; + default = pkg; +} diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 0000000..768b25c --- /dev/null +++ b/nix/shell.nix @@ -0,0 +1,57 @@ +{ + pkgs, + gitHooksLib, + gomod2nixPkgs, +}: let + hooks = gitHooksLib.run { + src = ../.; + hooks = { + gofmt.enable = true; + govet.enable = true; + stylua.enable = true; + + gomod2nix = { + enable = true; + name = "gomod2nix"; + entry = "gomod2nix --outdir ./nix"; + language = "system"; + files = "go\\.(mod|sum)$"; + pass_filenames = false; + }; + + inject-exec = { + enable = true; + name = "inject-exec"; + entry = "python3 .github/scripts/inject-exec.py README.md"; + language = "system"; + files = "(README\\.md|cmd/)"; + pass_filenames = false; + }; + + doctoc = { + enable = true; + name = "doctoc"; + entry = "doctoc --notitle README.md"; + language = "system"; + files = "(README\\.md|cmd/)"; + pass_filenames = false; + }; + }; + }; +in + pkgs.mkShell { + packages = with pkgs; + [ + go + gosec + govulncheck + python3 + doctoc + stylua + trufflehog + gomod2nixPkgs.gomod2nix + ] + ++ hooks.enabledPackages; + + shellHook = hooks.shellHook; + } diff --git a/settuings.go b/settuings.go new file mode 100644 index 0000000..dcd977b --- /dev/null +++ b/settuings.go @@ -0,0 +1,6 @@ +package settuings + +import "embed" + +//go:embed docs +var DocsFS embed.FS