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