From 7067717b51c242a3094041e384a9b5b218cc0911 Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:10:09 +0200 Subject: [PATCH] Add "power" page Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- README.md | 3 +- internal/config/config.go | 5 +- internal/config/default_config.yaml | 5 + internal/config/keybindings.go | 6 + internal/icons/icons.go | 16 ++- internal/keys/keys.go | 2 + internal/keys/power.go | 22 ++++ internal/power/battery.go | 175 ++++++++++++++++++++++++++++ internal/power/profiles.go | 103 ++++++++++++++++ internal/ui/app/model.go | 27 +++-- internal/ui/app/pages.go | 46 ++++++++ internal/ui/app/sidebar.go | 2 +- internal/ui/app/update.go | 2 +- internal/ui/power/battery.go | 139 ++++++++++++++++++++++ internal/ui/power/commands.go | 40 +++++++ internal/ui/power/keymap.go | 35 ++++++ internal/ui/power/messages.go | 23 ++++ internal/ui/power/model.go | 61 ++++++++++ internal/ui/power/profiles.go | 148 +++++++++++++++++++++++ internal/ui/power/style.go | 22 ++++ internal/ui/power/tabbar.go | 29 +++++ internal/ui/power/update.go | 45 +++++++ internal/ui/power/view.go | 69 +++++++++++ 23 files changed, 1005 insertions(+), 20 deletions(-) create mode 100644 internal/keys/power.go create mode 100644 internal/power/battery.go create mode 100644 internal/power/profiles.go create mode 100644 internal/ui/power/battery.go create mode 100644 internal/ui/power/commands.go create mode 100644 internal/ui/power/keymap.go create mode 100644 internal/ui/power/messages.go create mode 100644 internal/ui/power/model.go create mode 100644 internal/ui/power/profiles.go create mode 100644 internal/ui/power/style.go create mode 100644 internal/ui/power/tabbar.go create mode 100644 internal/ui/power/update.go create mode 100644 internal/ui/power/view.go diff --git a/README.md b/README.md index f1b21db..ae242ad 100644 --- a/README.md +++ b/README.md @@ -36,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. - **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. - **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 @@ -90,7 +91,7 @@ Flags: --add-default-config copy the default config file to the config path and exit -c, --config string path to config file -h, --help help for settuings - -p, --page string page to open at launch (About, Network, Bluetooth, Audio) + -p, --page string page to open at launch (About, Network, Bluetooth, Audio, Power) -v, --version version for settuings ``` diff --git a/internal/config/config.go b/internal/config/config.go index 778784a..db21da9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,8 +23,9 @@ type Config struct { } `mapstructure:"app"` TUI struct { - UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"` - DefaultSidebarState string `mapstructure:"default_sidebar_state"` + UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"` + DefaultSidebarState string `mapstructure:"default_sidebar_state"` + HiddenPages []string `mapstructure:"hidden_pages"` } `mapstructure:"tui"` Keybindings Keybindings `mapstructure:"keybindings"` diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml index 309dd55..55edf0d 100644 --- a/internal/config/default_config.yaml +++ b/internal/config/default_config.yaml @@ -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,7 @@ keybindings: refresh: "r" mute: "m" test: "t" + + power: + select: "enter" + refresh: "r" diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 31f7a88..989d2fc 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -54,10 +54,16 @@ type AudioKeys struct { Test string `mapstructure:"test"` } +type PowerKeys struct { + Select string `mapstructure:"select"` + Refresh string `mapstructure:"refresh"` +} + 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"` } diff --git a/internal/icons/icons.go b/internal/icons/icons.go index c54ee96..ab8dd81 100644 --- a/internal/icons/icons.go +++ b/internal/icons/icons.go @@ -11,6 +11,7 @@ type Icons struct { Network string Bluetooth string Audio string + Power string Wifi string Ethernet string @@ -18,9 +19,10 @@ type Icons struct { Check string Lock string - Output string - Input string - Apps string + Output string + Input string + Apps string + Battery string } var I *Icons @@ -36,6 +38,7 @@ func Init(cfg *config.Config) { Network: "󰛳 ", Bluetooth: "󰂯 ", Audio: "󰓃 ", + Power: "󰐥 ", Wifi: "󰤨 ", Ethernet: "󰈀 ", @@ -43,9 +46,10 @@ func Init(cfg *config.Config) { Check: "󰄬 ", Lock: "󰌾 ", - Output: "󰕾 ", - Input: "󰍬 ", - Apps: "󰀻 ", + Output: "󰕾 ", + Input: "󰍬 ", + Apps: "󰀻 ", + Battery: "󰁹 ", } } else { I = &Icons{} diff --git a/internal/keys/keys.go b/internal/keys/keys.go index 60ae1a0..78343ea 100644 --- a/internal/keys/keys.go +++ b/internal/keys/keys.go @@ -13,6 +13,7 @@ type KeyMap struct { Network NetworkKeyMap Bluetooth BluetoothKeyMap Audio AudioKeyMap + Power PowerKeyMap } var Keys *KeyMap @@ -25,6 +26,7 @@ func Init(cfg *config.Config) { Network: newNetworkKeyMap(kb.Network), Bluetooth: newBluetoothKeyMap(kb.Bluetooth), Audio: newAudioKeyMap(kb.Audio), + Power: newPowerKeyMap(kb.Power), } } diff --git a/internal/keys/power.go b/internal/keys/power.go new file mode 100644 index 0000000..a9634dc --- /dev/null +++ b/internal/keys/power.go @@ -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} +} diff --git a/internal/power/battery.go b/internal/power/battery.go new file mode 100644 index 0000000..a141deb --- /dev/null +++ b/internal/power/battery.go @@ -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 +} diff --git a/internal/power/profiles.go b/internal/power/profiles.go new file mode 100644 index 0000000..f2c1471 --- /dev/null +++ b/internal/power/profiles.go @@ -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 +} diff --git a/internal/ui/app/model.go b/internal/ui/app/model.go index 578b99e..7b09930 100644 --- a/internal/ui/app/model.go +++ b/internal/ui/app/model.go @@ -14,22 +14,28 @@ import ( bluetoothUI "github.com/anotherhadi/settuings/internal/ui/bluetooth" notificationsUI "github.com/anotherhadi/settuings/internal/ui/components/notifications" networkUI "github.com/anotherhadi/settuings/internal/ui/network" + 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 never leaves a gap in the shortcuts. It must run after +// 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 } return m -}() +} type Model struct { - page page - logPath string - fatalErr error - logFileErr error + page page + pageShortcuts map[string]page + logPath string + fatalErr error + logFileErr error width int height int @@ -38,6 +44,7 @@ type Model struct { network networkUI.Model bluetooth bluetoothUI.Model audio audioUI.Model + power powerUI.Model notifications notificationsUI.Model } @@ -61,10 +68,12 @@ func New(initialPage string) (Model, error) { m := Model{ page: startPage, + pageShortcuts: buildPageShortcuts(), about: aboutUI.New(), network: networkUI.New(), bluetooth: bluetoothUI.New(), audio: audioUI.New(), + power: powerUI.New(), notifications: notificationsUI.New(), sidebarState: sidebarState(cfg.TUI.DefaultSidebarState), logPath: logPath, @@ -83,7 +92,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()} if m.page != pageAbout { cmds = append(cmds, m.activatePage(m.page)) } diff --git a/internal/ui/app/pages.go b/internal/ui/app/pages.go index dbd61d1..8c9db34 100644 --- a/internal/ui/app/pages.go +++ b/internal/ui/app/pages.go @@ -4,11 +4,13 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/settuings/internal/config" "github.com/anotherhadi/settuings/internal/icons" aboutUI "github.com/anotherhadi/settuings/internal/ui/about" audioUI "github.com/anotherhadi/settuings/internal/ui/audio" bluetoothUI "github.com/anotherhadi/settuings/internal/ui/bluetooth" networkUI "github.com/anotherhadi/settuings/internal/ui/network" + powerUI "github.com/anotherhadi/settuings/internal/ui/power" ) type page string @@ -18,6 +20,7 @@ const ( pageNetwork page = "Network" pageBluetooth page = "Bluetooth" pageAudio page = "Audio" + pagePower page = "Power" ) // 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() }, 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 @@ -125,3 +142,32 @@ func lookupPage(name string) (page, bool) { } 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 +} diff --git a/internal/ui/app/sidebar.go b/internal/ui/app/sidebar.go index 71d8e3b..518e1ed 100644 --- a/internal/ui/app/sidebar.go +++ b/internal/ui/app/sidebar.go @@ -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 visiblePages() { selected := entry.id == m.page badgeStyle, textStyle := badgeNormal, textNormal if selected { diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go index 2db0048..353ab10 100644 --- a/internal/ui/app/update.go +++ b/internal/ui/app/update.go @@ -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 { diff --git a/internal/ui/power/battery.go b/internal/ui/power/battery.go new file mode 100644 index 0000000..373660e --- /dev/null +++ b/internal/ui/power/battery.go @@ -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) +} diff --git a/internal/ui/power/commands.go b/internal/ui/power/commands.go new file mode 100644 index 0000000..c30b408 --- /dev/null +++ b/internal/ui/power/commands.go @@ -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} + } +} diff --git a/internal/ui/power/keymap.go b/internal/ui/power/keymap.go new file mode 100644 index 0000000..e666169 --- /dev/null +++ b/internal/ui/power/keymap.go @@ -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) +} diff --git a/internal/ui/power/messages.go b/internal/ui/power/messages.go new file mode 100644 index 0000000..93be91a --- /dev/null +++ b/internal/ui/power/messages.go @@ -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 +} diff --git a/internal/ui/power/model.go b/internal/ui/power/model.go new file mode 100644 index 0000000..c0130ae --- /dev/null +++ b/internal/ui/power/model.go @@ -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) +} diff --git a/internal/ui/power/profiles.go b/internal/ui/power/profiles.go new file mode 100644 index 0000000..cd34861 --- /dev/null +++ b/internal/ui/power/profiles.go @@ -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() +} diff --git a/internal/ui/power/style.go b/internal/ui/power/style.go new file mode 100644 index 0000000..463ba0e --- /dev/null +++ b/internal/ui/power/style.go @@ -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) +} diff --git a/internal/ui/power/tabbar.go b/internal/ui/power/tabbar.go new file mode 100644 index 0000000..24d804e --- /dev/null +++ b/internal/ui/power/tabbar.go @@ -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 +} diff --git a/internal/ui/power/update.go b/internal/ui/power/update.go new file mode 100644 index 0000000..e02bb4d --- /dev/null +++ b/internal/ui/power/update.go @@ -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 +} diff --git a/internal/ui/power/view.go b/internal/ui/power/view.go new file mode 100644 index 0000000..38508e0 --- /dev/null +++ b/internal/ui/power/view.go @@ -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) +}