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
+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")
}