Files
2026-08-13 18:12:44 +02:00

206 lines
6.9 KiB
Go

package app
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
const (
pageAbout page = "About"
pageNetwork page = "Network"
pageBluetooth page = "Bluetooth"
pageAudio page = "Audio"
pagePower page = "Power"
pageInputs page = "Inputs"
)
// pageEntry describes a page and all its integration hooks.
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.
update func(m *Model, msg tea.Msg) tea.Cmd
// isEditing reports whether the page is in text-editing mode.
isEditing func(m *Model) bool
// resize propagates a new (w, h) to the page model.
resize func(m *Model, w, h int)
// hasUpdate reports whether the page has unseen updates.
hasUpdate func(m *Model) bool
// activate is called once when the user navigates onto this page.
// nil = no-op.
activate func(m *Model) tea.Cmd
// deactivate is called synchronously once when the user navigates away
// from this page (or quits the app). nil = no-op. Unlike activate, this
// runs synchronously rather than as a tea.Cmd: pages that hold a
// background OS process (e.g. Audio's live input-level meter) need it
// torn down immediately, not racing an async command against exit.
deactivate func(m *Model)
}
var pageRegistry = []pageEntry{
{
id: pageAbout,
icon: func() string { return icons.I.About },
render: func(m *Model) string { return m.about.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.about.Update(msg)
m.about = updated.(aboutUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.about.IsEditing() },
resize: func(m *Model, w, h int) { m.about.SetSize(w, h) },
},
{
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 {
updated, cmd := m.network.Update(msg)
m.network = updated.(networkUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.network.IsEditing() },
resize: func(m *Model, w, h int) { m.network.SetSize(w, h) },
activate: func(m *Model) tea.Cmd { return m.network.Activate() },
},
{
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 {
updated, cmd := m.bluetooth.Update(msg)
m.bluetooth = updated.(bluetoothUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.bluetooth.IsEditing() },
resize: func(m *Model, w, h int) { m.bluetooth.SetSize(w, h) },
activate: func(m *Model) tea.Cmd { return m.bluetooth.Activate() },
},
{
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 {
updated, cmd := m.audio.Update(msg)
m.audio = updated.(audioUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.audio.IsEditing() },
resize: func(m *Model, w, h int) { m.audio.SetSize(w, h) },
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
// with the --page flag.
func PageNames() []string {
names := make([]string, len(pageRegistry))
for i, e := range pageRegistry {
names[i] = string(e.id)
}
return names
}
// lookupPage resolves a case-insensitive page name (as passed on the
// command line) to its registry id.
func lookupPage(name string) (page, bool) {
for _, e := range pageRegistry {
if strings.EqualFold(string(e.id), name) {
return e.id, true
}
}
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
}