mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 16:18:24 +02:00
@@ -0,0 +1,282 @@
|
||||
package about
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
settuings "github.com/anotherhadi/settuings"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
"github.com/anotherhadi/settuings/internal/util"
|
||||
)
|
||||
|
||||
func readDoc(name string) string {
|
||||
b, _ := settuings.DocsFS.ReadFile("docs/" + name)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
var contentMarkdown = readDoc("about.md")
|
||||
|
||||
type matchEntry struct {
|
||||
line int
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
viewport viewport.Model
|
||||
help help.Model
|
||||
searchInput textinput.Model
|
||||
searching bool
|
||||
|
||||
matches []matchEntry
|
||||
matchIndex int
|
||||
|
||||
renderedLines []string
|
||||
strippedLines []string
|
||||
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = "/"
|
||||
s := ti.Styles()
|
||||
s.Focused.Prompt = lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||
ti.SetStyles(s)
|
||||
|
||||
return Model{
|
||||
viewport: viewport.New(),
|
||||
help: ilovetui.NewHelp(),
|
||||
searchInput: ti,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) IsEditing() bool { return m.searching }
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
m.help.SetWidth(w - 2)
|
||||
m.searchInput.SetWidth(w - 4)
|
||||
|
||||
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
|
||||
frameW := windowStyle().GetHorizontalFrameSize()
|
||||
frameH := windowStyle().GetVerticalFrameSize()
|
||||
|
||||
m.viewport.SetWidth(w - frameW)
|
||||
m.viewport.SetHeight(h - frameH - statusH)
|
||||
m.renderMarkdown()
|
||||
}
|
||||
|
||||
func (m *Model) applySearch() {
|
||||
query := m.searchInput.Value()
|
||||
m.matches = nil
|
||||
m.matchIndex = 0
|
||||
|
||||
if query != "" {
|
||||
re, err := regexp.Compile("(?i)" + regexp.QuoteMeta(query))
|
||||
if err == nil {
|
||||
for lineIdx, stripped := range m.strippedLines {
|
||||
for _, match := range re.FindAllStringIndex(stripped, -1) {
|
||||
m.matches = append(m.matches, matchEntry{
|
||||
line: lineIdx,
|
||||
start: match[0],
|
||||
end: match[1],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.rebuildViewportContent()
|
||||
if len(m.matches) > 0 {
|
||||
util.SetYOffset(&m.viewport, m.matches[0].line)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) searchNext() {
|
||||
if len(m.matches) == 0 {
|
||||
return
|
||||
}
|
||||
m.matchIndex = (m.matchIndex + 1) % len(m.matches)
|
||||
m.rebuildViewportContent()
|
||||
util.SetYOffset(&m.viewport, m.matches[m.matchIndex].line)
|
||||
}
|
||||
|
||||
func (m *Model) searchPrev() {
|
||||
if len(m.matches) == 0 {
|
||||
return
|
||||
}
|
||||
m.matchIndex = (m.matchIndex - 1 + len(m.matches)) % len(m.matches)
|
||||
m.rebuildViewportContent()
|
||||
util.SetYOffset(&m.viewport, m.matches[m.matchIndex].line)
|
||||
}
|
||||
|
||||
func (m *Model) rebuildViewportContent() {
|
||||
if len(m.matches) == 0 || m.searchInput.Value() == "" {
|
||||
m.viewport.SetContent(strings.Join(m.renderedLines, "\n"))
|
||||
return
|
||||
}
|
||||
|
||||
type lineInfo struct {
|
||||
intervals [][]int
|
||||
currentIdx int
|
||||
}
|
||||
byLine := make(map[int]*lineInfo)
|
||||
for i, match := range m.matches {
|
||||
li := byLine[match.line]
|
||||
if li == nil {
|
||||
li = &lineInfo{currentIdx: -1}
|
||||
byLine[match.line] = li
|
||||
}
|
||||
li.intervals = append(li.intervals, []int{match.start, match.end})
|
||||
if i == m.matchIndex {
|
||||
li.currentIdx = len(li.intervals) - 1
|
||||
}
|
||||
}
|
||||
|
||||
lines := make([]string, len(m.renderedLines))
|
||||
for i, ansiLine := range m.renderedLines {
|
||||
if li, ok := byLine[i]; ok {
|
||||
lines[i] = injectHighlightsInLine(ansiLine, li.intervals, li.currentIdx)
|
||||
} else {
|
||||
lines[i] = ansiLine
|
||||
}
|
||||
}
|
||||
m.viewport.SetContent(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func lipglossAnsiCodes(s lipgloss.Style) (open, close string) {
|
||||
const sentinel = "X"
|
||||
rendered := s.Render(sentinel)
|
||||
idx := strings.Index(rendered, sentinel)
|
||||
if idx < 0 {
|
||||
return "", ""
|
||||
}
|
||||
return rendered[:idx], rendered[idx+len(sentinel):]
|
||||
}
|
||||
|
||||
func injectHighlightsInLine(ansiLine string, intervals [][]int, currentIdx int) string {
|
||||
if len(intervals) == 0 {
|
||||
return ansiLine
|
||||
}
|
||||
|
||||
normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.SubtleBg))
|
||||
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.Primary).Foreground(ilovetui.S.Text))
|
||||
|
||||
type injection struct {
|
||||
visPos int
|
||||
code string
|
||||
priority int // 0 = close (emit before opens at same pos), 1 = open
|
||||
}
|
||||
var injections []injection
|
||||
for i, iv := range intervals {
|
||||
open, close := normalOpen, normalClose
|
||||
if i == currentIdx {
|
||||
open, close = currentOpen, currentClose
|
||||
}
|
||||
injections = append(injections, injection{visPos: iv[0], code: open, priority: 1})
|
||||
injections = append(injections, injection{visPos: iv[1], code: close, priority: 0})
|
||||
}
|
||||
sort.SliceStable(injections, func(a, b int) bool {
|
||||
if injections[a].visPos != injections[b].visPos {
|
||||
return injections[a].visPos < injections[b].visPos
|
||||
}
|
||||
return injections[a].priority < injections[b].priority
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
visPos := 0
|
||||
injIdx := 0
|
||||
i := 0
|
||||
for i < len(ansiLine) {
|
||||
for injIdx < len(injections) && injections[injIdx].visPos == visPos {
|
||||
sb.WriteString(injections[injIdx].code)
|
||||
injIdx++
|
||||
}
|
||||
if ansiLine[i] == '\x1b' {
|
||||
j := i + 1
|
||||
if j < len(ansiLine) {
|
||||
switch ansiLine[j] {
|
||||
case '[':
|
||||
j++
|
||||
for j < len(ansiLine) && (ansiLine[j] < '@' || ansiLine[j] > '~') {
|
||||
j++
|
||||
}
|
||||
if j < len(ansiLine) {
|
||||
j++
|
||||
}
|
||||
case ']':
|
||||
j++
|
||||
for j < len(ansiLine) {
|
||||
if ansiLine[j] == '\a' {
|
||||
j++
|
||||
break
|
||||
}
|
||||
if ansiLine[j] == '\x1b' && j+1 < len(ansiLine) && ansiLine[j+1] == '\\' {
|
||||
j += 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
default:
|
||||
j++
|
||||
}
|
||||
}
|
||||
sb.WriteString(ansiLine[i:j])
|
||||
i = j
|
||||
} else {
|
||||
_, size := utf8.DecodeRuneInString(ansiLine[i:])
|
||||
if size == 0 {
|
||||
size = 1
|
||||
}
|
||||
sb.WriteString(ansiLine[i : i+size])
|
||||
i += size
|
||||
visPos += size
|
||||
}
|
||||
}
|
||||
for injIdx < len(injections) {
|
||||
sb.WriteString(injections[injIdx].code)
|
||||
injIdx++
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m *Model) renderStatusBar() string {
|
||||
if m.searching {
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.searchInput.View())
|
||||
}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(aboutKeyMap{width: m.width}))
|
||||
}
|
||||
|
||||
type aboutKeyMap struct{ width int }
|
||||
|
||||
func (aboutKeyMap) ShortHelp() []key.Binding {
|
||||
g := keys.Keys.Global
|
||||
d := keys.Keys.Docs
|
||||
return []key.Binding{g.Up, g.Down, d.Search, g.Help}
|
||||
}
|
||||
|
||||
func (m aboutKeyMap) FullHelp() [][]key.Binding {
|
||||
g := keys.Keys.Global
|
||||
d := keys.Keys.Docs
|
||||
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown}
|
||||
all := append(d.Bindings(), pageGlobals...)
|
||||
all = append(all, g.CommonBindings()...)
|
||||
return keys.ChunkByWidth(all, m.width)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package about
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
"github.com/anotherhadi/settuings/internal/util"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
g := keys.Keys.Global
|
||||
d := keys.Keys.Docs
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.MouseWheelMsg:
|
||||
util.HandleMouseWheel(msg, &m.viewport)
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if m.searching {
|
||||
switch {
|
||||
case key.Matches(msg, g.Escape):
|
||||
m.searching = false
|
||||
m.searchInput.Blur()
|
||||
m.SetSize(m.width, m.height)
|
||||
case msg.String() == "enter":
|
||||
m.searching = false
|
||||
m.searchInput.Blur()
|
||||
m.SetSize(m.width, m.height)
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
m.applySearch()
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, d.Search):
|
||||
m.searching = true
|
||||
m.searchInput.SetValue("")
|
||||
m.searchInput.Focus()
|
||||
m.SetSize(m.width, m.height)
|
||||
case key.Matches(msg, d.SearchReset):
|
||||
m.matches = nil
|
||||
m.matchIndex = 0
|
||||
m.rebuildViewportContent()
|
||||
case key.Matches(msg, d.SearchNext):
|
||||
m.searchNext()
|
||||
case key.Matches(msg, d.SearchPrev):
|
||||
m.searchPrev()
|
||||
case key.Matches(msg, g.Up):
|
||||
util.ScrollLines(&m.viewport, -1)
|
||||
case key.Matches(msg, g.Down):
|
||||
util.ScrollLines(&m.viewport, 1)
|
||||
case key.Matches(msg, g.ScrollUp):
|
||||
util.ScrollViewport(&m.viewport, -1)
|
||||
case key.Matches(msg, g.ScrollDown):
|
||||
util.ScrollViewport(&m.viewport, 1)
|
||||
case key.Matches(msg, g.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
m.SetSize(m.width, m.height)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package about
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/glamour/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/config"
|
||||
"github.com/anotherhadi/settuings/internal/util"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func windowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ilovetui.S.Subtle).
|
||||
Padding(0, 0)
|
||||
}
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
statusBar := m.renderStatusBar()
|
||||
if len(m.matches) > 0 {
|
||||
var countText string
|
||||
if m.searching {
|
||||
countText = fmt.Sprintf("%d matches", len(m.matches))
|
||||
} else {
|
||||
countText = fmt.Sprintf("%d/%d", m.matchIndex+1, len(m.matches))
|
||||
}
|
||||
count := lipgloss.NewStyle().Padding(0, 1).
|
||||
Foreground(ilovetui.S.Muted).
|
||||
Render(countText)
|
||||
statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, count)
|
||||
}
|
||||
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
|
||||
windowStyle().Render(m.viewport.View()),
|
||||
statusBar,
|
||||
))
|
||||
}
|
||||
|
||||
func (m *Model) renderMarkdown() {
|
||||
cfg := config.Global
|
||||
data := struct {
|
||||
Cfg *config.Config
|
||||
OS string
|
||||
Kernel string
|
||||
Hostname string
|
||||
Arch string
|
||||
}{
|
||||
Cfg: cfg,
|
||||
OS: util.OSName(),
|
||||
Kernel: util.KernelVersion(),
|
||||
Hostname: util.Hostname(),
|
||||
Arch: util.Arch(),
|
||||
}
|
||||
|
||||
tmpl, err := template.New("about").Parse(contentMarkdown)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var processed bytes.Buffer
|
||||
if err := tmpl.Execute(&processed, data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
width := m.viewport.Width() - util.ScrollbarGutterWidth
|
||||
renderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
|
||||
str, _ := renderer.Render(processed.String())
|
||||
m.renderedLines = strings.Split(str, "\n")
|
||||
m.strippedLines = make([]string, len(m.renderedLines))
|
||||
for i, l := range m.renderedLines {
|
||||
m.strippedLines[i] = ansi.Strip(l)
|
||||
}
|
||||
m.applySearch()
|
||||
util.RefreshScrollbar(&m.viewport)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"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/audio"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
type appsState struct {
|
||||
loading bool
|
||||
err error
|
||||
streams []audio.Stream
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
}
|
||||
|
||||
func newAppsState() appsState {
|
||||
return appsState{loading: true}
|
||||
}
|
||||
|
||||
func (m *Model) handleAppsList(msg appsListMsg) {
|
||||
m.apps.loading = false
|
||||
m.apps.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.apps.streams = msg.streams
|
||||
if m.apps.cursor >= len(m.apps.streams) {
|
||||
m.apps.cursor = max(0, len(m.apps.streams)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleAppsAction(msg appsActionMsg) tea.Cmd {
|
||||
m.apps.pending = false
|
||||
if msg.err != nil {
|
||||
m.apps.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
return fetchAppsCmd()
|
||||
}
|
||||
|
||||
func (m Model) selectedStream() *audio.Stream {
|
||||
if m.apps.cursor < 0 || m.apps.cursor >= len(m.apps.streams) {
|
||||
return nil
|
||||
}
|
||||
return &m.apps.streams[m.apps.cursor]
|
||||
}
|
||||
|
||||
func (m *Model) updateApps(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
a := keys.Keys.Audio
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.apps.cursor > 0 {
|
||||
m.apps.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.apps.cursor < len(m.apps.streams)-1 {
|
||||
m.apps.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, g.Left):
|
||||
return m.adjustStreamVolume(-volumeStep)
|
||||
case key.Matches(keyMsg, g.Right):
|
||||
return m.adjustStreamVolume(volumeStep)
|
||||
case key.Matches(keyMsg, a.Refresh):
|
||||
m.apps.pending = true
|
||||
return fetchAppsCmd()
|
||||
case key.Matches(keyMsg, a.Mute):
|
||||
s := m.selectedStream()
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
m.apps.feedback = ""
|
||||
m.apps.pending = true
|
||||
return appsToggleMuteCmd(s.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) adjustStreamVolume(delta int) tea.Cmd {
|
||||
s := m.selectedStream()
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
pct := s.Volume + delta
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
} else if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
s.Volume = pct
|
||||
m.apps.feedback = ""
|
||||
return appsSetVolumeCmd(s.ID, pct)
|
||||
}
|
||||
|
||||
func sectionTitle(k audio.StreamKind) string {
|
||||
if k == audio.StreamInput {
|
||||
return "INPUT STREAMS (recording)"
|
||||
}
|
||||
return "OUTPUT STREAMS (playback)"
|
||||
}
|
||||
|
||||
// windowAroundCursor picks a [start, end) slice of size items (or fewer, if
|
||||
// total is smaller) that keeps cursor in view.
|
||||
func windowAroundCursor(cursor, total, size int) (start, end int) {
|
||||
if size >= total {
|
||||
return 0, total
|
||||
}
|
||||
start = cursor - size/2
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start+size > total {
|
||||
start = total - size
|
||||
}
|
||||
return start, start + size
|
||||
}
|
||||
|
||||
func (m Model) renderApps(width, height int) string {
|
||||
if m.apps.loading {
|
||||
return ilovetui.S.Faint.Render("Loading application streams…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("APPLICATIONS")
|
||||
if m.apps.pending {
|
||||
header += mutedStyle().Render(" refreshing…")
|
||||
}
|
||||
|
||||
if m.apps.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.apps.err.Error()))
|
||||
}
|
||||
if len(m.apps.streams) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No application is currently playing or recording audio."))
|
||||
}
|
||||
|
||||
maxItems := (height - 3) / streamRowHeight
|
||||
if maxItems < 1 {
|
||||
maxItems = 1
|
||||
}
|
||||
start, end := windowAroundCursor(m.apps.cursor, len(m.apps.streams), maxItems)
|
||||
|
||||
sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle)
|
||||
var b strings.Builder
|
||||
lastKind := audio.StreamKind(-1)
|
||||
for i := start; i < end; i++ {
|
||||
s := m.apps.streams[i]
|
||||
if s.Kind != lastKind {
|
||||
b.WriteString(sectionStyle.Render(sectionTitle(s.Kind)) + "\n\n")
|
||||
lastKind = s.Kind
|
||||
}
|
||||
b.WriteString(renderStreamRow(s, i == m.apps.cursor, width))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
list := strings.TrimRight(b.String(), "\n")
|
||||
|
||||
footer := ""
|
||||
if m.apps.feedback != "" {
|
||||
footer = subtleStyle().Render(m.apps.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/settuings/internal/audio"
|
||||
notificationsUI "github.com/anotherhadi/settuings/internal/ui/components/notifications"
|
||||
)
|
||||
|
||||
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: audio.CheckAvailable()} }
|
||||
}
|
||||
|
||||
func fetchOutputCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
devices, err := audio.ListSinks()
|
||||
return outputListMsg{devices: devices, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchInputCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
devices, err := audio.ListSources()
|
||||
return inputListMsg{devices: devices, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAppsCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
streams, err := audio.ListStreams()
|
||||
return appsListMsg{streams: streams, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func outputSetVolumeCmd(id string, pct int) tea.Cmd {
|
||||
return func() tea.Msg { return outputActionMsg{err: audio.SetVolume(id, pct)} }
|
||||
}
|
||||
|
||||
func outputToggleMuteCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg { return outputActionMsg{err: audio.ToggleMute(id)} }
|
||||
}
|
||||
|
||||
func outputSetDefaultCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg { return outputActionMsg{err: audio.SetDefault(id)} }
|
||||
}
|
||||
|
||||
func outputTestToneCmd(sinkID string) tea.Cmd {
|
||||
return func() tea.Msg { return outputActionMsg{err: audio.PlayTestTone(sinkID)} }
|
||||
}
|
||||
|
||||
func inputSetVolumeCmd(id string, pct int) tea.Cmd {
|
||||
return func() tea.Msg { return inputActionMsg{err: audio.SetVolume(id, pct)} }
|
||||
}
|
||||
|
||||
func inputToggleMuteCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg { return inputActionMsg{err: audio.ToggleMute(id)} }
|
||||
}
|
||||
|
||||
func inputSetDefaultCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg { return inputActionMsg{err: audio.SetDefault(id)} }
|
||||
}
|
||||
|
||||
func appsSetVolumeCmd(id string, pct int) tea.Cmd {
|
||||
return func() tea.Msg { return appsActionMsg{err: audio.SetVolume(id, pct)} }
|
||||
}
|
||||
|
||||
func appsToggleMuteCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg { return appsActionMsg{err: audio.ToggleMute(id)} }
|
||||
}
|
||||
|
||||
func notifyCmd(title, body string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return notificationsUI.NotificationMsg{Title: title, Body: body, Kind: notificationsUI.KindInfo}
|
||||
}
|
||||
}
|
||||
|
||||
func startLevelMonitorCmd(sourceID string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
mon, err := audio.StartLevelMonitor(sourceID)
|
||||
return levelStartedMsg{monitor: mon, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func waitLevelCmd(mon *audio.LevelMonitor) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
peak, err := mon.Next()
|
||||
return levelMsg{peak: peak, err: err}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"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/audio"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
type inputState struct {
|
||||
loading bool
|
||||
err error
|
||||
devices []audio.Device
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
|
||||
testing bool
|
||||
monitor *audio.LevelMonitor
|
||||
level float64 // last peak reading in [0, 1], only meaningful while testing
|
||||
}
|
||||
|
||||
func newInputState() inputState {
|
||||
return inputState{loading: true}
|
||||
}
|
||||
|
||||
// stopLevelMonitor tears down a running live input-level test, if any. Safe
|
||||
// to call unconditionally.
|
||||
func (s *inputState) stopLevelMonitor() {
|
||||
if s.monitor != nil {
|
||||
s.monitor.Stop()
|
||||
s.monitor = nil
|
||||
}
|
||||
s.testing = false
|
||||
s.level = 0
|
||||
}
|
||||
|
||||
func (m *Model) handleInputList(msg inputListMsg) {
|
||||
m.input.loading = false
|
||||
m.input.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.input.devices = msg.devices
|
||||
if m.input.cursor >= len(m.input.devices) {
|
||||
m.input.cursor = max(0, len(m.input.devices)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleInputAction(msg inputActionMsg) tea.Cmd {
|
||||
m.input.pending = false
|
||||
if msg.err != nil {
|
||||
m.input.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
return fetchInputCmd()
|
||||
}
|
||||
|
||||
// handleLevelStarted reacts to a StartLevelMonitor result. If the user
|
||||
// toggled testing off again before this arrived, the freshly-started monitor
|
||||
// is stopped immediately instead of being adopted.
|
||||
func (m *Model) handleLevelStarted(msg levelStartedMsg) tea.Cmd {
|
||||
if msg.err != nil {
|
||||
m.input.testing = false
|
||||
m.input.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
if !m.input.testing {
|
||||
msg.monitor.Stop()
|
||||
return nil
|
||||
}
|
||||
m.input.monitor = msg.monitor
|
||||
return waitLevelCmd(msg.monitor)
|
||||
}
|
||||
|
||||
// handleLevel reacts to one peak reading, re-arming the wait for the next
|
||||
// one as long as the test is still active.
|
||||
func (m *Model) handleLevel(msg levelMsg) tea.Cmd {
|
||||
if !m.input.testing || m.input.monitor == nil {
|
||||
return nil
|
||||
}
|
||||
if msg.err != nil {
|
||||
m.input.feedback = "Input test stopped: " + msg.err.Error()
|
||||
m.input.stopLevelMonitor()
|
||||
return nil
|
||||
}
|
||||
m.input.level = msg.peak
|
||||
return waitLevelCmd(m.input.monitor)
|
||||
}
|
||||
|
||||
func (m Model) selectedInput() *audio.Device {
|
||||
if m.input.cursor < 0 || m.input.cursor >= len(m.input.devices) {
|
||||
return nil
|
||||
}
|
||||
return &m.input.devices[m.input.cursor]
|
||||
}
|
||||
|
||||
func (m *Model) updateInput(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
a := keys.Keys.Audio
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.input.cursor > 0 {
|
||||
m.input.stopLevelMonitor()
|
||||
m.input.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.input.cursor < len(m.input.devices)-1 {
|
||||
m.input.stopLevelMonitor()
|
||||
m.input.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, g.Left):
|
||||
return m.adjustInputVolume(-volumeStep)
|
||||
case key.Matches(keyMsg, g.Right):
|
||||
return m.adjustInputVolume(volumeStep)
|
||||
case key.Matches(keyMsg, a.Refresh):
|
||||
m.input.pending = true
|
||||
return fetchInputCmd()
|
||||
case key.Matches(keyMsg, a.Mute):
|
||||
dev := m.selectedInput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
m.input.feedback = ""
|
||||
m.input.pending = true
|
||||
return inputToggleMuteCmd(dev.ID)
|
||||
case key.Matches(keyMsg, a.Select):
|
||||
dev := m.selectedInput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
if dev.Default {
|
||||
m.input.feedback = "Already the default input."
|
||||
return nil
|
||||
}
|
||||
m.input.feedback = ""
|
||||
m.input.pending = true
|
||||
return inputSetDefaultCmd(dev.ID)
|
||||
case key.Matches(keyMsg, a.Test):
|
||||
dev := m.selectedInput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
if m.input.testing {
|
||||
m.input.stopLevelMonitor()
|
||||
return nil
|
||||
}
|
||||
m.input.testing = true
|
||||
m.input.feedback = ""
|
||||
return startLevelMonitorCmd(dev.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) adjustInputVolume(delta int) tea.Cmd {
|
||||
dev := m.selectedInput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
pct := dev.Volume + delta
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
} else if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
dev.Volume = pct
|
||||
m.input.feedback = ""
|
||||
return inputSetVolumeCmd(dev.ID, pct)
|
||||
}
|
||||
|
||||
func (m Model) renderInput(width, height int) string {
|
||||
if m.input.loading {
|
||||
return ilovetui.S.Faint.Render("Loading input devices…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("INPUT DEVICES")
|
||||
if m.input.pending {
|
||||
header += mutedStyle().Render(" refreshing…")
|
||||
}
|
||||
|
||||
if m.input.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.input.err.Error()))
|
||||
}
|
||||
if len(m.input.devices) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No input devices found."))
|
||||
}
|
||||
|
||||
available := (height - 3) / deviceRowHeight
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.input.cursor, len(m.input.devices), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
selected := i == m.input.cursor
|
||||
row := renderDeviceRow(m.input.devices[i], selected, width)
|
||||
if selected && m.input.testing {
|
||||
row += "\n " + renderLevelMeter(m.input.level, barWidth(width))
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
list := strings.Join(rows, "\n\n")
|
||||
|
||||
footer := renderPaginatorDots(p)
|
||||
if m.input.feedback != "" {
|
||||
footer = subtleStyle().Render(m.input.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
// audioKeyMap renders the bottom help bar, adapting to the active tab.
|
||||
type audioKeyMap struct {
|
||||
m Model
|
||||
width int
|
||||
}
|
||||
|
||||
// relabel reuses an existing binding's keys under a different help
|
||||
// description. Volume adjustment reuses the global Left/Right binding (h/l,
|
||||
// arrow keys), which is normally labelled "scroll left/right" — here it
|
||||
// means something else, so the help bar needs its own wording.
|
||||
func relabel(b key.Binding, desc string) key.Binding {
|
||||
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(b.Help().Key, desc))
|
||||
}
|
||||
|
||||
func (k audioKeyMap) ShortHelp() []key.Binding {
|
||||
g := keys.Keys.Global
|
||||
a := keys.Keys.Audio
|
||||
volDown, volUp := relabel(g.Left, "vol down"), relabel(g.Right, "vol up")
|
||||
|
||||
switch k.m.active {
|
||||
case tabApps:
|
||||
return []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, g.CycleFocus, g.Help}
|
||||
default:
|
||||
return []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, a.Test, g.Help}
|
||||
}
|
||||
}
|
||||
|
||||
func (k audioKeyMap) FullHelp() [][]key.Binding {
|
||||
g := keys.Keys.Global
|
||||
a := keys.Keys.Audio
|
||||
volDown, volUp := relabel(g.Left, "volume down"), relabel(g.Right, "volume up")
|
||||
|
||||
var bindings []key.Binding
|
||||
switch k.m.active {
|
||||
case tabOutput:
|
||||
bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Select, a.Mute, a.Test, a.Refresh, g.CycleFocus}
|
||||
case tabInput:
|
||||
bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Select, a.Mute, relabel(a.Test, "test mic"), a.Refresh, g.CycleFocus}
|
||||
case tabApps:
|
||||
bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, a.Refresh, g.CycleFocus}
|
||||
}
|
||||
bindings = append(bindings, g.CommonBindings()...)
|
||||
return keys.ChunkByWidth(bindings, k.width)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package audio
|
||||
|
||||
import "github.com/anotherhadi/settuings/internal/audio"
|
||||
|
||||
// checkMsg carries the result of audio.CheckAvailable.
|
||||
type checkMsg struct{ err error }
|
||||
|
||||
// tickMsg triggers a periodic refresh of every tab while this page is active.
|
||||
type tickMsg struct{}
|
||||
|
||||
type outputListMsg struct {
|
||||
devices []audio.Device
|
||||
err error
|
||||
}
|
||||
|
||||
type inputListMsg struct {
|
||||
devices []audio.Device
|
||||
err error
|
||||
}
|
||||
|
||||
type appsListMsg struct {
|
||||
streams []audio.Stream
|
||||
err error
|
||||
}
|
||||
|
||||
// outputActionMsg carries the result of a control action (set volume, mute,
|
||||
// set default, play test tone) performed on the Output tab.
|
||||
type outputActionMsg struct{ err error }
|
||||
|
||||
// inputActionMsg is the Input tab's equivalent of outputActionMsg.
|
||||
type inputActionMsg struct{ err error }
|
||||
|
||||
// appsActionMsg is the Apps tab's equivalent of outputActionMsg.
|
||||
type appsActionMsg struct{ err error }
|
||||
|
||||
// levelStartedMsg carries the result of starting a live input-level meter.
|
||||
type levelStartedMsg struct {
|
||||
monitor *audio.LevelMonitor
|
||||
err error
|
||||
}
|
||||
|
||||
// levelMsg carries one peak reading from the live input-level meter.
|
||||
type levelMsg struct {
|
||||
peak float64
|
||||
err error
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Package audio provides the "Audio" TUI page: a thin, tabbed wrapper around
|
||||
// wpctl (and the pw-play/pw-cat helper tools that ship with it) covering
|
||||
// output devices, input devices, and per-application streams, in the same
|
||||
// spirit as the network page's tabs.
|
||||
package audio
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/icons"
|
||||
)
|
||||
|
||||
type tab int
|
||||
|
||||
const (
|
||||
tabOutput tab = iota
|
||||
tabInput
|
||||
tabApps
|
||||
)
|
||||
|
||||
var visibleTabs = []tab{tabOutput, tabInput, tabApps}
|
||||
|
||||
func (t tab) label() string {
|
||||
switch t {
|
||||
case tabOutput:
|
||||
return "Output"
|
||||
case tabInput:
|
||||
return "Input"
|
||||
case tabApps:
|
||||
return "Apps"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (t tab) icon() string {
|
||||
switch t {
|
||||
case tabOutput:
|
||||
return icons.I.Output
|
||||
case tabInput:
|
||||
return icons.I.Input
|
||||
case tabApps:
|
||||
return icons.I.Apps
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
active tab
|
||||
width int
|
||||
height int
|
||||
help help.Model
|
||||
|
||||
checkErr error // reason wpctl is unusable; nil once confirmed OK
|
||||
checked bool
|
||||
|
||||
output outputState
|
||||
input inputState
|
||||
apps appsState
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
return Model{
|
||||
active: tabOutput,
|
||||
help: ilovetui.NewHelp(),
|
||||
output: newOutputState(),
|
||||
input: newInputState(),
|
||||
apps: newAppsState(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Activate is called every time the user navigates onto this page. It
|
||||
// re-checks wpctl availability, kicks off a fetch for every tab, and starts
|
||||
// the periodic auto-refresh loop.
|
||||
func (m *Model) Activate() tea.Cmd {
|
||||
return tea.Batch(
|
||||
checkCmd(),
|
||||
fetchOutputCmd(),
|
||||
fetchInputCmd(),
|
||||
fetchAppsCmd(),
|
||||
tickCmd(),
|
||||
)
|
||||
}
|
||||
|
||||
// Deactivate is called synchronously when the user navigates away from this
|
||||
// page (or quits) so the live input-level meter, if running, never leaks a
|
||||
// background pw-cat process.
|
||||
func (m *Model) Deactivate() {
|
||||
m.input.stopLevelMonitor()
|
||||
}
|
||||
|
||||
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,165 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"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/audio"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
const volumeStep = 5
|
||||
|
||||
type outputState struct {
|
||||
loading bool
|
||||
err error
|
||||
devices []audio.Device
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
}
|
||||
|
||||
func newOutputState() outputState {
|
||||
return outputState{loading: true}
|
||||
}
|
||||
|
||||
func (m *Model) handleOutputList(msg outputListMsg) {
|
||||
m.output.loading = false
|
||||
m.output.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.output.devices = msg.devices
|
||||
if m.output.cursor >= len(m.output.devices) {
|
||||
m.output.cursor = max(0, len(m.output.devices)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleOutputAction(msg outputActionMsg) tea.Cmd {
|
||||
m.output.pending = false
|
||||
if msg.err != nil {
|
||||
m.output.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
return fetchOutputCmd()
|
||||
}
|
||||
|
||||
func (m Model) selectedOutput() *audio.Device {
|
||||
if m.output.cursor < 0 || m.output.cursor >= len(m.output.devices) {
|
||||
return nil
|
||||
}
|
||||
return &m.output.devices[m.output.cursor]
|
||||
}
|
||||
|
||||
func (m *Model) updateOutput(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
a := keys.Keys.Audio
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.output.cursor > 0 {
|
||||
m.output.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.output.cursor < len(m.output.devices)-1 {
|
||||
m.output.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, g.Left):
|
||||
return m.adjustOutputVolume(-volumeStep)
|
||||
case key.Matches(keyMsg, g.Right):
|
||||
return m.adjustOutputVolume(volumeStep)
|
||||
case key.Matches(keyMsg, a.Refresh):
|
||||
m.output.pending = true
|
||||
return fetchOutputCmd()
|
||||
case key.Matches(keyMsg, a.Mute):
|
||||
dev := m.selectedOutput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
m.output.feedback = ""
|
||||
m.output.pending = true
|
||||
return outputToggleMuteCmd(dev.ID)
|
||||
case key.Matches(keyMsg, a.Select):
|
||||
dev := m.selectedOutput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
if dev.Default {
|
||||
m.output.feedback = "Already the default output."
|
||||
return nil
|
||||
}
|
||||
m.output.feedback = ""
|
||||
m.output.pending = true
|
||||
return outputSetDefaultCmd(dev.ID)
|
||||
case key.Matches(keyMsg, a.Test):
|
||||
dev := m.selectedOutput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
return tea.Batch(outputTestToneCmd(dev.ID), notifyCmd("Audio", "Playing test sound on "+dev.Name+"…"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// adjustOutputVolume nudges the selected device's volume by delta, updating
|
||||
// it locally right away so the bar responds instantly instead of waiting on
|
||||
// the round trip to wpctl and back.
|
||||
func (m *Model) adjustOutputVolume(delta int) tea.Cmd {
|
||||
dev := m.selectedOutput()
|
||||
if dev == nil {
|
||||
return nil
|
||||
}
|
||||
pct := dev.Volume + delta
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
} else if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
dev.Volume = pct
|
||||
m.output.feedback = ""
|
||||
return outputSetVolumeCmd(dev.ID, pct)
|
||||
}
|
||||
|
||||
func (m Model) renderOutput(width, height int) string {
|
||||
if m.output.loading {
|
||||
return ilovetui.S.Faint.Render("Loading output devices…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("OUTPUT DEVICES")
|
||||
if m.output.pending {
|
||||
header += mutedStyle().Render(" refreshing…")
|
||||
}
|
||||
|
||||
if m.output.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.output.err.Error()))
|
||||
}
|
||||
if len(m.output.devices) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No output devices found."))
|
||||
}
|
||||
|
||||
available := (height - 3) / deviceRowHeight
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.output.cursor, len(m.output.devices), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
rows = append(rows, renderDeviceRow(m.output.devices[i], i == m.output.cursor, width))
|
||||
}
|
||||
list := strings.Join(rows, "\n\n")
|
||||
|
||||
footer := renderPaginatorDots(p)
|
||||
if m.output.feedback != "" {
|
||||
footer = subtleStyle().Render(m.output.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/paginator"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
func newPaginator() paginator.Model {
|
||||
p := paginator.New()
|
||||
p.Type = paginator.Dots
|
||||
p.ActiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render("•")
|
||||
p.InactiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render("•")
|
||||
return p
|
||||
}
|
||||
|
||||
// paginate slices a list of length total into pages of availableRows items,
|
||||
// deriving the current page from cursor (the globally-indexed selection)
|
||||
// rather than tracking page state separately. It returns the configured
|
||||
// paginator and the [start, end) bounds of the visible slice.
|
||||
func paginate(cursor, total, availableRows int) (p paginator.Model, start, end int) {
|
||||
p = newPaginator()
|
||||
perPage := availableRows
|
||||
if perPage < 1 {
|
||||
perPage = 1
|
||||
}
|
||||
p.PerPage = perPage
|
||||
p.SetTotalPages(total)
|
||||
if p.TotalPages > 0 {
|
||||
p.Page = cursor / perPage
|
||||
if p.Page >= p.TotalPages {
|
||||
p.Page = p.TotalPages - 1
|
||||
}
|
||||
}
|
||||
start, end = p.GetSliceBounds(total)
|
||||
return p, start, end
|
||||
}
|
||||
|
||||
func renderPaginatorDots(p paginator.Model) string {
|
||||
if p.TotalPages <= 1 {
|
||||
return ""
|
||||
}
|
||||
return mutedStyle().Render(p.View())
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/audio"
|
||||
"github.com/anotherhadi/settuings/internal/icons"
|
||||
)
|
||||
|
||||
// deviceRowHeight and streamRowHeight are the number of lines each list row
|
||||
// occupies (name line + bar line + one blank spacer line), used to size how
|
||||
// many rows fit in the available height.
|
||||
const (
|
||||
deviceRowHeight = 3
|
||||
streamRowHeight = 3
|
||||
)
|
||||
|
||||
// barWidth derives a volume-bar width from the row's available width,
|
||||
// leaving room for the name line's cursor/default marker on one side and the
|
||||
// percentage/MUTED label on the other.
|
||||
func barWidth(rowWidth int) int {
|
||||
w := rowWidth - 24
|
||||
switch {
|
||||
case w > 30:
|
||||
w = 30
|
||||
case w < 10:
|
||||
w = 10
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func renderDeviceRow(d audio.Device, selected bool, width int) string {
|
||||
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||
if selected {
|
||||
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
|
||||
}
|
||||
cursor := " "
|
||||
if selected {
|
||||
cursor = "> "
|
||||
}
|
||||
name := textStyle.Render(d.Name)
|
||||
if d.Default {
|
||||
name += " " + successStyle().Render(icons.I.Check+"default")
|
||||
}
|
||||
nameLine := cursor + name
|
||||
barLine := " " + renderVolumeBar(d.Volume, barWidth(width), d.Muted)
|
||||
return nameLine + "\n" + barLine
|
||||
}
|
||||
|
||||
func renderStreamRow(s audio.Stream, selected bool, width int) string {
|
||||
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||
if selected {
|
||||
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
|
||||
}
|
||||
cursor := " "
|
||||
if selected {
|
||||
cursor = "> "
|
||||
}
|
||||
nameLine := cursor + textStyle.Render(s.App)
|
||||
barLine := " " + renderVolumeBar(s.Volume, barWidth(width), s.Muted)
|
||||
return nameLine + "\n" + barLine
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
// renderVolumeBar draws a static block-bar slider. bubbles has no dedicated
|
||||
// slider widget, only an animated progress bar meant for async progress, not
|
||||
// an immediately-adjustable value — a static bar reflects every keypress
|
||||
// instantly without depending on progress.Model's frame-tick animation.
|
||||
func renderVolumeBar(pct, barWidth int, muted bool) string {
|
||||
if barWidth < 4 {
|
||||
barWidth = 4
|
||||
}
|
||||
filled := pct * barWidth / 100
|
||||
switch {
|
||||
case filled > barWidth:
|
||||
filled = barWidth
|
||||
case filled < 0:
|
||||
filled = 0
|
||||
}
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||
pctText := fmt.Sprintf("%3d%%", pct)
|
||||
|
||||
barColor := ilovetui.S.Primary
|
||||
if muted {
|
||||
barColor = ilovetui.S.Subtle
|
||||
}
|
||||
line := lipgloss.NewStyle().Foreground(barColor).Render("["+bar+"]") + " " + subtleStyle().Render(pctText)
|
||||
if muted {
|
||||
line += " " + errorStyle().Bold(true).Render("MUTED")
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// renderLevelMeter draws a live input-level meter from a peak value in
|
||||
// [0, 1], used to test a microphone.
|
||||
func renderLevelMeter(peak float64, barWidth int) string {
|
||||
if barWidth < 4 {
|
||||
barWidth = 4
|
||||
}
|
||||
filled := int(peak*float64(barWidth) + 0.5)
|
||||
switch {
|
||||
case filled > barWidth:
|
||||
filled = barWidth
|
||||
case filled < 0:
|
||||
filled = 0
|
||||
}
|
||||
color := ilovetui.S.Success
|
||||
if peak > 0.9 {
|
||||
color = ilovetui.S.Error
|
||||
} else if peak > 0.65 {
|
||||
color = ilovetui.S.Warning
|
||||
}
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||
return "Level: " + lipgloss.NewStyle().Foreground(color).Render("["+bar+"]")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package audio
|
||||
|
||||
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,49 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
// cycleTab moves to the next tab, stopping any live input-level test running
|
||||
// on the tab being left.
|
||||
func (m *Model) cycleTab() {
|
||||
m.input.stopLevelMonitor()
|
||||
for i, t := range visibleTabs {
|
||||
if t == m.active {
|
||||
m.active = visibleTabs[(i+1)%len(visibleTabs)]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderTabBar(width int) string {
|
||||
active := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 2).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Primary)
|
||||
inactive := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Padding(0, 2).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Subtle)
|
||||
|
||||
var tabs []string
|
||||
for _, t := range visibleTabs {
|
||||
style := inactive
|
||||
if t == m.active {
|
||||
style = active
|
||||
}
|
||||
tabs = append(tabs, style.Render(t.icon()+t.label()))
|
||||
}
|
||||
row := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...)
|
||||
|
||||
filler := width - lipgloss.Width(row)
|
||||
if filler > 0 {
|
||||
row = lipgloss.JoinHorizontal(lipgloss.Bottom, row,
|
||||
lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Subtle).
|
||||
Render(strings.Repeat(" ", filler)))
|
||||
}
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package audio
|
||||
|
||||
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 + the auto-refresh loop
|
||||
return m, tea.Batch(fetchOutputCmd(), fetchInputCmd(), fetchAppsCmd(), tickCmd())
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
if m.checkErr != nil {
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Batch(fetchOutputCmd(), fetchInputCmd(), fetchAppsCmd(), tickCmd())
|
||||
|
||||
case outputListMsg:
|
||||
m.handleOutputList(msg)
|
||||
return m, nil
|
||||
case outputActionMsg:
|
||||
return m, m.handleOutputAction(msg)
|
||||
|
||||
case inputListMsg:
|
||||
m.handleInputList(msg)
|
||||
return m, nil
|
||||
case inputActionMsg:
|
||||
return m, m.handleInputAction(msg)
|
||||
case levelStartedMsg:
|
||||
return m, m.handleLevelStarted(msg)
|
||||
case levelMsg:
|
||||
return m, m.handleLevel(msg)
|
||||
|
||||
case appsListMsg:
|
||||
m.handleAppsList(msg)
|
||||
return m, nil
|
||||
case appsActionMsg:
|
||||
return m, m.handleAppsAction(msg)
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if m.checkErr != nil {
|
||||
if key.Matches(msg, keys.Keys.Audio.Refresh) {
|
||||
return m, checkCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
g := keys.Keys.Global
|
||||
switch {
|
||||
case key.Matches(msg, g.CycleFocus):
|
||||
m.cycleTab()
|
||||
return m, nil
|
||||
case key.Matches(msg, g.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.active {
|
||||
case tabOutput:
|
||||
cmd = m.updateOutput(msg)
|
||||
case tabInput:
|
||||
cmd = m.updateInput(msg)
|
||||
case tabApps:
|
||||
cmd = m.updateApps(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/audio"
|
||||
)
|
||||
|
||||
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()
|
||||
statusH := strings.Count(statusBar, "\n") + 1
|
||||
|
||||
frameW := windowStyle().GetHorizontalFrameSize()
|
||||
frameH := windowStyle().GetVerticalFrameSize()
|
||||
innerW := m.width - frameW
|
||||
|
||||
tabBar := m.renderTabBar(innerW)
|
||||
tabBarH := lipgloss.Height(tabBar)
|
||||
|
||||
innerH := m.height - tabBarH - frameH - statusH
|
||||
|
||||
var body string
|
||||
switch {
|
||||
case !m.checked:
|
||||
body = ilovetui.S.Faint.Render("Checking wpctl…")
|
||||
case m.checkErr != nil:
|
||||
body = m.renderUnavailable()
|
||||
default:
|
||||
body = m.renderActiveTab(innerW, innerH)
|
||||
}
|
||||
|
||||
box := lipgloss.NewStyle().Width(innerW).Height(innerH).Render(body)
|
||||
content := windowStyle().Render(lipgloss.JoinVertical(lipgloss.Left, tabBar, box))
|
||||
|
||||
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, content, statusBar))
|
||||
}
|
||||
|
||||
func (m Model) renderStatusBar() string {
|
||||
km := audioKeyMap{m: m, width: m.width}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km))
|
||||
}
|
||||
|
||||
func (m Model) renderActiveTab(width, height int) string {
|
||||
switch m.active {
|
||||
case tabOutput:
|
||||
return m.renderOutput(width, height)
|
||||
case tabInput:
|
||||
return m.renderInput(width, height)
|
||||
case tabApps:
|
||||
return m.renderApps(width, height)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderUnavailable() string {
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Error).Render("Audio page unavailable")
|
||||
|
||||
var hint string
|
||||
switch {
|
||||
case errors.Is(m.checkErr, audio.ErrNotFound):
|
||||
hint = "wpctl was not found in PATH. Install PipeWire/WirePlumber to use this page."
|
||||
default:
|
||||
hint = m.checkErr.Error()
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
subtleStyle().Render(hint),
|
||||
"",
|
||||
mutedStyle().Render("Press r to try again once fixed."),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package bluetooth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/settuings/internal/bluetooth"
|
||||
)
|
||||
|
||||
const (
|
||||
tickInterval = 5 * time.Second
|
||||
scanSeconds = 5
|
||||
)
|
||||
|
||||
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: bluetooth.CheckAvailable()} }
|
||||
}
|
||||
|
||||
func fetchControllerCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
info, err := bluetooth.ShowController()
|
||||
return controllerMsg{info: info, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchDevicesCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
devices, err := bluetooth.ListDevices()
|
||||
return deviceListMsg{devices: devices, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func togglePowerCmd(on bool) tea.Cmd {
|
||||
return func() tea.Msg { return powerActionMsg{err: bluetooth.SetPower(on)} }
|
||||
}
|
||||
|
||||
func scanCmd() tea.Cmd {
|
||||
return func() tea.Msg { return scanDoneMsg{err: bluetooth.Scan(scanSeconds)} }
|
||||
}
|
||||
|
||||
func connectCmd(address string) tea.Cmd {
|
||||
return func() tea.Msg { return deviceActionMsg{err: bluetooth.Connect(address)} }
|
||||
}
|
||||
|
||||
func disconnectCmd(address string) tea.Cmd {
|
||||
return func() tea.Msg { return deviceActionMsg{err: bluetooth.Disconnect(address)} }
|
||||
}
|
||||
|
||||
// pairCmd pairs with a device and, on success, immediately connects it —
|
||||
// most devices don't auto-connect right after pairing.
|
||||
func pairCmd(address string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := bluetooth.Pair(address); err != nil {
|
||||
return deviceActionMsg{err: err}
|
||||
}
|
||||
return deviceActionMsg{err: bluetooth.Connect(address)}
|
||||
}
|
||||
}
|
||||
|
||||
func trustCmd(address string, trust bool) tea.Cmd {
|
||||
return func() tea.Msg { return deviceActionMsg{err: bluetooth.SetTrusted(address, trust)} }
|
||||
}
|
||||
|
||||
func removeCmd(address string) tea.Cmd {
|
||||
return func() tea.Msg { return deviceActionMsg{err: bluetooth.Remove(address)} }
|
||||
}
|
||||
|
||||
func fetchDetailCmd(address string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
detail, err := bluetooth.Info(address)
|
||||
return detailMsg{detail: detail, err: err}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package bluetooth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/bluetooth"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
"github.com/anotherhadi/settuings/internal/util"
|
||||
)
|
||||
|
||||
// deviceDetailState is the "device info" screen for whichever device (paired
|
||||
// or not) the user asked about. It overlays the main list. Content can
|
||||
// exceed a short terminal, so it's shown in a viewport.
|
||||
type deviceDetailState struct {
|
||||
open bool
|
||||
address string
|
||||
title string
|
||||
|
||||
loading bool
|
||||
err error
|
||||
data bluetooth.DeviceDetail
|
||||
|
||||
pending bool
|
||||
feedback string
|
||||
|
||||
viewport viewport.Model
|
||||
}
|
||||
|
||||
func (m *Model) openDetail(address, title string) tea.Cmd {
|
||||
w, h := m.contentBudget()
|
||||
vp := viewport.New()
|
||||
vp.SetWidth(w)
|
||||
vp.SetHeight(h)
|
||||
|
||||
m.detail = deviceDetailState{
|
||||
open: true,
|
||||
address: address,
|
||||
title: title,
|
||||
loading: true,
|
||||
viewport: vp,
|
||||
}
|
||||
m.refreshDetailContent()
|
||||
return fetchDetailCmd(address)
|
||||
}
|
||||
|
||||
// contentBudget returns the exact space available inside the bordered page
|
||||
// box, below the tab bar and above the status bar. View() renders the box
|
||||
// at this same size, so the detail viewport fills it exactly instead of
|
||||
// leaving it short. Since the status bar's height depends on live state
|
||||
// (e.g. whether help is expanded), callers must call resizeDetail whenever
|
||||
// that state changes.
|
||||
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
|
||||
}
|
||||
|
||||
func (m *Model) resizeDetail() {
|
||||
if !m.detail.open {
|
||||
return
|
||||
}
|
||||
w, h := m.contentBudget()
|
||||
m.detail.viewport.SetWidth(w)
|
||||
m.detail.viewport.SetHeight(h)
|
||||
m.refreshDetailContent()
|
||||
}
|
||||
|
||||
func (m *Model) handleDetail(msg detailMsg) {
|
||||
if !m.detail.open {
|
||||
return
|
||||
}
|
||||
m.detail.loading = false
|
||||
m.detail.data = msg.detail
|
||||
m.detail.err = msg.err
|
||||
m.refreshDetailContent()
|
||||
}
|
||||
|
||||
// closeDetailIfPending closes the info screen once the connect/disconnect
|
||||
// it triggered has completed, but only if it's still the one waiting.
|
||||
func (m *Model) closeDetailIfPending() {
|
||||
if m.detail.open && m.detail.pending {
|
||||
m.detail = deviceDetailState{}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) updateDetail(msg tea.Msg) tea.Cmd {
|
||||
if wheel, ok := msg.(tea.MouseWheelMsg); ok {
|
||||
util.HandleMouseWheel(wheel, &m.detail.viewport)
|
||||
return nil
|
||||
}
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
b := keys.Keys.Bluetooth
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Escape), key.Matches(keyMsg, g.CycleFocus):
|
||||
m.detail = deviceDetailState{}
|
||||
case key.Matches(keyMsg, g.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
m.resizeDetail()
|
||||
case key.Matches(keyMsg, b.Select):
|
||||
m.detail.pending = true
|
||||
m.refreshDetailContent()
|
||||
switch {
|
||||
case m.detail.data.Connected:
|
||||
return disconnectCmd(m.detail.address)
|
||||
case m.detail.data.Paired:
|
||||
return connectCmd(m.detail.address)
|
||||
default:
|
||||
return pairCmd(m.detail.address)
|
||||
}
|
||||
case key.Matches(keyMsg, b.ToggleTrust):
|
||||
m.detail.pending = true
|
||||
m.refreshDetailContent()
|
||||
return trustCmd(m.detail.address, !m.detail.data.Trusted)
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
util.ScrollLines(&m.detail.viewport, -1)
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
util.ScrollLines(&m.detail.viewport, 1)
|
||||
case key.Matches(keyMsg, g.ScrollUp):
|
||||
util.ScrollViewport(&m.detail.viewport, -1)
|
||||
case key.Matches(keyMsg, g.ScrollDown):
|
||||
util.ScrollViewport(&m.detail.viewport, 1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) renderDetail() string {
|
||||
return m.detail.viewport.View()
|
||||
}
|
||||
|
||||
func (m *Model) refreshDetailContent() {
|
||||
m.detail.viewport.SetContent(m.detailContent())
|
||||
util.RefreshScrollbar(&m.detail.viewport)
|
||||
}
|
||||
|
||||
func (m Model) detailContent() string {
|
||||
d := m.detail
|
||||
if d.loading {
|
||||
return ilovetui.S.Faint.Render("Loading device info…")
|
||||
}
|
||||
|
||||
title := lipgloss.NewStyle().Bold(true).Render(d.title)
|
||||
if d.pending {
|
||||
title += mutedStyle().Render(" working…")
|
||||
}
|
||||
rows := []string{title, subtleStyle().Render("Address: ") + d.address}
|
||||
|
||||
if d.err != nil {
|
||||
rows = append(rows, "", errorStyle().Render(d.err.Error()))
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
rows = append(rows,
|
||||
subtleStyle().Render("Paired: ")+yesNo(d.data.Paired),
|
||||
subtleStyle().Render("Trusted: ")+yesNo(d.data.Trusted),
|
||||
subtleStyle().Render("Connected: ")+yesNo(d.data.Connected),
|
||||
subtleStyle().Render("Blocked: ")+yesNo(d.data.Blocked),
|
||||
)
|
||||
if d.data.Icon != "" {
|
||||
rows = append(rows, subtleStyle().Render("Type: ")+d.data.Icon)
|
||||
}
|
||||
if d.data.Battery >= 0 {
|
||||
rows = append(rows, subtleStyle().Render("Battery: ")+fmt.Sprintf("%d%%", d.data.Battery))
|
||||
}
|
||||
if d.data.Modalias != "" {
|
||||
rows = append(rows, subtleStyle().Render("Modalias: ")+d.data.Modalias)
|
||||
}
|
||||
if len(d.data.UUIDs) > 0 {
|
||||
rows = append(rows, "", subtleStyle().Render("Services:"))
|
||||
for _, u := range d.data.UUIDs {
|
||||
rows = append(rows, " "+u)
|
||||
}
|
||||
}
|
||||
|
||||
if d.feedback != "" {
|
||||
rows = append(rows, "", errorStyle().Render(d.feedback))
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
func yesNo(b bool) string {
|
||||
if b {
|
||||
return successStyle().Render("yes")
|
||||
}
|
||||
return subtleStyle().Render("no")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package bluetooth
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
// bluetoothKeyMap renders the bottom help bar, adapting to whether the
|
||||
// confirm-forget prompt or the device info screen is open.
|
||||
type bluetoothKeyMap struct {
|
||||
m Model
|
||||
width int
|
||||
}
|
||||
|
||||
func (k bluetoothKeyMap) ShortHelp() []key.Binding {
|
||||
g := keys.Keys.Global
|
||||
b := keys.Keys.Bluetooth
|
||||
|
||||
switch {
|
||||
case k.m.list.confirmRemove:
|
||||
return []key.Binding{b.Forget, g.Escape}
|
||||
case k.m.detail.open:
|
||||
return []key.Binding{g.Up, g.Down, b.Select, b.ToggleTrust, g.Escape, g.Help}
|
||||
default:
|
||||
return []key.Binding{g.Up, g.Down, b.Select, b.Refresh, b.Info, g.Help}
|
||||
}
|
||||
}
|
||||
|
||||
func (k bluetoothKeyMap) FullHelp() [][]key.Binding {
|
||||
g := keys.Keys.Global
|
||||
b := keys.Keys.Bluetooth
|
||||
|
||||
var bindings []key.Binding
|
||||
switch {
|
||||
case k.m.list.confirmRemove:
|
||||
bindings = []key.Binding{b.Forget, g.Escape}
|
||||
case k.m.detail.open:
|
||||
bindings = []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, b.Select, b.ToggleTrust, g.Escape}
|
||||
default:
|
||||
bindings = []key.Binding{g.Up, g.Down, b.Select, b.Refresh, b.Info, b.ToggleTrust, b.Forget, b.TogglePower}
|
||||
}
|
||||
bindings = append(bindings, g.CommonBindings()...)
|
||||
return keys.ChunkByWidth(bindings, k.width)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package bluetooth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"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/bluetooth"
|
||||
"github.com/anotherhadi/settuings/internal/icons"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
// deviceListState is the main screen: paired devices (fixed profiles)
|
||||
// followed by other devices seen during a scan but not yet paired. Both
|
||||
// live in one cursor-navigable, height-budgeted, paginated list.
|
||||
type deviceListState struct {
|
||||
loading bool
|
||||
err error
|
||||
paired []bluetooth.Device
|
||||
others []bluetooth.Device
|
||||
cursor int
|
||||
pending bool
|
||||
scanning bool
|
||||
feedback string
|
||||
|
||||
confirmRemove bool
|
||||
}
|
||||
|
||||
func newDeviceListState() deviceListState {
|
||||
return deviceListState{loading: true}
|
||||
}
|
||||
|
||||
func (s deviceListState) total() int { return len(s.paired) + len(s.others) }
|
||||
|
||||
func (s deviceListState) selected() *bluetooth.Device {
|
||||
if s.cursor < 0 || s.cursor >= s.total() {
|
||||
return nil
|
||||
}
|
||||
if s.cursor < len(s.paired) {
|
||||
return &s.paired[s.cursor]
|
||||
}
|
||||
return &s.others[s.cursor-len(s.paired)]
|
||||
}
|
||||
|
||||
func (m *Model) handleDeviceList(msg deviceListMsg) {
|
||||
m.list.loading = false
|
||||
m.list.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
paired := make([]bluetooth.Device, 0, len(msg.devices))
|
||||
others := make([]bluetooth.Device, 0, len(msg.devices))
|
||||
for _, d := range msg.devices {
|
||||
if d.Paired {
|
||||
paired = append(paired, d)
|
||||
} else {
|
||||
others = append(others, d)
|
||||
}
|
||||
}
|
||||
sort.Slice(paired, func(i, j int) bool {
|
||||
if paired[i].Connected != paired[j].Connected {
|
||||
return paired[i].Connected
|
||||
}
|
||||
return strings.ToLower(paired[i].Name) < strings.ToLower(paired[j].Name)
|
||||
})
|
||||
sort.Slice(others, func(i, j int) bool {
|
||||
return strings.ToLower(others[i].Name) < strings.ToLower(others[j].Name)
|
||||
})
|
||||
m.list.paired = paired
|
||||
m.list.others = others
|
||||
if m.list.cursor >= m.list.total() {
|
||||
m.list.cursor = max(0, m.list.total()-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleDeviceAction(msg deviceActionMsg) tea.Cmd {
|
||||
m.list.pending = false
|
||||
if msg.err != nil {
|
||||
m.list.feedback = msg.err.Error()
|
||||
m.detail.pending = false
|
||||
m.detail.feedback = msg.err.Error()
|
||||
m.refreshDetailContent()
|
||||
return nil
|
||||
}
|
||||
m.list.feedback = ""
|
||||
m.closeDetailIfPending()
|
||||
return fetchDevicesCmd()
|
||||
}
|
||||
|
||||
func (m *Model) handlePowerAction(msg powerActionMsg) tea.Cmd {
|
||||
m.list.pending = false
|
||||
if msg.err != nil {
|
||||
m.list.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
m.list.feedback = ""
|
||||
return tea.Batch(fetchControllerCmd(), fetchDevicesCmd())
|
||||
}
|
||||
|
||||
func (m *Model) handleScanDone(msg scanDoneMsg) tea.Cmd {
|
||||
m.list.scanning = false
|
||||
if msg.err != nil {
|
||||
m.list.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
m.list.feedback = ""
|
||||
return fetchDevicesCmd()
|
||||
}
|
||||
|
||||
func (m *Model) updateList(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.list.confirmRemove {
|
||||
return m.updateConfirmRemove(keyMsg)
|
||||
}
|
||||
|
||||
g := keys.Keys.Global
|
||||
b := keys.Keys.Bluetooth
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.list.cursor > 0 {
|
||||
m.list.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.list.cursor < m.list.total()-1 {
|
||||
m.list.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, b.Refresh):
|
||||
m.list.pending = true
|
||||
m.list.scanning = true
|
||||
return scanCmd()
|
||||
case key.Matches(keyMsg, b.Select):
|
||||
return m.selectDevice()
|
||||
case key.Matches(keyMsg, b.Info):
|
||||
return m.openInfo()
|
||||
case key.Matches(keyMsg, b.ToggleTrust):
|
||||
return m.toggleTrust()
|
||||
case key.Matches(keyMsg, b.Forget):
|
||||
if m.list.selected() != nil {
|
||||
m.list.confirmRemove = true
|
||||
}
|
||||
case key.Matches(keyMsg, b.TogglePower):
|
||||
m.list.pending = true
|
||||
return togglePowerCmd(!m.controller.Powered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) updateConfirmRemove(keyMsg tea.KeyPressMsg) tea.Cmd {
|
||||
switch keyMsg.String() {
|
||||
case "y", "enter":
|
||||
sel := m.list.selected()
|
||||
m.list.confirmRemove = false
|
||||
if sel == nil {
|
||||
return nil
|
||||
}
|
||||
m.list.pending = true
|
||||
return removeCmd(sel.Address)
|
||||
default:
|
||||
m.list.confirmRemove = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) selectDevice() tea.Cmd {
|
||||
sel := m.list.selected()
|
||||
if sel == nil {
|
||||
return nil
|
||||
}
|
||||
m.list.pending = true
|
||||
if sel.Paired {
|
||||
if sel.Connected {
|
||||
return disconnectCmd(sel.Address)
|
||||
}
|
||||
return connectCmd(sel.Address)
|
||||
}
|
||||
return pairCmd(sel.Address)
|
||||
}
|
||||
|
||||
func (m *Model) toggleTrust() tea.Cmd {
|
||||
sel := m.list.selected()
|
||||
if sel == nil {
|
||||
return nil
|
||||
}
|
||||
m.list.pending = true
|
||||
return trustCmd(sel.Address, !sel.Trusted)
|
||||
}
|
||||
|
||||
func (m *Model) openInfo() tea.Cmd {
|
||||
sel := m.list.selected()
|
||||
if sel == nil {
|
||||
return nil
|
||||
}
|
||||
return m.openDetail(sel.Address, sel.Name)
|
||||
}
|
||||
|
||||
func (m Model) renderList(width, height int) string {
|
||||
if m.list.loading {
|
||||
return ilovetui.S.Faint.Render("Loading Bluetooth devices…")
|
||||
}
|
||||
if m.list.err != nil {
|
||||
return errorStyle().Render(m.list.err.Error())
|
||||
}
|
||||
if m.list.total() == 0 {
|
||||
return ilovetui.S.Faint.Render("No devices found. Press r to scan for nearby devices.")
|
||||
}
|
||||
|
||||
// Reserve a line for each section header that could appear, so the
|
||||
// paginated device rows never overflow the available height even on
|
||||
// the one page where a section boundary falls.
|
||||
headerReserve := 0
|
||||
if len(m.list.paired) > 0 {
|
||||
headerReserve++
|
||||
}
|
||||
if len(m.list.others) > 0 {
|
||||
headerReserve++
|
||||
}
|
||||
|
||||
available := height - headerReserve - 1 // trailing line for paginator/feedback
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.list.cursor, m.list.total(), available)
|
||||
|
||||
var lines []string
|
||||
for i := start; i < end; i++ {
|
||||
if i == 0 && len(m.list.paired) > 0 {
|
||||
lines = append(lines, sectionHeader("PAIRED DEVICES"))
|
||||
}
|
||||
if i == len(m.list.paired) && len(m.list.others) > 0 {
|
||||
lines = append(lines, sectionHeader("OTHER DEVICES"))
|
||||
}
|
||||
if i < len(m.list.paired) {
|
||||
lines = append(lines, m.renderDeviceRow(m.list.paired[i], i == m.list.cursor, width))
|
||||
} else {
|
||||
lines = append(lines, m.renderDeviceRow(m.list.others[i-len(m.list.paired)], i == m.list.cursor, width))
|
||||
}
|
||||
}
|
||||
list := strings.Join(lines, "\n")
|
||||
|
||||
footer := renderPaginatorDots(p)
|
||||
switch {
|
||||
case m.list.confirmRemove:
|
||||
name := ""
|
||||
if sel := m.list.selected(); sel != nil {
|
||||
name = sel.Name
|
||||
}
|
||||
footer = errorStyle().Render(fmt.Sprintf("Forget %q? press y to confirm, any other key to cancel", name))
|
||||
case m.list.feedback != "":
|
||||
footer = errorStyle().Render(m.list.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, list, footer)
|
||||
}
|
||||
|
||||
func sectionHeader(text string) string {
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render(text)
|
||||
}
|
||||
|
||||
func (m Model) renderDeviceRow(d bluetooth.Device, selected bool, width int) string {
|
||||
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||
if selected {
|
||||
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
|
||||
}
|
||||
cursor := " "
|
||||
if selected {
|
||||
cursor = "> "
|
||||
}
|
||||
check := " "
|
||||
if d.Connected {
|
||||
check = successStyle().Render(icons.I.Check)
|
||||
}
|
||||
|
||||
var tags []string
|
||||
if d.Trusted {
|
||||
tags = append(tags, "trusted")
|
||||
}
|
||||
if d.Battery >= 0 {
|
||||
tags = append(tags, fmt.Sprintf("%d%%", d.Battery))
|
||||
}
|
||||
tag := strings.Join(tags, " · ")
|
||||
|
||||
row := fmt.Sprintf("%s%s%s", cursor, check, textStyle.Render(d.Name))
|
||||
if tag == "" {
|
||||
return row
|
||||
}
|
||||
pad := width - lipgloss.Width(row) - lipgloss.Width(tag)
|
||||
if pad < 1 {
|
||||
pad = 1
|
||||
}
|
||||
return row + strings.Repeat(" ", pad) + subtleStyle().Render(tag)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package bluetooth
|
||||
|
||||
import "github.com/anotherhadi/settuings/internal/bluetooth"
|
||||
|
||||
// checkMsg carries the result of bluetooth.CheckAvailable.
|
||||
type checkMsg struct{ err error }
|
||||
|
||||
// tickMsg triggers a periodic refresh of the controller + device list while
|
||||
// this page is active.
|
||||
type tickMsg struct{}
|
||||
|
||||
type controllerMsg struct {
|
||||
info bluetooth.ControllerInfo
|
||||
err error
|
||||
}
|
||||
|
||||
type deviceListMsg struct {
|
||||
devices []bluetooth.Device
|
||||
err error
|
||||
}
|
||||
|
||||
type deviceActionMsg struct{ err error }
|
||||
|
||||
type powerActionMsg struct{ err error }
|
||||
|
||||
type scanDoneMsg struct{ err error }
|
||||
|
||||
type detailMsg struct {
|
||||
detail bluetooth.DeviceDetail
|
||||
err error
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Package bluetooth provides the "Bluetooth" TUI page: a thin wrapper
|
||||
// around bluetoothctl covering controller power and device
|
||||
// pairing/connection, in the same spirit as the network page's Wi-Fi tab.
|
||||
package bluetooth
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/bluetooth"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
width int
|
||||
height int
|
||||
help help.Model
|
||||
|
||||
checkErr error // reason bluetoothctl is unusable; nil once confirmed OK
|
||||
checked bool
|
||||
|
||||
controller bluetooth.ControllerInfo
|
||||
|
||||
list deviceListState
|
||||
detail deviceDetailState
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
return Model{
|
||||
help: ilovetui.NewHelp(),
|
||||
list: newDeviceListState(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Activate is called every time the user navigates onto this page. It
|
||||
// re-checks bluetoothctl availability, fetches controller + device state,
|
||||
// and starts the periodic auto-refresh loop.
|
||||
func (m *Model) Activate() tea.Cmd {
|
||||
return tea.Batch(checkCmd(), fetchControllerCmd(), fetchDevicesCmd(), tickCmd())
|
||||
}
|
||||
|
||||
func (m Model) IsEditing() bool {
|
||||
return m.list.confirmRemove
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
m.help.SetWidth(w - 2)
|
||||
m.resizeDetail()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package bluetooth
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/paginator"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
func newPaginator() paginator.Model {
|
||||
p := paginator.New()
|
||||
p.Type = paginator.Dots
|
||||
p.ActiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render("•")
|
||||
p.InactiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render("•")
|
||||
return p
|
||||
}
|
||||
|
||||
// paginate slices a list of length total into pages of availableRows items,
|
||||
// deriving the current page from cursor (the globally-indexed selection)
|
||||
// rather than tracking page state separately. It returns the configured
|
||||
// paginator and the [start, end) bounds of the visible slice.
|
||||
func paginate(cursor, total, availableRows int) (p paginator.Model, start, end int) {
|
||||
p = newPaginator()
|
||||
perPage := availableRows
|
||||
if perPage < 1 {
|
||||
perPage = 1
|
||||
}
|
||||
p.PerPage = perPage
|
||||
p.SetTotalPages(total)
|
||||
if p.TotalPages > 0 {
|
||||
p.Page = cursor / perPage
|
||||
if p.Page >= p.TotalPages {
|
||||
p.Page = p.TotalPages - 1
|
||||
}
|
||||
}
|
||||
start, end = p.GetSliceBounds(total)
|
||||
return p, start, end
|
||||
}
|
||||
|
||||
func renderPaginatorDots(p paginator.Model) string {
|
||||
if p.TotalPages <= 1 {
|
||||
return ""
|
||||
}
|
||||
return mutedStyle().Render(p.View())
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package bluetooth
|
||||
|
||||
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,30 @@
|
||||
package bluetooth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/icons"
|
||||
)
|
||||
|
||||
// renderTabBar draws a header matching the network page's tab bar, but with
|
||||
// a single always-active entry: this page has no tabs of its own, just a
|
||||
// visual label for consistency.
|
||||
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.Bluetooth + "Bluetooth")
|
||||
|
||||
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,70 @@
|
||||
package bluetooth
|
||||
|
||||
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 + the auto-refresh loop
|
||||
return m, tea.Batch(fetchControllerCmd(), fetchDevicesCmd(), tickCmd())
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
if m.checkErr != nil {
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Batch(fetchControllerCmd(), fetchDevicesCmd(), tickCmd())
|
||||
|
||||
case controllerMsg:
|
||||
m.controller = msg.info
|
||||
return m, nil
|
||||
|
||||
case deviceListMsg:
|
||||
m.handleDeviceList(msg)
|
||||
return m, nil
|
||||
case deviceActionMsg:
|
||||
return m, m.handleDeviceAction(msg)
|
||||
case powerActionMsg:
|
||||
return m, m.handlePowerAction(msg)
|
||||
case scanDoneMsg:
|
||||
return m, m.handleScanDone(msg)
|
||||
case detailMsg:
|
||||
m.handleDetail(msg)
|
||||
return m, nil
|
||||
|
||||
case tea.MouseWheelMsg:
|
||||
if m.detail.open {
|
||||
return m, m.updateDetail(msg)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if m.checkErr != nil {
|
||||
if key.Matches(msg, keys.Keys.Bluetooth.Refresh) {
|
||||
return m, checkCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.detail.open {
|
||||
return m, m.updateDetail(msg)
|
||||
}
|
||||
|
||||
if !m.IsEditing() && key.Matches(msg, keys.Keys.Global.Help) {
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, m.updateList(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package bluetooth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/bluetooth"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
var body string
|
||||
switch {
|
||||
case !m.checked:
|
||||
body = ilovetui.S.Faint.Render("Checking bluetoothctl…")
|
||||
case m.checkErr != nil:
|
||||
body = m.renderUnavailable()
|
||||
case m.detail.open:
|
||||
body = m.renderDetail()
|
||||
default:
|
||||
body = m.renderMain(innerW, innerH)
|
||||
}
|
||||
|
||||
box := lipgloss.NewStyle().Width(innerW).Height(innerH).Render(body)
|
||||
content := windowStyle().Render(lipgloss.JoinVertical(lipgloss.Left, tabBar, box))
|
||||
|
||||
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, content, statusBar))
|
||||
}
|
||||
|
||||
func (m Model) renderStatusBar() string {
|
||||
km := bluetoothKeyMap{m: m, width: m.width}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km))
|
||||
}
|
||||
|
||||
func (m Model) renderMain(width, height int) string {
|
||||
header := m.renderPowerStatus()
|
||||
headerH := lipgloss.Height(header) + 1 // blank line after
|
||||
listH := height - headerH
|
||||
if listH < 1 {
|
||||
listH = 1
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", m.renderList(width, listH))
|
||||
}
|
||||
|
||||
func (m Model) renderPowerStatus() string {
|
||||
label := "Bluetooth: "
|
||||
status := errorStyle().Bold(true).Render("Off")
|
||||
if m.controller.Powered {
|
||||
status = successStyle().Bold(true).Render("On")
|
||||
}
|
||||
line := label + status
|
||||
switch {
|
||||
case m.list.scanning:
|
||||
line += mutedStyle().Render(" scanning…")
|
||||
case m.list.pending:
|
||||
line += mutedStyle().Render(" working…")
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func (m Model) renderUnavailable() string {
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Error).Render("Bluetooth page unavailable")
|
||||
|
||||
var hint string
|
||||
switch {
|
||||
case errors.Is(m.checkErr, bluetooth.ErrNotFound):
|
||||
hint = "bluetoothctl was not found in PATH. Install BlueZ to use this page."
|
||||
case errors.Is(m.checkErr, bluetooth.ErrNoController):
|
||||
hint = "No Bluetooth controller was found. Try: sudo systemctl start bluetooth"
|
||||
default:
|
||||
hint = m.checkErr.Error()
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
subtleStyle().Render(hint),
|
||||
"",
|
||||
mutedStyle().Render("Press r to try again once fixed."),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindInfo Kind = "info"
|
||||
KindSuccess Kind = "success"
|
||||
KindWarning Kind = "warning"
|
||||
KindError Kind = "error"
|
||||
)
|
||||
|
||||
type NotificationMsg struct {
|
||||
Title string
|
||||
Body string
|
||||
Kind Kind
|
||||
}
|
||||
|
||||
type DismissMsg struct{ ID int }
|
||||
|
||||
type notification struct {
|
||||
id int
|
||||
title string
|
||||
body string
|
||||
kind Kind
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
queue []notification
|
||||
nextID int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func New() Model { return Model{} }
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
}
|
||||
|
||||
func (m Model) HasNotifications() bool {
|
||||
return len(m.queue) > 0
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case NotificationMsg:
|
||||
n := notification{id: m.nextID, title: msg.Title, body: msg.Body, kind: msg.Kind}
|
||||
m.nextID++
|
||||
m.queue = append(m.queue, n)
|
||||
return m, tea.Tick(4*time.Second, func(time.Time) tea.Msg { return DismissMsg{ID: n.id} })
|
||||
case DismissMsg:
|
||||
for i, n := range m.queue {
|
||||
if n.id == msg.ID {
|
||||
m.queue = append(m.queue[:i], m.queue[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View(background string) string {
|
||||
if len(m.queue) == 0 {
|
||||
return background
|
||||
}
|
||||
|
||||
const popupW = 34
|
||||
|
||||
var popups []string
|
||||
start := len(m.queue) - 3
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
for i := start; i < len(m.queue); i++ {
|
||||
n := m.queue[i]
|
||||
var accent color.Color
|
||||
switch n.kind {
|
||||
case KindSuccess:
|
||||
accent = ilovetui.S.Success
|
||||
case KindWarning:
|
||||
accent = ilovetui.S.Warning
|
||||
case KindError:
|
||||
accent = ilovetui.S.Error
|
||||
default:
|
||||
accent = ilovetui.S.Primary
|
||||
}
|
||||
|
||||
titleStr := lipgloss.NewStyle().Foreground(accent).Bold(true).Render(n.title)
|
||||
bodyStr := lipgloss.NewStyle().Foreground(ilovetui.S.Text).Width(popupW).Render(n.body)
|
||||
|
||||
inner := lipgloss.JoinVertical(lipgloss.Left, titleStr, bodyStr)
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(accent).
|
||||
Padding(0, 1).
|
||||
Render(inner)
|
||||
popups = append(popups, box)
|
||||
}
|
||||
|
||||
popup := strings.Join(popups, "\n")
|
||||
return overlayTopRight(background, popup, m.width, m.height)
|
||||
}
|
||||
|
||||
func overlayTopRight(bg, popup string, w, h int) string {
|
||||
bgLines := strings.Split(bg, "\n")
|
||||
|
||||
popupLines := strings.Split(popup, "\n")
|
||||
popupH := len(popupLines)
|
||||
popupW := 0
|
||||
for _, l := range popupLines {
|
||||
if vw := lipgloss.Width(l); vw > popupW {
|
||||
popupW = vw
|
||||
}
|
||||
}
|
||||
|
||||
const marginTop = 1
|
||||
const marginRight = 2
|
||||
startY := marginTop
|
||||
startX := w - popupW - marginRight
|
||||
if startX < 0 {
|
||||
startX = 0
|
||||
}
|
||||
|
||||
result := make([]string, h)
|
||||
for y := 0; y < h; y++ {
|
||||
bgLine := ""
|
||||
if y < len(bgLines) {
|
||||
bgLine = bgLines[y]
|
||||
}
|
||||
|
||||
popupY := y - startY
|
||||
if popupY >= 0 && popupY < popupH {
|
||||
prefix := ansi.Truncate(bgLine, startX, "")
|
||||
suffix := ansi.TruncateLeft(bgLine, startX+popupW, "")
|
||||
result[y] = prefix + popupLines[popupY] + suffix
|
||||
} else {
|
||||
result[y] = bgLine
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/settuings/internal/network"
|
||||
)
|
||||
|
||||
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: network.CheckAvailable()} }
|
||||
}
|
||||
|
||||
func fetchWifiCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
radioOn, _ := network.RadioEnabled()
|
||||
networks, err := network.ListWifi()
|
||||
if err != nil {
|
||||
return wifiListMsg{radioOn: radioOn, err: err}
|
||||
}
|
||||
var ip, device, connectionName string
|
||||
if dev, err := network.WifiDevice(); err == nil {
|
||||
device = dev.Name
|
||||
if dev.Connected() {
|
||||
ip, _ = network.DeviceIP4(dev.Name)
|
||||
connectionName = dev.Connection
|
||||
}
|
||||
}
|
||||
return wifiListMsg{networks: networks, ip: ip, device: device, connectionName: connectionName, radioOn: radioOn}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleWifiRadioCmd(on bool) tea.Cmd {
|
||||
return func() tea.Msg { return radioActionMsg{err: network.SetWifiRadio(on)} }
|
||||
}
|
||||
|
||||
func rescanWifiCmd() tea.Cmd {
|
||||
return func() tea.Msg { return wifiActionMsg{err: network.Rescan()} }
|
||||
}
|
||||
|
||||
func connectWifiCmd(ssid, password string) tea.Cmd {
|
||||
return func() tea.Msg { return wifiActionMsg{err: network.ConnectWifi(ssid, password)} }
|
||||
}
|
||||
|
||||
func disconnectWifiCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
dev, err := network.WifiDevice()
|
||||
if err != nil {
|
||||
return wifiActionMsg{err: err}
|
||||
}
|
||||
return wifiActionMsg{err: network.DisconnectWifi(dev.Name)}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchEthernetCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
devices, err := network.ListEthernet()
|
||||
return ethernetListMsg{devices: devices, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func ethernetConnectCmd(device string) tea.Cmd {
|
||||
return func() tea.Msg { return ethernetActionMsg{err: network.DeviceConnect(device)} }
|
||||
}
|
||||
|
||||
func ethernetDownCmd(device string) tea.Cmd {
|
||||
return func() tea.Msg { return ethernetActionMsg{err: network.EthernetDown(device)} }
|
||||
}
|
||||
|
||||
func fetchVPNCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
conns, err := network.ListVPN()
|
||||
return vpnListMsg{conns: conns, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func vpnUpCmd(name string) tea.Cmd {
|
||||
return func() tea.Msg { return vpnActionMsg{err: network.VPNUp(name)} }
|
||||
}
|
||||
|
||||
func vpnDownCmd(name string) tea.Cmd {
|
||||
return func() tea.Msg { return vpnActionMsg{err: network.VPNDown(name)} }
|
||||
}
|
||||
|
||||
func fetchKnownCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
networks, err := network.ListKnownWifi()
|
||||
return knownListMsg{networks: networks, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSecretCmd(name string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
secret, err := network.Secret(name)
|
||||
return knownSecretMsg{name: name, secret: secret, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleAutoConnectCmd(name string, enabled bool) tea.Cmd {
|
||||
return func() tea.Msg { return knownActionMsg{err: network.SetAutoConnect(name, enabled)} }
|
||||
}
|
||||
|
||||
func forgetCmd(name string) tea.Cmd {
|
||||
return func() tea.Msg { return knownActionMsg{err: network.Forget(name)} }
|
||||
}
|
||||
|
||||
func fetchDeviceDetailCmd(device string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
detail, err := network.DeviceDetails(device)
|
||||
return deviceDetailMsg{detail: detail, err: err}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
"github.com/anotherhadi/settuings/internal/network"
|
||||
"github.com/anotherhadi/settuings/internal/util"
|
||||
)
|
||||
|
||||
// deviceDetailState is the "network info" screen: full addressing/hardware
|
||||
// details for whichever connected device (wifi or ethernet) the user asked
|
||||
// about, plus a way to disconnect it. It overlays whichever tab opened it.
|
||||
// Content can exceed a short terminal, so it's shown in a viewport.
|
||||
type deviceDetailState struct {
|
||||
open bool
|
||||
kind tab // tabWifi or tabEthernet: decides which disconnect action to run
|
||||
device string
|
||||
title string
|
||||
security string // wifi only
|
||||
signal int // wifi only, <=0 means n/a
|
||||
connectionName string // wifi only: profile name, for secret lookup
|
||||
|
||||
loading bool
|
||||
err error
|
||||
data network.DeviceDetail
|
||||
|
||||
secretLoading bool
|
||||
secretValue string
|
||||
secretRevealed bool
|
||||
secretErr error
|
||||
|
||||
pending bool
|
||||
feedback string
|
||||
|
||||
viewport viewport.Model
|
||||
}
|
||||
|
||||
func (m *Model) openDeviceDetail(kind tab, device, title, security string, signal int) tea.Cmd {
|
||||
w, h := m.contentBudget()
|
||||
vp := viewport.New()
|
||||
vp.SetWidth(w)
|
||||
vp.SetHeight(h)
|
||||
|
||||
m.deviceDetail = deviceDetailState{
|
||||
open: true,
|
||||
kind: kind,
|
||||
device: device,
|
||||
title: title,
|
||||
security: security,
|
||||
signal: signal,
|
||||
connectionName: m.wifi.connectionName,
|
||||
loading: true,
|
||||
secretLoading: kind == tabWifi,
|
||||
viewport: vp,
|
||||
}
|
||||
m.refreshDeviceDetailContent()
|
||||
|
||||
cmds := []tea.Cmd{fetchDeviceDetailCmd(device)}
|
||||
if kind == tabWifi && m.wifi.connectionName != "" {
|
||||
cmds = append(cmds, fetchSecretCmd(m.wifi.connectionName))
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// contentBudget returns the exact space available inside the bordered tab
|
||||
// box, below the tab bar and above the status bar. View() renders the box
|
||||
// at this same size, so anything sized against contentBudget (e.g. a detail
|
||||
// viewport) fills the box exactly instead of leaving it short. Since the
|
||||
// status bar's height depends on live state (e.g. whether help is
|
||||
// expanded), callers must re-size whenever that state changes — see
|
||||
// resizeDeviceDetail/resizeKnownDetail.
|
||||
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(m.renderTabBar(w))
|
||||
|
||||
h = m.height - tabBarH - frameH - statusH
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
return w, h
|
||||
}
|
||||
|
||||
// resizeDetails re-sizes every open detail viewport to match the current
|
||||
// content budget. Call it whenever that budget could have changed: on
|
||||
// window resize, or when the status bar's height changes (e.g. toggling
|
||||
// help).
|
||||
func (m *Model) resizeDetails() {
|
||||
m.resizeDeviceDetail()
|
||||
m.resizeKnownDetail()
|
||||
}
|
||||
|
||||
func (m *Model) resizeDeviceDetail() {
|
||||
if !m.deviceDetail.open {
|
||||
return
|
||||
}
|
||||
w, h := m.contentBudget()
|
||||
m.deviceDetail.viewport.SetWidth(w)
|
||||
m.deviceDetail.viewport.SetHeight(h)
|
||||
m.refreshDeviceDetailContent()
|
||||
}
|
||||
|
||||
func (m *Model) handleDeviceDetail(msg deviceDetailMsg) {
|
||||
if !m.deviceDetail.open {
|
||||
return
|
||||
}
|
||||
m.deviceDetail.loading = false
|
||||
m.deviceDetail.data = msg.detail
|
||||
m.deviceDetail.err = msg.err
|
||||
m.refreshDeviceDetailContent()
|
||||
}
|
||||
|
||||
// handleDeviceDetailSecret feeds a knownSecretMsg to the info screen if it's
|
||||
// the one that asked for it. The known-networks tab checks the same message
|
||||
// for its own detail view; both can be true at once only in theory (they're
|
||||
// on different tabs), so this is safe to call unconditionally.
|
||||
func (m *Model) handleDeviceDetailSecret(msg knownSecretMsg) {
|
||||
if !m.deviceDetail.open || m.deviceDetail.kind != tabWifi || m.deviceDetail.connectionName != msg.name {
|
||||
return
|
||||
}
|
||||
m.deviceDetail.secretLoading = false
|
||||
m.deviceDetail.secretValue = msg.secret
|
||||
m.deviceDetail.secretErr = msg.err
|
||||
m.refreshDeviceDetailContent()
|
||||
}
|
||||
|
||||
// closeDeviceDetailIfPending closes the info screen once the disconnect it
|
||||
// triggered has completed, but only if it's still the one waiting on kind.
|
||||
func (m *Model) closeDeviceDetailIfPending(kind tab) {
|
||||
if m.deviceDetail.open && m.deviceDetail.kind == kind && m.deviceDetail.pending {
|
||||
m.deviceDetail = deviceDetailState{}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) updateDeviceDetail(msg tea.Msg) tea.Cmd {
|
||||
if wheel, ok := msg.(tea.MouseWheelMsg); ok {
|
||||
util.HandleMouseWheel(wheel, &m.deviceDetail.viewport)
|
||||
return nil
|
||||
}
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Escape), key.Matches(keyMsg, g.CycleFocus):
|
||||
m.deviceDetail = deviceDetailState{}
|
||||
case key.Matches(keyMsg, g.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
m.resizeDetails()
|
||||
case key.Matches(keyMsg, n.Disconnect):
|
||||
m.deviceDetail.pending = true
|
||||
m.refreshDeviceDetailContent()
|
||||
switch m.deviceDetail.kind {
|
||||
case tabWifi:
|
||||
return disconnectWifiCmd()
|
||||
case tabEthernet:
|
||||
return ethernetDownCmd(m.deviceDetail.device)
|
||||
}
|
||||
case key.Matches(keyMsg, n.RevealSecret):
|
||||
if m.deviceDetail.kind == tabWifi {
|
||||
m.deviceDetail.secretRevealed = !m.deviceDetail.secretRevealed
|
||||
m.refreshDeviceDetailContent()
|
||||
}
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
util.ScrollLines(&m.deviceDetail.viewport, -1)
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
util.ScrollLines(&m.deviceDetail.viewport, 1)
|
||||
case key.Matches(keyMsg, g.ScrollUp):
|
||||
util.ScrollViewport(&m.deviceDetail.viewport, -1)
|
||||
case key.Matches(keyMsg, g.ScrollDown):
|
||||
util.ScrollViewport(&m.deviceDetail.viewport, 1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) renderDeviceDetail() string {
|
||||
return m.deviceDetail.viewport.View()
|
||||
}
|
||||
|
||||
func (m *Model) refreshDeviceDetailContent() {
|
||||
m.deviceDetail.viewport.SetContent(m.deviceDetailContent())
|
||||
util.RefreshScrollbar(&m.deviceDetail.viewport)
|
||||
}
|
||||
|
||||
func (m Model) deviceDetailContent() string {
|
||||
d := m.deviceDetail
|
||||
if d.loading {
|
||||
return ilovetui.S.Faint.Render("Loading network info…")
|
||||
}
|
||||
|
||||
title := lipgloss.NewStyle().Bold(true).Render(d.title)
|
||||
if d.pending {
|
||||
title += mutedStyle().Render(" disconnecting…")
|
||||
}
|
||||
rows := []string{title}
|
||||
if d.kind == tabWifi {
|
||||
rows = append(rows, subtleStyle().Render("Security: ")+securityLabel(d.security))
|
||||
if d.signal > 0 {
|
||||
rows = append(rows, subtleStyle().Render("Signal: ")+fmt.Sprintf("%d%%", d.signal))
|
||||
}
|
||||
rows = append(rows, subtleStyle().Render("Password: ")+m.renderDeviceSecret())
|
||||
}
|
||||
rows = append(rows, subtleStyle().Render("Interface: ")+d.device)
|
||||
|
||||
if d.err != nil {
|
||||
rows = append(rows, "", errorStyle().Render(d.err.Error()))
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
rows = append(rows,
|
||||
subtleStyle().Render("IPv4 Address: ")+valueOrDash(d.data.IPv4),
|
||||
subtleStyle().Render("Gateway: ")+valueOrDash(d.data.Gateway4),
|
||||
subtleStyle().Render("DNS: ")+valueOrDash(strings.Join(d.data.DNS4, ", ")),
|
||||
subtleStyle().Render("MAC Address: ")+valueOrDash(d.data.HWAddr),
|
||||
subtleStyle().Render("MTU: ")+valueOrDash(d.data.MTU),
|
||||
)
|
||||
|
||||
if len(d.data.IPv6) > 0 {
|
||||
rows = append(rows, "", subtleStyle().Render("IPv6 Address: ")+d.data.IPv6[0])
|
||||
for _, addr := range d.data.IPv6[1:] {
|
||||
rows = append(rows, " "+addr)
|
||||
}
|
||||
}
|
||||
if d.data.Gateway6 != "" {
|
||||
rows = append(rows, subtleStyle().Render("IPv6 Gateway: ")+d.data.Gateway6)
|
||||
}
|
||||
if len(d.data.DNS6) > 0 {
|
||||
rows = append(rows, subtleStyle().Render("IPv6 DNS: ")+strings.Join(d.data.DNS6, ", "))
|
||||
}
|
||||
|
||||
if d.feedback != "" {
|
||||
rows = append(rows, "", errorStyle().Render(d.feedback))
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
func (m Model) renderDeviceSecret() string {
|
||||
d := m.deviceDetail
|
||||
if d.secretLoading {
|
||||
return mutedStyle().Render("loading…")
|
||||
}
|
||||
if d.secretErr != nil {
|
||||
return errorStyle().Render(d.secretErr.Error())
|
||||
}
|
||||
if d.secretValue == "" {
|
||||
return subtleStyle().Render("(none)")
|
||||
}
|
||||
if !d.secretRevealed {
|
||||
return subtleStyle().Render(strings.Repeat("•", len(d.secretValue)))
|
||||
}
|
||||
return d.secretValue
|
||||
}
|
||||
|
||||
func valueOrDash(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"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/network"
|
||||
)
|
||||
|
||||
type ethernetState struct {
|
||||
loading bool
|
||||
err error
|
||||
devices []network.EthernetDevice
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
}
|
||||
|
||||
func newEthernetState() ethernetState {
|
||||
return ethernetState{loading: true}
|
||||
}
|
||||
|
||||
func (m *Model) handleEthernetList(msg ethernetListMsg) {
|
||||
m.ethernet.loading = false
|
||||
m.ethernet.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.ethernet.devices = msg.devices
|
||||
if m.ethernet.cursor >= len(m.ethernet.devices) {
|
||||
m.ethernet.cursor = max(0, len(m.ethernet.devices)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleEthernetAction(msg ethernetActionMsg) tea.Cmd {
|
||||
m.ethernet.pending = false
|
||||
if msg.err != nil {
|
||||
m.ethernet.feedback = msg.err.Error()
|
||||
m.deviceDetail.pending = false
|
||||
m.deviceDetail.feedback = msg.err.Error()
|
||||
m.refreshDeviceDetailContent()
|
||||
return nil
|
||||
}
|
||||
m.ethernet.feedback = ""
|
||||
m.closeDeviceDetailIfPending(tabEthernet)
|
||||
return fetchEthernetCmd()
|
||||
}
|
||||
|
||||
func (m *Model) updateEthernet(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.ethernet.cursor > 0 {
|
||||
m.ethernet.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.ethernet.cursor < len(m.ethernet.devices)-1 {
|
||||
m.ethernet.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, n.Refresh):
|
||||
return fetchEthernetCmd()
|
||||
case key.Matches(keyMsg, n.Select):
|
||||
return m.selectEthernet()
|
||||
case key.Matches(keyMsg, n.Info):
|
||||
return m.infoEthernet()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) infoEthernet() tea.Cmd {
|
||||
if m.ethernet.cursor < 0 || m.ethernet.cursor >= len(m.ethernet.devices) {
|
||||
return nil
|
||||
}
|
||||
dev := m.ethernet.devices[m.ethernet.cursor]
|
||||
if !dev.Connected() {
|
||||
m.ethernet.feedback = "Not connected."
|
||||
return nil
|
||||
}
|
||||
return m.openDeviceDetail(tabEthernet, dev.Device, dev.Device, "", 0)
|
||||
}
|
||||
|
||||
func (m *Model) selectEthernet() tea.Cmd {
|
||||
if m.ethernet.cursor < 0 || m.ethernet.cursor >= len(m.ethernet.devices) {
|
||||
return nil
|
||||
}
|
||||
dev := m.ethernet.devices[m.ethernet.cursor]
|
||||
m.ethernet.pending = true
|
||||
if dev.Connected() {
|
||||
return ethernetDownCmd(dev.Device)
|
||||
}
|
||||
return ethernetConnectCmd(dev.Device)
|
||||
}
|
||||
|
||||
func (m Model) renderEthernet(height int) string {
|
||||
if m.ethernet.loading {
|
||||
return ilovetui.S.Faint.Render("Loading ethernet devices…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("ETHERNET DEVICES")
|
||||
if m.ethernet.pending {
|
||||
header += mutedStyle().Render(" working…")
|
||||
}
|
||||
|
||||
if m.ethernet.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.ethernet.err.Error()))
|
||||
}
|
||||
if len(m.ethernet.devices) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No ethernet devices found."))
|
||||
}
|
||||
|
||||
available := height - 3 // header + blank line + a trailing line for the paginator/feedback
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.ethernet.cursor, len(m.ethernet.devices), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
rows = append(rows, m.renderEthernetRow(m.ethernet.devices[i], i == m.ethernet.cursor))
|
||||
}
|
||||
list := strings.Join(rows, "\n")
|
||||
|
||||
footer := renderPaginatorDots(p)
|
||||
if m.ethernet.feedback != "" {
|
||||
footer = errorStyle().Render(m.ethernet.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
|
||||
}
|
||||
|
||||
func (m Model) renderEthernetRow(d network.EthernetDevice, 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 d.Connected() {
|
||||
check = successStyle().Render(icons.I.Check)
|
||||
}
|
||||
name := d.Device
|
||||
if d.Connection != "" {
|
||||
name = d.Connection
|
||||
}
|
||||
details := d.State
|
||||
if d.IP4 != "" {
|
||||
details += " · " + d.IP4
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s %s %s", cursor, check, textStyle.Render(name), subtleStyle().Render("("+d.Device+")"), subtleStyle().Render(details))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
// networkKeyMap renders the bottom help bar, adapting to the active tab and
|
||||
// whether a sub-view (password prompt, device info, known-network detail)
|
||||
// is open.
|
||||
type networkKeyMap struct {
|
||||
m Model
|
||||
width int
|
||||
}
|
||||
|
||||
func (k networkKeyMap) ShortHelp() []key.Binding {
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
switch {
|
||||
case k.m.wifi.connecting:
|
||||
return []key.Binding{n.Select, g.Escape, g.Help}
|
||||
case k.m.deviceDetail.open && k.m.deviceDetail.kind == tabWifi:
|
||||
return []key.Binding{g.Up, g.Down, n.RevealSecret, n.Disconnect, g.Escape, g.Help}
|
||||
case k.m.deviceDetail.open:
|
||||
return []key.Binding{g.Up, g.Down, n.Disconnect, g.Escape, g.Help}
|
||||
case k.m.active == tabKnown && k.m.known.detail:
|
||||
return []key.Binding{n.RevealSecret, n.ToggleAuto, n.Forget, g.Escape, g.Help}
|
||||
case k.m.active == tabKnown:
|
||||
return []key.Binding{g.Up, g.Down, n.Select, n.Known, g.Help}
|
||||
case k.m.active == tabWifi:
|
||||
return []key.Binding{g.Up, g.Down, n.Select, n.Disconnect, n.ToggleRadio, g.Help}
|
||||
default:
|
||||
return []key.Binding{g.Up, g.Down, n.Select, g.CycleFocus, n.Known, g.Help}
|
||||
}
|
||||
}
|
||||
|
||||
func (k networkKeyMap) FullHelp() [][]key.Binding {
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
var bindings []key.Binding
|
||||
switch {
|
||||
case k.m.wifi.connecting:
|
||||
bindings = []key.Binding{n.Select, g.Escape}
|
||||
case k.m.deviceDetail.open && k.m.deviceDetail.kind == tabWifi:
|
||||
bindings = []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, n.RevealSecret, n.Disconnect, g.Escape}
|
||||
case k.m.deviceDetail.open:
|
||||
bindings = []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, n.Disconnect, g.Escape}
|
||||
case k.m.active == tabKnown && k.m.known.detail:
|
||||
bindings = []key.Binding{n.RevealSecret, n.ToggleAuto, n.Forget, g.Escape}
|
||||
case k.m.active == tabKnown:
|
||||
bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, n.Known}
|
||||
case k.m.active == tabWifi:
|
||||
bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, n.Info, n.Disconnect, n.ToggleRadio, g.CycleFocus, n.Known}
|
||||
case k.m.active == tabEthernet:
|
||||
bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, n.Info, g.CycleFocus, n.Known}
|
||||
default:
|
||||
bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, g.CycleFocus, n.Known}
|
||||
}
|
||||
bindings = append(bindings, g.CommonBindings()...)
|
||||
return keys.ChunkByWidth(bindings, k.width)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
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/network"
|
||||
"github.com/anotherhadi/settuings/internal/util"
|
||||
)
|
||||
|
||||
type knownState struct {
|
||||
loading bool
|
||||
err error
|
||||
networks []network.KnownNetwork
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
|
||||
detail bool
|
||||
secretLoading bool
|
||||
secretRevealed bool
|
||||
secretValue string
|
||||
secretErr error
|
||||
|
||||
confirmForget bool
|
||||
|
||||
// Content can exceed a short terminal, so the detail screen is shown in
|
||||
// a viewport.
|
||||
viewport viewport.Model
|
||||
}
|
||||
|
||||
func newKnownState() knownState {
|
||||
return knownState{loading: true}
|
||||
}
|
||||
|
||||
func (m *Model) handleKnownList(msg knownListMsg) {
|
||||
m.known.loading = false
|
||||
m.known.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.known.networks = msg.networks
|
||||
if m.known.cursor >= len(m.known.networks) {
|
||||
m.known.cursor = max(0, len(m.known.networks)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleKnownSecret(msg knownSecretMsg) {
|
||||
if m.known.selected() == nil || m.known.selected().Name != msg.name {
|
||||
return // stale response for a network we've since navigated away from
|
||||
}
|
||||
m.known.secretLoading = false
|
||||
m.known.secretValue = msg.secret
|
||||
m.known.secretErr = msg.err
|
||||
m.refreshKnownDetailContent()
|
||||
}
|
||||
|
||||
func (m *Model) handleKnownAction(msg knownActionMsg) tea.Cmd {
|
||||
m.known.pending = false
|
||||
if msg.err != nil {
|
||||
m.known.feedback = msg.err.Error()
|
||||
m.refreshKnownDetailContent()
|
||||
return nil
|
||||
}
|
||||
m.known.feedback = ""
|
||||
m.known.detail = false
|
||||
return fetchKnownCmd()
|
||||
}
|
||||
|
||||
func (s knownState) selected() *network.KnownNetwork {
|
||||
if s.cursor < 0 || s.cursor >= len(s.networks) {
|
||||
return nil
|
||||
}
|
||||
return &s.networks[s.cursor]
|
||||
}
|
||||
|
||||
func (m *Model) updateKnown(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if m.known.detail {
|
||||
return m.updateKnownDetail(keyMsg)
|
||||
}
|
||||
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.known.cursor > 0 {
|
||||
m.known.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.known.cursor < len(m.known.networks)-1 {
|
||||
m.known.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, n.Refresh):
|
||||
return fetchKnownCmd()
|
||||
case key.Matches(keyMsg, n.Select):
|
||||
if sel := m.known.selected(); sel != nil {
|
||||
m.known.detail = true
|
||||
m.known.secretRevealed = false
|
||||
m.known.secretLoading = true
|
||||
m.known.secretValue = ""
|
||||
m.known.secretErr = nil
|
||||
m.openKnownDetail()
|
||||
return fetchSecretCmd(sel.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) updateKnownDetail(keyMsg tea.KeyPressMsg) tea.Cmd {
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
if m.known.confirmForget {
|
||||
m.known.confirmForget = false
|
||||
defer m.refreshKnownDetailContent()
|
||||
if keyMsg.String() == "y" || keyMsg.String() == "enter" {
|
||||
if sel := m.known.selected(); sel != nil {
|
||||
m.known.pending = true
|
||||
return forgetCmd(sel.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Escape):
|
||||
m.known.detail = false
|
||||
case key.Matches(keyMsg, n.RevealSecret):
|
||||
m.known.secretRevealed = !m.known.secretRevealed
|
||||
m.refreshKnownDetailContent()
|
||||
case key.Matches(keyMsg, n.ToggleAuto):
|
||||
if sel := m.known.selected(); sel != nil {
|
||||
m.known.pending = true
|
||||
return toggleAutoConnectCmd(sel.Name, !sel.AutoConnect)
|
||||
}
|
||||
case key.Matches(keyMsg, n.Forget):
|
||||
m.known.confirmForget = true
|
||||
m.refreshKnownDetailContent()
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
util.ScrollLines(&m.known.viewport, -1)
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
util.ScrollLines(&m.known.viewport, 1)
|
||||
case key.Matches(keyMsg, g.ScrollUp):
|
||||
util.ScrollViewport(&m.known.viewport, -1)
|
||||
case key.Matches(keyMsg, g.ScrollDown):
|
||||
util.ScrollViewport(&m.known.viewport, 1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openKnownDetail sizes and populates the detail viewport for the currently
|
||||
// selected network. Call it whenever the detail screen is opened.
|
||||
func (m *Model) openKnownDetail() {
|
||||
w, h := m.contentBudget()
|
||||
vp := viewport.New()
|
||||
vp.SetWidth(w)
|
||||
vp.SetHeight(h)
|
||||
m.known.viewport = vp
|
||||
m.refreshKnownDetailContent()
|
||||
}
|
||||
|
||||
// resizeKnownDetail keeps the detail viewport sized to the available space
|
||||
// whenever the terminal is resized.
|
||||
func (m *Model) resizeKnownDetail() {
|
||||
if !m.known.detail {
|
||||
return
|
||||
}
|
||||
w, h := m.contentBudget()
|
||||
m.known.viewport.SetWidth(w)
|
||||
m.known.viewport.SetHeight(h)
|
||||
m.refreshKnownDetailContent()
|
||||
}
|
||||
|
||||
func (m *Model) refreshKnownDetailContent() {
|
||||
m.known.viewport.SetContent(m.knownDetailContent())
|
||||
util.RefreshScrollbar(&m.known.viewport)
|
||||
}
|
||||
|
||||
func (m Model) renderKnown(height int) string {
|
||||
if m.known.detail {
|
||||
return m.renderKnownDetail()
|
||||
}
|
||||
if m.known.loading {
|
||||
return ilovetui.S.Faint.Render("Loading known networks…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("KNOWN NETWORKS")
|
||||
|
||||
if m.known.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.known.err.Error()))
|
||||
}
|
||||
if len(m.known.networks) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No saved Wi-Fi networks."))
|
||||
}
|
||||
|
||||
available := height - 3 // header + blank line + a trailing line for the paginator
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.known.cursor, len(m.known.networks), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
rows = append(rows, m.renderKnownRow(m.known.networks[i], i == m.known.cursor))
|
||||
}
|
||||
list := strings.Join(rows, "\n")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, renderPaginatorDots(p))
|
||||
}
|
||||
|
||||
func (m Model) renderKnownRow(k network.KnownNetwork, 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 k.Active {
|
||||
check = successStyle().Render(icons.I.Check)
|
||||
}
|
||||
auto := "manual"
|
||||
if k.AutoConnect {
|
||||
auto = "auto-join"
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s %s", cursor, check, textStyle.Render(k.Name), subtleStyle().Render(auto))
|
||||
}
|
||||
|
||||
func (m Model) renderKnownDetail() string {
|
||||
return m.known.viewport.View()
|
||||
}
|
||||
|
||||
func (m Model) knownDetailContent() string {
|
||||
sel := m.known.selected()
|
||||
if sel == nil {
|
||||
return ilovetui.S.Faint.Render("No network selected.")
|
||||
}
|
||||
|
||||
rows := []string{
|
||||
lipgloss.NewStyle().Bold(true).Render(sel.Name),
|
||||
"",
|
||||
subtleStyle().Render("Security: ") + securityLabel(sel.Security),
|
||||
subtleStyle().Render("Auto-join: ") + fmt.Sprintf("%v", sel.AutoConnect),
|
||||
subtleStyle().Render("Active: ") + fmt.Sprintf("%v", sel.Active),
|
||||
subtleStyle().Render("Password: ") + m.renderKnownSecret(),
|
||||
}
|
||||
|
||||
if m.known.feedback != "" {
|
||||
rows = append(rows, "", errorStyle().Render(m.known.feedback))
|
||||
}
|
||||
|
||||
if m.known.confirmForget {
|
||||
rows = append(rows, "", errorStyle().Render(fmt.Sprintf("Forget %q? press y to confirm, any other key to cancel", sel.Name)))
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
func (m Model) renderKnownSecret() string {
|
||||
if m.known.secretLoading {
|
||||
return mutedStyle().Render("loading…")
|
||||
}
|
||||
if m.known.secretErr != nil {
|
||||
return errorStyle().Render(m.known.secretErr.Error())
|
||||
}
|
||||
if m.known.secretValue == "" {
|
||||
return subtleStyle().Render("(none)")
|
||||
}
|
||||
if !m.known.secretRevealed {
|
||||
return subtleStyle().Render(strings.Repeat("•", len(m.known.secretValue)))
|
||||
}
|
||||
return m.known.secretValue
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package network
|
||||
|
||||
import "github.com/anotherhadi/settuings/internal/network"
|
||||
|
||||
// checkMsg carries the result of network.CheckAvailable.
|
||||
type checkMsg struct{ err error }
|
||||
|
||||
// tickMsg triggers a periodic refresh of every tab while this page is active.
|
||||
type tickMsg struct{}
|
||||
|
||||
type wifiListMsg struct {
|
||||
networks []network.WifiNetwork
|
||||
ip string
|
||||
device string // wifi interface name, e.g. "wlo1"
|
||||
connectionName string // active connection profile name, for secret lookup
|
||||
radioOn bool
|
||||
err error
|
||||
}
|
||||
|
||||
type wifiActionMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type ethernetListMsg struct {
|
||||
devices []network.EthernetDevice
|
||||
err error
|
||||
}
|
||||
|
||||
type ethernetActionMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type vpnListMsg struct {
|
||||
conns []network.VPNConnection
|
||||
err error
|
||||
}
|
||||
|
||||
type vpnActionMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type knownListMsg struct {
|
||||
networks []network.KnownNetwork
|
||||
err error
|
||||
}
|
||||
|
||||
type knownSecretMsg struct {
|
||||
name string
|
||||
secret string
|
||||
err error
|
||||
}
|
||||
|
||||
type knownActionMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type deviceDetailMsg struct {
|
||||
detail network.DeviceDetail
|
||||
err error
|
||||
}
|
||||
|
||||
type radioActionMsg struct {
|
||||
err error
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package network provides the "Network" TUI page: a thin, tabbed wrapper
|
||||
// around nmcli covering Wi-Fi, Ethernet, VPN, and known Wi-Fi networks.
|
||||
package network
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/icons"
|
||||
)
|
||||
|
||||
type tab int
|
||||
|
||||
const (
|
||||
tabWifi tab = iota
|
||||
tabEthernet
|
||||
tabVPN
|
||||
tabKnown // hidden from the tab bar; reachable only via its own shortcut
|
||||
)
|
||||
|
||||
// visibleTabs are the tabs shown in the tab bar and reachable by cycling.
|
||||
var visibleTabs = []tab{tabWifi, tabEthernet, tabVPN}
|
||||
|
||||
func (t tab) label() string {
|
||||
switch t {
|
||||
case tabWifi:
|
||||
return "Wifi"
|
||||
case tabEthernet:
|
||||
return "Ethernet"
|
||||
case tabVPN:
|
||||
return "VPN"
|
||||
case tabKnown:
|
||||
return "Known"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (t tab) icon() string {
|
||||
switch t {
|
||||
case tabWifi:
|
||||
return icons.I.Wifi
|
||||
case tabEthernet:
|
||||
return icons.I.Ethernet
|
||||
case tabVPN:
|
||||
return icons.I.VPN
|
||||
case tabKnown:
|
||||
return icons.I.Lock
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
active tab
|
||||
previousTab tab // where to return to when leaving the hidden Known tab
|
||||
width int
|
||||
height int
|
||||
help help.Model
|
||||
|
||||
checkErr error // reason nmcli/NetworkManager is unusable; nil once confirmed OK
|
||||
checked bool
|
||||
|
||||
wifi wifiState
|
||||
ethernet ethernetState
|
||||
vpn vpnState
|
||||
known knownState
|
||||
deviceDetail deviceDetailState
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
return Model{
|
||||
active: tabWifi,
|
||||
help: ilovetui.NewHelp(),
|
||||
wifi: newWifiState(),
|
||||
ethernet: newEthernetState(),
|
||||
vpn: newVPNState(),
|
||||
known: newKnownState(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Activate is called every time the user navigates onto this page. It
|
||||
// re-checks nmcli availability, kicks off a fetch for every tab, and starts
|
||||
// the periodic auto-refresh loop.
|
||||
func (m *Model) Activate() tea.Cmd {
|
||||
return tea.Batch(
|
||||
checkCmd(),
|
||||
fetchWifiCmd(),
|
||||
fetchEthernetCmd(),
|
||||
fetchVPNCmd(),
|
||||
fetchKnownCmd(),
|
||||
tickCmd(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) IsEditing() bool {
|
||||
return m.wifi.connecting || m.known.confirmForget
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
m.help.SetWidth(w - 2)
|
||||
m.resizeDetails()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/paginator"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
func newPaginator() paginator.Model {
|
||||
p := paginator.New()
|
||||
p.Type = paginator.Dots
|
||||
p.ActiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render("•")
|
||||
p.InactiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render("•")
|
||||
return p
|
||||
}
|
||||
|
||||
// paginate slices a list of length total into pages of availableRows items,
|
||||
// deriving the current page from cursor (the globally-indexed selection)
|
||||
// rather than tracking page state separately. It returns the configured
|
||||
// paginator and the [start, end) bounds of the visible slice.
|
||||
func paginate(cursor, total, availableRows int) (p paginator.Model, start, end int) {
|
||||
p = newPaginator()
|
||||
perPage := availableRows
|
||||
if perPage < 1 {
|
||||
perPage = 1
|
||||
}
|
||||
p.PerPage = perPage
|
||||
p.SetTotalPages(total)
|
||||
if p.TotalPages > 0 {
|
||||
p.Page = cursor / perPage
|
||||
if p.Page >= p.TotalPages {
|
||||
p.Page = p.TotalPages - 1
|
||||
}
|
||||
}
|
||||
start, end = p.GetSliceBounds(total)
|
||||
return p, start, end
|
||||
}
|
||||
|
||||
func renderPaginatorDots(p paginator.Model) string {
|
||||
if p.TotalPages <= 1 {
|
||||
return ""
|
||||
}
|
||||
return mutedStyle().Render(p.View())
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package network
|
||||
|
||||
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,63 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
// cycleTab moves to the next visible tab. From the hidden Known tab it
|
||||
// returns to whichever tab was active before Known was opened.
|
||||
func (m *Model) cycleTab() {
|
||||
if m.active == tabKnown {
|
||||
m.active = m.previousTab
|
||||
return
|
||||
}
|
||||
for i, t := range visibleTabs {
|
||||
if t == m.active {
|
||||
m.active = visibleTabs[(i+1)%len(visibleTabs)]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toggleKnown opens the hidden Known tab, or leaves it back to the
|
||||
// previously active tab if it's already open.
|
||||
func (m *Model) toggleKnown() {
|
||||
if m.active == tabKnown {
|
||||
m.active = m.previousTab
|
||||
return
|
||||
}
|
||||
m.previousTab = m.active
|
||||
m.active = tabKnown
|
||||
}
|
||||
|
||||
func (m Model) renderTabBar(width int) string {
|
||||
active := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 2).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Primary)
|
||||
inactive := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Padding(0, 2).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Subtle)
|
||||
|
||||
var tabs []string
|
||||
for _, t := range visibleTabs {
|
||||
style := inactive
|
||||
if t == m.active {
|
||||
style = active
|
||||
}
|
||||
tabs = append(tabs, style.Render(t.icon()+t.label()))
|
||||
}
|
||||
row := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...)
|
||||
|
||||
filler := width - lipgloss.Width(row)
|
||||
if filler > 0 {
|
||||
row = lipgloss.JoinHorizontal(lipgloss.Bottom, row,
|
||||
lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Subtle).
|
||||
Render(strings.Repeat(" ", filler)))
|
||||
}
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
"github.com/anotherhadi/settuings/internal/util"
|
||||
)
|
||||
|
||||
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 + the auto-refresh loop
|
||||
return m, tea.Batch(fetchWifiCmd(), fetchEthernetCmd(), fetchVPNCmd(), fetchKnownCmd(), tickCmd())
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
if m.checkErr != nil {
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Batch(fetchWifiCmd(), fetchEthernetCmd(), fetchVPNCmd(), fetchKnownCmd(), tickCmd())
|
||||
|
||||
case wifiListMsg:
|
||||
m.handleWifiList(msg)
|
||||
return m, nil
|
||||
case wifiActionMsg:
|
||||
return m, m.handleWifiAction(msg)
|
||||
case radioActionMsg:
|
||||
return m, m.handleRadioAction(msg)
|
||||
|
||||
case ethernetListMsg:
|
||||
m.handleEthernetList(msg)
|
||||
return m, nil
|
||||
case ethernetActionMsg:
|
||||
return m, m.handleEthernetAction(msg)
|
||||
|
||||
case vpnListMsg:
|
||||
m.handleVPNList(msg)
|
||||
return m, nil
|
||||
case vpnActionMsg:
|
||||
return m, m.handleVPNAction(msg)
|
||||
|
||||
case knownListMsg:
|
||||
m.handleKnownList(msg)
|
||||
return m, nil
|
||||
case knownSecretMsg:
|
||||
m.handleKnownSecret(msg)
|
||||
m.handleDeviceDetailSecret(msg)
|
||||
return m, nil
|
||||
case knownActionMsg:
|
||||
return m, m.handleKnownAction(msg)
|
||||
|
||||
case deviceDetailMsg:
|
||||
m.handleDeviceDetail(msg)
|
||||
return m, nil
|
||||
|
||||
case tea.MouseWheelMsg:
|
||||
if m.deviceDetail.open {
|
||||
return m, m.updateDeviceDetail(msg)
|
||||
}
|
||||
if m.active == tabKnown && m.known.detail {
|
||||
util.HandleMouseWheel(msg, &m.known.viewport)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if m.checkErr != nil {
|
||||
if key.Matches(msg, keys.Keys.Network.Refresh) {
|
||||
return m, checkCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.deviceDetail.open {
|
||||
return m, m.updateDeviceDetail(msg)
|
||||
}
|
||||
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
if !m.IsEditing() {
|
||||
switch {
|
||||
case key.Matches(msg, g.CycleFocus):
|
||||
m.cycleTab()
|
||||
return m, nil
|
||||
case key.Matches(msg, n.Known):
|
||||
m.toggleKnown()
|
||||
return m, nil
|
||||
case key.Matches(msg, g.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
m.resizeDetails()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.active {
|
||||
case tabWifi:
|
||||
cmd = m.updateWifi(msg)
|
||||
case tabEthernet:
|
||||
cmd = m.updateEthernet(msg)
|
||||
case tabVPN:
|
||||
cmd = m.updateVPN(msg)
|
||||
case tabKnown:
|
||||
cmd = m.updateKnown(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/network"
|
||||
)
|
||||
|
||||
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 := m.renderTabBar(innerW)
|
||||
|
||||
var body string
|
||||
switch {
|
||||
case !m.checked:
|
||||
body = ilovetui.S.Faint.Render("Checking nmcli…")
|
||||
case m.checkErr != nil:
|
||||
body = m.renderUnavailable()
|
||||
case m.deviceDetail.open:
|
||||
body = m.renderDeviceDetail()
|
||||
default:
|
||||
body = m.renderActiveTab(innerW, innerH)
|
||||
}
|
||||
|
||||
box := lipgloss.NewStyle().Width(innerW).Height(innerH).Render(body)
|
||||
content := windowStyle().Render(lipgloss.JoinVertical(lipgloss.Left, tabBar, box))
|
||||
|
||||
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, content, statusBar))
|
||||
}
|
||||
|
||||
func (m Model) renderStatusBar() string {
|
||||
km := networkKeyMap{m: m, width: m.width}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km))
|
||||
}
|
||||
|
||||
func (m Model) renderActiveTab(width, height int) string {
|
||||
switch m.active {
|
||||
case tabWifi:
|
||||
return m.renderWifi(width, height)
|
||||
case tabEthernet:
|
||||
return m.renderEthernet(height)
|
||||
case tabVPN:
|
||||
return m.renderVPN(height)
|
||||
case tabKnown:
|
||||
return m.renderKnown(height)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderUnavailable() string {
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Error).Render("Network page unavailable")
|
||||
|
||||
var hint string
|
||||
switch {
|
||||
case errors.Is(m.checkErr, network.ErrNotFound):
|
||||
hint = "nmcli was not found in PATH. Install NetworkManager to use this page."
|
||||
case errors.Is(m.checkErr, network.ErrNotRunning):
|
||||
hint = "NetworkManager doesn't seem to be running. Try: sudo systemctl start NetworkManager"
|
||||
default:
|
||||
hint = m.checkErr.Error()
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
subtleStyle().Render(hint),
|
||||
"",
|
||||
mutedStyle().Render("Press r to try again once fixed."),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"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/network"
|
||||
)
|
||||
|
||||
type vpnState struct {
|
||||
loading bool
|
||||
err error
|
||||
conns []network.VPNConnection
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
}
|
||||
|
||||
func newVPNState() vpnState {
|
||||
return vpnState{loading: true}
|
||||
}
|
||||
|
||||
func (m *Model) handleVPNList(msg vpnListMsg) {
|
||||
m.vpn.loading = false
|
||||
m.vpn.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.vpn.conns = msg.conns
|
||||
if m.vpn.cursor >= len(m.vpn.conns) {
|
||||
m.vpn.cursor = max(0, len(m.vpn.conns)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleVPNAction(msg vpnActionMsg) tea.Cmd {
|
||||
m.vpn.pending = false
|
||||
if msg.err != nil {
|
||||
m.vpn.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
m.vpn.feedback = ""
|
||||
return fetchVPNCmd()
|
||||
}
|
||||
|
||||
func (m *Model) updateVPN(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.vpn.cursor > 0 {
|
||||
m.vpn.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.vpn.cursor < len(m.vpn.conns)-1 {
|
||||
m.vpn.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, n.Refresh):
|
||||
return fetchVPNCmd()
|
||||
case key.Matches(keyMsg, n.Select):
|
||||
return m.selectVPN()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) selectVPN() tea.Cmd {
|
||||
if m.vpn.cursor < 0 || m.vpn.cursor >= len(m.vpn.conns) {
|
||||
return nil
|
||||
}
|
||||
conn := m.vpn.conns[m.vpn.cursor]
|
||||
m.vpn.pending = true
|
||||
if conn.Active {
|
||||
return vpnDownCmd(conn.Name)
|
||||
}
|
||||
return vpnUpCmd(conn.Name)
|
||||
}
|
||||
|
||||
func (m Model) renderVPN(height int) string {
|
||||
if m.vpn.loading {
|
||||
return ilovetui.S.Faint.Render("Loading VPN connections…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("VPN CONNECTIONS")
|
||||
if m.vpn.pending {
|
||||
header += mutedStyle().Render(" working…")
|
||||
}
|
||||
|
||||
if m.vpn.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.vpn.err.Error()))
|
||||
}
|
||||
if len(m.vpn.conns) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No VPN connections configured."))
|
||||
}
|
||||
|
||||
available := height - 3 // header + blank line + a trailing line for the paginator/feedback
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.vpn.cursor, len(m.vpn.conns), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
rows = append(rows, m.renderVPNRow(m.vpn.conns[i], i == m.vpn.cursor))
|
||||
}
|
||||
list := strings.Join(rows, "\n")
|
||||
|
||||
footer := renderPaginatorDots(p)
|
||||
if m.vpn.feedback != "" {
|
||||
footer = errorStyle().Render(m.vpn.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
|
||||
}
|
||||
|
||||
func (m Model) renderVPNRow(c network.VPNConnection, 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 c.Active {
|
||||
check = successStyle().Render(icons.I.Check)
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s %s", cursor, check, textStyle.Render(c.Name), subtleStyle().Render(c.Type))
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
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/network"
|
||||
)
|
||||
|
||||
type wifiState struct {
|
||||
loading bool
|
||||
err error
|
||||
ip string
|
||||
device string // wifi interface name, e.g. "wlo1"
|
||||
connectionName string // active connection profile name, e.g. "Djoon 1"
|
||||
radioOn bool
|
||||
current *network.WifiNetwork // the AP we're associated with, if any
|
||||
others []network.WifiNetwork
|
||||
cursor int
|
||||
pending bool // a connect/disconnect/rescan/radio-toggle is in flight
|
||||
feedback string
|
||||
|
||||
connecting bool
|
||||
connectSSID string
|
||||
password textinput.Model
|
||||
}
|
||||
|
||||
func newWifiState() wifiState {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = "Password: "
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.EchoCharacter = '•'
|
||||
s := ti.Styles()
|
||||
s.Focused.Prompt = lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||
ti.SetStyles(s)
|
||||
return wifiState{password: ti, loading: true}
|
||||
}
|
||||
|
||||
func (m Model) isKnownSSID(ssid string) bool {
|
||||
for _, k := range m.known.networks {
|
||||
if k.Name == ssid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Model) handleWifiList(msg wifiListMsg) {
|
||||
m.wifi.loading = false
|
||||
m.wifi.err = msg.err
|
||||
m.wifi.radioOn = msg.radioOn
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.wifi.ip = msg.ip
|
||||
m.wifi.device = msg.device
|
||||
m.wifi.connectionName = msg.connectionName
|
||||
m.wifi.current = nil
|
||||
m.wifi.others = m.wifi.others[:0]
|
||||
for _, n := range msg.networks {
|
||||
n := n
|
||||
if n.Active {
|
||||
m.wifi.current = &n
|
||||
continue
|
||||
}
|
||||
m.wifi.others = append(m.wifi.others, n)
|
||||
}
|
||||
if m.wifi.cursor >= len(m.wifi.others) {
|
||||
m.wifi.cursor = max(0, len(m.wifi.others)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleWifiAction(msg wifiActionMsg) tea.Cmd {
|
||||
m.wifi.pending = false
|
||||
if msg.err != nil {
|
||||
m.wifi.feedback = msg.err.Error()
|
||||
m.deviceDetail.pending = false
|
||||
m.deviceDetail.feedback = msg.err.Error()
|
||||
m.refreshDeviceDetailContent()
|
||||
return nil
|
||||
}
|
||||
m.wifi.feedback = ""
|
||||
m.closeDeviceDetailIfPending(tabWifi)
|
||||
return tea.Batch(fetchWifiCmd(), fetchKnownCmd())
|
||||
}
|
||||
|
||||
func (m *Model) handleRadioAction(msg radioActionMsg) tea.Cmd {
|
||||
m.wifi.pending = false
|
||||
if msg.err != nil {
|
||||
m.wifi.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
m.wifi.feedback = ""
|
||||
return fetchWifiCmd()
|
||||
}
|
||||
|
||||
func (m *Model) updateWifi(msg tea.Msg) tea.Cmd {
|
||||
if m.wifi.connecting {
|
||||
return m.updateWifiPasswordPrompt(msg)
|
||||
}
|
||||
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.wifi.cursor > 0 {
|
||||
m.wifi.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.wifi.cursor < len(m.wifi.others)-1 {
|
||||
m.wifi.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, n.Refresh):
|
||||
m.wifi.pending = true
|
||||
return tea.Batch(rescanWifiCmd(), fetchWifiCmd())
|
||||
case key.Matches(keyMsg, n.Select):
|
||||
return m.selectWifi()
|
||||
case key.Matches(keyMsg, n.ToggleRadio):
|
||||
m.wifi.pending = true
|
||||
return toggleWifiRadioCmd(!m.wifi.radioOn)
|
||||
case key.Matches(keyMsg, n.Disconnect):
|
||||
if m.wifi.current == nil {
|
||||
m.wifi.feedback = "Not connected to a network."
|
||||
return nil
|
||||
}
|
||||
m.wifi.pending = true
|
||||
return disconnectWifiCmd()
|
||||
case key.Matches(keyMsg, n.Info):
|
||||
if m.wifi.current == nil {
|
||||
m.wifi.feedback = "Not connected to a network."
|
||||
return nil
|
||||
}
|
||||
cur := m.wifi.current
|
||||
return m.openDeviceDetail(tabWifi, m.wifi.device, cur.SSID, cur.Security, cur.Signal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) selectWifi() tea.Cmd {
|
||||
if len(m.wifi.others) == 0 {
|
||||
return nil
|
||||
}
|
||||
target := m.wifi.others[m.wifi.cursor]
|
||||
if target.Secured() && !m.isKnownSSID(target.SSID) {
|
||||
m.wifi.connecting = true
|
||||
m.wifi.connectSSID = target.SSID
|
||||
m.wifi.password.SetValue("")
|
||||
m.wifi.password.Focus()
|
||||
return nil
|
||||
}
|
||||
m.wifi.pending = true
|
||||
return connectWifiCmd(target.SSID, "")
|
||||
}
|
||||
|
||||
func (m *Model) updateWifiPasswordPrompt(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Escape):
|
||||
m.wifi.connecting = false
|
||||
m.wifi.password.Blur()
|
||||
return nil
|
||||
case keyMsg.String() == "enter":
|
||||
ssid := m.wifi.connectSSID
|
||||
password := m.wifi.password.Value()
|
||||
m.wifi.connecting = false
|
||||
m.wifi.password.Blur()
|
||||
m.wifi.pending = true
|
||||
return connectWifiCmd(ssid, password)
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.wifi.password, cmd = m.wifi.password.Update(keyMsg)
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderWifi(width, height int) string {
|
||||
if m.wifi.connecting {
|
||||
return m.renderWifiPasswordPrompt()
|
||||
}
|
||||
|
||||
if m.wifi.loading {
|
||||
return ilovetui.S.Faint.Render("Loading Wi-Fi networks…")
|
||||
}
|
||||
|
||||
othersHeader := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("OTHER NETWORKS")
|
||||
if m.wifi.pending {
|
||||
othersHeader += mutedStyle().Render(" refreshing…")
|
||||
}
|
||||
|
||||
fixed := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.renderWifiRadioStatus(),
|
||||
"",
|
||||
lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("CURRENT NETWORK"),
|
||||
"",
|
||||
m.renderCurrentWifiCard(width),
|
||||
"",
|
||||
othersHeader,
|
||||
"",
|
||||
)
|
||||
fixedH := lipgloss.Height(fixed)
|
||||
|
||||
if m.wifi.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, fixed, errorStyle().Render(m.wifi.err.Error()))
|
||||
}
|
||||
if len(m.wifi.others) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, fixed, ilovetui.S.Faint.Render("No other networks in range."))
|
||||
}
|
||||
|
||||
available := height - fixedH - 1 // reserve a trailing line for the paginator/feedback
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.wifi.cursor, len(m.wifi.others), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
rows = append(rows, m.renderWifiRow(m.wifi.others[i], i == m.wifi.cursor))
|
||||
}
|
||||
list := strings.Join(rows, "\n")
|
||||
|
||||
footer := renderPaginatorDots(p)
|
||||
if m.wifi.feedback != "" {
|
||||
footer = errorStyle().Render(m.wifi.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, fixed, list, footer)
|
||||
}
|
||||
|
||||
func (m Model) renderWifiRadioStatus() string {
|
||||
label := "Wi-Fi: "
|
||||
if m.wifi.radioOn {
|
||||
return label + successStyle().Bold(true).Render("On")
|
||||
}
|
||||
return label + errorStyle().Bold(true).Render("Off")
|
||||
}
|
||||
|
||||
func (m Model) renderCurrentWifiCard(width int) string {
|
||||
if m.wifi.current == nil {
|
||||
return ilovetui.S.Faint.Render("Not connected")
|
||||
}
|
||||
cur := m.wifi.current
|
||||
check := lipgloss.NewStyle().Foreground(ilovetui.S.Success).Render(icons.I.Check)
|
||||
title := lipgloss.NewStyle().Bold(true).Render(cur.SSID)
|
||||
line1 := check + title
|
||||
line2 := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render(
|
||||
fmt.Sprintf("%s · %d%%", securityLabel(cur.Security), cur.Signal))
|
||||
if m.wifi.ip != "" {
|
||||
line2 += lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render(" · " + m.wifi.ip)
|
||||
}
|
||||
return lipgloss.NewStyle().Width(width).Render(lipgloss.JoinVertical(lipgloss.Left, line1, line2))
|
||||
}
|
||||
|
||||
func (m Model) renderWifiRow(n network.WifiNetwork, selected bool) string {
|
||||
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||
subtleStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
|
||||
if selected {
|
||||
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
|
||||
}
|
||||
lock := ""
|
||||
if n.Secured() {
|
||||
lock = " " + icons.I.Lock
|
||||
}
|
||||
cursor := " "
|
||||
if selected {
|
||||
cursor = "> "
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s %s", cursor, textStyle.Render(n.SSID), lock, subtleStyle.Render(fmt.Sprintf("%d%%", n.Signal)))
|
||||
}
|
||||
|
||||
func (m Model) renderWifiPasswordPrompt() string {
|
||||
title := lipgloss.NewStyle().Bold(true).Render("Connect to " + m.wifi.connectSSID)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, "", m.wifi.password.View())
|
||||
}
|
||||
|
||||
func securityLabel(security string) string {
|
||||
if security == "" || security == "--" {
|
||||
return "Open"
|
||||
}
|
||||
return security
|
||||
}
|
||||
Reference in New Issue
Block a user