Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-08-13 00:58:37 +02:00
commit 59b31e5ca3
103 changed files with 8577 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
package app
import (
"fmt"
"log"
"os"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/settuings/internal/config"
aboutUI "github.com/anotherhadi/settuings/internal/ui/about"
audioUI "github.com/anotherhadi/settuings/internal/ui/audio"
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"
"github.com/sirupsen/logrus"
)
var pageShortcuts = func() map[string]page {
m := make(map[string]page, len(pageRegistry))
for i, e := range pageRegistry {
m[strconv.Itoa(i+1)] = e.id
}
return m
}()
type Model struct {
page page
logPath string
fatalErr error
logFileErr error
width int
height int
sidebarState sidebarState
about aboutUI.Model
network networkUI.Model
bluetooth bluetoothUI.Model
audio audioUI.Model
notifications notificationsUI.Model
}
const logPath = "/tmp/settuings.log"
// New creates the app's root model. initialPage selects the page shown on
// launch (matched case-insensitively against PageNames); an empty string
// falls back to the default page. It returns an error if initialPage is
// non-empty and does not match a known page.
func New(initialPage string) (Model, error) {
startPage := pageAbout
if initialPage != "" {
p, ok := lookupPage(initialPage)
if !ok {
return Model{}, fmt.Errorf("unknown page %q (valid: %s)", initialPage, strings.Join(PageNames(), ", "))
}
startPage = p
}
cfg := config.Global
m := Model{
page: startPage,
about: aboutUI.New(),
network: networkUI.New(),
bluetooth: bluetoothUI.New(),
audio: audioUI.New(),
notifications: notificationsUI.New(),
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
logPath: logPath,
}
if lf, err := os.OpenFile(m.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
log.SetOutput(lf)
logrus.SetOutput(lf)
} else {
m.logFileErr = err
}
return m, nil
}
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()}
if m.page != pageAbout {
cmds = append(cmds, m.activatePage(m.page))
}
if m.logFileErr != nil {
err := m.logFileErr
cmds = append(cmds, func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Warning",
Body: "Could not open log file: " + err.Error(),
Kind: notificationsUI.KindWarning,
}
})
}
return tea.Batch(cmds...)
}
+127
View File
@@ -0,0 +1,127 @@
package app
import (
"strings"
tea "charm.land/bubbletea/v2"
"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"
)
type page string
const (
pageAbout page = "About"
pageNetwork page = "Network"
pageBluetooth page = "Bluetooth"
pageAudio page = "Audio"
)
// pageEntry describes a page and all its integration hooks.
type pageEntry struct {
id page
icon func() string
// 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 },
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 },
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 },
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() },
},
}
// 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
}
+96
View File
@@ -0,0 +1,96 @@
package app
import (
"strconv"
"strings"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
)
type sidebarState string
const (
sidebarHidden sidebarState = "hidden"
sidebarCollapsed sidebarState = "collapsed"
sidebarExpanded sidebarState = "expanded"
)
func (m *Model) cycleSidebarState() {
switch m.sidebarState {
case sidebarHidden:
m.sidebarState = sidebarCollapsed
case sidebarCollapsed:
m.sidebarState = sidebarExpanded
default:
m.sidebarState = sidebarHidden
}
}
func (m Model) getSidebarWidth() int {
switch m.sidebarState {
case sidebarHidden:
return 0
case sidebarCollapsed:
return 8
default:
return 18
}
}
func (m *Model) renderSidebar() string {
if m.sidebarState == sidebarHidden {
return ""
}
// content width inside bordered panel
inner := m.getSidebarWidth() - 2
titleText := "SETTUINGS"
if m.sidebarState == sidebarCollapsed {
titleText = "STUI"
}
title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 1).Render(titleText)
divider := strings.Repeat("─", inner)
badgeSelected := lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Bold(true)
badgeNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
textSelected := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
textNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
var items strings.Builder
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
for i, entry := range pageRegistry {
selected := entry.id == m.page
badgeStyle, textStyle := badgeNormal, textNormal
if selected {
badgeStyle, textStyle = badgeSelected, textSelected
} else if entry.hasUpdate != nil && entry.hasUpdate(m) {
badgeStyle = badgeUnread
}
icon := ""
if entry.icon != nil {
icon = entry.icon()
}
label := " " + icon
if m.sidebarState != sidebarCollapsed {
label += string(entry.id)
}
line := lineStyle.Render(badgeStyle.Render(strconv.Itoa(i+1)) + textStyle.Render(label))
if m.sidebarState == sidebarCollapsed && icon == "" {
line = " " + line
}
items.WriteString(line + "\n")
}
parts := []string{
title,
lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render(divider),
items.String(),
}
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
return ilovetui.S.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
}
+116
View File
@@ -0,0 +1,116 @@
package app
import (
"os/exec"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/settuings/internal/keys"
notificationsUI "github.com/anotherhadi/settuings/internal/ui/components/notifications"
"github.com/anotherhadi/settuings/internal/util"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case notificationsUI.NotificationMsg:
var cmd tea.Cmd
m.notifications, cmd = m.notifications.Update(msg)
return m, cmd
case notificationsUI.DismissMsg:
var cmd tea.Cmd
m.notifications, cmd = m.notifications.Update(msg)
return m, cmd
}
var activateCmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resizeChildren()
case tea.KeyPressMsg:
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
m.deactivatePage(m.page)
return m, tea.Quit
}
if key.Matches(msg, keys.Keys.Global.OpenLogs) {
return m, tea.ExecProcess(exec.Command(util.ResolveEditor(), m.logPath), nil) // #nosec G204 -- editor from trusted config/$EDITOR, logPath is a fixed path
}
if !m.activeIsEditing() {
switch {
case key.Matches(msg, keys.Keys.Global.ToggleSidebar):
m.cycleSidebarState()
m.resizeChildren()
default:
if p, ok := pageShortcuts[msg.String()]; ok {
prev := m.page
m.page = p
if prev != p {
m.deactivatePage(prev)
activateCmd = m.activatePage(p)
}
}
}
}
}
var cmd tea.Cmd
m, cmd = m.updateActivePage(msg)
return m, tea.Batch(activateCmd, cmd)
}
func (m *Model) activatePage(p page) tea.Cmd {
for _, e := range pageRegistry {
if e.id == p && e.activate != nil {
return e.activate(m)
}
}
return nil
}
// deactivatePage synchronously tears down page p's background state, if it
// has any (see pageEntry.deactivate).
func (m *Model) deactivatePage(p page) {
for _, e := range pageRegistry {
if e.id == p && e.deactivate != nil {
e.deactivate(m)
return
}
}
}
func (m Model) activeIsEditing() bool {
for _, e := range pageRegistry {
if e.id == m.page && e.isEditing != nil {
return e.isEditing(&m)
}
}
return false
}
func (m Model) updateActivePage(msg tea.Msg) (Model, tea.Cmd) {
for _, e := range pageRegistry {
if e.id == m.page && e.update != nil {
cmd := e.update(&m, msg)
return m, cmd
}
}
return m, nil
}
func (m *Model) resizeChildren() {
sidebarW := m.getSidebarWidth()
h := m.height
for _, e := range pageRegistry {
if e.resize == nil {
continue
}
e.resize(m, m.width-sidebarW, h)
}
m.notifications.SetSize(m.width, m.height)
}
+44
View File
@@ -0,0 +1,44 @@
package app
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
)
func (m Model) newView(content string) tea.View {
v := tea.NewView(content)
v.AltScreen = true
v.WindowTitle = "Settuings"
return v
}
func (m Model) View() tea.View {
if m.width == 0 {
return m.newView("")
}
rendered := m.renderNormal()
if m.notifications.HasNotifications() {
rendered = m.notifications.View(rendered)
}
v := m.newView(rendered)
v.MouseMode = tea.MouseModeCellMotion
return v
}
func (m Model) renderNormal() string {
sidebar := m.renderSidebar()
content := m.renderActivePage()
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content)
}
func (m *Model) renderActivePage() string {
for _, e := range pageRegistry {
if e.id == m.page && e.render != nil {
return e.render(m)
}
}
return ilovetui.S.Faint.Render("Work in progress")
}