commit 59b31e5ca32595d1afcc44a3ef52712b5ceb519a
Author: Hadi <112569860+anotherhadi@users.noreply.github.com>
Date: Thu Aug 13 00:58:37 2026 +0200
init
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
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 0000000..58f432b
Binary files /dev/null and b/.github/assets/banner.png differ
diff --git a/.github/assets/logo.png b/.github/assets/logo.png
new file mode 100644
index 0000000..d123e87
Binary files /dev/null and b/.github/assets/logo.png differ
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 @@
+
+

+
+
+
+
+# SetTUIngs
+
+> A TUI to manage your Linux system settings like wifi, bluetooth, and more, without leaving the terminal.
+
+
+
+
+[](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).
+
+
+
+## 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