Add "power" page

Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-08-13 12:10:09 +02:00
parent d54817d62c
commit 7067717b51
23 changed files with 1005 additions and 20 deletions
+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)
}