mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 16:18:24 +02:00
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user