mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 16:18:24 +02:00
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package power
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/power"
|
||||
)
|
||||
|
||||
func (m *Model) handleBattery(msg batteryMsg) {
|
||||
m.batteryLoading = false
|
||||
m.batteryErr = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.batteries = msg.batteries
|
||||
m.ac = msg.ac
|
||||
}
|
||||
|
||||
func (m Model) renderBattery(width int) string {
|
||||
title := lipgloss.NewStyle().Bold(true).Render("Battery")
|
||||
|
||||
switch {
|
||||
case m.batteryLoading:
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, "", ilovetui.S.Faint.Render("Reading battery status…"))
|
||||
case m.batteryErr != nil:
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, "", errorStyle().Render(m.batteryErr.Error()))
|
||||
}
|
||||
|
||||
lines := []string{title, ""}
|
||||
if len(m.batteries) == 0 {
|
||||
lines = append(lines, subtleStyle().Render("No battery detected — this looks like a desktop."))
|
||||
}
|
||||
for i, b := range m.batteries {
|
||||
if i > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, renderBatteryInfo(b, width)...)
|
||||
}
|
||||
|
||||
lines = append(lines, "", subtleStyle().Render("Power source: ")+acLabel(m.ac))
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
}
|
||||
|
||||
func renderBatteryInfo(b power.Battery, width int) []string {
|
||||
rows := []string{
|
||||
subtleStyle().Render("Name: ") + b.Name,
|
||||
subtleStyle().Render("Status: ") + statusLabel(b.Status),
|
||||
subtleStyle().Render("Charge: ") + renderBatteryBar(b.Capacity, width),
|
||||
}
|
||||
if remaining := timeRemainingLabel(b); remaining != "" {
|
||||
rows = append(rows, subtleStyle().Render("Time left: ")+remaining)
|
||||
}
|
||||
if b.Health >= 0 {
|
||||
rows = append(rows, subtleStyle().Render("Health: ")+fmt.Sprintf("%d%%", b.Health))
|
||||
}
|
||||
if b.CycleCount >= 0 {
|
||||
rows = append(rows, subtleStyle().Render("Cycle count: ")+fmt.Sprintf("%d", b.CycleCount))
|
||||
}
|
||||
if b.Technology != "" {
|
||||
rows = append(rows, subtleStyle().Render("Technology: ")+b.Technology)
|
||||
}
|
||||
if model := strings.TrimSpace(b.Manufacturer + " " + b.Model); model != "" {
|
||||
rows = append(rows, subtleStyle().Render("Model: ")+model)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func timeRemainingLabel(b power.Battery) string {
|
||||
switch {
|
||||
case b.TimeToEmpty != "":
|
||||
return b.TimeToEmpty + " until empty"
|
||||
case b.TimeToFull != "":
|
||||
return b.TimeToFull + " until full"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func statusLabel(status string) string {
|
||||
switch status {
|
||||
case "Charging":
|
||||
return successStyle().Render("⚡ Charging")
|
||||
case "Full":
|
||||
return successStyle().Render(status)
|
||||
case "Discharging":
|
||||
return status
|
||||
default:
|
||||
return mutedStyle().Render(status)
|
||||
}
|
||||
}
|
||||
|
||||
func acLabel(ac power.ACStatus) string {
|
||||
switch {
|
||||
case !ac.Present:
|
||||
return mutedStyle().Render("unknown")
|
||||
case ac.Online:
|
||||
return successStyle().Render("connected")
|
||||
default:
|
||||
return mutedStyle().Render("disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
// renderBatteryBar draws a static block-bar gauge for the charge level,
|
||||
// colored by how low it is, same spirit as the audio page's volume bar.
|
||||
func renderBatteryBar(pct, width int) string {
|
||||
if pct < 0 {
|
||||
return subtleStyle().Render("unknown")
|
||||
}
|
||||
|
||||
barWidth := width / 3
|
||||
switch {
|
||||
case barWidth < 10:
|
||||
barWidth = 10
|
||||
case barWidth > 30:
|
||||
barWidth = 30
|
||||
}
|
||||
|
||||
filled := pct * barWidth / 100
|
||||
switch {
|
||||
case filled > barWidth:
|
||||
filled = barWidth
|
||||
case filled < 0:
|
||||
filled = 0
|
||||
}
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||
|
||||
color := ilovetui.S.Success
|
||||
switch {
|
||||
case pct <= 15:
|
||||
color = ilovetui.S.Error
|
||||
case pct <= 35:
|
||||
color = ilovetui.S.Warning
|
||||
}
|
||||
gauge := lipgloss.NewStyle().Foreground(color).Render("[" + bar + "]")
|
||||
return fmt.Sprintf("%s %3d%%", gauge, pct)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package power
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/settuings/internal/power"
|
||||
)
|
||||
|
||||
const tickInterval = 5 * time.Second
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} })
|
||||
}
|
||||
|
||||
func checkCmd() tea.Cmd {
|
||||
return func() tea.Msg { return checkMsg{err: power.CheckAvailable()} }
|
||||
}
|
||||
|
||||
func fetchProfilesCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
profiles, err := power.ListProfiles()
|
||||
return profilesMsg{profiles: profiles, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func setProfileCmd(name string) tea.Cmd {
|
||||
return func() tea.Msg { return setProfileMsg{err: power.SetActive(name)} }
|
||||
}
|
||||
|
||||
func fetchBatteryCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
batteries, err := power.ReadBatteries()
|
||||
if err != nil {
|
||||
return batteryMsg{err: err}
|
||||
}
|
||||
ac, err := power.ReadAC()
|
||||
return batteryMsg{batteries: batteries, ac: ac, err: err}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package power
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
// powerKeyMap renders the bottom help bar.
|
||||
type powerKeyMap struct {
|
||||
m Model
|
||||
width int
|
||||
}
|
||||
|
||||
func (k powerKeyMap) ShortHelp() []key.Binding {
|
||||
g := keys.Keys.Global
|
||||
p := keys.Keys.Power
|
||||
if k.m.checkErr != nil {
|
||||
return []key.Binding{p.Refresh, g.Help}
|
||||
}
|
||||
return []key.Binding{g.Up, g.Down, p.Select, p.Refresh, g.Help}
|
||||
}
|
||||
|
||||
func (k powerKeyMap) FullHelp() [][]key.Binding {
|
||||
g := keys.Keys.Global
|
||||
p := keys.Keys.Power
|
||||
|
||||
var bindings []key.Binding
|
||||
if k.m.checkErr != nil {
|
||||
bindings = []key.Binding{p.Refresh}
|
||||
} else {
|
||||
bindings = []key.Binding{g.Up, g.Down, p.Select, p.Refresh}
|
||||
}
|
||||
bindings = append(bindings, g.CommonBindings()...)
|
||||
return keys.ChunkByWidth(bindings, k.width)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package power
|
||||
|
||||
import "github.com/anotherhadi/settuings/internal/power"
|
||||
|
||||
// checkMsg carries the result of power.CheckAvailable (powerprofilesctl).
|
||||
type checkMsg struct{ err error }
|
||||
|
||||
// tickMsg triggers a periodic refresh of profiles + battery while this page
|
||||
// is active.
|
||||
type tickMsg struct{}
|
||||
|
||||
type profilesMsg struct {
|
||||
profiles []power.Profile
|
||||
err error
|
||||
}
|
||||
|
||||
type setProfileMsg struct{ err error }
|
||||
|
||||
type batteryMsg struct {
|
||||
batteries []power.Battery
|
||||
ac power.ACStatus
|
||||
err error
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package power provides the "Power" TUI page: power-profile selection (via
|
||||
// powerprofilesctl) and battery/AC status, in the same spirit as the
|
||||
// bluetooth page's controller power + device state.
|
||||
package power
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/power"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
width int
|
||||
height int
|
||||
help help.Model
|
||||
|
||||
checkErr error // reason powerprofilesctl is unusable; nil once confirmed OK
|
||||
checked bool
|
||||
|
||||
profilesLoading bool
|
||||
profilesErr error
|
||||
profiles []power.Profile
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
|
||||
batteryLoading bool
|
||||
batteryErr error
|
||||
batteries []power.Battery
|
||||
ac power.ACStatus
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
return Model{
|
||||
help: ilovetui.NewHelp(),
|
||||
profilesLoading: true,
|
||||
batteryLoading: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Activate is called every time the user navigates onto this page. It
|
||||
// re-checks powerprofilesctl availability, fetches profiles + battery
|
||||
// state, and starts the periodic auto-refresh loop.
|
||||
func (m *Model) Activate() tea.Cmd {
|
||||
return tea.Batch(checkCmd(), fetchProfilesCmd(), fetchBatteryCmd(), tickCmd())
|
||||
}
|
||||
|
||||
func (m Model) IsEditing() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
m.help.SetWidth(w - 2)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package power
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/icons"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
"github.com/anotherhadi/settuings/internal/power"
|
||||
)
|
||||
|
||||
func (m *Model) handleProfiles(msg profilesMsg) {
|
||||
m.profilesLoading = false
|
||||
m.profilesErr = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.profiles = msg.profiles
|
||||
if m.cursor >= len(m.profiles) {
|
||||
m.cursor = max(0, len(m.profiles)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleSetProfile(msg setProfileMsg) tea.Cmd {
|
||||
m.pending = false
|
||||
if msg.err != nil {
|
||||
m.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
m.feedback = ""
|
||||
return fetchProfilesCmd()
|
||||
}
|
||||
|
||||
func (m Model) selectedProfile() *power.Profile {
|
||||
if m.cursor < 0 || m.cursor >= len(m.profiles) {
|
||||
return nil
|
||||
}
|
||||
return &m.profiles[m.cursor]
|
||||
}
|
||||
|
||||
func (m *Model) updateProfiles(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if m.checkErr != nil {
|
||||
if key.Matches(keyMsg, keys.Keys.Power.Refresh) {
|
||||
return checkCmd()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
g := keys.Keys.Global
|
||||
p := keys.Keys.Power
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.cursor < len(m.profiles)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, p.Refresh):
|
||||
return tea.Batch(fetchProfilesCmd(), fetchBatteryCmd())
|
||||
case key.Matches(keyMsg, p.Select):
|
||||
return m.applySelected()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) applySelected() tea.Cmd {
|
||||
sel := m.selectedProfile()
|
||||
if sel == nil || sel.Active {
|
||||
return nil
|
||||
}
|
||||
m.pending = true
|
||||
return setProfileCmd(sel.Name)
|
||||
}
|
||||
|
||||
func (m Model) renderProfiles() string {
|
||||
title := lipgloss.NewStyle().Bold(true).Render("Power Profile")
|
||||
|
||||
switch {
|
||||
case !m.checked:
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, "", ilovetui.S.Faint.Render("Checking powerprofilesctl…"))
|
||||
case m.checkErr != nil:
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, "", subtleStyle().Render(unavailableHint(m.checkErr)))
|
||||
case m.profilesLoading:
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, "", ilovetui.S.Faint.Render("Loading profiles…"))
|
||||
case m.profilesErr != nil:
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, "", errorStyle().Render(m.profilesErr.Error()))
|
||||
}
|
||||
|
||||
lines := []string{title, ""}
|
||||
for i, p := range m.profiles {
|
||||
lines = append(lines, m.renderProfileRow(p, i == m.cursor))
|
||||
}
|
||||
switch {
|
||||
case m.pending:
|
||||
lines = append(lines, "", mutedStyle().Render("applying…"))
|
||||
case m.feedback != "":
|
||||
lines = append(lines, "", errorStyle().Render(m.feedback))
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
}
|
||||
|
||||
func (m Model) renderProfileRow(p power.Profile, selected bool) string {
|
||||
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||
if selected {
|
||||
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
|
||||
}
|
||||
cursor := " "
|
||||
if selected {
|
||||
cursor = "> "
|
||||
}
|
||||
check := " "
|
||||
if p.Active {
|
||||
check = successStyle().Render(icons.I.Check)
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s", cursor, check, textStyle.Render(displayName(p.Name)))
|
||||
}
|
||||
|
||||
// displayName turns a powerprofilesctl identifier ("power-saver") into a
|
||||
// readable label ("Power Saver").
|
||||
func displayName(name string) string {
|
||||
words := strings.Split(strings.ReplaceAll(name, "-", " "), " ")
|
||||
for i, w := range words {
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
words[i] = strings.ToUpper(w[:1]) + w[1:]
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
func unavailableHint(err error) string {
|
||||
if errors.Is(err, power.ErrNotFound) {
|
||||
return "powerprofilesctl was not found in PATH.\nInstall power-profiles-daemon to select a profile."
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package power
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
func errorStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(ilovetui.S.Error)
|
||||
}
|
||||
|
||||
func successStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(ilovetui.S.Success)
|
||||
}
|
||||
|
||||
func subtleStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
|
||||
}
|
||||
|
||||
func mutedStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(ilovetui.S.Muted)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package power
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/icons"
|
||||
)
|
||||
|
||||
// renderTabBar draws a header matching the bluetooth page's tab bar: a
|
||||
// single always-active label, since this page has no tabs of its own.
|
||||
func renderTabBar(width int) string {
|
||||
style := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 2).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Primary)
|
||||
|
||||
label := style.Render(icons.I.Power + "Power")
|
||||
|
||||
filler := width - lipgloss.Width(label)
|
||||
if filler > 0 {
|
||||
label = lipgloss.JoinHorizontal(lipgloss.Bottom, label,
|
||||
lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Primary).
|
||||
Render(strings.Repeat(" ", filler)))
|
||||
}
|
||||
return label
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package power
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case checkMsg:
|
||||
wasUnavailable := m.checkErr != nil
|
||||
m.checked = true
|
||||
m.checkErr = msg.err
|
||||
if msg.err == nil && wasUnavailable {
|
||||
// recovered: resume fetching profiles
|
||||
return m, fetchProfilesCmd()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
cmds := []tea.Cmd{tickCmd(), fetchBatteryCmd()}
|
||||
if m.checkErr == nil {
|
||||
cmds = append(cmds, fetchProfilesCmd())
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case profilesMsg:
|
||||
m.handleProfiles(msg)
|
||||
return m, nil
|
||||
case setProfileMsg:
|
||||
return m, m.handleSetProfile(msg)
|
||||
case batteryMsg:
|
||||
m.handleBattery(msg)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if key.Matches(msg, keys.Keys.Global.Help) {
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
return m, nil
|
||||
}
|
||||
return m, m.updateProfiles(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package power
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
func windowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ilovetui.S.Subtle).
|
||||
Padding(1, 2)
|
||||
}
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
statusBar := m.renderStatusBar()
|
||||
innerW, innerH := m.contentBudget()
|
||||
tabBar := renderTabBar(innerW)
|
||||
|
||||
box := lipgloss.NewStyle().Width(innerW).Height(innerH).Render(m.renderBody(innerW))
|
||||
content := windowStyle().Render(lipgloss.JoinVertical(lipgloss.Left, tabBar, box))
|
||||
|
||||
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, content, statusBar))
|
||||
}
|
||||
|
||||
func (m Model) renderStatusBar() string {
|
||||
km := powerKeyMap{m: m, width: m.width}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km))
|
||||
}
|
||||
|
||||
// contentBudget returns the exact space available inside the bordered page
|
||||
// box, below the tab bar and above the status bar.
|
||||
func (m Model) contentBudget() (w, h int) {
|
||||
frameW := windowStyle().GetHorizontalFrameSize()
|
||||
frameH := windowStyle().GetVerticalFrameSize()
|
||||
w = m.width - frameW
|
||||
|
||||
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
|
||||
tabBarH := lipgloss.Height(renderTabBar(w))
|
||||
|
||||
h = m.height - tabBarH - frameH - statusH
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
return w, h
|
||||
}
|
||||
|
||||
// renderBody lays out the two sections side by side once there's enough
|
||||
// width for both to breathe, and stacks them otherwise.
|
||||
func (m Model) renderBody(width int) string {
|
||||
const splitThreshold = 60
|
||||
const gap = 3
|
||||
|
||||
if width < splitThreshold {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, m.renderProfiles(), "", m.renderBattery(width))
|
||||
}
|
||||
|
||||
leftW := width / 3
|
||||
if leftW < 20 {
|
||||
leftW = 20
|
||||
}
|
||||
rightW := width - leftW - gap
|
||||
left := lipgloss.NewStyle().Width(leftW).Render(m.renderProfiles())
|
||||
right := lipgloss.NewStyle().Width(rightW).Render(m.renderBattery(rightW))
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", gap), right)
|
||||
}
|
||||
Reference in New Issue
Block a user