Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-08-13 00:58:37 +02:00
commit 59b31e5ca3
103 changed files with 8577 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
package audio
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// Device is one physical/virtual audio sink (output) or source (input), as
// listed under wpctl status's Sinks/Sources sections.
type Device struct {
ID string
Name string
Default bool
Volume int // 0-100
Muted bool
}
// ListSinks returns the available audio output devices.
func ListSinks() ([]Device, error) {
return listDevices("Sinks:")
}
// ListSources returns the available audio input devices.
func ListSources() ([]Device, error) {
return listDevices("Sources:")
}
func listDevices(header string) ([]Device, error) {
out, err := status()
if err != nil {
return nil, err
}
entries := parseSection(out, header)
devices := make([]Device, 0, len(entries))
for _, e := range entries {
devices = append(devices, Device{
ID: e.ID,
Name: e.Name,
Default: e.Default,
Volume: e.Volume,
Muted: e.Muted,
})
}
return devices, nil
}
var volumeLineRe = regexp.MustCompile(`^Volume:\s+([0-9.]+)(\s+\[MUTED\])?`)
// GetVolume returns id's current volume (0-100) and mute state.
func GetVolume(id string) (int, bool, error) {
out, err := run("get-volume", id)
if err != nil {
return 0, false, err
}
m := volumeLineRe.FindStringSubmatch(strings.TrimSpace(out))
if m == nil {
return 0, false, fmt.Errorf("unexpected wpctl get-volume output: %q", out)
}
v, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0, false, err
}
return int(v*100 + 0.5), m[2] != "", nil
}
// SetVolume sets id's volume to an absolute percentage, clamped to [0, 100].
func SetVolume(id string, pct int) error {
if pct < 0 {
pct = 0
} else if pct > 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
}
+87
View File
@@ -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()
}
+97
View File
@@ -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[:])
}
+97
View File
@@ -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
}
+113
View File
@@ -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 <id>` 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
}
+54
View File
@@ -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
}
+71
View File
@@ -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
}
+61
View File
@@ -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
}
+225
View File
@@ -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 <address> <name>" 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
}
+96
View File
@@ -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
}
+56
View File
@@ -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"
+63
View File
@@ -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"`
}
+53
View File
@@ -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{}
}
}
+26
View File
@@ -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}
}
+32
View File
@@ -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,
}
}
+26
View File
@@ -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}
}
+61
View File
@@ -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}
}
+66
View File
@@ -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
}
+39
View File
@@ -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,
}
}
+122
View File
@@ -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
}
+45
View File
@@ -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
}
+62
View File
@@ -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
}
+120
View File
@@ -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 <field> ...` 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 <fields> 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]
}
+44
View File
@@ -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
}
+118
View File
@@ -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
}
+10
View File
@@ -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)
}
+20
View File
@@ -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),
}
}
+282
View File
@@ -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)
}
+66
View File
@@ -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
}
+85
View File
@@ -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)
}
+101
View File
@@ -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...)
}
+127
View File
@@ -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
}
+96
View File
@@ -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)
}
+116
View File
@@ -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)
}
+44
View File
@@ -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")
}
+173
View File
@@ -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)
}
+96
View File
@@ -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}
}
}
+220
View File
@@ -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)
}
+51
View File
@@ -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)
}
+46
View File
@@ -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
}
+106
View File
@@ -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)
}
+165
View File
@@ -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)
}
+44
View File
@@ -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())
}
+62
View File
@@ -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
}
+61
View File
@@ -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+"]")
}
+22
View File
@@ -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)
}
+49
View File
@@ -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
}
+79
View File
@@ -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
}
+85
View File
@@ -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."),
)
}
+77
View File
@@ -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}
}
}
+206
View File
@@ -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")
}
+44
View File
@@ -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)
}
+301
View File
@@ -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)
}
+31
View File
@@ -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
}
+54
View File
@@ -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()
}
+44
View File
@@ -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())
}
+22
View File
@@ -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)
}
+30
View File
@@ -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
}
+70
View File
@@ -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
}
+93
View File
@@ -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."),
)
}
@@ -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")
}
+118
View File
@@ -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}
}
}
+273
View File
@@ -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
}
+165
View File
@@ -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))
}
+63
View File
@@ -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)
}
+286
View File
@@ -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
}
+64
View File
@@ -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
}
+109
View File
@@ -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()
}
+44
View File
@@ -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())
}
+22
View File
@@ -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)
}
+63
View File
@@ -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
}
+115
View File
@@ -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
}
+82
View File
@@ -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."),
)
}
+139
View File
@@ -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))
}
+296
View File
@@ -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
}
+30
View File
@@ -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
}
+65
View File
@@ -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
})
}
+48
View File
@@ -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
}
+58
View File
@@ -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"
}
+107
View File
@@ -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("│") + " "
}
}