mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 08:18:23 +02:00
@@ -0,0 +1,34 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- binary: settuings
|
||||||
|
main: ./cmd/settuings
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.version={{.Version}}
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- formats:
|
||||||
|
- tar.gz
|
||||||
|
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: checksums.txt
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
sort: asc
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- "^docs:"
|
||||||
|
- "^test:"
|
||||||
|
- "^ci:"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Everybody is invited and welcome to contribute. There is a lot to do... Check the issues!
|
||||||
|
|
||||||
|
The process is straight-forward.
|
||||||
|
|
||||||
|
- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1)
|
||||||
|
- Fork this git repository
|
||||||
|
- Write your changes (bug fixes, new features, ...).
|
||||||
|
- Create a Pull Request against the main branch.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ko_fi: anotherhadi
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: "🐞 Bug Report"
|
||||||
|
description: Report a reproducible error
|
||||||
|
title: "[BUG] "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Describe the bug
|
||||||
|
description: A clear and concise description of the issue.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Step-by-step instructions to trigger the bug.
|
||||||
|
placeholder: |
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
3. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: What should have happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Operating system
|
||||||
|
placeholder: "e.g. Ubuntu 24.04, macOS 15"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: settuings version
|
||||||
|
description: Output of `settuings -v`
|
||||||
|
placeholder: "e.g. v0.3.1"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Logs, screenshots, or anything else relevant.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: "🚀 Feature Request"
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
title: "[FEATURE] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
description: Is your feature request related to a problem? Describe it.
|
||||||
|
placeholder: "I'm always frustrated when..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: A clear description of what you want to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: Any alternative solutions or features you've thought about.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Mockups, related issues, or anything else relevant.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PATTERN = re.compile(r"<!-- exec: (.+?) -->.*?<!-- endexec -->", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def replace(match):
|
||||||
|
cmd = match.group(1).strip()
|
||||||
|
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||||
|
output = result.stdout
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[inject-exec] command failed ({result.returncode}): {cmd}", file=sys.stderr)
|
||||||
|
print(result.stderr, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
output = re.sub(r"<!-- exec: .+? -->\n?|<!-- endexec -->\n?", "", output)
|
||||||
|
if output and not output.endswith("\n"):
|
||||||
|
output += "\n"
|
||||||
|
return f"<!-- exec: {cmd} -->\n{output}<!-- endexec -->"
|
||||||
|
|
||||||
|
|
||||||
|
def process(path):
|
||||||
|
content = Path(path).read_text()
|
||||||
|
new_content = PATTERN.sub(replace, content)
|
||||||
|
if new_content != content:
|
||||||
|
Path(path).write_text(new_content)
|
||||||
|
print(f"[inject-exec] updated {path}")
|
||||||
|
|
||||||
|
|
||||||
|
for p in sys.argv[1:]:
|
||||||
|
process(p)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
version: "~> v2"
|
||||||
|
args: release --clean --config .github/.goreleaser.yaml
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
result/
|
||||||
|
.pre-commit-config.yaml
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hadi
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img alt="logo" src="./.github/assets/logo.png" width="120px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
# SetTUIngs
|
||||||
|
|
||||||
|
> A TUI to manage your Linux system settings like wifi, bluetooth, and more, without leaving the terminal.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
[](https://ko-fi.com/anotherhadi)
|
||||||
|
|
||||||
|
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
|
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||||
|
|
||||||
|
- [What is Spilltea?](#what-is-settuings)
|
||||||
|
- [Legal Disclaimer](#legal-disclaimer)
|
||||||
|
- [Features](#features)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Project Management](#project-management)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Per-project configuration](#per-project-configuration)
|
||||||
|
- [CLI Flags](#cli-flags)
|
||||||
|
- [Plugin System](#plugin-system)
|
||||||
|
- [Vim / Neovim Integration](#vim--neovim-integration)
|
||||||
|
- [Deployment](#deployment)
|
||||||
|
- [Tech Stack](#tech-stack)
|
||||||
|
|
||||||
|
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
|
|
||||||
|
## What is SetTUIngs?
|
||||||
|
|
||||||
|
SetTUIngs is a keyboard-driven TUI for the everyday Linux system settings you'd otherwise dig through a GUI panel for: WiFi, Ethernet, VPN, Bluetooth, and audio. Every page is a thin, readable wrapper around the CLI tools you already have installed (`nmcli`, `bluetoothctl`, and `wpctl`), so there's no background daemon or D-Bus client library involved, and it always reflects whatever changed things elsewhere (a NetworkManager applet, `pavucontrol`, another terminal).
|
||||||
|
|
||||||
|
<img alt="demo" src="./.github/assets/demo.gif" width="700" />
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **WiFi**: Scan, connect, and manage networks, including signal strength, security, saved passwords, and known networks, with the radio toggled on/off in one keystroke.
|
||||||
|
- **Ethernet & VPN**: Bring interfaces up or down and connect to VPN profiles from the same Network page.
|
||||||
|
- **Bluetooth**: Discover, pair, connect, trust, and forget devices, and power the adapter on/off.
|
||||||
|
- **Audio**: Adjust output/input volume, mute, and pick the default speaker or mic; preview a device with a test tone or a live input-level meter; control the volume of individual apps (browser tab, Spotify, etc.) independently.
|
||||||
|
- **Vim-like Navigation**: The entire interface is keyboard-driven with Vim-inspired shortcuts. Use `h/j/k/l` to move, `gg`/`G` to jump to the top/bottom, `/` to search, `q` to close panels, and more. All keybindings are fully customizable via the config file.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Go install</summary>
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install github.com/anotherhadi/settuings/cmd/settuings@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Go 1.22+. The binary will be placed in `$GOPATH/bin` (or `~/go/bin`).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Nix (temporary run, no install)</summary>
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run github:anotherhadi/settuings
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>NixOS (flake)</summary>
|
||||||
|
|
||||||
|
Add settuings to your flake inputs:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
inputs.settuings.url = "github:anotherhadi/settuings";
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the package to your system or home-manager packages:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
environment.systemPackages = [ inputs.settuings.packages.${pkgs.system}.default ];
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## CLI Flags
|
||||||
|
|
||||||
|
<!-- exec: echo '```' && go run ./cmd/settuings -h && echo '```' -->
|
||||||
|
|
||||||
|
```
|
||||||
|
A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
spilltea [flags]
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--add-default-config copy the default config file to the config path and exit
|
||||||
|
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||||
|
-c, --config string path to config file
|
||||||
|
-h, --help help for spilltea
|
||||||
|
--host string proxy host (overrides config)
|
||||||
|
--plugins-dir string path to plugins dir (overrides config)
|
||||||
|
-p, --port int proxy port (overrides config)
|
||||||
|
-P, --project string project name to open directly, or "tmp" for a temporary session
|
||||||
|
--ssl-insecure skip TLS certificate verification (overrides config)
|
||||||
|
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||||
|
-v, --version version for spilltea
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- endexec -->
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Component | Library |
|
||||||
|
| --------- | ------------------------------------------------------- |
|
||||||
|
| TUI | [bubbletea](https://github.com/charmbracelet/bubbletea) |
|
||||||
|
| Styles | [lipgloss](https://github.com/charmbracelet/lipgloss) |
|
||||||
|
| Config | [viper](https://github.com/spf13/viper) |
|
||||||
|
| Plugins | [gopher-lua](https://github.com/yuin/gopher-lua) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="https://github.com/anotherhadi/settuings">github</a> |
|
||||||
|
<a href="https://gitlab.com/anotherhadi_mirror/settuings">gitlab (mirror)</a> |
|
||||||
|
<a href="https://git.hadi.icu/anotherhadi/settuings">gitea (mirror)</a>
|
||||||
|
</div
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/anotherhadi/settuings/internal/config"
|
||||||
|
"github.com/anotherhadi/settuings/internal/icons"
|
||||||
|
"github.com/anotherhadi/settuings/internal/keys"
|
||||||
|
"github.com/anotherhadi/settuings/internal/style"
|
||||||
|
appUI "github.com/anotherhadi/settuings/internal/ui/app"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if version != "dev" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||||
|
version = info.Main.Version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
flagConfig string
|
||||||
|
flagAddDefaultConfig bool
|
||||||
|
flagPage string
|
||||||
|
)
|
||||||
|
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "settuings",
|
||||||
|
Short: "A TUI to manage your Linux system settings like wifi, bluetooth, and more, without leaving the terminal.",
|
||||||
|
Version: version,
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
cfgPath := filepath.Join(home, ".config", "settuings", "config.yaml")
|
||||||
|
if flagConfig != "" {
|
||||||
|
cfgPath = flagConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagAddDefaultConfig {
|
||||||
|
if err := config.WriteDefaultConfig(cfgPath); err != nil {
|
||||||
|
return fmt.Errorf("add-default-config: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("default config written to %s\n", cfgPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Load(cfgPath); err != nil {
|
||||||
|
return fmt.Errorf("config: %w", err)
|
||||||
|
}
|
||||||
|
config.Global.Version = version
|
||||||
|
|
||||||
|
style.Init()
|
||||||
|
icons.Init(config.Global)
|
||||||
|
keys.Init(config.Global)
|
||||||
|
|
||||||
|
m, err := appUI.New(flagPage)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
final, err := tea.NewProgram(m).Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tui: %w", err)
|
||||||
|
}
|
||||||
|
if app, ok := final.(appUI.Model); ok {
|
||||||
|
if ferr := app.FatalErr(); ferr != nil {
|
||||||
|
return ferr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.Flags().StringVarP(&flagConfig, "config", "c", "", "path to config file")
|
||||||
|
rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit")
|
||||||
|
rootCmd.Flags().StringVarP(&flagPage, "page", "p", "", fmt.Sprintf("page to open at launch (%s)", strings.Join(appUI.PageNames(), ", ")))
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
```txt
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⠀⢀⣴⣾⣦⣀⣀⣠⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⠀⠈⢻⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⠀⢀⣾⣿⡿⠋⠁⠈⠙⢿⣿⣷⣶⣶⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⡇⠀⠀⠀⠀⢸⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠘⠛⠛⠻⣿⣷⣤⣀⣀⣴⣿⣿⠏
|
||||||
|
⠀⠀⠀⠀⠀⣾⣿⣿⡿⠿⢿⣿⣿⣷
|
||||||
|
⠀⠀⠀⠀⠸⠿⣿⡿⠀⠀⠀⠻⠿⠋
|
||||||
|
```
|
||||||
|
|
||||||
|
# About
|
||||||
|
|
||||||
|
A TUI to manage your Linux system settings like wifi, bluetooth, and more, without leaving the terminal.
|
||||||
|
|
||||||
|
- **Version**: `{{.Cfg.Version}}`
|
||||||
|
- **Repository**: `https://github.com/anotherhadi/settuings`
|
||||||
|
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
|
||||||
|
|
||||||
|
## System
|
||||||
|
|
||||||
|
- **OS**: `{{.OS}}`
|
||||||
|
- **Kernel**: `{{.Kernel}}`
|
||||||
|
- **Hostname**: `{{.Hostname}}`
|
||||||
|
- **Architecture**: `{{.Arch}}`
|
||||||
Generated
+120
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1767039857,
|
||||||
|
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784288435,
|
||||||
|
"narHash": "sha256-ReRHaLgr/uVqdD8afFSn+myXIfpHeOhP0yYe0TJqAA8=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "43b3c1ab9d40fb1dbb008f451988a91e375825e9",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gomod2nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1770585520,
|
||||||
|
"narHash": "sha256-yBz9Ozd5Wb56i3e3cHZ8WcbzCQ9RlVaiW18qDYA/AzA=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "gomod2nix",
|
||||||
|
"rev": "1201ddd1279c35497754f016ef33d5e060f3da8d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "gomod2nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1786384358,
|
||||||
|
"narHash": "sha256-RzPPiWeUtuvymnpuEWsdtzli5w4kjZs49FqEs3/1u+I=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "2fcb964de67fcf60b43471c55d5d99e61a9ccb5a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"gomod2nix": "gomod2nix",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
description = "A TUI to manage your Linux system settings like wifi, bluetooth, and more, without leaving the terminal.";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
gomod2nix = {
|
||||||
|
url = "github:nix-community/gomod2nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
git-hooks = {
|
||||||
|
url = "github:cachix/git-hooks.nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = {
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
gomod2nix,
|
||||||
|
git-hooks,
|
||||||
|
}: let
|
||||||
|
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
||||||
|
|
||||||
|
forAllSystems = f:
|
||||||
|
nixpkgs.lib.genAttrs supportedSystems
|
||||||
|
(system: f system (import nixpkgs {inherit system;}));
|
||||||
|
in {
|
||||||
|
packages = forAllSystems (system: pkgs:
|
||||||
|
import ./nix/package.nix {
|
||||||
|
inherit pkgs;
|
||||||
|
buildGoApplication = gomod2nix.legacyPackages.${system}.buildGoApplication;
|
||||||
|
});
|
||||||
|
|
||||||
|
devShells = forAllSystems (system: pkgs: {
|
||||||
|
default = import ./nix/shell.nix {
|
||||||
|
inherit pkgs;
|
||||||
|
gitHooksLib = git-hooks.lib.${system};
|
||||||
|
gomod2nixPkgs = gomod2nix.legacyPackages.${system};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
module github.com/anotherhadi/settuings
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
charm.land/bubbles/v2 v2.1.0
|
||||||
|
charm.land/bubbletea/v2 v2.0.6
|
||||||
|
charm.land/glamour/v2 v2.0.0
|
||||||
|
charm.land/lipgloss/v2 v2.0.3
|
||||||
|
github.com/anotherhadi/ilovetui v0.1.6
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.7
|
||||||
|
github.com/sirupsen/logrus v1.9.4
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/net v0.55.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||||
|
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||||
|
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
|
||||||
|
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
|
||||||
|
charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
|
||||||
|
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
|
||||||
|
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||||
|
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||||
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
|
github.com/anotherhadi/ilovetui v0.1.6 h1:NKg+T1DpV08Q4r+iowFrXF+0bTd6Y2f4OFpFwhsfsyY=
|
||||||
|
github.com/anotherhadi/ilovetui v0.1.6/go.mod h1:HVai6u5NGKSMOpmioYpwrN0lSxQjc7HtISUc5hTwvOw=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 h1:pxGjlWZFcRQMWAdtjRelpL3Gbu8iYIyuO3Eqbd037Ow=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3/go.mod h1:SnKWaPaTnkTNXJgdgdquu66de12V8pW/b/qlTGaF9xg=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be h1:O22D2Od8gEsRGTDPKDTRzx2BGrvVcIAJlwBf+1sTeN0=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
|
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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[:])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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+"]")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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."),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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."),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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("│") + " "
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
schema = 3
|
||||||
|
|
||||||
|
[mod]
|
||||||
|
[mod.'charm.land/bubbles/v2']
|
||||||
|
version = 'v2.1.0'
|
||||||
|
hash = 'sha256-2OmqpBrl+taOJzAhVM6OReLmoYRxZOXx9JqFNjQjsPA='
|
||||||
|
|
||||||
|
[mod.'charm.land/bubbletea/v2']
|
||||||
|
version = 'v2.0.6'
|
||||||
|
hash = 'sha256-1jxXmcnI4peUE0Xs3HGe57pIhRONx235aAaeqm2r434='
|
||||||
|
|
||||||
|
[mod.'charm.land/glamour/v2']
|
||||||
|
version = 'v2.0.0'
|
||||||
|
hash = 'sha256-CZYlNGw2MihqnSHf1Xxqz55NnqW9fVpLxyvLItryIw4='
|
||||||
|
|
||||||
|
[mod.'charm.land/lipgloss/v2']
|
||||||
|
version = 'v2.0.3'
|
||||||
|
hash = 'sha256-/RFkSUscU3NwymzT+PfizGf3XyQIdVGQlX7vxktCUGk='
|
||||||
|
|
||||||
|
[mod.'github.com/alecthomas/chroma/v2']
|
||||||
|
version = 'v2.24.1'
|
||||||
|
hash = 'sha256-DufsljWRKireFuLFcnPozuF0N3UoRYGlEfNFMD+z0ng='
|
||||||
|
|
||||||
|
[mod.'github.com/andybalholm/brotli']
|
||||||
|
version = 'v1.0.4'
|
||||||
|
hash = 'sha256-gAnPRdGP4yna4hiRIEDyBtDOVJqd7RU27wlPu96Rdf8='
|
||||||
|
|
||||||
|
[mod.'github.com/anotherhadi/ilovetui']
|
||||||
|
version = 'v0.1.6'
|
||||||
|
hash = 'sha256-7E+7UFks5vM3XWCvX2joFmHGcW7qqnoox6ZPFglaLO4='
|
||||||
|
|
||||||
|
[mod.'github.com/atotto/clipboard']
|
||||||
|
version = 'v0.1.4'
|
||||||
|
hash = 'sha256-ZZ7U5X0gWOu8zcjZcWbcpzGOGdycwq0TjTFh/eZHjXk='
|
||||||
|
|
||||||
|
[mod.'github.com/aymerick/douceur']
|
||||||
|
version = 'v0.2.0'
|
||||||
|
hash = 'sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/colorprofile']
|
||||||
|
version = 'v0.4.3'
|
||||||
|
hash = 'sha256-y+QDUxGOKhugEMQLRUTZYT2C+wKqYHnMLJ44jbh7+JA='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/ultraviolet']
|
||||||
|
version = 'v0.0.0-20260511121909-c840852527f3'
|
||||||
|
hash = 'sha256-oWEEaaetCmXvflD03MWy8qD105Kd62iI62+lxofE9Ns='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/ansi']
|
||||||
|
version = 'v0.11.7'
|
||||||
|
hash = 'sha256-q8BZJq4K7NE5ETocN9/G/EoV0dUyD703ONSfHiUYzWQ='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/exp/slice']
|
||||||
|
version = 'v0.0.0-20260517005351-920740d613be'
|
||||||
|
hash = 'sha256-bMsbEzP1gHA2OJx4zMYZUI3UFhcTG+mcFm8rRY+Khh8='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/term']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-KF7IU1Luxl/sZP6XjomWB2e3lxSUS4/5AahhapGir/4='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/termios']
|
||||||
|
version = 'v0.1.1'
|
||||||
|
hash = 'sha256-sri3LpHCBhGvnJldDzBxwbbZpeSGZVCJFOUL45uBFds='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/windows']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-CvmE8kAC5wlPSeWjl2hc5xizvGS2FeOLHw84froldkk='
|
||||||
|
|
||||||
|
[mod.'github.com/clipperhouse/displaywidth']
|
||||||
|
version = 'v0.11.0'
|
||||||
|
hash = 'sha256-WokyTaofEy95xlshqK5YDzpemhXV5oaQifxS9YyfCXo='
|
||||||
|
|
||||||
|
[mod.'github.com/clipperhouse/uax29/v2']
|
||||||
|
version = 'v2.7.0'
|
||||||
|
hash = 'sha256-GO3az7WiGcwU0OvmocwdfR5ohGRL8NbjscIaMyhAdxE='
|
||||||
|
|
||||||
|
[mod.'github.com/dlclark/regexp2']
|
||||||
|
version = 'v1.12.0'
|
||||||
|
hash = 'sha256-PVX2rDCkiG0vyA1CbDi3bzLeZ2T8hcqJv3pZ5YwGzMI='
|
||||||
|
|
||||||
|
[mod.'github.com/dustin/go-humanize']
|
||||||
|
version = 'v1.0.1'
|
||||||
|
hash = 'sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc='
|
||||||
|
|
||||||
|
[mod.'github.com/fsnotify/fsnotify']
|
||||||
|
version = 'v1.10.1'
|
||||||
|
hash = 'sha256-6LBLgsh4nKkMpgRKVsYFEaGDSU1fncBcWVSjKBdfgjU='
|
||||||
|
|
||||||
|
[mod.'github.com/go-viper/mapstructure/v2']
|
||||||
|
version = 'v2.5.0'
|
||||||
|
hash = 'sha256-LbrCBANBprVI84M0CWrXc7rriJL5ac5VKbh58LBTw7U='
|
||||||
|
|
||||||
|
[mod.'github.com/golang/groupcache']
|
||||||
|
version = 'v0.0.0-20210331224755-41bb18bfe9da'
|
||||||
|
hash = 'sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0='
|
||||||
|
|
||||||
|
[mod.'github.com/google/uuid']
|
||||||
|
version = 'v1.6.0'
|
||||||
|
hash = 'sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw='
|
||||||
|
|
||||||
|
[mod.'github.com/gorilla/css']
|
||||||
|
version = 'v1.0.1'
|
||||||
|
hash = 'sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A='
|
||||||
|
|
||||||
|
[mod.'github.com/gorilla/websocket']
|
||||||
|
version = 'v1.5.0'
|
||||||
|
hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc='
|
||||||
|
|
||||||
|
[mod.'github.com/inconshreveable/mousetrap']
|
||||||
|
version = 'v1.1.0'
|
||||||
|
hash = 'sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE='
|
||||||
|
|
||||||
|
[mod.'github.com/klauspost/compress']
|
||||||
|
version = 'v1.17.8'
|
||||||
|
hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs='
|
||||||
|
|
||||||
|
[mod.'github.com/lqqyt2423/go-mitmproxy']
|
||||||
|
version = 'v1.8.11'
|
||||||
|
hash = 'sha256-PoCL6TOt99JsenH4XFx+7MqIzzYnnLHYwUpEQ7kxxyg='
|
||||||
|
|
||||||
|
[mod.'github.com/lucasb-eyer/go-colorful']
|
||||||
|
version = 'v1.4.0'
|
||||||
|
hash = 'sha256-i/3GDHKEMLCy0kc3mtyk58UWYOPmKoUVaq6QCAWXKP0='
|
||||||
|
|
||||||
|
[mod.'github.com/mattn/go-isatty']
|
||||||
|
version = 'v0.0.22'
|
||||||
|
hash = 'sha256-6O/0jc33pKUzlzUGpH8Ekk54XgJvx6Qe7kJtbcNJAV4='
|
||||||
|
|
||||||
|
[mod.'github.com/mattn/go-runewidth']
|
||||||
|
version = 'v0.0.23'
|
||||||
|
hash = 'sha256-SmChZ2U1aR8pW3LPhdM7KcVF5TO6VcHgRzBtUXbBWJA='
|
||||||
|
|
||||||
|
[mod.'github.com/microcosm-cc/bluemonday']
|
||||||
|
version = 'v1.0.27'
|
||||||
|
hash = 'sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es='
|
||||||
|
|
||||||
|
[mod.'github.com/muesli/cancelreader']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ='
|
||||||
|
|
||||||
|
[mod.'github.com/ncruces/go-strftime']
|
||||||
|
version = 'v1.0.0'
|
||||||
|
hash = 'sha256-GYIwYDONuv/yTE0AEugCHQbtV3oiBaco93xUNYFcVBQ='
|
||||||
|
|
||||||
|
[mod.'github.com/pelletier/go-toml/v2']
|
||||||
|
version = 'v2.3.1'
|
||||||
|
hash = 'sha256-5H8+UOtPOs+Yc+8oVT/3bugCCdbq3jFMH6eOW8dadyg='
|
||||||
|
|
||||||
|
[mod.'github.com/remyoudompheng/bigfft']
|
||||||
|
version = 'v0.0.0-20230129092748-24d4a6f8daec'
|
||||||
|
hash = 'sha256-vYmpyCE37eBYP/navhaLV4oX4/nu0Z/StAocLIFqrmM='
|
||||||
|
|
||||||
|
[mod.'github.com/rivo/uniseg']
|
||||||
|
version = 'v0.4.7'
|
||||||
|
hash = 'sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo='
|
||||||
|
|
||||||
|
[mod.'github.com/sagikazarmark/locafero']
|
||||||
|
version = 'v0.12.0'
|
||||||
|
hash = 'sha256-EXk9S5Z5sYyApAzCgHIugsGMbt/pHWRfHYFZH5D+5Ws='
|
||||||
|
|
||||||
|
[mod.'github.com/sahilm/fuzzy']
|
||||||
|
version = 'v0.1.1'
|
||||||
|
hash = 'sha256-f2VsDI6G+V2w31tSDzbZPi9EI2E7jRV6Aq8yeOorSZY='
|
||||||
|
|
||||||
|
[mod.'github.com/satori/go.uuid']
|
||||||
|
version = 'v1.2.0'
|
||||||
|
hash = 'sha256-y/lSGbnZa7mYJCs30a3LTyjfCFQSaYp8GbVR8dwtmsg='
|
||||||
|
|
||||||
|
[mod.'github.com/sirupsen/logrus']
|
||||||
|
version = 'v1.9.4'
|
||||||
|
hash = 'sha256-ltRvmtM3XTCAFwY0IesfRqYIivyXPPuvkFjL4ARh1wg='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/afero']
|
||||||
|
version = 'v1.15.0'
|
||||||
|
hash = 'sha256-LhcezbOqfuBzacytbqck0hNUxi6NbWNhifUc5/9uHQ8='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/cast']
|
||||||
|
version = 'v1.10.0'
|
||||||
|
hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/cobra']
|
||||||
|
version = 'v1.10.2'
|
||||||
|
hash = 'sha256-nbRCTFiDCC2jKK7AHi79n7urYCMP5yDZnWtNVJrDi+k='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/pflag']
|
||||||
|
version = 'v1.0.10'
|
||||||
|
hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/viper']
|
||||||
|
version = 'v1.21.0'
|
||||||
|
hash = 'sha256-A9A8i7HH/ge4j3hw7G++HNj8BjhhpZKvxHhfY+QAxkI='
|
||||||
|
|
||||||
|
[mod.'github.com/subosito/gotenv']
|
||||||
|
version = 'v1.6.0'
|
||||||
|
hash = 'sha256-LspbjTniiq2xAICSXmgqP7carwlNaLqnCTQfw2pa80A='
|
||||||
|
|
||||||
|
[mod.'github.com/xo/terminfo']
|
||||||
|
version = 'v0.0.0-20220910002029-abceb7e1c41e'
|
||||||
|
hash = 'sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/goldmark']
|
||||||
|
version = 'v1.8.2'
|
||||||
|
hash = 'sha256-LoWfW1Tb6mNuMR7SoA/4SJv4pTKfsVXqeXEVm4uEQ7Q='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/goldmark-emoji']
|
||||||
|
version = 'v1.0.6'
|
||||||
|
hash = 'sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/gopher-lua']
|
||||||
|
version = 'v1.1.2'
|
||||||
|
hash = 'sha256-2YMCxv7RO3uqq6OTjvE0kR9nTt+n2cF+MrLtGEW68po='
|
||||||
|
|
||||||
|
[mod.'go.uber.org/atomic']
|
||||||
|
version = 'v1.11.0'
|
||||||
|
hash = 'sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY='
|
||||||
|
|
||||||
|
[mod.'go.yaml.in/yaml/v3']
|
||||||
|
version = 'v3.0.4'
|
||||||
|
hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/net']
|
||||||
|
version = 'v0.55.0'
|
||||||
|
hash = 'sha256-Phi2mSmBGOJcvqPPAit3uqF3UP8SKRI9dHj6yTM3s5s='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/sync']
|
||||||
|
version = 'v0.20.0'
|
||||||
|
hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/sys']
|
||||||
|
version = 'v0.45.0'
|
||||||
|
hash = 'sha256-hkBoNazrDA67ER6sWhb+EKxx9nJ24+nz3zGy+zT5Hvw='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/text']
|
||||||
|
version = 'v0.37.0'
|
||||||
|
hash = 'sha256-8XDOnlPIybcDRy89fkjG5VqtIt5Ku+LmaqYhgKl7i1E='
|
||||||
|
|
||||||
|
[mod.'gopkg.in/yaml.v3']
|
||||||
|
version = 'v3.0.1'
|
||||||
|
hash = 'sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU='
|
||||||
|
|
||||||
|
[mod.'modernc.org/libc']
|
||||||
|
version = 'v1.72.3'
|
||||||
|
hash = 'sha256-eOCoqSnX/VzpG63nh1j3JpXRndnZZcn2cDTeutexXAI='
|
||||||
|
|
||||||
|
[mod.'modernc.org/mathutil']
|
||||||
|
version = 'v1.7.1'
|
||||||
|
hash = 'sha256-COZ5rF2GhQVR1r6a0DanJ8qwQ94JSKdQxTMWrDzE0Cc='
|
||||||
|
|
||||||
|
[mod.'modernc.org/memory']
|
||||||
|
version = 'v1.11.0'
|
||||||
|
hash = 'sha256-MkybF8vvrxXS5j7O8w3skwTo0aMo1yjWS0K440rYcHM='
|
||||||
|
|
||||||
|
[mod.'modernc.org/sqlite']
|
||||||
|
version = 'v1.50.1'
|
||||||
|
hash = 'sha256-JbGP3eerH/6mgzI0aq9SpLLwe6ld6PV/zJReG8mLQN0='
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user