From fc35732dbf2a47de8377211abda2bc396f69a17e Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:12:44 +0200 Subject: [PATCH] only show what is available Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- internal/audio/wpctl.go | 8 ++++ internal/bluetooth/bluetoothctl.go | 8 ++++ internal/input/hyprctl.go | 8 ++++ internal/network/nmcli.go | 8 ++++ internal/power/profiles.go | 8 ++++ internal/ui/app/model.go | 12 +++--- internal/ui/app/pages.go | 60 +++++++++++++++++++----------- internal/ui/app/sidebar.go | 2 +- 8 files changed, 86 insertions(+), 28 deletions(-) diff --git a/internal/audio/wpctl.go b/internal/audio/wpctl.go index e0ceaf5..bb0b3f3 100644 --- a/internal/audio/wpctl.go +++ b/internal/audio/wpctl.go @@ -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() diff --git a/internal/bluetooth/bluetoothctl.go b/internal/bluetooth/bluetoothctl.go index fa28d82..5418da1 100644 --- a/internal/bluetooth/bluetoothctl.go +++ b/internal/bluetooth/bluetoothctl.go @@ -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 diff --git a/internal/input/hyprctl.go b/internal/input/hyprctl.go index 8dcda26..ed0cd36 100644 --- a/internal/input/hyprctl.go +++ b/internal/input/hyprctl.go @@ -22,6 +22,14 @@ func CheckAvailable() error { 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 diff --git a/internal/network/nmcli.go b/internal/network/nmcli.go index dcd2304..8fa0b19 100644 --- a/internal/network/nmcli.go +++ b/internal/network/nmcli.go @@ -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() diff --git a/internal/power/profiles.go b/internal/power/profiles.go index f2c1471..dc56893 100644 --- a/internal/power/profiles.go +++ b/internal/power/profiles.go @@ -36,6 +36,14 @@ func CheckAvailable() error { 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) { diff --git a/internal/ui/app/model.go b/internal/ui/app/model.go index 31099de..b55991e 100644 --- a/internal/ui/app/model.go +++ b/internal/ui/app/model.go @@ -20,10 +20,9 @@ import ( ) // 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() +// 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 @@ -34,6 +33,7 @@ func buildPageShortcuts() map[string]page { type Model struct { page page pageShortcuts map[string]page + visiblePages []pageEntry logPath string fatalErr error logFileErr error @@ -67,10 +67,12 @@ func New(initialPage string) (Model, error) { } cfg := config.Global + visible := visiblePages() m := Model{ page: startPage, - pageShortcuts: buildPageShortcuts(), + pageShortcuts: buildPageShortcuts(visible), + visiblePages: visible, about: aboutUI.New(), network: networkUI.New(), bluetooth: bluetoothUI.New(), diff --git a/internal/ui/app/pages.go b/internal/ui/app/pages.go index c4f429f..d7002e4 100644 --- a/internal/ui/app/pages.go +++ b/internal/ui/app/pages.go @@ -4,8 +4,13 @@ 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" @@ -30,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. @@ -66,8 +77,9 @@ var pageRegistry = []pageEntry{ resize: func(m *Model, w, h int) { m.about.SetSize(w, h) }, }, { - id: pageNetwork, - icon: func() string { return icons.I.Network }, + 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 { @@ -80,8 +92,9 @@ var pageRegistry = []pageEntry{ activate: func(m *Model) tea.Cmd { return m.network.Activate() }, }, { - id: pageBluetooth, - icon: func() string { return icons.I.Bluetooth }, + 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 { @@ -94,8 +107,9 @@ var pageRegistry = []pageEntry{ activate: func(m *Model) tea.Cmd { return m.bluetooth.Activate() }, }, { - id: pageAudio, - icon: func() string { return icons.I.Audio }, + 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 { @@ -109,8 +123,9 @@ var pageRegistry = []pageEntry{ deactivate: func(m *Model) { m.audio.Deactivate() }, }, { - id: pagePower, - icon: func() string { return icons.I.Power }, + 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 { @@ -123,8 +138,9 @@ var pageRegistry = []pageEntry{ activate: func(m *Model) tea.Cmd { return m.power.Activate() }, }, { - id: pageInputs, - icon: func() string { return icons.I.Inputs }, + 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 { @@ -160,30 +176,30 @@ func lookupPage(name string) (page, bool) { } // 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. +// 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 - 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) + if hidden[e.id] { + continue } + if e.available != nil && !e.available() { + continue + } + visible = append(visible, e) } return visible } diff --git a/internal/ui/app/sidebar.go b/internal/ui/app/sidebar.go index 518e1ed..03b8691 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 visiblePages() { + for i, entry := range m.visiblePages { selected := entry.id == m.page badgeStyle, textStyle := badgeNormal, textNormal if selected {