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