8 Commits
Author SHA1 Message Date
Hadi 7f75c7b6c0 Add bluetooth agent
Signed-off-by: Hadi <[email protected]>
2026-08-13 23:00:59 +02:00
Hadi 0f7738be68 add filter
Signed-off-by: Hadi <[email protected]>
2026-08-13 18:22:44 +02:00
Hadi fc35732dbf only show what is available
Signed-off-by: Hadi <[email protected]>
2026-08-13 18:12:44 +02:00
Hadi 6e4f8b889c add "inputs" page
Signed-off-by: Hadi <[email protected]>
2026-08-13 17:59:03 +02:00
Hadi df530f39b0 remove stylua
Signed-off-by: Hadi <[email protected]>
2026-08-13 13:41:08 +02:00
Hadi 5e3d86e811 v0.0.2
Signed-off-by: Hadi <[email protected]>
2026-08-13 12:10:26 +02:00
Hadi 7067717b51 Add "power" page
Signed-off-by: Hadi <[email protected]>
2026-08-13 12:10:09 +02:00
Hadi d54817d62c run pre commit hook
Signed-off-by: Hadi <[email protected]>
2026-08-13 01:14:12 +02:00
49 changed files with 2327 additions and 125 deletions
+8 -21
View File
@@ -16,17 +16,10 @@
<!-- 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)
- [What is SetTUIngs?](#what-is-settuings)
- [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 -->
@@ -43,6 +36,8 @@ 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.
- **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.
- **Power**: Switch power profiles (performance, balanced, power-saver) and check battery charge, health, and time remaining at a glance.
- **Inputs**: Change the keyboard layout and adjust mouse pointer speed, via `hyprctl` (Hyprland only).
- **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
@@ -87,27 +82,19 @@ environment.systemPackages = [ inputs.settuings.packages.${pkgs.system}.default
## CLI Flags
<!-- 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:
spilltea [flags]
settuings [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
-h, --help help for settuings
-p, --page string page to open at launch (About, Network, Bluetooth, Audio, Power, Inputs)
-v, --version version for settuings
```
<!-- endexec -->
## Tech Stack
+1
View File
@@ -9,6 +9,7 @@ require (
charm.land/lipgloss/v2 v2.0.3
github.com/anotherhadi/ilovetui v0.1.6
github.com/charmbracelet/x/ansi v0.11.7
github.com/godbus/dbus/v5 v5.2.2
github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
+2
View File
@@ -53,6 +53,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
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=
+8
View File
@@ -31,6 +31,14 @@ func CheckAvailable() error {
return nil
}
// Installed reports whether wpctl is present in PATH, without probing
// PipeWire. Cheap enough to call when deciding whether to show the Audio
// page in the sidebar.
func Installed() bool {
_, err := exec.LookPath("wpctl")
return err == nil
}
func run(args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
+83
View File
@@ -0,0 +1,83 @@
package bluetooth
import (
"fmt"
"sync"
"github.com/godbus/dbus/v5"
)
const (
agentPath = dbus.ObjectPath("/org/anotherhadi/settuings/agent")
agentCapability = "DisplayYesNo"
)
// agent implements the BlueZ org.bluez.Agent1 interface, auto-accepting
// every pairing prompt. `bluetoothctl pair` run non-interactively never
// registers an agent, so BlueZ has nobody to ask for PIN/passkey
// confirmation and any device that isn't "Just Works" fails with
// AuthenticationFailed. Registering this agent as the system default
// fixes that even though pairing itself still happens in a separate
// bluetoothctl process: BlueZ routes auth requests to whichever agent is
// registered as default, not to whoever initiated the pairing.
type agent struct{}
func (agent) Release() *dbus.Error { return nil }
func (agent) RequestPinCode(_ dbus.ObjectPath) (string, *dbus.Error) {
return "", dbus.NewError("org.bluez.Error.Rejected", nil)
}
func (agent) DisplayPinCode(_ dbus.ObjectPath, _ string) *dbus.Error { return nil }
func (agent) RequestPasskey(_ dbus.ObjectPath) (uint32, *dbus.Error) {
return 0, dbus.NewError("org.bluez.Error.Rejected", nil)
}
func (agent) DisplayPasskey(_ dbus.ObjectPath, _ uint32, _ uint16) *dbus.Error { return nil }
func (agent) RequestConfirmation(_ dbus.ObjectPath, _ uint32) *dbus.Error { return nil }
func (agent) RequestAuthorization(_ dbus.ObjectPath) *dbus.Error { return nil }
func (agent) AuthorizeService(_ dbus.ObjectPath, _ string) *dbus.Error { return nil }
func (agent) Cancel() *dbus.Error { return nil }
var (
agentOnce sync.Once
agentErr error
// agentConn is kept alive for the process lifetime: BlueZ calls back
// into our agent over this connection while pairing is in progress.
agentConn *dbus.Conn
)
// ensureAgent registers this process as BlueZ's default pairing agent, once
// per run. Call before any Pair.
func ensureAgent() error {
agentOnce.Do(func() {
conn, err := dbus.ConnectSystemBus()
if err != nil {
agentErr = fmt.Errorf("connect to system bus: %w", err)
return
}
if err := conn.Export(agent{}, agentPath, "org.bluez.Agent1"); err != nil {
agentErr = fmt.Errorf("export agent: %w", err)
return
}
manager := conn.Object("org.bluez", dbus.ObjectPath("/org/bluez"))
if call := manager.Call("org.bluez.AgentManager1.RegisterAgent", 0, agentPath, agentCapability); call.Err != nil {
agentErr = fmt.Errorf("register agent: %w", call.Err)
return
}
if call := manager.Call("org.bluez.AgentManager1.RequestDefaultAgent", 0, agentPath); call.Err != nil {
agentErr = fmt.Errorf("request default agent: %w", call.Err)
return
}
agentConn = conn
})
return agentErr
}
+8
View File
@@ -39,6 +39,14 @@ func CheckAvailable() error {
return nil
}
// Installed reports whether bluetoothctl is present in PATH, without
// probing for a controller. Cheap enough to call when deciding whether to
// show the Bluetooth page in the sidebar.
func Installed() bool {
_, err := exec.LookPath("bluetoothctl")
return err == 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
+6 -3
View File
@@ -185,10 +185,13 @@ func Scan(seconds int) error {
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).
// Pair initiates pairing with a device, first registering settuings as
// BlueZ's default agent so devices that need PIN/passkey confirmation
// (not just "just works" ones) can complete pairing.
func Pair(address string) error {
if err := ensureAgent(); err != nil {
return err
}
_, err := run(actionTimeout, "pair", address)
return err
}
+1
View File
@@ -25,6 +25,7 @@ type Config struct {
TUI struct {
UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"`
DefaultSidebarState string `mapstructure:"default_sidebar_state"`
HiddenPages []string `mapstructure:"hidden_pages"`
} `mapstructure:"tui"`
Keybindings Keybindings `mapstructure:"keybindings"`
+10
View File
@@ -4,6 +4,7 @@ app:
tui:
use_nerdfont_icons: true
default_sidebar_state: "expanded" # hidden, collapsed, expanded
hidden_pages: [] # page names to hide from the sidebar, e.g. ["Audio", "Power"]
keybindings:
global:
@@ -54,3 +55,12 @@ keybindings:
refresh: "r"
mute: "m"
test: "t"
power:
select: "enter"
refresh: "r"
input:
select: "enter"
reset: "r"
filter: "/"
+13
View File
@@ -54,10 +54,23 @@ type AudioKeys struct {
Test string `mapstructure:"test"`
}
type PowerKeys struct {
Select string `mapstructure:"select"`
Refresh string `mapstructure:"refresh"`
}
type InputKeys struct {
Select string `mapstructure:"select"`
Reset string `mapstructure:"reset"`
Filter string `mapstructure:"filter"`
}
type Keybindings struct {
Global GlobalKeys `mapstructure:"global"`
Docs DocsKeys `mapstructure:"docs"`
Network NetworkKeys `mapstructure:"network"`
Bluetooth BluetoothKeys `mapstructure:"bluetooth"`
Audio AudioKeys `mapstructure:"audio"`
Power PowerKeys `mapstructure:"power"`
Input InputKeys `mapstructure:"input"`
}
+6
View File
@@ -11,6 +11,8 @@ type Icons struct {
Network string
Bluetooth string
Audio string
Power string
Inputs string
Wifi string
Ethernet string
@@ -21,6 +23,7 @@ type Icons struct {
Output string
Input string
Apps string
Battery string
}
var I *Icons
@@ -36,6 +39,8 @@ func Init(cfg *config.Config) {
Network: "󰛳 ",
Bluetooth: "󰂯 ",
Audio: "󰓃 ",
Power: "󰐥 ",
Inputs: "󰌌 ",
Wifi: "󰤨 ",
Ethernet: "󰈀 ",
@@ -46,6 +51,7 @@ func Init(cfg *config.Config) {
Output: "󰕾 ",
Input: "󰍬 ",
Apps: "󰀻 ",
Battery: "󰁹 ",
}
} else {
I = &Icons{}
+61
View File
@@ -0,0 +1,61 @@
package input
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)
var ErrNotFound = errors.New("hyprctl not found in PATH")
const defaultTimeout = 15 * time.Second
func CheckAvailable() error {
if _, err := exec.LookPath("hyprctl"); err != nil {
return ErrNotFound
}
_, err := run("version")
return err
}
// Installed reports whether hyprctl is present in PATH, without probing
// Hyprland. Cheap enough to call when deciding whether to show the Inputs
// page in the sidebar.
func Installed() bool {
_, err := exec.LookPath("hyprctl")
return err == nil
}
func Reset() error {
_, err := run("reload")
return err
}
func run(args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "hyprctl", args...)
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 = strings.TrimSpace(stdout.String())
}
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("hyprctl %s: %s", strings.Join(args, " "), msg)
}
return stdout.String(), nil
}
+145
View File
@@ -0,0 +1,145 @@
package input
import (
"encoding/json"
"fmt"
"strings"
)
type Layout struct {
Code string
Name string
}
var Layouts = []Layout{
{"al", "Albanian"},
{"et", "Amharic"},
{"am", "Armenian"},
{"ara", "Arabic"},
{"eg", "Arabic (Egypt)"},
{"iq", "Arabic (Iraq)"},
{"ma", "Arabic (Morocco)"},
{"sy", "Arabic (Syria)"},
{"az", "Azerbaijani"},
{"ml", "Bambara"},
{"bd", "Bangla"},
{"by", "Belarusian"},
{"be", "Belgian"},
{"dz", "Berber (Algeria, Latin)"},
{"ba", "Bosnian"},
{"brai", "Braille"},
{"bg", "Bulgarian"},
{"mm", "Burmese"},
{"cn", "Chinese"},
{"hr", "Croatian"},
{"cz", "Czech"},
{"dk", "Danish"},
{"af", "Dari"},
{"mv", "Dhivehi"},
{"nl", "Dutch"},
{"bt", "Dzongkha"},
{"au", "English (Australia)"},
{"cm", "English (Cameroon)"},
{"gh", "English (Ghana)"},
{"nz", "English (New Zealand)"},
{"ng", "English (Nigeria)"},
{"za", "English (South Africa)"},
{"gb", "English (UK)"},
{"us", "English (US)"},
{"epo", "Esperanto"},
{"ee", "Estonian"},
{"fo", "Faroese"},
{"ph", "Filipino"},
{"fi", "Finnish"},
{"fr", "French"},
{"ca", "French (Canada)"},
{"cd", "French (Democratic Republic of the Congo)"},
{"tg", "French (Togo)"},
{"ge", "Georgian"},
{"de", "German"},
{"at", "German (Austria)"},
{"ch", "German (Switzerland)"},
{"gr", "Greek"},
{"il", "Hebrew"},
{"hu", "Hungarian"},
{"is", "Icelandic"},
{"in", "Indian"},
{"id", "Indonesian (Latin)"},
{"ie", "Irish"},
{"it", "Italian"},
{"jp", "Japanese"},
{"kz", "Kazakh"},
{"kh", "Khmer (Cambodia)"},
{"kr", "Korean"},
{"kg", "Kyrgyz"},
{"la", "Lao"},
{"lv", "Latvian"},
{"lt", "Lithuanian"},
{"mk", "Macedonian"},
{"my", "Malay (Jawi, Arabic Keyboard)"},
{"mt", "Maltese"},
{"md", "Moldavian"},
{"mn", "Mongolian"},
{"me", "Montenegrin"},
{"np", "Nepali"},
{"gn", "N'Ko (AZERTY)"},
{"no", "Norwegian"},
{"ir", "Persian"},
{"pl", "Polish"},
{"pt", "Portuguese"},
{"br", "Portuguese (Brazil)"},
{"ro", "Romanian"},
{"ru", "Russian"},
{"rs", "Serbian"},
{"lk", "Sinhala (phonetic)"},
{"sk", "Slovak"},
{"si", "Slovenian"},
{"es", "Spanish"},
{"latam", "Spanish (Latin American)"},
{"ke", "Swahili (Kenya)"},
{"tz", "Swahili (Tanzania)"},
{"se", "Swedish"},
{"tw", "Taiwanese"},
{"tj", "Tajik"},
{"th", "Thai"},
{"bw", "Tswana"},
{"tm", "Turkmen"},
{"tr", "Turkish"},
{"ua", "Ukrainian"},
{"pk", "Urdu (Pakistan)"},
{"uz", "Uzbek"},
{"vn", "Vietnamese"},
{"sn", "Wolof"},
}
func LayoutName(code string) string {
for _, l := range Layouts {
if l.Code == code {
return l.Name
}
}
return code
}
// getOptionResp is the shape of `hyprctl getoption <name> -j`.
type getOptionResp struct {
Str string `json:"str"`
Float float64 `json:"float"`
}
func CurrentLayout() (string, error) {
out, err := run("getoption", "input:kb_layout", "-j")
if err != nil {
return "", err
}
var resp getOptionResp
if err := json.Unmarshal([]byte(out), &resp); err != nil {
return "", fmt.Errorf("parse getoption input:kb_layout: %w", err)
}
return strings.TrimSpace(resp.Str), nil
}
func SetLayout(code string) error {
_, err := run("keyword", "input:kb_layout", code)
return err
}
+34
View File
@@ -0,0 +1,34 @@
package input
import (
"encoding/json"
"fmt"
)
const (
MinSensitivity = -1.0
MaxSensitivity = 1.0
)
func CurrentSensitivity() (float64, error) {
out, err := run("getoption", "input:sensitivity", "-j")
if err != nil {
return 0, err
}
var resp getOptionResp
if err := json.Unmarshal([]byte(out), &resp); err != nil {
return 0, fmt.Errorf("parse getoption input:sensitivity: %w", err)
}
return resp.Float, nil
}
func SetSensitivity(v float64) error {
switch {
case v < MinSensitivity:
v = MinSensitivity
case v > MaxSensitivity:
v = MaxSensitivity
}
_, err := run("keyword", "input:sensitivity", fmt.Sprintf("%.2f", v))
return err
}
+24
View File
@@ -0,0 +1,24 @@
package keys
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/settuings/internal/config"
)
type InputKeyMap struct {
Select key.Binding
Reset key.Binding
Filter key.Binding
}
func newInputKeyMap(cfg config.InputKeys) InputKeyMap {
return InputKeyMap{
Select: binding(cfg.Select, "apply layout"),
Reset: binding(cfg.Reset, "reset to config"),
Filter: binding(cfg.Filter, "filter"),
}
}
func (i InputKeyMap) Bindings() []key.Binding {
return []key.Binding{i.Select, i.Reset, i.Filter}
}
+4
View File
@@ -13,6 +13,8 @@ type KeyMap struct {
Network NetworkKeyMap
Bluetooth BluetoothKeyMap
Audio AudioKeyMap
Power PowerKeyMap
Input InputKeyMap
}
var Keys *KeyMap
@@ -25,6 +27,8 @@ func Init(cfg *config.Config) {
Network: newNetworkKeyMap(kb.Network),
Bluetooth: newBluetoothKeyMap(kb.Bluetooth),
Audio: newAudioKeyMap(kb.Audio),
Power: newPowerKeyMap(kb.Power),
Input: newInputKeyMap(kb.Input),
}
}
+22
View File
@@ -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}
}
+8
View File
@@ -36,6 +36,14 @@ func CheckAvailable() error {
return nil
}
// Installed reports whether nmcli is present in PATH, without probing
// NetworkManager. Cheap enough to call when deciding whether to show the
// Network page in the sidebar.
func Installed() bool {
_, err := exec.LookPath("nmcli")
return err == nil
}
func run(args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
+175
View File
@@ -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
}
+111
View File
@@ -0,0 +1,111 @@
// 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
}
// Installed reports whether powerprofilesctl is present in PATH, without
// probing power-profiles-daemon. Cheap enough to call when deciding whether
// to show the Power page in the sidebar.
func Installed() bool {
_, err := exec.LookPath("powerprofilesctl")
return err == nil
}
// 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
}
+19 -5
View File
@@ -13,20 +13,27 @@ import (
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"
inputUI "github.com/anotherhadi/settuings/internal/ui/input"
networkUI "github.com/anotherhadi/settuings/internal/ui/network"
powerUI "github.com/anotherhadi/settuings/internal/ui/power"
"github.com/sirupsen/logrus"
)
var pageShortcuts = func() map[string]page {
m := make(map[string]page, len(pageRegistry))
for i, e := range pageRegistry {
// buildPageShortcuts numbers the visible pages 1..N in sidebar order, so
// hiding a page (or a missing CLI tool) never leaves a gap in the
// shortcuts.
func buildPageShortcuts(visible []pageEntry) map[string]page {
m := make(map[string]page, len(visible))
for i, e := range visible {
m[strconv.Itoa(i+1)] = e.id
}
return m
}()
}
type Model struct {
page page
pageShortcuts map[string]page
visiblePages []pageEntry
logPath string
fatalErr error
logFileErr error
@@ -38,6 +45,8 @@ type Model struct {
network networkUI.Model
bluetooth bluetoothUI.Model
audio audioUI.Model
power powerUI.Model
input inputUI.Model
notifications notificationsUI.Model
}
@@ -58,13 +67,18 @@ func New(initialPage string) (Model, error) {
}
cfg := config.Global
visible := visiblePages()
m := Model{
page: startPage,
pageShortcuts: buildPageShortcuts(visible),
visiblePages: visible,
about: aboutUI.New(),
network: networkUI.New(),
bluetooth: bluetoothUI.New(),
audio: audioUI.New(),
power: powerUI.New(),
input: inputUI.New(),
notifications: notificationsUI.New(),
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
logPath: logPath,
@@ -83,7 +97,7 @@ func New(initialPage string) (Model, error) {
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()}
cmds := []tea.Cmd{m.about.Init(), m.network.Init(), m.bluetooth.Init(), m.audio.Init(), m.power.Init(), m.input.Init()}
if m.page != pageAbout {
cmds = append(cmds, m.activatePage(m.page))
}
+78
View File
@@ -4,11 +4,19 @@ import (
"strings"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/settuings/internal/audio"
"github.com/anotherhadi/settuings/internal/bluetooth"
"github.com/anotherhadi/settuings/internal/config"
"github.com/anotherhadi/settuings/internal/icons"
"github.com/anotherhadi/settuings/internal/input"
"github.com/anotherhadi/settuings/internal/network"
"github.com/anotherhadi/settuings/internal/power"
aboutUI "github.com/anotherhadi/settuings/internal/ui/about"
audioUI "github.com/anotherhadi/settuings/internal/ui/audio"
bluetoothUI "github.com/anotherhadi/settuings/internal/ui/bluetooth"
inputUI "github.com/anotherhadi/settuings/internal/ui/input"
networkUI "github.com/anotherhadi/settuings/internal/ui/network"
powerUI "github.com/anotherhadi/settuings/internal/ui/power"
)
type page string
@@ -18,6 +26,8 @@ const (
pageNetwork page = "Network"
pageBluetooth page = "Bluetooth"
pageAudio page = "Audio"
pagePower page = "Power"
pageInputs page = "Inputs"
)
// pageEntry describes a page and all its integration hooks.
@@ -25,6 +35,12 @@ type pageEntry struct {
id page
icon func() string
// available reports whether this page's underlying CLI tool is
// installed. nil = always available (e.g. About has no dependency).
// A page that isn't available is left out of the sidebar and its
// numbered shortcuts, but stays reachable via --page.
available func() bool
// 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.
@@ -63,6 +79,7 @@ var pageRegistry = []pageEntry{
{
id: pageNetwork,
icon: func() string { return icons.I.Network },
available: network.Installed,
render: func(m *Model) string { return m.network.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
@@ -77,6 +94,7 @@ var pageRegistry = []pageEntry{
{
id: pageBluetooth,
icon: func() string { return icons.I.Bluetooth },
available: bluetooth.Installed,
render: func(m *Model) string { return m.bluetooth.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
@@ -91,6 +109,7 @@ var pageRegistry = []pageEntry{
{
id: pageAudio,
icon: func() string { return icons.I.Audio },
available: audio.Installed,
render: func(m *Model) string { return m.audio.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
@@ -103,6 +122,36 @@ var pageRegistry = []pageEntry{
activate: func(m *Model) tea.Cmd { return m.audio.Activate() },
deactivate: func(m *Model) { m.audio.Deactivate() },
},
{
id: pagePower,
icon: func() string { return icons.I.Power },
available: power.Installed,
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() },
},
{
id: pageInputs,
icon: func() string { return icons.I.Inputs },
available: input.Installed,
render: func(m *Model) string { return m.input.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.input.Update(msg)
m.input = updated.(inputUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.input.IsEditing() },
resize: func(m *Model, w, h int) { m.input.SetSize(w, h) },
activate: func(m *Model) tea.Cmd { return m.input.Activate() },
},
}
// PageNames returns the valid page identifiers, in sidebar order, usable
@@ -125,3 +174,32 @@ func lookupPage(name string) (page, bool) {
}
return "", false
}
// visiblePages returns pageRegistry entries not listed in the user's
// tui.hidden_pages config and whose underlying CLI tool (if any) is
// installed, preserving registry order. A page left out this way is still
// reachable via --page: this only affects the sidebar and its numbered
// shortcuts. It shells out to `exec.LookPath` per page, so callers should
// compute it once (e.g. in New) and cache the result rather than calling it
// on every render.
func visiblePages() []pageEntry {
hiddenNames := config.Global.TUI.HiddenPages
hidden := make(map[page]bool, len(hiddenNames))
for _, name := range hiddenNames {
if p, ok := lookupPage(name); ok {
hidden[p] = true
}
}
visible := make([]pageEntry, 0, len(pageRegistry))
for _, e := range pageRegistry {
if hidden[e.id] {
continue
}
if e.available != nil && !e.available() {
continue
}
visible = append(visible, e)
}
return visible
}
+1 -1
View File
@@ -62,7 +62,7 @@ func (m *Model) renderSidebar() string {
var items strings.Builder
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
for i, entry := range pageRegistry {
for i, entry := range m.visiblePages {
selected := entry.id == m.page
badgeStyle, textStyle := badgeNormal, textNormal
if selected {
+1 -1
View File
@@ -47,7 +47,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.resizeChildren()
default:
if p, ok := pageShortcuts[msg.String()]; ok {
if p, ok := m.pageShortcuts[msg.String()]; ok {
prev := m.page
m.page = p
if prev != p {
+44
View File
@@ -0,0 +1,44 @@
package input
import (
"time"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/settuings/internal/input"
)
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: input.CheckAvailable()} }
}
func fetchLayoutCmd() tea.Cmd {
return func() tea.Msg {
layout, err := input.CurrentLayout()
return layoutMsg{layout: layout, err: err}
}
}
func setLayoutCmd(code string) tea.Cmd {
return func() tea.Msg { return setLayoutMsg{err: input.SetLayout(code)} }
}
func fetchSensitivityCmd() tea.Cmd {
return func() tea.Msg {
value, err := input.CurrentSensitivity()
return sensitivityMsg{value: value, err: err}
}
}
func setSensitivityCmd(v float64) tea.Cmd {
return func() tea.Msg { return setSensitivityMsg{err: input.SetSensitivity(v)} }
}
func resetCmd() tea.Cmd {
return func() tea.Msg { return resetMsg{err: input.Reset()} }
}
+204
View File
@@ -0,0 +1,204 @@
package input
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"
inputpkg "github.com/anotherhadi/settuings/internal/input"
"github.com/anotherhadi/settuings/internal/keys"
)
func (m *Model) handleLayout(msg layoutMsg) {
m.layoutErr = msg.err
if msg.err != nil {
return
}
m.currentLayout = msg.layout
if !m.cursorInit {
m.cursorInit = true
for i, l := range inputpkg.Layouts {
if l.Code == msg.layout {
m.cursor = i
break
}
}
}
}
func (m *Model) handleSetLayout(msg setLayoutMsg) tea.Cmd {
m.layoutPending = false
if msg.err != nil {
m.layoutFeedback = msg.err.Error()
return nil
}
m.layoutFeedback = ""
return fetchLayoutCmd()
}
// visibleLayouts returns the layouts matching the current filter query (by
// code or name, case-insensitive), or every layout when there's no query.
func (m Model) visibleLayouts() []inputpkg.Layout {
q := strings.ToLower(strings.TrimSpace(m.filterInput.Value()))
if q == "" {
return inputpkg.Layouts
}
matches := make([]inputpkg.Layout, 0, len(inputpkg.Layouts))
for _, l := range inputpkg.Layouts {
if strings.Contains(strings.ToLower(l.Name), q) || strings.Contains(strings.ToLower(l.Code), q) {
matches = append(matches, l)
}
}
return matches
}
// clampCursor keeps the cursor within the currently visible (filtered)
// list, called whenever the filter query — and so the list length —
// changes.
func (m *Model) clampCursor() {
n := len(m.visibleLayouts())
switch {
case m.cursor >= n:
m.cursor = n - 1
case m.cursor < 0:
m.cursor = 0
}
}
func (m Model) selectedLayout() *inputpkg.Layout {
list := m.visibleLayouts()
if m.cursor < 0 || m.cursor >= len(list) {
return nil
}
return &list[m.cursor]
}
func (m *Model) updateLayout(msg tea.Msg) tea.Cmd {
keyMsg, ok := msg.(tea.KeyPressMsg)
if !ok {
return nil
}
g := keys.Keys.Global
in := keys.Keys.Input
if m.filtering {
switch {
case key.Matches(keyMsg, g.Escape):
m.filtering = false
m.filterInput.Blur()
m.filterInput.SetValue("")
m.cursor = 0
case key.Matches(keyMsg, in.Select):
m.filtering = false
m.filterInput.Blur()
m.clampCursor()
default:
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(keyMsg)
m.clampCursor()
return cmd
}
return nil
}
switch {
case key.Matches(keyMsg, in.Filter):
m.filtering = true
m.filterInput.Focus()
case key.Matches(keyMsg, g.Up):
if m.cursor > 0 {
m.cursor--
}
case key.Matches(keyMsg, g.Down):
if m.cursor < len(m.visibleLayouts())-1 {
m.cursor++
}
case key.Matches(keyMsg, in.Select):
return m.applySelectedLayout()
}
return nil
}
func (m *Model) applySelectedLayout() tea.Cmd {
sel := m.selectedLayout()
if sel == nil || sel.Code == m.currentLayout {
return nil
}
m.layoutPending = true
m.layoutFeedback = ""
return setLayoutCmd(sel.Code)
}
func (m Model) renderLayout(width, height int) string {
title := sectionTitleStyle(m.focus == focusLayout).Render("Keyboard Layout")
switch {
case m.layoutErr != nil:
return lipgloss.JoinVertical(lipgloss.Left, title, "", errorStyle().Render(m.layoutErr.Error()))
case m.currentLayout == "" && !m.cursorInit:
return lipgloss.JoinVertical(lipgloss.Left, title, "", ilovetui.S.Faint.Render("Loading current layout…"))
}
current := subtleStyle().Render("Current: ") + inputpkg.LayoutName(m.currentLayout) + subtleStyle().Render(" ("+m.currentLayout+")")
lines := []string{title, "", current}
switch {
case m.filtering:
lines = append(lines, m.filterInput.View())
case m.filterInput.Value() != "":
lines = append(lines, subtleStyle().Render(fmt.Sprintf("Filter: %q — press / to change", m.filterInput.Value())))
}
lines = append(lines, "")
list := m.visibleLayouts()
available := height - len(lines) - 2
if available < 1 {
available = 1
}
if len(list) == 0 {
lines = append(lines, ilovetui.S.Faint.Render("No layout matches the filter."))
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
p, start, end := paginate(m.cursor, len(list), available)
for i := start; i < end; i++ {
lines = append(lines, m.renderLayoutRow(list[i], i == m.cursor))
}
footer := renderPaginatorDots(p)
switch {
case m.layoutPending:
footer = mutedStyle().Render("applying…")
case m.layoutFeedback != "":
footer = errorStyle().Render(m.layoutFeedback)
}
lines = append(lines, "", footer)
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
func (m Model) renderLayoutRow(l inputpkg.Layout, selected bool) string {
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
if selected {
textStyle = textStyle.Bold(true)
if m.focus == focusLayout {
textStyle = textStyle.Foreground(ilovetui.S.Primary)
}
}
cursor := " "
if selected {
cursor = "> "
}
check := " "
if l.Code == m.currentLayout {
check = successStyle().Render(icons.I.Check)
}
return fmt.Sprintf("%s%s%s", cursor, check, textStyle.Render(l.Name+" ("+l.Code+")"))
}
+63
View File
@@ -0,0 +1,63 @@
package input
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/settuings/internal/keys"
)
// inputKeyMap renders the bottom help bar, adapting to which section
// currently has focus.
type inputKeyMap struct {
m Model
width int
}
// relabel reuses an existing binding's keys under a different help
// description, same trick the audio page uses for its volume keys.
func relabel(b key.Binding, desc string) key.Binding {
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(b.Help().Key, desc))
}
func (k inputKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
in := keys.Keys.Input
if k.m.checkErr != nil {
return []key.Binding{g.Help}
}
if k.m.filtering {
confirm := relabel(in.Select, "confirm")
cancel := relabel(g.Escape, "cancel")
return []key.Binding{confirm, cancel}
}
switch k.m.focus {
case focusMouse:
speedDown, speedUp := relabel(g.Left, "speed down"), relabel(g.Right, "speed up")
return []key.Binding{speedDown, speedUp, in.Reset, g.CycleFocus, g.Help}
default:
return []key.Binding{g.Up, g.Down, in.Select, in.Filter, in.Reset, g.CycleFocus, g.Help}
}
}
func (k inputKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global
in := keys.Keys.Input
if k.m.filtering {
confirm := relabel(in.Select, "confirm filter")
cancel := relabel(g.Escape, "cancel filter")
return keys.ChunkByWidth([]key.Binding{confirm, cancel}, k.width)
}
var bindings []key.Binding
switch {
case k.m.checkErr != nil:
bindings = nil
case k.m.focus == focusMouse:
speedDown, speedUp := relabel(g.Left, "speed down"), relabel(g.Right, "speed up")
bindings = []key.Binding{speedDown, speedUp, in.Reset, g.CycleFocus}
default:
bindings = []key.Binding{g.Up, g.Down, in.Select, in.Filter, in.Reset, g.CycleFocus}
}
bindings = append(bindings, g.CommonBindings()...)
return keys.ChunkByWidth(bindings, k.width)
}
+27
View File
@@ -0,0 +1,27 @@
package input
// checkMsg carries the result of input.CheckAvailable (hyprctl).
type checkMsg struct{ err error }
// tickMsg triggers a periodic refresh of layout + sensitivity while this
// page is active, so it reflects layout/sensitivity changes made elsewhere
// (hyprland.conf reload, another tool).
type tickMsg struct{}
type layoutMsg struct {
layout string
err error
}
type setLayoutMsg struct{ err error }
type sensitivityMsg struct {
value float64
err error
}
type setSensitivityMsg struct{ err error }
// resetMsg carries the result of resetting both the keyboard layout and
// mouse sensitivity to their hyprland.conf values.
type resetMsg struct{ err error }
+81
View File
@@ -0,0 +1,81 @@
// Package input provides the "Inputs" TUI page: keyboard layout and mouse
// sensitivity, controlled via hyprctl, in the same spirit as the power
// page's controller-power + status split.
package input
import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
)
// focusZone tracks which of the two sections (keyboard layout list, mouse
// sensitivity slider) currently receives Up/Down/Left/Right/Select.
type focusZone int
const (
focusLayout focusZone = iota
focusMouse
)
type Model struct {
width int
height int
help help.Model
focus focusZone
checkErr error // reason hyprctl is unusable; nil once confirmed OK
checked bool
layoutErr error
currentLayout string
cursor int
cursorInit bool // whether cursor has been snapped to the current layout yet
layoutPending bool
layoutFeedback string
filterInput textinput.Model
filtering bool
sensitivityErr error
sensitivity float64
sensitivityKnown bool
mousePending bool
mouseFeedback string
}
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{
help: ilovetui.NewHelp(),
filterInput: ti,
}
}
func (m Model) Init() tea.Cmd {
return nil
}
// Activate is called every time the user navigates onto this page. It
// re-checks hyprctl availability and fetches the current layout + mouse
// sensitivity, then starts the periodic auto-refresh loop.
func (m *Model) Activate() tea.Cmd {
return tea.Batch(checkCmd(), fetchLayoutCmd(), fetchSensitivityCmd(), tickCmd())
}
func (m Model) IsEditing() bool {
return m.filtering
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.help.SetWidth(w - 2)
m.filterInput.SetWidth(w - 4)
}
+118
View File
@@ -0,0 +1,118 @@
package input
import (
"fmt"
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
inputpkg "github.com/anotherhadi/settuings/internal/input"
"github.com/anotherhadi/settuings/internal/keys"
)
const sensitivityStep = 0.05
func (m *Model) handleSensitivity(msg sensitivityMsg) {
m.sensitivityErr = msg.err
if msg.err != nil {
return
}
m.sensitivity = msg.value
m.sensitivityKnown = true
}
func (m *Model) handleSetSensitivity(msg setSensitivityMsg) tea.Cmd {
m.mousePending = false
if msg.err != nil {
m.mouseFeedback = msg.err.Error()
return nil
}
m.mouseFeedback = ""
return fetchSensitivityCmd()
}
func (m *Model) updateMouse(msg tea.Msg) tea.Cmd {
keyMsg, ok := msg.(tea.KeyPressMsg)
if !ok {
return nil
}
g := keys.Keys.Global
switch {
case key.Matches(keyMsg, g.Left):
return m.adjustSensitivity(-sensitivityStep)
case key.Matches(keyMsg, g.Right):
return m.adjustSensitivity(sensitivityStep)
}
return nil
}
// adjustSensitivity nudges the mouse sensitivity by delta, updating it
// locally right away so the bar responds instantly instead of waiting on
// the round trip to hyprctl and back, mirroring the audio page's volume
// adjustment.
func (m *Model) adjustSensitivity(delta float64) tea.Cmd {
if !m.sensitivityKnown {
return nil
}
v := m.sensitivity + delta
switch {
case v < inputpkg.MinSensitivity:
v = inputpkg.MinSensitivity
case v > inputpkg.MaxSensitivity:
v = inputpkg.MaxSensitivity
}
m.sensitivity = v
m.mouseFeedback = ""
m.mousePending = true
return setSensitivityCmd(v)
}
func (m Model) renderMouse(width int) string {
title := sectionTitleStyle(m.focus == focusMouse).Render("Mouse Speed")
switch {
case m.sensitivityErr != nil:
return lipgloss.JoinVertical(lipgloss.Left, title, "", errorStyle().Render(m.sensitivityErr.Error()))
case !m.sensitivityKnown:
return lipgloss.JoinVertical(lipgloss.Left, title, "", ilovetui.S.Faint.Render("Loading mouse sensitivity…"))
}
lines := []string{title, "", renderSensitivityBar(m.sensitivity, width)}
switch {
case m.mousePending:
lines = append(lines, "", mutedStyle().Render("applying…"))
case m.mouseFeedback != "":
lines = append(lines, "", errorStyle().Render(m.mouseFeedback))
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
// renderSensitivityBar draws a static block-bar gauge for a sensitivity in
// [MinSensitivity, MaxSensitivity], in the same spirit as the audio page's
// volume bar and the power page's battery gauge.
func renderSensitivityBar(value float64, width int) string {
barWidth := width / 2
switch {
case barWidth < 10:
barWidth = 10
case barWidth > 30:
barWidth = 30
}
span := inputpkg.MaxSensitivity - inputpkg.MinSensitivity
filled := int((value-inputpkg.MinSensitivity)/span*float64(barWidth) + 0.5)
switch {
case filled > barWidth:
filled = barWidth
case filled < 0:
filled = 0
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
gauge := lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render("[" + bar + "]")
return fmt.Sprintf("%s %+.2f", gauge, value)
}
+44
View File
@@ -0,0 +1,44 @@
package input
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())
}
+26
View File
@@ -0,0 +1,26 @@
package input
import tea "charm.land/bubbletea/v2"
// applyReset kicks off a `hyprctl reload`, which restores the keyboard
// layout and mouse sensitivity to their hyprland.conf values (along with
// anything else set there); both sections are marked pending since a
// reload touches them together.
func (m *Model) applyReset() tea.Cmd {
m.layoutPending = true
m.mousePending = true
m.layoutFeedback = ""
m.mouseFeedback = ""
return resetCmd()
}
func (m *Model) handleReset(msg resetMsg) tea.Cmd {
m.layoutPending = false
m.mousePending = false
if msg.err != nil {
m.layoutFeedback = msg.err.Error()
m.mouseFeedback = msg.err.Error()
return nil
}
return tea.Batch(fetchLayoutCmd(), fetchSensitivityCmd())
}
+33
View File
@@ -0,0 +1,33 @@
package input
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)
}
// sectionTitleStyle highlights a section's title when it holds keyboard
// focus, so Tab-cycling between the layout list and the mouse slider is
// visible even before the user starts navigating within a section.
func sectionTitleStyle(focused bool) lipgloss.Style {
style := lipgloss.NewStyle().Bold(true)
if focused {
style = style.Foreground(ilovetui.S.Primary)
}
return style
}
+29
View File
@@ -0,0 +1,29 @@
package input
import (
"strings"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/settuings/internal/icons"
)
// renderTabBar draws a header matching the power 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.Inputs + "Inputs")
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
}
+87
View File
@@ -0,0 +1,87 @@
package input
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
return m, tea.Batch(fetchLayoutCmd(), fetchSensitivityCmd())
}
return m, nil
case tickMsg:
if m.checkErr != nil {
// Not manually retriable anymore (no refresh key): keep
// re-checking on the tick so the page self-heals once
// hyprctl/Hyprland becomes available again.
return m, tea.Batch(tickCmd(), checkCmd())
}
return m, tea.Batch(tickCmd(), fetchLayoutCmd(), fetchSensitivityCmd())
case layoutMsg:
m.handleLayout(msg)
return m, nil
case setLayoutMsg:
return m, m.handleSetLayout(msg)
case sensitivityMsg:
m.handleSensitivity(msg)
return m, nil
case setSensitivityMsg:
return m, m.handleSetSensitivity(msg)
case resetMsg:
return m, m.handleReset(msg)
case tea.KeyPressMsg:
if m.filtering {
// Every key goes straight to the filter box while typing (a
// query like "tab" or "r" must not also trigger CycleFocus or
// Reset), same precedence the About page's search box uses.
return m, m.updateLayout(msg)
}
if key.Matches(msg, keys.Keys.Global.Help) {
m.help.ShowAll = !m.help.ShowAll
return m, nil
}
if m.checkErr != nil {
return m, nil
}
if key.Matches(msg, keys.Keys.Global.CycleFocus) {
m.cycleFocus()
return m, nil
}
if key.Matches(msg, keys.Keys.Input.Reset) {
return m, m.applyReset()
}
switch m.focus {
case focusLayout:
return m, m.updateLayout(msg)
case focusMouse:
return m, m.updateMouse(msg)
}
}
return m, nil
}
func (m *Model) cycleFocus() {
if m.focus == focusLayout {
m.focus = focusMouse
} else {
m.focus = focusLayout
}
}
+103
View File
@@ -0,0 +1,103 @@
package input
import (
"errors"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/settuings/internal/input"
)
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 hyprctl…")
case m.checkErr != nil:
body = m.renderUnavailable()
default:
body = m.renderBody(innerW)
}
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 := inputKeyMap{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
_, height := m.contentBudget()
if width < splitThreshold {
return lipgloss.JoinVertical(lipgloss.Left, m.renderLayout(width, height/2), "", m.renderMouse(width))
}
leftW := width * 3 / 5
if leftW < 30 {
leftW = 30
}
rightW := width - leftW - gap
left := lipgloss.NewStyle().Width(leftW).Render(m.renderLayout(leftW, height))
right := lipgloss.NewStyle().Width(rightW).Render(m.renderMouse(rightW))
return lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", gap), right)
}
func (m Model) renderUnavailable() string {
title := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Error).Render("Inputs page unavailable")
var hint string
switch {
case errors.Is(m.checkErr, input.ErrNotFound):
hint = "hyprctl was not found in PATH. This page only works under Hyprland."
default:
hint = m.checkErr.Error()
}
return lipgloss.JoinVertical(lipgloss.Left,
title,
"",
subtleStyle().Render(hint),
"",
mutedStyle().Render("Retrying automatically…"),
)
}
+139
View File
@@ -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)
}
+40
View File
@@ -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}
}
}
+35
View File
@@ -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)
}
+23
View File
@@ -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
}
+61
View File
@@ -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)
}
+148
View File
@@ -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()
}
+22
View File
@@ -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)
}
+29
View File
@@ -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
}
+45
View File
@@ -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
}
+69
View File
@@ -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)
}
+7 -71
View File
@@ -21,10 +21,6 @@ schema = 3
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='
@@ -77,10 +73,6 @@ schema = 3
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='
@@ -89,42 +81,22 @@ schema = 3
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/godbus/dbus/v5']
version = 'v5.2.2'
hash = 'sha256-j+LicPlXcB1R3oVFPa8cd4yVJLpBx+ca5gXmHqOJSN8='
[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='
@@ -137,18 +109,10 @@ schema = 3
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='
@@ -157,14 +121,6 @@ schema = 3
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='
@@ -205,14 +161,6 @@ schema = 3
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='
@@ -233,22 +181,10 @@ schema = 3
version = 'v0.37.0'
hash = 'sha256-8XDOnlPIybcDRy89fkjG5VqtIt5Ku+LmaqYhgKl7i1E='
[mod.'gopkg.in/check.v1']
version = 'v1.0.0-20201130134442-10cb98267c6c'
hash = 'sha256-VlIpM2r/OD+kkyItn6vW35dyc0rtkJufA93rjFyzncs='
[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='
+1 -1
View File
@@ -3,7 +3,7 @@
buildGoApplication,
}: let
pname = "settuings";
version = "0.0.1";
version = "0.0.2";
ldflags = ["-s" "-w" "-X main.version=${version}"];
pkg = buildGoApplication {
inherit pname version ldflags;
-2
View File
@@ -8,7 +8,6 @@
hooks = {
gofmt.enable = true;
govet.enable = true;
stylua.enable = true;
gomod2nix = {
enable = true;
@@ -47,7 +46,6 @@ in
govulncheck
python3
doctoc
stylua
trufflehog
gomod2nixPkgs.gomod2nix
]