Files
Hadi 59b31e5ca3 init
Signed-off-by: Hadi <[email protected]>
2026-08-13 00:58:37 +02:00

274 lines
8.1 KiB
Go

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
}