Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-08-13 00:58:37 +02:00
commit 59b31e5ca3
103 changed files with 8577 additions and 0 deletions
+77
View File
@@ -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}
}
}
+206
View File
@@ -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")
}
+44
View File
@@ -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)
}
+301
View File
@@ -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)
}
+31
View File
@@ -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
}
+54
View File
@@ -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()
}
+44
View File
@@ -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())
}
+22
View File
@@ -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)
}
+30
View File
@@ -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
}
+70
View File
@@ -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
}
+93
View File
@@ -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."),
)
}