mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 08:18:23 +02:00
274 lines
8.1 KiB
Go
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
|
|
}
|