mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 16:18:24 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e3d86e811 | ||
|
|
7067717b51 | ||
|
|
d54817d62c |
@@ -16,17 +16,10 @@
|
|||||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||||
|
|
||||||
- [What is Spilltea?](#what-is-settuings)
|
- [What is SetTUIngs?](#what-is-settuings)
|
||||||
- [Legal Disclaimer](#legal-disclaimer)
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Project Management](#project-management)
|
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [Per-project configuration](#per-project-configuration)
|
|
||||||
- [CLI Flags](#cli-flags)
|
- [CLI Flags](#cli-flags)
|
||||||
- [Plugin System](#plugin-system)
|
|
||||||
- [Vim / Neovim Integration](#vim--neovim-integration)
|
|
||||||
- [Deployment](#deployment)
|
|
||||||
- [Tech Stack](#tech-stack)
|
- [Tech Stack](#tech-stack)
|
||||||
|
|
||||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
@@ -43,6 +36,7 @@ SetTUIngs is a keyboard-driven TUI for the everyday Linux system settings you'd
|
|||||||
- **Ethernet & VPN**: Bring interfaces up or down and connect to VPN profiles from the same Network page.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
- **Power**: Switch power profiles (performance, balanced, power-saver) and check battery charge, health, and time remaining at a glance.
|
||||||
- **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.
|
- **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
|
## Installation
|
||||||
@@ -87,27 +81,19 @@ environment.systemPackages = [ inputs.settuings.packages.${pkgs.system}.default
|
|||||||
## CLI Flags
|
## CLI Flags
|
||||||
|
|
||||||
<!-- exec: echo '```' && go run ./cmd/settuings -h && echo '```' -->
|
<!-- exec: echo '```' && go run ./cmd/settuings -h && echo '```' -->
|
||||||
|
|
||||||
```
|
```
|
||||||
A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
A TUI to manage your Linux system settings like wifi, bluetooth, and more, without leaving the terminal.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
spilltea [flags]
|
settuings [flags]
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--add-default-config copy the default config file to the config path and exit
|
--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
|
||||||
-c, --config string path to config file
|
-h, --help help for settuings
|
||||||
-h, --help help for spilltea
|
-p, --page string page to open at launch (About, Network, Bluetooth, Audio, Power)
|
||||||
--host string proxy host (overrides config)
|
-v, --version version for settuings
|
||||||
--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 -->
|
<!-- endexec -->
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ type Config struct {
|
|||||||
} `mapstructure:"app"`
|
} `mapstructure:"app"`
|
||||||
|
|
||||||
TUI struct {
|
TUI struct {
|
||||||
UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"`
|
UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"`
|
||||||
DefaultSidebarState string `mapstructure:"default_sidebar_state"`
|
DefaultSidebarState string `mapstructure:"default_sidebar_state"`
|
||||||
|
HiddenPages []string `mapstructure:"hidden_pages"`
|
||||||
} `mapstructure:"tui"`
|
} `mapstructure:"tui"`
|
||||||
|
|
||||||
Keybindings Keybindings `mapstructure:"keybindings"`
|
Keybindings Keybindings `mapstructure:"keybindings"`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ app:
|
|||||||
tui:
|
tui:
|
||||||
use_nerdfont_icons: true
|
use_nerdfont_icons: true
|
||||||
default_sidebar_state: "expanded" # hidden, collapsed, expanded
|
default_sidebar_state: "expanded" # hidden, collapsed, expanded
|
||||||
|
hidden_pages: [] # page names to hide from the sidebar, e.g. ["Audio", "Power"]
|
||||||
|
|
||||||
keybindings:
|
keybindings:
|
||||||
global:
|
global:
|
||||||
@@ -54,3 +55,7 @@ keybindings:
|
|||||||
refresh: "r"
|
refresh: "r"
|
||||||
mute: "m"
|
mute: "m"
|
||||||
test: "t"
|
test: "t"
|
||||||
|
|
||||||
|
power:
|
||||||
|
select: "enter"
|
||||||
|
refresh: "r"
|
||||||
|
|||||||
@@ -54,10 +54,16 @@ type AudioKeys struct {
|
|||||||
Test string `mapstructure:"test"`
|
Test string `mapstructure:"test"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PowerKeys struct {
|
||||||
|
Select string `mapstructure:"select"`
|
||||||
|
Refresh string `mapstructure:"refresh"`
|
||||||
|
}
|
||||||
|
|
||||||
type Keybindings struct {
|
type Keybindings struct {
|
||||||
Global GlobalKeys `mapstructure:"global"`
|
Global GlobalKeys `mapstructure:"global"`
|
||||||
Docs DocsKeys `mapstructure:"docs"`
|
Docs DocsKeys `mapstructure:"docs"`
|
||||||
Network NetworkKeys `mapstructure:"network"`
|
Network NetworkKeys `mapstructure:"network"`
|
||||||
Bluetooth BluetoothKeys `mapstructure:"bluetooth"`
|
Bluetooth BluetoothKeys `mapstructure:"bluetooth"`
|
||||||
Audio AudioKeys `mapstructure:"audio"`
|
Audio AudioKeys `mapstructure:"audio"`
|
||||||
|
Power PowerKeys `mapstructure:"power"`
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -11,6 +11,7 @@ type Icons struct {
|
|||||||
Network string
|
Network string
|
||||||
Bluetooth string
|
Bluetooth string
|
||||||
Audio string
|
Audio string
|
||||||
|
Power string
|
||||||
|
|
||||||
Wifi string
|
Wifi string
|
||||||
Ethernet string
|
Ethernet string
|
||||||
@@ -18,9 +19,10 @@ type Icons struct {
|
|||||||
Check string
|
Check string
|
||||||
Lock string
|
Lock string
|
||||||
|
|
||||||
Output string
|
Output string
|
||||||
Input string
|
Input string
|
||||||
Apps string
|
Apps string
|
||||||
|
Battery string
|
||||||
}
|
}
|
||||||
|
|
||||||
var I *Icons
|
var I *Icons
|
||||||
@@ -36,6 +38,7 @@ func Init(cfg *config.Config) {
|
|||||||
Network: " ",
|
Network: " ",
|
||||||
Bluetooth: " ",
|
Bluetooth: " ",
|
||||||
Audio: " ",
|
Audio: " ",
|
||||||
|
Power: " ",
|
||||||
|
|
||||||
Wifi: " ",
|
Wifi: " ",
|
||||||
Ethernet: " ",
|
Ethernet: " ",
|
||||||
@@ -43,9 +46,10 @@ func Init(cfg *config.Config) {
|
|||||||
Check: " ",
|
Check: " ",
|
||||||
Lock: " ",
|
Lock: " ",
|
||||||
|
|
||||||
Output: " ",
|
Output: " ",
|
||||||
Input: " ",
|
Input: " ",
|
||||||
Apps: " ",
|
Apps: " ",
|
||||||
|
Battery: " ",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
I = &Icons{}
|
I = &Icons{}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type KeyMap struct {
|
|||||||
Network NetworkKeyMap
|
Network NetworkKeyMap
|
||||||
Bluetooth BluetoothKeyMap
|
Bluetooth BluetoothKeyMap
|
||||||
Audio AudioKeyMap
|
Audio AudioKeyMap
|
||||||
|
Power PowerKeyMap
|
||||||
}
|
}
|
||||||
|
|
||||||
var Keys *KeyMap
|
var Keys *KeyMap
|
||||||
@@ -25,6 +26,7 @@ func Init(cfg *config.Config) {
|
|||||||
Network: newNetworkKeyMap(kb.Network),
|
Network: newNetworkKeyMap(kb.Network),
|
||||||
Bluetooth: newBluetoothKeyMap(kb.Bluetooth),
|
Bluetooth: newBluetoothKeyMap(kb.Bluetooth),
|
||||||
Audio: newAudioKeyMap(kb.Audio),
|
Audio: newAudioKeyMap(kb.Audio),
|
||||||
|
Power: newPowerKeyMap(kb.Power),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package keys
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
"github.com/anotherhadi/settuings/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PowerKeyMap struct {
|
||||||
|
Select key.Binding
|
||||||
|
Refresh key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPowerKeyMap(cfg config.PowerKeys) PowerKeyMap {
|
||||||
|
return PowerKeyMap{
|
||||||
|
Select: binding(cfg.Select, "apply profile"),
|
||||||
|
Refresh: binding(cfg.Refresh, "refresh"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PowerKeyMap) Bindings() []key.Binding {
|
||||||
|
return []key.Binding{p.Select, p.Refresh}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package power
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const powerSupplyPath = "/sys/class/power_supply"
|
||||||
|
|
||||||
|
// Battery holds the readable state of one system battery. Peripheral
|
||||||
|
// batteries (mouse, keyboard, ...) report scope "Device" in sysfs and are
|
||||||
|
// filtered out by ReadBatteries.
|
||||||
|
type Battery struct {
|
||||||
|
Name string
|
||||||
|
Status string // Charging, Discharging, Full, Not charging, Unknown
|
||||||
|
Capacity int // percent, 0-100; -1 if unavailable
|
||||||
|
Health int // percent of design capacity still available; -1 if unavailable
|
||||||
|
TimeToEmpty string // formatted estimate ("2h15m"); empty if not computable
|
||||||
|
TimeToFull string
|
||||||
|
Technology string
|
||||||
|
CycleCount int // -1 if unavailable
|
||||||
|
Manufacturer string
|
||||||
|
Model string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACStatus reports whether external power (AC adapter, USB-PD, dock, ...)
|
||||||
|
// is present and delivering power.
|
||||||
|
type ACStatus struct {
|
||||||
|
Present bool
|
||||||
|
Online bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBatteries scans /sys/class/power_supply for system battery devices.
|
||||||
|
func ReadBatteries() ([]Battery, error) {
|
||||||
|
entries, err := os.ReadDir(powerSupplyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var batteries []Battery
|
||||||
|
for _, e := range entries {
|
||||||
|
dir := filepath.Join(powerSupplyPath, e.Name())
|
||||||
|
if readAttr(dir, "type") != "Battery" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if readAttr(dir, "scope") == "Device" {
|
||||||
|
continue // peripheral battery (mouse, keyboard, ...), not this machine's
|
||||||
|
}
|
||||||
|
if readAttr(dir, "present") == "0" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batteries = append(batteries, readBattery(dir, e.Name()))
|
||||||
|
}
|
||||||
|
sort.Slice(batteries, func(i, j int) bool { return batteries[i].Name < batteries[j].Name })
|
||||||
|
return batteries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBattery(dir, name string) Battery {
|
||||||
|
status := readAttrDefault(dir, "status", "Unknown")
|
||||||
|
|
||||||
|
// Prefer energy_* (µWh); fall back to charge_* (µAh) for fuel gauges
|
||||||
|
// that only report the latter. The ratio math below (percent, hours)
|
||||||
|
// is unit-agnostic as long as full/now/rate come from the same family.
|
||||||
|
full := readAttrInt(dir, "energy_full", 0)
|
||||||
|
design := readAttrInt(dir, "energy_full_design", 0)
|
||||||
|
now := readAttrInt(dir, "energy_now", 0)
|
||||||
|
rate := readAttrInt(dir, "power_now", 0)
|
||||||
|
if full == 0 {
|
||||||
|
full = readAttrInt(dir, "charge_full", 0)
|
||||||
|
design = readAttrInt(dir, "charge_full_design", 0)
|
||||||
|
now = readAttrInt(dir, "charge_now", 0)
|
||||||
|
rate = readAttrInt(dir, "current_now", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
capacity := readAttrInt(dir, "capacity", -1)
|
||||||
|
if capacity < 0 && full > 0 {
|
||||||
|
capacity = now * 100 / full
|
||||||
|
}
|
||||||
|
|
||||||
|
health := -1
|
||||||
|
if full > 0 && design > 0 {
|
||||||
|
health = full * 100 / design
|
||||||
|
}
|
||||||
|
|
||||||
|
b := Battery{
|
||||||
|
Name: name,
|
||||||
|
Status: status,
|
||||||
|
Capacity: capacity,
|
||||||
|
Health: health,
|
||||||
|
Technology: readAttr(dir, "technology"),
|
||||||
|
CycleCount: readAttrInt(dir, "cycle_count", -1),
|
||||||
|
Manufacturer: readAttr(dir, "manufacturer"),
|
||||||
|
Model: readAttr(dir, "model_name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if rate > 0 {
|
||||||
|
switch status {
|
||||||
|
case "Discharging":
|
||||||
|
b.TimeToEmpty = formatHours(float64(now) / float64(rate))
|
||||||
|
case "Charging":
|
||||||
|
if full > now {
|
||||||
|
b.TimeToFull = formatHours(float64(full-now) / float64(rate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAC reports whether any Mains/USB power-supply device is present, and
|
||||||
|
// whether at least one of them is actively delivering power. Desktops with
|
||||||
|
// no AC-detection device at all get a zero-value, not-present ACStatus.
|
||||||
|
func ReadAC() (ACStatus, error) {
|
||||||
|
entries, err := os.ReadDir(powerSupplyPath)
|
||||||
|
if err != nil {
|
||||||
|
return ACStatus{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var status ACStatus
|
||||||
|
for _, e := range entries {
|
||||||
|
dir := filepath.Join(powerSupplyPath, e.Name())
|
||||||
|
t := readAttr(dir, "type")
|
||||||
|
if t != "Mains" && t != "USB" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
online := readAttr(dir, "online")
|
||||||
|
if online == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status.Present = true
|
||||||
|
if online == "1" {
|
||||||
|
status.Online = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatHours(hours float64) string {
|
||||||
|
if hours <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
totalMinutes := int(hours*60 + 0.5)
|
||||||
|
return fmt.Sprintf("%dh%02dm", totalMinutes/60, totalMinutes%60)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAttr(dir, name string) string {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, name)) // #nosec G304 -- dir is enumerated from /sys/class/power_supply, name is a fixed attribute
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAttrDefault(dir, name, def string) string {
|
||||||
|
if v := readAttr(dir, name); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAttrInt(dir, name string, def int) int {
|
||||||
|
v := readAttr(dir, name)
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Package power reads and controls the active power profile (via
|
||||||
|
// powerprofilesctl / power-profiles-daemon) and reports battery/AC status.
|
||||||
|
// Profile control shells out to a CLI tool, in the same spirit as the
|
||||||
|
// bluetooth and audio packages; battery status has no universally-installed
|
||||||
|
// CLI equivalent, so it's read directly from /sys/class/power_supply.
|
||||||
|
package power
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("powerprofilesctl not found in PATH")
|
||||||
|
|
||||||
|
const defaultTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
// Profile is one entry from `powerprofilesctl list`.
|
||||||
|
type Profile struct {
|
||||||
|
Name string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAvailable reports whether powerprofilesctl is installed and
|
||||||
|
// power-profiles-daemon is reachable. Call it before using the rest of
|
||||||
|
// this file's functions.
|
||||||
|
func CheckAvailable() error {
|
||||||
|
if _, err := exec.LookPath("powerprofilesctl"); err != nil {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
_, err := run("get")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProfiles returns the available profiles in the order reported by
|
||||||
|
// powerprofilesctl, each flagged with whether it's currently active.
|
||||||
|
func ListProfiles() ([]Profile, error) {
|
||||||
|
out, err := run("list")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var profiles []Profile
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
// Top-level profile entries are indented two spaces (or "* " when
|
||||||
|
// active); their attribute lines (Driver:, Degraded:, ...) are
|
||||||
|
// indented four, so those are skipped here.
|
||||||
|
if line == "" || strings.HasPrefix(line, " ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
active := false
|
||||||
|
trimmed := line
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(trimmed, "* "):
|
||||||
|
active = true
|
||||||
|
trimmed = trimmed[2:]
|
||||||
|
case strings.HasPrefix(trimmed, " "):
|
||||||
|
trimmed = trimmed[2:]
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSuffix(trimmed, ":")
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
profiles = append(profiles, Profile{Name: name, Active: active})
|
||||||
|
}
|
||||||
|
return profiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActive switches the active power profile.
|
||||||
|
func SetActive(name string) error {
|
||||||
|
_, err := run("set", name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args ...string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "powerprofilesctl", args...) // #nosec G204 -- args are fixed powerprofilesctl subcommands or profile names read back from `powerprofilesctl list`, 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("powerprofilesctl %s: %s", strings.Join(args, " "), msg)
|
||||||
|
}
|
||||||
|
return stdout.String(), nil
|
||||||
|
}
|
||||||
@@ -14,22 +14,28 @@ import (
|
|||||||
bluetoothUI "github.com/anotherhadi/settuings/internal/ui/bluetooth"
|
bluetoothUI "github.com/anotherhadi/settuings/internal/ui/bluetooth"
|
||||||
notificationsUI "github.com/anotherhadi/settuings/internal/ui/components/notifications"
|
notificationsUI "github.com/anotherhadi/settuings/internal/ui/components/notifications"
|
||||||
networkUI "github.com/anotherhadi/settuings/internal/ui/network"
|
networkUI "github.com/anotherhadi/settuings/internal/ui/network"
|
||||||
|
powerUI "github.com/anotherhadi/settuings/internal/ui/power"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
var pageShortcuts = func() map[string]page {
|
// buildPageShortcuts numbers the visible pages 1..N in sidebar order, so
|
||||||
m := make(map[string]page, len(pageRegistry))
|
// hiding a page never leaves a gap in the shortcuts. It must run after
|
||||||
for i, e := range pageRegistry {
|
// config.Load, hence a function called from New rather than a package var.
|
||||||
|
func buildPageShortcuts() map[string]page {
|
||||||
|
visible := visiblePages()
|
||||||
|
m := make(map[string]page, len(visible))
|
||||||
|
for i, e := range visible {
|
||||||
m[strconv.Itoa(i+1)] = e.id
|
m[strconv.Itoa(i+1)] = e.id
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}()
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
page page
|
page page
|
||||||
logPath string
|
pageShortcuts map[string]page
|
||||||
fatalErr error
|
logPath string
|
||||||
logFileErr error
|
fatalErr error
|
||||||
|
logFileErr error
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
@@ -38,6 +44,7 @@ type Model struct {
|
|||||||
network networkUI.Model
|
network networkUI.Model
|
||||||
bluetooth bluetoothUI.Model
|
bluetooth bluetoothUI.Model
|
||||||
audio audioUI.Model
|
audio audioUI.Model
|
||||||
|
power powerUI.Model
|
||||||
notifications notificationsUI.Model
|
notifications notificationsUI.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,10 +68,12 @@ func New(initialPage string) (Model, error) {
|
|||||||
|
|
||||||
m := Model{
|
m := Model{
|
||||||
page: startPage,
|
page: startPage,
|
||||||
|
pageShortcuts: buildPageShortcuts(),
|
||||||
about: aboutUI.New(),
|
about: aboutUI.New(),
|
||||||
network: networkUI.New(),
|
network: networkUI.New(),
|
||||||
bluetooth: bluetoothUI.New(),
|
bluetooth: bluetoothUI.New(),
|
||||||
audio: audioUI.New(),
|
audio: audioUI.New(),
|
||||||
|
power: powerUI.New(),
|
||||||
notifications: notificationsUI.New(),
|
notifications: notificationsUI.New(),
|
||||||
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
|
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
|
||||||
logPath: logPath,
|
logPath: logPath,
|
||||||
@@ -83,7 +92,7 @@ func New(initialPage string) (Model, error) {
|
|||||||
func (m Model) FatalErr() error { return m.fatalErr }
|
func (m Model) FatalErr() error { return m.fatalErr }
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
cmds := []tea.Cmd{m.about.Init(), m.network.Init(), m.bluetooth.Init(), m.audio.Init()}
|
cmds := []tea.Cmd{m.about.Init(), m.network.Init(), m.bluetooth.Init(), m.audio.Init(), m.power.Init()}
|
||||||
if m.page != pageAbout {
|
if m.page != pageAbout {
|
||||||
cmds = append(cmds, m.activatePage(m.page))
|
cmds = append(cmds, m.activatePage(m.page))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/anotherhadi/settuings/internal/config"
|
||||||
"github.com/anotherhadi/settuings/internal/icons"
|
"github.com/anotherhadi/settuings/internal/icons"
|
||||||
aboutUI "github.com/anotherhadi/settuings/internal/ui/about"
|
aboutUI "github.com/anotherhadi/settuings/internal/ui/about"
|
||||||
audioUI "github.com/anotherhadi/settuings/internal/ui/audio"
|
audioUI "github.com/anotherhadi/settuings/internal/ui/audio"
|
||||||
bluetoothUI "github.com/anotherhadi/settuings/internal/ui/bluetooth"
|
bluetoothUI "github.com/anotherhadi/settuings/internal/ui/bluetooth"
|
||||||
networkUI "github.com/anotherhadi/settuings/internal/ui/network"
|
networkUI "github.com/anotherhadi/settuings/internal/ui/network"
|
||||||
|
powerUI "github.com/anotherhadi/settuings/internal/ui/power"
|
||||||
)
|
)
|
||||||
|
|
||||||
type page string
|
type page string
|
||||||
@@ -18,6 +20,7 @@ const (
|
|||||||
pageNetwork page = "Network"
|
pageNetwork page = "Network"
|
||||||
pageBluetooth page = "Bluetooth"
|
pageBluetooth page = "Bluetooth"
|
||||||
pageAudio page = "Audio"
|
pageAudio page = "Audio"
|
||||||
|
pagePower page = "Power"
|
||||||
)
|
)
|
||||||
|
|
||||||
// pageEntry describes a page and all its integration hooks.
|
// pageEntry describes a page and all its integration hooks.
|
||||||
@@ -103,6 +106,20 @@ var pageRegistry = []pageEntry{
|
|||||||
activate: func(m *Model) tea.Cmd { return m.audio.Activate() },
|
activate: func(m *Model) tea.Cmd { return m.audio.Activate() },
|
||||||
deactivate: func(m *Model) { m.audio.Deactivate() },
|
deactivate: func(m *Model) { m.audio.Deactivate() },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: pagePower,
|
||||||
|
icon: func() string { return icons.I.Power },
|
||||||
|
|
||||||
|
render: func(m *Model) string { return m.power.View().Content },
|
||||||
|
update: func(m *Model, msg tea.Msg) tea.Cmd {
|
||||||
|
updated, cmd := m.power.Update(msg)
|
||||||
|
m.power = updated.(powerUI.Model)
|
||||||
|
return cmd
|
||||||
|
},
|
||||||
|
isEditing: func(m *Model) bool { return m.power.IsEditing() },
|
||||||
|
resize: func(m *Model, w, h int) { m.power.SetSize(w, h) },
|
||||||
|
activate: func(m *Model) tea.Cmd { return m.power.Activate() },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageNames returns the valid page identifiers, in sidebar order, usable
|
// PageNames returns the valid page identifiers, in sidebar order, usable
|
||||||
@@ -125,3 +142,32 @@ func lookupPage(name string) (page, bool) {
|
|||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// visiblePages returns pageRegistry entries not listed in the user's
|
||||||
|
// tui.hidden_pages config, preserving registry order. A page hidden this
|
||||||
|
// way is still reachable via --page: hiding only affects the sidebar and
|
||||||
|
// its numbered shortcuts.
|
||||||
|
func visiblePages() []pageEntry {
|
||||||
|
hiddenNames := config.Global.TUI.HiddenPages
|
||||||
|
if len(hiddenNames) == 0 {
|
||||||
|
return pageRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
hidden := make(map[page]bool, len(hiddenNames))
|
||||||
|
for _, name := range hiddenNames {
|
||||||
|
if p, ok := lookupPage(name); ok {
|
||||||
|
hidden[p] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hidden) == 0 {
|
||||||
|
return pageRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := make([]pageEntry, 0, len(pageRegistry))
|
||||||
|
for _, e := range pageRegistry {
|
||||||
|
if !hidden[e.id] {
|
||||||
|
visible = append(visible, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func (m *Model) renderSidebar() string {
|
|||||||
var items strings.Builder
|
var items strings.Builder
|
||||||
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
|
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
|
||||||
|
|
||||||
for i, entry := range pageRegistry {
|
for i, entry := range visiblePages() {
|
||||||
selected := entry.id == m.page
|
selected := entry.id == m.page
|
||||||
badgeStyle, textStyle := badgeNormal, textNormal
|
badgeStyle, textStyle := badgeNormal, textNormal
|
||||||
if selected {
|
if selected {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.resizeChildren()
|
m.resizeChildren()
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if p, ok := pageShortcuts[msg.String()]; ok {
|
if p, ok := m.pageShortcuts[msg.String()]; ok {
|
||||||
prev := m.page
|
prev := m.page
|
||||||
m.page = p
|
m.page = p
|
||||||
if prev != p {
|
if prev != p {
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package power
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
|
"github.com/anotherhadi/settuings/internal/power"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Model) handleBattery(msg batteryMsg) {
|
||||||
|
m.batteryLoading = false
|
||||||
|
m.batteryErr = msg.err
|
||||||
|
if msg.err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.batteries = msg.batteries
|
||||||
|
m.ac = msg.ac
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderBattery(width int) string {
|
||||||
|
title := lipgloss.NewStyle().Bold(true).Render("Battery")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case m.batteryLoading:
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, title, "", ilovetui.S.Faint.Render("Reading battery status…"))
|
||||||
|
case m.batteryErr != nil:
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, title, "", errorStyle().Render(m.batteryErr.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{title, ""}
|
||||||
|
if len(m.batteries) == 0 {
|
||||||
|
lines = append(lines, subtleStyle().Render("No battery detected — this looks like a desktop."))
|
||||||
|
}
|
||||||
|
for i, b := range m.batteries {
|
||||||
|
if i > 0 {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
lines = append(lines, renderBatteryInfo(b, width)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, "", subtleStyle().Render("Power source: ")+acLabel(m.ac))
|
||||||
|
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBatteryInfo(b power.Battery, width int) []string {
|
||||||
|
rows := []string{
|
||||||
|
subtleStyle().Render("Name: ") + b.Name,
|
||||||
|
subtleStyle().Render("Status: ") + statusLabel(b.Status),
|
||||||
|
subtleStyle().Render("Charge: ") + renderBatteryBar(b.Capacity, width),
|
||||||
|
}
|
||||||
|
if remaining := timeRemainingLabel(b); remaining != "" {
|
||||||
|
rows = append(rows, subtleStyle().Render("Time left: ")+remaining)
|
||||||
|
}
|
||||||
|
if b.Health >= 0 {
|
||||||
|
rows = append(rows, subtleStyle().Render("Health: ")+fmt.Sprintf("%d%%", b.Health))
|
||||||
|
}
|
||||||
|
if b.CycleCount >= 0 {
|
||||||
|
rows = append(rows, subtleStyle().Render("Cycle count: ")+fmt.Sprintf("%d", b.CycleCount))
|
||||||
|
}
|
||||||
|
if b.Technology != "" {
|
||||||
|
rows = append(rows, subtleStyle().Render("Technology: ")+b.Technology)
|
||||||
|
}
|
||||||
|
if model := strings.TrimSpace(b.Manufacturer + " " + b.Model); model != "" {
|
||||||
|
rows = append(rows, subtleStyle().Render("Model: ")+model)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeRemainingLabel(b power.Battery) string {
|
||||||
|
switch {
|
||||||
|
case b.TimeToEmpty != "":
|
||||||
|
return b.TimeToEmpty + " until empty"
|
||||||
|
case b.TimeToFull != "":
|
||||||
|
return b.TimeToFull + " until full"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLabel(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "Charging":
|
||||||
|
return successStyle().Render("⚡ Charging")
|
||||||
|
case "Full":
|
||||||
|
return successStyle().Render(status)
|
||||||
|
case "Discharging":
|
||||||
|
return status
|
||||||
|
default:
|
||||||
|
return mutedStyle().Render(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func acLabel(ac power.ACStatus) string {
|
||||||
|
switch {
|
||||||
|
case !ac.Present:
|
||||||
|
return mutedStyle().Render("unknown")
|
||||||
|
case ac.Online:
|
||||||
|
return successStyle().Render("connected")
|
||||||
|
default:
|
||||||
|
return mutedStyle().Render("disconnected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderBatteryBar draws a static block-bar gauge for the charge level,
|
||||||
|
// colored by how low it is, same spirit as the audio page's volume bar.
|
||||||
|
func renderBatteryBar(pct, width int) string {
|
||||||
|
if pct < 0 {
|
||||||
|
return subtleStyle().Render("unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
barWidth := width / 3
|
||||||
|
switch {
|
||||||
|
case barWidth < 10:
|
||||||
|
barWidth = 10
|
||||||
|
case barWidth > 30:
|
||||||
|
barWidth = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
filled := pct * barWidth / 100
|
||||||
|
switch {
|
||||||
|
case filled > barWidth:
|
||||||
|
filled = barWidth
|
||||||
|
case filled < 0:
|
||||||
|
filled = 0
|
||||||
|
}
|
||||||
|
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||||
|
|
||||||
|
color := ilovetui.S.Success
|
||||||
|
switch {
|
||||||
|
case pct <= 15:
|
||||||
|
color = ilovetui.S.Error
|
||||||
|
case pct <= 35:
|
||||||
|
color = ilovetui.S.Warning
|
||||||
|
}
|
||||||
|
gauge := lipgloss.NewStyle().Foreground(color).Render("[" + bar + "]")
|
||||||
|
return fmt.Sprintf("%s %3d%%", gauge, pct)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package power
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/anotherhadi/settuings/internal/power"
|
||||||
|
)
|
||||||
|
|
||||||
|
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: power.CheckAvailable()} }
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchProfilesCmd() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
profiles, err := power.ListProfiles()
|
||||||
|
return profilesMsg{profiles: profiles, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setProfileCmd(name string) tea.Cmd {
|
||||||
|
return func() tea.Msg { return setProfileMsg{err: power.SetActive(name)} }
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchBatteryCmd() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
batteries, err := power.ReadBatteries()
|
||||||
|
if err != nil {
|
||||||
|
return batteryMsg{err: err}
|
||||||
|
}
|
||||||
|
ac, err := power.ReadAC()
|
||||||
|
return batteryMsg{batteries: batteries, ac: ac, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package power
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
"github.com/anotherhadi/settuings/internal/keys"
|
||||||
|
)
|
||||||
|
|
||||||
|
// powerKeyMap renders the bottom help bar.
|
||||||
|
type powerKeyMap struct {
|
||||||
|
m Model
|
||||||
|
width int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k powerKeyMap) ShortHelp() []key.Binding {
|
||||||
|
g := keys.Keys.Global
|
||||||
|
p := keys.Keys.Power
|
||||||
|
if k.m.checkErr != nil {
|
||||||
|
return []key.Binding{p.Refresh, g.Help}
|
||||||
|
}
|
||||||
|
return []key.Binding{g.Up, g.Down, p.Select, p.Refresh, g.Help}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k powerKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
g := keys.Keys.Global
|
||||||
|
p := keys.Keys.Power
|
||||||
|
|
||||||
|
var bindings []key.Binding
|
||||||
|
if k.m.checkErr != nil {
|
||||||
|
bindings = []key.Binding{p.Refresh}
|
||||||
|
} else {
|
||||||
|
bindings = []key.Binding{g.Up, g.Down, p.Select, p.Refresh}
|
||||||
|
}
|
||||||
|
bindings = append(bindings, g.CommonBindings()...)
|
||||||
|
return keys.ChunkByWidth(bindings, k.width)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package power
|
||||||
|
|
||||||
|
import "github.com/anotherhadi/settuings/internal/power"
|
||||||
|
|
||||||
|
// checkMsg carries the result of power.CheckAvailable (powerprofilesctl).
|
||||||
|
type checkMsg struct{ err error }
|
||||||
|
|
||||||
|
// tickMsg triggers a periodic refresh of profiles + battery while this page
|
||||||
|
// is active.
|
||||||
|
type tickMsg struct{}
|
||||||
|
|
||||||
|
type profilesMsg struct {
|
||||||
|
profiles []power.Profile
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type setProfileMsg struct{ err error }
|
||||||
|
|
||||||
|
type batteryMsg struct {
|
||||||
|
batteries []power.Battery
|
||||||
|
ac power.ACStatus
|
||||||
|
err error
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Package power provides the "Power" TUI page: power-profile selection (via
|
||||||
|
// powerprofilesctl) and battery/AC status, in the same spirit as the
|
||||||
|
// bluetooth page's controller power + device state.
|
||||||
|
package power
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/help"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
|
"github.com/anotherhadi/settuings/internal/power"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
help help.Model
|
||||||
|
|
||||||
|
checkErr error // reason powerprofilesctl is unusable; nil once confirmed OK
|
||||||
|
checked bool
|
||||||
|
|
||||||
|
profilesLoading bool
|
||||||
|
profilesErr error
|
||||||
|
profiles []power.Profile
|
||||||
|
cursor int
|
||||||
|
pending bool
|
||||||
|
feedback string
|
||||||
|
|
||||||
|
batteryLoading bool
|
||||||
|
batteryErr error
|
||||||
|
batteries []power.Battery
|
||||||
|
ac power.ACStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() Model {
|
||||||
|
return Model{
|
||||||
|
help: ilovetui.NewHelp(),
|
||||||
|
profilesLoading: true,
|
||||||
|
batteryLoading: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate is called every time the user navigates onto this page. It
|
||||||
|
// re-checks powerprofilesctl availability, fetches profiles + battery
|
||||||
|
// state, and starts the periodic auto-refresh loop.
|
||||||
|
func (m *Model) Activate() tea.Cmd {
|
||||||
|
return tea.Batch(checkCmd(), fetchProfilesCmd(), fetchBatteryCmd(), tickCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
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,148 @@
|
|||||||
|
package power
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"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/power"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Model) handleProfiles(msg profilesMsg) {
|
||||||
|
m.profilesLoading = false
|
||||||
|
m.profilesErr = msg.err
|
||||||
|
if msg.err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.profiles = msg.profiles
|
||||||
|
if m.cursor >= len(m.profiles) {
|
||||||
|
m.cursor = max(0, len(m.profiles)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleSetProfile(msg setProfileMsg) tea.Cmd {
|
||||||
|
m.pending = false
|
||||||
|
if msg.err != nil {
|
||||||
|
m.feedback = msg.err.Error()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.feedback = ""
|
||||||
|
return fetchProfilesCmd()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectedProfile() *power.Profile {
|
||||||
|
if m.cursor < 0 || m.cursor >= len(m.profiles) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &m.profiles[m.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) updateProfiles(msg tea.Msg) tea.Cmd {
|
||||||
|
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if m.checkErr != nil {
|
||||||
|
if key.Matches(keyMsg, keys.Keys.Power.Refresh) {
|
||||||
|
return checkCmd()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g := keys.Keys.Global
|
||||||
|
p := keys.Keys.Power
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case key.Matches(keyMsg, g.Up):
|
||||||
|
if m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
|
case key.Matches(keyMsg, g.Down):
|
||||||
|
if m.cursor < len(m.profiles)-1 {
|
||||||
|
m.cursor++
|
||||||
|
}
|
||||||
|
case key.Matches(keyMsg, p.Refresh):
|
||||||
|
return tea.Batch(fetchProfilesCmd(), fetchBatteryCmd())
|
||||||
|
case key.Matches(keyMsg, p.Select):
|
||||||
|
return m.applySelected()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) applySelected() tea.Cmd {
|
||||||
|
sel := m.selectedProfile()
|
||||||
|
if sel == nil || sel.Active {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.pending = true
|
||||||
|
return setProfileCmd(sel.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderProfiles() string {
|
||||||
|
title := lipgloss.NewStyle().Bold(true).Render("Power Profile")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !m.checked:
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, title, "", ilovetui.S.Faint.Render("Checking powerprofilesctl…"))
|
||||||
|
case m.checkErr != nil:
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, title, "", subtleStyle().Render(unavailableHint(m.checkErr)))
|
||||||
|
case m.profilesLoading:
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, title, "", ilovetui.S.Faint.Render("Loading profiles…"))
|
||||||
|
case m.profilesErr != nil:
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, title, "", errorStyle().Render(m.profilesErr.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{title, ""}
|
||||||
|
for i, p := range m.profiles {
|
||||||
|
lines = append(lines, m.renderProfileRow(p, i == m.cursor))
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case m.pending:
|
||||||
|
lines = append(lines, "", mutedStyle().Render("applying…"))
|
||||||
|
case m.feedback != "":
|
||||||
|
lines = append(lines, "", errorStyle().Render(m.feedback))
|
||||||
|
}
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderProfileRow(p power.Profile, 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 p.Active {
|
||||||
|
check = successStyle().Render(icons.I.Check)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%s%s", cursor, check, textStyle.Render(displayName(p.Name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayName turns a powerprofilesctl identifier ("power-saver") into a
|
||||||
|
// readable label ("Power Saver").
|
||||||
|
func displayName(name string) string {
|
||||||
|
words := strings.Split(strings.ReplaceAll(name, "-", " "), " ")
|
||||||
|
for i, w := range words {
|
||||||
|
if w == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
words[i] = strings.ToUpper(w[:1]) + w[1:]
|
||||||
|
}
|
||||||
|
return strings.Join(words, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func unavailableHint(err error) string {
|
||||||
|
if errors.Is(err, power.ErrNotFound) {
|
||||||
|
return "powerprofilesctl was not found in PATH.\nInstall power-profiles-daemon to select a profile."
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package power
|
||||||
|
|
||||||
|
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,29 @@
|
|||||||
|
package power
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
|
"github.com/anotherhadi/settuings/internal/icons"
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderTabBar draws a header matching the bluetooth page's tab bar: a
|
||||||
|
// single always-active label, since this page has no tabs of its own.
|
||||||
|
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.Power + "Power")
|
||||||
|
|
||||||
|
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,45 @@
|
|||||||
|
package power
|
||||||
|
|
||||||
|
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 profiles
|
||||||
|
return m, fetchProfilesCmd()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tickMsg:
|
||||||
|
cmds := []tea.Cmd{tickCmd(), fetchBatteryCmd()}
|
||||||
|
if m.checkErr == nil {
|
||||||
|
cmds = append(cmds, fetchProfilesCmd())
|
||||||
|
}
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
|
case profilesMsg:
|
||||||
|
m.handleProfiles(msg)
|
||||||
|
return m, nil
|
||||||
|
case setProfileMsg:
|
||||||
|
return m, m.handleSetProfile(msg)
|
||||||
|
case batteryMsg:
|
||||||
|
m.handleBattery(msg)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
if key.Matches(msg, keys.Keys.Global.Help) {
|
||||||
|
m.help.ShowAll = !m.help.ShowAll
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, m.updateProfiles(msg)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package power
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
box := lipgloss.NewStyle().Width(innerW).Height(innerH).Render(m.renderBody(innerW))
|
||||||
|
content := windowStyle().Render(lipgloss.JoinVertical(lipgloss.Left, tabBar, box))
|
||||||
|
|
||||||
|
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, content, statusBar))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderStatusBar() string {
|
||||||
|
km := powerKeyMap{m: m, width: m.width}
|
||||||
|
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km))
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentBudget returns the exact space available inside the bordered page
|
||||||
|
// box, below the tab bar and above the status bar.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderBody lays out the two sections side by side once there's enough
|
||||||
|
// width for both to breathe, and stacks them otherwise.
|
||||||
|
func (m Model) renderBody(width int) string {
|
||||||
|
const splitThreshold = 60
|
||||||
|
const gap = 3
|
||||||
|
|
||||||
|
if width < splitThreshold {
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, m.renderProfiles(), "", m.renderBattery(width))
|
||||||
|
}
|
||||||
|
|
||||||
|
leftW := width / 3
|
||||||
|
if leftW < 20 {
|
||||||
|
leftW = 20
|
||||||
|
}
|
||||||
|
rightW := width - leftW - gap
|
||||||
|
left := lipgloss.NewStyle().Width(leftW).Render(m.renderProfiles())
|
||||||
|
right := lipgloss.NewStyle().Width(rightW).Render(m.renderBattery(rightW))
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", gap), right)
|
||||||
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
buildGoApplication,
|
buildGoApplication,
|
||||||
}: let
|
}: let
|
||||||
pname = "settuings";
|
pname = "settuings";
|
||||||
version = "0.0.1";
|
version = "0.0.2";
|
||||||
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
||||||
pkg = buildGoApplication {
|
pkg = buildGoApplication {
|
||||||
inherit pname version ldflags;
|
inherit pname version ldflags;
|
||||||
|
|||||||
Reference in New Issue
Block a user