mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 16:18:24 +02:00
@@ -0,0 +1,118 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/settuings/internal/network"
|
||||
)
|
||||
|
||||
const tickInterval = 5 * time.Second
|
||||
|
||||
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: network.CheckAvailable()} }
|
||||
}
|
||||
|
||||
func fetchWifiCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
radioOn, _ := network.RadioEnabled()
|
||||
networks, err := network.ListWifi()
|
||||
if err != nil {
|
||||
return wifiListMsg{radioOn: radioOn, err: err}
|
||||
}
|
||||
var ip, device, connectionName string
|
||||
if dev, err := network.WifiDevice(); err == nil {
|
||||
device = dev.Name
|
||||
if dev.Connected() {
|
||||
ip, _ = network.DeviceIP4(dev.Name)
|
||||
connectionName = dev.Connection
|
||||
}
|
||||
}
|
||||
return wifiListMsg{networks: networks, ip: ip, device: device, connectionName: connectionName, radioOn: radioOn}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleWifiRadioCmd(on bool) tea.Cmd {
|
||||
return func() tea.Msg { return radioActionMsg{err: network.SetWifiRadio(on)} }
|
||||
}
|
||||
|
||||
func rescanWifiCmd() tea.Cmd {
|
||||
return func() tea.Msg { return wifiActionMsg{err: network.Rescan()} }
|
||||
}
|
||||
|
||||
func connectWifiCmd(ssid, password string) tea.Cmd {
|
||||
return func() tea.Msg { return wifiActionMsg{err: network.ConnectWifi(ssid, password)} }
|
||||
}
|
||||
|
||||
func disconnectWifiCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
dev, err := network.WifiDevice()
|
||||
if err != nil {
|
||||
return wifiActionMsg{err: err}
|
||||
}
|
||||
return wifiActionMsg{err: network.DisconnectWifi(dev.Name)}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchEthernetCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
devices, err := network.ListEthernet()
|
||||
return ethernetListMsg{devices: devices, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func ethernetConnectCmd(device string) tea.Cmd {
|
||||
return func() tea.Msg { return ethernetActionMsg{err: network.DeviceConnect(device)} }
|
||||
}
|
||||
|
||||
func ethernetDownCmd(device string) tea.Cmd {
|
||||
return func() tea.Msg { return ethernetActionMsg{err: network.EthernetDown(device)} }
|
||||
}
|
||||
|
||||
func fetchVPNCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
conns, err := network.ListVPN()
|
||||
return vpnListMsg{conns: conns, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func vpnUpCmd(name string) tea.Cmd {
|
||||
return func() tea.Msg { return vpnActionMsg{err: network.VPNUp(name)} }
|
||||
}
|
||||
|
||||
func vpnDownCmd(name string) tea.Cmd {
|
||||
return func() tea.Msg { return vpnActionMsg{err: network.VPNDown(name)} }
|
||||
}
|
||||
|
||||
func fetchKnownCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
networks, err := network.ListKnownWifi()
|
||||
return knownListMsg{networks: networks, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSecretCmd(name string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
secret, err := network.Secret(name)
|
||||
return knownSecretMsg{name: name, secret: secret, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleAutoConnectCmd(name string, enabled bool) tea.Cmd {
|
||||
return func() tea.Msg { return knownActionMsg{err: network.SetAutoConnect(name, enabled)} }
|
||||
}
|
||||
|
||||
func forgetCmd(name string) tea.Cmd {
|
||||
return func() tea.Msg { return knownActionMsg{err: network.Forget(name)} }
|
||||
}
|
||||
|
||||
func fetchDeviceDetailCmd(device string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
detail, err := network.DeviceDetails(device)
|
||||
return deviceDetailMsg{detail: detail, err: err}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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/icons"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
"github.com/anotherhadi/settuings/internal/network"
|
||||
)
|
||||
|
||||
type ethernetState struct {
|
||||
loading bool
|
||||
err error
|
||||
devices []network.EthernetDevice
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
}
|
||||
|
||||
func newEthernetState() ethernetState {
|
||||
return ethernetState{loading: true}
|
||||
}
|
||||
|
||||
func (m *Model) handleEthernetList(msg ethernetListMsg) {
|
||||
m.ethernet.loading = false
|
||||
m.ethernet.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.ethernet.devices = msg.devices
|
||||
if m.ethernet.cursor >= len(m.ethernet.devices) {
|
||||
m.ethernet.cursor = max(0, len(m.ethernet.devices)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleEthernetAction(msg ethernetActionMsg) tea.Cmd {
|
||||
m.ethernet.pending = false
|
||||
if msg.err != nil {
|
||||
m.ethernet.feedback = msg.err.Error()
|
||||
m.deviceDetail.pending = false
|
||||
m.deviceDetail.feedback = msg.err.Error()
|
||||
m.refreshDeviceDetailContent()
|
||||
return nil
|
||||
}
|
||||
m.ethernet.feedback = ""
|
||||
m.closeDeviceDetailIfPending(tabEthernet)
|
||||
return fetchEthernetCmd()
|
||||
}
|
||||
|
||||
func (m *Model) updateEthernet(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.ethernet.cursor > 0 {
|
||||
m.ethernet.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.ethernet.cursor < len(m.ethernet.devices)-1 {
|
||||
m.ethernet.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, n.Refresh):
|
||||
return fetchEthernetCmd()
|
||||
case key.Matches(keyMsg, n.Select):
|
||||
return m.selectEthernet()
|
||||
case key.Matches(keyMsg, n.Info):
|
||||
return m.infoEthernet()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) infoEthernet() tea.Cmd {
|
||||
if m.ethernet.cursor < 0 || m.ethernet.cursor >= len(m.ethernet.devices) {
|
||||
return nil
|
||||
}
|
||||
dev := m.ethernet.devices[m.ethernet.cursor]
|
||||
if !dev.Connected() {
|
||||
m.ethernet.feedback = "Not connected."
|
||||
return nil
|
||||
}
|
||||
return m.openDeviceDetail(tabEthernet, dev.Device, dev.Device, "", 0)
|
||||
}
|
||||
|
||||
func (m *Model) selectEthernet() tea.Cmd {
|
||||
if m.ethernet.cursor < 0 || m.ethernet.cursor >= len(m.ethernet.devices) {
|
||||
return nil
|
||||
}
|
||||
dev := m.ethernet.devices[m.ethernet.cursor]
|
||||
m.ethernet.pending = true
|
||||
if dev.Connected() {
|
||||
return ethernetDownCmd(dev.Device)
|
||||
}
|
||||
return ethernetConnectCmd(dev.Device)
|
||||
}
|
||||
|
||||
func (m Model) renderEthernet(height int) string {
|
||||
if m.ethernet.loading {
|
||||
return ilovetui.S.Faint.Render("Loading ethernet devices…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("ETHERNET DEVICES")
|
||||
if m.ethernet.pending {
|
||||
header += mutedStyle().Render(" working…")
|
||||
}
|
||||
|
||||
if m.ethernet.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.ethernet.err.Error()))
|
||||
}
|
||||
if len(m.ethernet.devices) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No ethernet devices found."))
|
||||
}
|
||||
|
||||
available := height - 3 // header + blank line + a trailing line for the paginator/feedback
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.ethernet.cursor, len(m.ethernet.devices), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
rows = append(rows, m.renderEthernetRow(m.ethernet.devices[i], i == m.ethernet.cursor))
|
||||
}
|
||||
list := strings.Join(rows, "\n")
|
||||
|
||||
footer := renderPaginatorDots(p)
|
||||
if m.ethernet.feedback != "" {
|
||||
footer = errorStyle().Render(m.ethernet.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
|
||||
}
|
||||
|
||||
func (m Model) renderEthernetRow(d network.EthernetDevice, selected bool) 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)
|
||||
}
|
||||
name := d.Device
|
||||
if d.Connection != "" {
|
||||
name = d.Connection
|
||||
}
|
||||
details := d.State
|
||||
if d.IP4 != "" {
|
||||
details += " · " + d.IP4
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s %s %s", cursor, check, textStyle.Render(name), subtleStyle().Render("("+d.Device+")"), subtleStyle().Render(details))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
)
|
||||
|
||||
// networkKeyMap renders the bottom help bar, adapting to the active tab and
|
||||
// whether a sub-view (password prompt, device info, known-network detail)
|
||||
// is open.
|
||||
type networkKeyMap struct {
|
||||
m Model
|
||||
width int
|
||||
}
|
||||
|
||||
func (k networkKeyMap) ShortHelp() []key.Binding {
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
switch {
|
||||
case k.m.wifi.connecting:
|
||||
return []key.Binding{n.Select, g.Escape, g.Help}
|
||||
case k.m.deviceDetail.open && k.m.deviceDetail.kind == tabWifi:
|
||||
return []key.Binding{g.Up, g.Down, n.RevealSecret, n.Disconnect, g.Escape, g.Help}
|
||||
case k.m.deviceDetail.open:
|
||||
return []key.Binding{g.Up, g.Down, n.Disconnect, g.Escape, g.Help}
|
||||
case k.m.active == tabKnown && k.m.known.detail:
|
||||
return []key.Binding{n.RevealSecret, n.ToggleAuto, n.Forget, g.Escape, g.Help}
|
||||
case k.m.active == tabKnown:
|
||||
return []key.Binding{g.Up, g.Down, n.Select, n.Known, g.Help}
|
||||
case k.m.active == tabWifi:
|
||||
return []key.Binding{g.Up, g.Down, n.Select, n.Disconnect, n.ToggleRadio, g.Help}
|
||||
default:
|
||||
return []key.Binding{g.Up, g.Down, n.Select, g.CycleFocus, n.Known, g.Help}
|
||||
}
|
||||
}
|
||||
|
||||
func (k networkKeyMap) FullHelp() [][]key.Binding {
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
var bindings []key.Binding
|
||||
switch {
|
||||
case k.m.wifi.connecting:
|
||||
bindings = []key.Binding{n.Select, g.Escape}
|
||||
case k.m.deviceDetail.open && k.m.deviceDetail.kind == tabWifi:
|
||||
bindings = []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, n.RevealSecret, n.Disconnect, g.Escape}
|
||||
case k.m.deviceDetail.open:
|
||||
bindings = []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, n.Disconnect, g.Escape}
|
||||
case k.m.active == tabKnown && k.m.known.detail:
|
||||
bindings = []key.Binding{n.RevealSecret, n.ToggleAuto, n.Forget, g.Escape}
|
||||
case k.m.active == tabKnown:
|
||||
bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, n.Known}
|
||||
case k.m.active == tabWifi:
|
||||
bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, n.Info, n.Disconnect, n.ToggleRadio, g.CycleFocus, n.Known}
|
||||
case k.m.active == tabEthernet:
|
||||
bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, n.Info, g.CycleFocus, n.Known}
|
||||
default:
|
||||
bindings = []key.Binding{g.Up, g.Down, n.Select, n.Refresh, g.CycleFocus, n.Known}
|
||||
}
|
||||
bindings = append(bindings, g.CommonBindings()...)
|
||||
return keys.ChunkByWidth(bindings, k.width)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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/icons"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
"github.com/anotherhadi/settuings/internal/network"
|
||||
"github.com/anotherhadi/settuings/internal/util"
|
||||
)
|
||||
|
||||
type knownState struct {
|
||||
loading bool
|
||||
err error
|
||||
networks []network.KnownNetwork
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
|
||||
detail bool
|
||||
secretLoading bool
|
||||
secretRevealed bool
|
||||
secretValue string
|
||||
secretErr error
|
||||
|
||||
confirmForget bool
|
||||
|
||||
// Content can exceed a short terminal, so the detail screen is shown in
|
||||
// a viewport.
|
||||
viewport viewport.Model
|
||||
}
|
||||
|
||||
func newKnownState() knownState {
|
||||
return knownState{loading: true}
|
||||
}
|
||||
|
||||
func (m *Model) handleKnownList(msg knownListMsg) {
|
||||
m.known.loading = false
|
||||
m.known.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.known.networks = msg.networks
|
||||
if m.known.cursor >= len(m.known.networks) {
|
||||
m.known.cursor = max(0, len(m.known.networks)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleKnownSecret(msg knownSecretMsg) {
|
||||
if m.known.selected() == nil || m.known.selected().Name != msg.name {
|
||||
return // stale response for a network we've since navigated away from
|
||||
}
|
||||
m.known.secretLoading = false
|
||||
m.known.secretValue = msg.secret
|
||||
m.known.secretErr = msg.err
|
||||
m.refreshKnownDetailContent()
|
||||
}
|
||||
|
||||
func (m *Model) handleKnownAction(msg knownActionMsg) tea.Cmd {
|
||||
m.known.pending = false
|
||||
if msg.err != nil {
|
||||
m.known.feedback = msg.err.Error()
|
||||
m.refreshKnownDetailContent()
|
||||
return nil
|
||||
}
|
||||
m.known.feedback = ""
|
||||
m.known.detail = false
|
||||
return fetchKnownCmd()
|
||||
}
|
||||
|
||||
func (s knownState) selected() *network.KnownNetwork {
|
||||
if s.cursor < 0 || s.cursor >= len(s.networks) {
|
||||
return nil
|
||||
}
|
||||
return &s.networks[s.cursor]
|
||||
}
|
||||
|
||||
func (m *Model) updateKnown(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if m.known.detail {
|
||||
return m.updateKnownDetail(keyMsg)
|
||||
}
|
||||
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.known.cursor > 0 {
|
||||
m.known.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.known.cursor < len(m.known.networks)-1 {
|
||||
m.known.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, n.Refresh):
|
||||
return fetchKnownCmd()
|
||||
case key.Matches(keyMsg, n.Select):
|
||||
if sel := m.known.selected(); sel != nil {
|
||||
m.known.detail = true
|
||||
m.known.secretRevealed = false
|
||||
m.known.secretLoading = true
|
||||
m.known.secretValue = ""
|
||||
m.known.secretErr = nil
|
||||
m.openKnownDetail()
|
||||
return fetchSecretCmd(sel.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) updateKnownDetail(keyMsg tea.KeyPressMsg) tea.Cmd {
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
if m.known.confirmForget {
|
||||
m.known.confirmForget = false
|
||||
defer m.refreshKnownDetailContent()
|
||||
if keyMsg.String() == "y" || keyMsg.String() == "enter" {
|
||||
if sel := m.known.selected(); sel != nil {
|
||||
m.known.pending = true
|
||||
return forgetCmd(sel.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Escape):
|
||||
m.known.detail = false
|
||||
case key.Matches(keyMsg, n.RevealSecret):
|
||||
m.known.secretRevealed = !m.known.secretRevealed
|
||||
m.refreshKnownDetailContent()
|
||||
case key.Matches(keyMsg, n.ToggleAuto):
|
||||
if sel := m.known.selected(); sel != nil {
|
||||
m.known.pending = true
|
||||
return toggleAutoConnectCmd(sel.Name, !sel.AutoConnect)
|
||||
}
|
||||
case key.Matches(keyMsg, n.Forget):
|
||||
m.known.confirmForget = true
|
||||
m.refreshKnownDetailContent()
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
util.ScrollLines(&m.known.viewport, -1)
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
util.ScrollLines(&m.known.viewport, 1)
|
||||
case key.Matches(keyMsg, g.ScrollUp):
|
||||
util.ScrollViewport(&m.known.viewport, -1)
|
||||
case key.Matches(keyMsg, g.ScrollDown):
|
||||
util.ScrollViewport(&m.known.viewport, 1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openKnownDetail sizes and populates the detail viewport for the currently
|
||||
// selected network. Call it whenever the detail screen is opened.
|
||||
func (m *Model) openKnownDetail() {
|
||||
w, h := m.contentBudget()
|
||||
vp := viewport.New()
|
||||
vp.SetWidth(w)
|
||||
vp.SetHeight(h)
|
||||
m.known.viewport = vp
|
||||
m.refreshKnownDetailContent()
|
||||
}
|
||||
|
||||
// resizeKnownDetail keeps the detail viewport sized to the available space
|
||||
// whenever the terminal is resized.
|
||||
func (m *Model) resizeKnownDetail() {
|
||||
if !m.known.detail {
|
||||
return
|
||||
}
|
||||
w, h := m.contentBudget()
|
||||
m.known.viewport.SetWidth(w)
|
||||
m.known.viewport.SetHeight(h)
|
||||
m.refreshKnownDetailContent()
|
||||
}
|
||||
|
||||
func (m *Model) refreshKnownDetailContent() {
|
||||
m.known.viewport.SetContent(m.knownDetailContent())
|
||||
util.RefreshScrollbar(&m.known.viewport)
|
||||
}
|
||||
|
||||
func (m Model) renderKnown(height int) string {
|
||||
if m.known.detail {
|
||||
return m.renderKnownDetail()
|
||||
}
|
||||
if m.known.loading {
|
||||
return ilovetui.S.Faint.Render("Loading known networks…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("KNOWN NETWORKS")
|
||||
|
||||
if m.known.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.known.err.Error()))
|
||||
}
|
||||
if len(m.known.networks) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No saved Wi-Fi networks."))
|
||||
}
|
||||
|
||||
available := height - 3 // header + blank line + a trailing line for the paginator
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.known.cursor, len(m.known.networks), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
rows = append(rows, m.renderKnownRow(m.known.networks[i], i == m.known.cursor))
|
||||
}
|
||||
list := strings.Join(rows, "\n")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, renderPaginatorDots(p))
|
||||
}
|
||||
|
||||
func (m Model) renderKnownRow(k network.KnownNetwork, selected bool) string {
|
||||
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||
if selected {
|
||||
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
|
||||
}
|
||||
cursor := " "
|
||||
if selected {
|
||||
cursor = "> "
|
||||
}
|
||||
check := " "
|
||||
if k.Active {
|
||||
check = successStyle().Render(icons.I.Check)
|
||||
}
|
||||
auto := "manual"
|
||||
if k.AutoConnect {
|
||||
auto = "auto-join"
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s %s", cursor, check, textStyle.Render(k.Name), subtleStyle().Render(auto))
|
||||
}
|
||||
|
||||
func (m Model) renderKnownDetail() string {
|
||||
return m.known.viewport.View()
|
||||
}
|
||||
|
||||
func (m Model) knownDetailContent() string {
|
||||
sel := m.known.selected()
|
||||
if sel == nil {
|
||||
return ilovetui.S.Faint.Render("No network selected.")
|
||||
}
|
||||
|
||||
rows := []string{
|
||||
lipgloss.NewStyle().Bold(true).Render(sel.Name),
|
||||
"",
|
||||
subtleStyle().Render("Security: ") + securityLabel(sel.Security),
|
||||
subtleStyle().Render("Auto-join: ") + fmt.Sprintf("%v", sel.AutoConnect),
|
||||
subtleStyle().Render("Active: ") + fmt.Sprintf("%v", sel.Active),
|
||||
subtleStyle().Render("Password: ") + m.renderKnownSecret(),
|
||||
}
|
||||
|
||||
if m.known.feedback != "" {
|
||||
rows = append(rows, "", errorStyle().Render(m.known.feedback))
|
||||
}
|
||||
|
||||
if m.known.confirmForget {
|
||||
rows = append(rows, "", errorStyle().Render(fmt.Sprintf("Forget %q? press y to confirm, any other key to cancel", sel.Name)))
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
func (m Model) renderKnownSecret() string {
|
||||
if m.known.secretLoading {
|
||||
return mutedStyle().Render("loading…")
|
||||
}
|
||||
if m.known.secretErr != nil {
|
||||
return errorStyle().Render(m.known.secretErr.Error())
|
||||
}
|
||||
if m.known.secretValue == "" {
|
||||
return subtleStyle().Render("(none)")
|
||||
}
|
||||
if !m.known.secretRevealed {
|
||||
return subtleStyle().Render(strings.Repeat("•", len(m.known.secretValue)))
|
||||
}
|
||||
return m.known.secretValue
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package network
|
||||
|
||||
import "github.com/anotherhadi/settuings/internal/network"
|
||||
|
||||
// checkMsg carries the result of network.CheckAvailable.
|
||||
type checkMsg struct{ err error }
|
||||
|
||||
// tickMsg triggers a periodic refresh of every tab while this page is active.
|
||||
type tickMsg struct{}
|
||||
|
||||
type wifiListMsg struct {
|
||||
networks []network.WifiNetwork
|
||||
ip string
|
||||
device string // wifi interface name, e.g. "wlo1"
|
||||
connectionName string // active connection profile name, for secret lookup
|
||||
radioOn bool
|
||||
err error
|
||||
}
|
||||
|
||||
type wifiActionMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type ethernetListMsg struct {
|
||||
devices []network.EthernetDevice
|
||||
err error
|
||||
}
|
||||
|
||||
type ethernetActionMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type vpnListMsg struct {
|
||||
conns []network.VPNConnection
|
||||
err error
|
||||
}
|
||||
|
||||
type vpnActionMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type knownListMsg struct {
|
||||
networks []network.KnownNetwork
|
||||
err error
|
||||
}
|
||||
|
||||
type knownSecretMsg struct {
|
||||
name string
|
||||
secret string
|
||||
err error
|
||||
}
|
||||
|
||||
type knownActionMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type deviceDetailMsg struct {
|
||||
detail network.DeviceDetail
|
||||
err error
|
||||
}
|
||||
|
||||
type radioActionMsg struct {
|
||||
err error
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package network provides the "Network" TUI page: a thin, tabbed wrapper
|
||||
// around nmcli covering Wi-Fi, Ethernet, VPN, and known Wi-Fi networks.
|
||||
package network
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/icons"
|
||||
)
|
||||
|
||||
type tab int
|
||||
|
||||
const (
|
||||
tabWifi tab = iota
|
||||
tabEthernet
|
||||
tabVPN
|
||||
tabKnown // hidden from the tab bar; reachable only via its own shortcut
|
||||
)
|
||||
|
||||
// visibleTabs are the tabs shown in the tab bar and reachable by cycling.
|
||||
var visibleTabs = []tab{tabWifi, tabEthernet, tabVPN}
|
||||
|
||||
func (t tab) label() string {
|
||||
switch t {
|
||||
case tabWifi:
|
||||
return "Wifi"
|
||||
case tabEthernet:
|
||||
return "Ethernet"
|
||||
case tabVPN:
|
||||
return "VPN"
|
||||
case tabKnown:
|
||||
return "Known"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (t tab) icon() string {
|
||||
switch t {
|
||||
case tabWifi:
|
||||
return icons.I.Wifi
|
||||
case tabEthernet:
|
||||
return icons.I.Ethernet
|
||||
case tabVPN:
|
||||
return icons.I.VPN
|
||||
case tabKnown:
|
||||
return icons.I.Lock
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
active tab
|
||||
previousTab tab // where to return to when leaving the hidden Known tab
|
||||
width int
|
||||
height int
|
||||
help help.Model
|
||||
|
||||
checkErr error // reason nmcli/NetworkManager is unusable; nil once confirmed OK
|
||||
checked bool
|
||||
|
||||
wifi wifiState
|
||||
ethernet ethernetState
|
||||
vpn vpnState
|
||||
known knownState
|
||||
deviceDetail deviceDetailState
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
return Model{
|
||||
active: tabWifi,
|
||||
help: ilovetui.NewHelp(),
|
||||
wifi: newWifiState(),
|
||||
ethernet: newEthernetState(),
|
||||
vpn: newVPNState(),
|
||||
known: newKnownState(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Activate is called every time the user navigates onto this page. It
|
||||
// re-checks nmcli availability, kicks off a fetch for every tab, and starts
|
||||
// the periodic auto-refresh loop.
|
||||
func (m *Model) Activate() tea.Cmd {
|
||||
return tea.Batch(
|
||||
checkCmd(),
|
||||
fetchWifiCmd(),
|
||||
fetchEthernetCmd(),
|
||||
fetchVPNCmd(),
|
||||
fetchKnownCmd(),
|
||||
tickCmd(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) IsEditing() bool {
|
||||
return m.wifi.connecting || m.known.confirmForget
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
m.help.SetWidth(w - 2)
|
||||
m.resizeDetails()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package network
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package network
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
// cycleTab moves to the next visible tab. From the hidden Known tab it
|
||||
// returns to whichever tab was active before Known was opened.
|
||||
func (m *Model) cycleTab() {
|
||||
if m.active == tabKnown {
|
||||
m.active = m.previousTab
|
||||
return
|
||||
}
|
||||
for i, t := range visibleTabs {
|
||||
if t == m.active {
|
||||
m.active = visibleTabs[(i+1)%len(visibleTabs)]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toggleKnown opens the hidden Known tab, or leaves it back to the
|
||||
// previously active tab if it's already open.
|
||||
func (m *Model) toggleKnown() {
|
||||
if m.active == tabKnown {
|
||||
m.active = m.previousTab
|
||||
return
|
||||
}
|
||||
m.previousTab = m.active
|
||||
m.active = tabKnown
|
||||
}
|
||||
|
||||
func (m Model) renderTabBar(width int) string {
|
||||
active := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 2).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Primary)
|
||||
inactive := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Padding(0, 2).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Subtle)
|
||||
|
||||
var tabs []string
|
||||
for _, t := range visibleTabs {
|
||||
style := inactive
|
||||
if t == m.active {
|
||||
style = active
|
||||
}
|
||||
tabs = append(tabs, style.Render(t.icon()+t.label()))
|
||||
}
|
||||
row := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...)
|
||||
|
||||
filler := width - lipgloss.Width(row)
|
||||
if filler > 0 {
|
||||
row = lipgloss.JoinHorizontal(lipgloss.Bottom, row,
|
||||
lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(ilovetui.S.Subtle).
|
||||
Render(strings.Repeat(" ", filler)))
|
||||
}
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
"github.com/anotherhadi/settuings/internal/util"
|
||||
)
|
||||
|
||||
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(fetchWifiCmd(), fetchEthernetCmd(), fetchVPNCmd(), fetchKnownCmd(), tickCmd())
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
if m.checkErr != nil {
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Batch(fetchWifiCmd(), fetchEthernetCmd(), fetchVPNCmd(), fetchKnownCmd(), tickCmd())
|
||||
|
||||
case wifiListMsg:
|
||||
m.handleWifiList(msg)
|
||||
return m, nil
|
||||
case wifiActionMsg:
|
||||
return m, m.handleWifiAction(msg)
|
||||
case radioActionMsg:
|
||||
return m, m.handleRadioAction(msg)
|
||||
|
||||
case ethernetListMsg:
|
||||
m.handleEthernetList(msg)
|
||||
return m, nil
|
||||
case ethernetActionMsg:
|
||||
return m, m.handleEthernetAction(msg)
|
||||
|
||||
case vpnListMsg:
|
||||
m.handleVPNList(msg)
|
||||
return m, nil
|
||||
case vpnActionMsg:
|
||||
return m, m.handleVPNAction(msg)
|
||||
|
||||
case knownListMsg:
|
||||
m.handleKnownList(msg)
|
||||
return m, nil
|
||||
case knownSecretMsg:
|
||||
m.handleKnownSecret(msg)
|
||||
m.handleDeviceDetailSecret(msg)
|
||||
return m, nil
|
||||
case knownActionMsg:
|
||||
return m, m.handleKnownAction(msg)
|
||||
|
||||
case deviceDetailMsg:
|
||||
m.handleDeviceDetail(msg)
|
||||
return m, nil
|
||||
|
||||
case tea.MouseWheelMsg:
|
||||
if m.deviceDetail.open {
|
||||
return m, m.updateDeviceDetail(msg)
|
||||
}
|
||||
if m.active == tabKnown && m.known.detail {
|
||||
util.HandleMouseWheel(msg, &m.known.viewport)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if m.checkErr != nil {
|
||||
if key.Matches(msg, keys.Keys.Network.Refresh) {
|
||||
return m, checkCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.deviceDetail.open {
|
||||
return m, m.updateDeviceDetail(msg)
|
||||
}
|
||||
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
if !m.IsEditing() {
|
||||
switch {
|
||||
case key.Matches(msg, g.CycleFocus):
|
||||
m.cycleTab()
|
||||
return m, nil
|
||||
case key.Matches(msg, n.Known):
|
||||
m.toggleKnown()
|
||||
return m, nil
|
||||
case key.Matches(msg, g.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
m.resizeDetails()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.active {
|
||||
case tabWifi:
|
||||
cmd = m.updateWifi(msg)
|
||||
case tabEthernet:
|
||||
cmd = m.updateEthernet(msg)
|
||||
case tabVPN:
|
||||
cmd = m.updateVPN(msg)
|
||||
case tabKnown:
|
||||
cmd = m.updateKnown(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/network"
|
||||
)
|
||||
|
||||
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 := m.renderTabBar(innerW)
|
||||
|
||||
var body string
|
||||
switch {
|
||||
case !m.checked:
|
||||
body = ilovetui.S.Faint.Render("Checking nmcli…")
|
||||
case m.checkErr != nil:
|
||||
body = m.renderUnavailable()
|
||||
case m.deviceDetail.open:
|
||||
body = m.renderDeviceDetail()
|
||||
default:
|
||||
body = m.renderActiveTab(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 := networkKeyMap{m: m, width: m.width}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km))
|
||||
}
|
||||
|
||||
func (m Model) renderActiveTab(width, height int) string {
|
||||
switch m.active {
|
||||
case tabWifi:
|
||||
return m.renderWifi(width, height)
|
||||
case tabEthernet:
|
||||
return m.renderEthernet(height)
|
||||
case tabVPN:
|
||||
return m.renderVPN(height)
|
||||
case tabKnown:
|
||||
return m.renderKnown(height)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderUnavailable() string {
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Error).Render("Network page unavailable")
|
||||
|
||||
var hint string
|
||||
switch {
|
||||
case errors.Is(m.checkErr, network.ErrNotFound):
|
||||
hint = "nmcli was not found in PATH. Install NetworkManager to use this page."
|
||||
case errors.Is(m.checkErr, network.ErrNotRunning):
|
||||
hint = "NetworkManager doesn't seem to be running. Try: sudo systemctl start NetworkManager"
|
||||
default:
|
||||
hint = m.checkErr.Error()
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
subtleStyle().Render(hint),
|
||||
"",
|
||||
mutedStyle().Render("Press r to try again once fixed."),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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/icons"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
"github.com/anotherhadi/settuings/internal/network"
|
||||
)
|
||||
|
||||
type vpnState struct {
|
||||
loading bool
|
||||
err error
|
||||
conns []network.VPNConnection
|
||||
cursor int
|
||||
pending bool
|
||||
feedback string
|
||||
}
|
||||
|
||||
func newVPNState() vpnState {
|
||||
return vpnState{loading: true}
|
||||
}
|
||||
|
||||
func (m *Model) handleVPNList(msg vpnListMsg) {
|
||||
m.vpn.loading = false
|
||||
m.vpn.err = msg.err
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.vpn.conns = msg.conns
|
||||
if m.vpn.cursor >= len(m.vpn.conns) {
|
||||
m.vpn.cursor = max(0, len(m.vpn.conns)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleVPNAction(msg vpnActionMsg) tea.Cmd {
|
||||
m.vpn.pending = false
|
||||
if msg.err != nil {
|
||||
m.vpn.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
m.vpn.feedback = ""
|
||||
return fetchVPNCmd()
|
||||
}
|
||||
|
||||
func (m *Model) updateVPN(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.vpn.cursor > 0 {
|
||||
m.vpn.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.vpn.cursor < len(m.vpn.conns)-1 {
|
||||
m.vpn.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, n.Refresh):
|
||||
return fetchVPNCmd()
|
||||
case key.Matches(keyMsg, n.Select):
|
||||
return m.selectVPN()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) selectVPN() tea.Cmd {
|
||||
if m.vpn.cursor < 0 || m.vpn.cursor >= len(m.vpn.conns) {
|
||||
return nil
|
||||
}
|
||||
conn := m.vpn.conns[m.vpn.cursor]
|
||||
m.vpn.pending = true
|
||||
if conn.Active {
|
||||
return vpnDownCmd(conn.Name)
|
||||
}
|
||||
return vpnUpCmd(conn.Name)
|
||||
}
|
||||
|
||||
func (m Model) renderVPN(height int) string {
|
||||
if m.vpn.loading {
|
||||
return ilovetui.S.Faint.Render("Loading VPN connections…")
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("VPN CONNECTIONS")
|
||||
if m.vpn.pending {
|
||||
header += mutedStyle().Render(" working…")
|
||||
}
|
||||
|
||||
if m.vpn.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.vpn.err.Error()))
|
||||
}
|
||||
if len(m.vpn.conns) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No VPN connections configured."))
|
||||
}
|
||||
|
||||
available := height - 3 // header + blank line + a trailing line for the paginator/feedback
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.vpn.cursor, len(m.vpn.conns), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
rows = append(rows, m.renderVPNRow(m.vpn.conns[i], i == m.vpn.cursor))
|
||||
}
|
||||
list := strings.Join(rows, "\n")
|
||||
|
||||
footer := renderPaginatorDots(p)
|
||||
if m.vpn.feedback != "" {
|
||||
footer = errorStyle().Render(m.vpn.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
|
||||
}
|
||||
|
||||
func (m Model) renderVPNRow(c network.VPNConnection, selected bool) string {
|
||||
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||
if selected {
|
||||
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
|
||||
}
|
||||
cursor := " "
|
||||
if selected {
|
||||
cursor = "> "
|
||||
}
|
||||
check := " "
|
||||
if c.Active {
|
||||
check = successStyle().Render(icons.I.Check)
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s %s", cursor, check, textStyle.Render(c.Name), subtleStyle().Render(c.Type))
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/settuings/internal/icons"
|
||||
"github.com/anotherhadi/settuings/internal/keys"
|
||||
"github.com/anotherhadi/settuings/internal/network"
|
||||
)
|
||||
|
||||
type wifiState struct {
|
||||
loading bool
|
||||
err error
|
||||
ip string
|
||||
device string // wifi interface name, e.g. "wlo1"
|
||||
connectionName string // active connection profile name, e.g. "Djoon 1"
|
||||
radioOn bool
|
||||
current *network.WifiNetwork // the AP we're associated with, if any
|
||||
others []network.WifiNetwork
|
||||
cursor int
|
||||
pending bool // a connect/disconnect/rescan/radio-toggle is in flight
|
||||
feedback string
|
||||
|
||||
connecting bool
|
||||
connectSSID string
|
||||
password textinput.Model
|
||||
}
|
||||
|
||||
func newWifiState() wifiState {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = "Password: "
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.EchoCharacter = '•'
|
||||
s := ti.Styles()
|
||||
s.Focused.Prompt = lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||
ti.SetStyles(s)
|
||||
return wifiState{password: ti, loading: true}
|
||||
}
|
||||
|
||||
func (m Model) isKnownSSID(ssid string) bool {
|
||||
for _, k := range m.known.networks {
|
||||
if k.Name == ssid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Model) handleWifiList(msg wifiListMsg) {
|
||||
m.wifi.loading = false
|
||||
m.wifi.err = msg.err
|
||||
m.wifi.radioOn = msg.radioOn
|
||||
if msg.err != nil {
|
||||
return
|
||||
}
|
||||
m.wifi.ip = msg.ip
|
||||
m.wifi.device = msg.device
|
||||
m.wifi.connectionName = msg.connectionName
|
||||
m.wifi.current = nil
|
||||
m.wifi.others = m.wifi.others[:0]
|
||||
for _, n := range msg.networks {
|
||||
n := n
|
||||
if n.Active {
|
||||
m.wifi.current = &n
|
||||
continue
|
||||
}
|
||||
m.wifi.others = append(m.wifi.others, n)
|
||||
}
|
||||
if m.wifi.cursor >= len(m.wifi.others) {
|
||||
m.wifi.cursor = max(0, len(m.wifi.others)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleWifiAction(msg wifiActionMsg) tea.Cmd {
|
||||
m.wifi.pending = false
|
||||
if msg.err != nil {
|
||||
m.wifi.feedback = msg.err.Error()
|
||||
m.deviceDetail.pending = false
|
||||
m.deviceDetail.feedback = msg.err.Error()
|
||||
m.refreshDeviceDetailContent()
|
||||
return nil
|
||||
}
|
||||
m.wifi.feedback = ""
|
||||
m.closeDeviceDetailIfPending(tabWifi)
|
||||
return tea.Batch(fetchWifiCmd(), fetchKnownCmd())
|
||||
}
|
||||
|
||||
func (m *Model) handleRadioAction(msg radioActionMsg) tea.Cmd {
|
||||
m.wifi.pending = false
|
||||
if msg.err != nil {
|
||||
m.wifi.feedback = msg.err.Error()
|
||||
return nil
|
||||
}
|
||||
m.wifi.feedback = ""
|
||||
return fetchWifiCmd()
|
||||
}
|
||||
|
||||
func (m *Model) updateWifi(msg tea.Msg) tea.Cmd {
|
||||
if m.wifi.connecting {
|
||||
return m.updateWifiPasswordPrompt(msg)
|
||||
}
|
||||
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
n := keys.Keys.Network
|
||||
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Up):
|
||||
if m.wifi.cursor > 0 {
|
||||
m.wifi.cursor--
|
||||
}
|
||||
case key.Matches(keyMsg, g.Down):
|
||||
if m.wifi.cursor < len(m.wifi.others)-1 {
|
||||
m.wifi.cursor++
|
||||
}
|
||||
case key.Matches(keyMsg, n.Refresh):
|
||||
m.wifi.pending = true
|
||||
return tea.Batch(rescanWifiCmd(), fetchWifiCmd())
|
||||
case key.Matches(keyMsg, n.Select):
|
||||
return m.selectWifi()
|
||||
case key.Matches(keyMsg, n.ToggleRadio):
|
||||
m.wifi.pending = true
|
||||
return toggleWifiRadioCmd(!m.wifi.radioOn)
|
||||
case key.Matches(keyMsg, n.Disconnect):
|
||||
if m.wifi.current == nil {
|
||||
m.wifi.feedback = "Not connected to a network."
|
||||
return nil
|
||||
}
|
||||
m.wifi.pending = true
|
||||
return disconnectWifiCmd()
|
||||
case key.Matches(keyMsg, n.Info):
|
||||
if m.wifi.current == nil {
|
||||
m.wifi.feedback = "Not connected to a network."
|
||||
return nil
|
||||
}
|
||||
cur := m.wifi.current
|
||||
return m.openDeviceDetail(tabWifi, m.wifi.device, cur.SSID, cur.Security, cur.Signal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) selectWifi() tea.Cmd {
|
||||
if len(m.wifi.others) == 0 {
|
||||
return nil
|
||||
}
|
||||
target := m.wifi.others[m.wifi.cursor]
|
||||
if target.Secured() && !m.isKnownSSID(target.SSID) {
|
||||
m.wifi.connecting = true
|
||||
m.wifi.connectSSID = target.SSID
|
||||
m.wifi.password.SetValue("")
|
||||
m.wifi.password.Focus()
|
||||
return nil
|
||||
}
|
||||
m.wifi.pending = true
|
||||
return connectWifiCmd(target.SSID, "")
|
||||
}
|
||||
|
||||
func (m *Model) updateWifiPasswordPrompt(msg tea.Msg) tea.Cmd {
|
||||
keyMsg, ok := msg.(tea.KeyPressMsg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
g := keys.Keys.Global
|
||||
switch {
|
||||
case key.Matches(keyMsg, g.Escape):
|
||||
m.wifi.connecting = false
|
||||
m.wifi.password.Blur()
|
||||
return nil
|
||||
case keyMsg.String() == "enter":
|
||||
ssid := m.wifi.connectSSID
|
||||
password := m.wifi.password.Value()
|
||||
m.wifi.connecting = false
|
||||
m.wifi.password.Blur()
|
||||
m.wifi.pending = true
|
||||
return connectWifiCmd(ssid, password)
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.wifi.password, cmd = m.wifi.password.Update(keyMsg)
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderWifi(width, height int) string {
|
||||
if m.wifi.connecting {
|
||||
return m.renderWifiPasswordPrompt()
|
||||
}
|
||||
|
||||
if m.wifi.loading {
|
||||
return ilovetui.S.Faint.Render("Loading Wi-Fi networks…")
|
||||
}
|
||||
|
||||
othersHeader := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("OTHER NETWORKS")
|
||||
if m.wifi.pending {
|
||||
othersHeader += mutedStyle().Render(" refreshing…")
|
||||
}
|
||||
|
||||
fixed := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.renderWifiRadioStatus(),
|
||||
"",
|
||||
lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("CURRENT NETWORK"),
|
||||
"",
|
||||
m.renderCurrentWifiCard(width),
|
||||
"",
|
||||
othersHeader,
|
||||
"",
|
||||
)
|
||||
fixedH := lipgloss.Height(fixed)
|
||||
|
||||
if m.wifi.err != nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, fixed, errorStyle().Render(m.wifi.err.Error()))
|
||||
}
|
||||
if len(m.wifi.others) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, fixed, ilovetui.S.Faint.Render("No other networks in range."))
|
||||
}
|
||||
|
||||
available := height - fixedH - 1 // reserve a trailing line for the paginator/feedback
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
p, start, end := paginate(m.wifi.cursor, len(m.wifi.others), available)
|
||||
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
rows = append(rows, m.renderWifiRow(m.wifi.others[i], i == m.wifi.cursor))
|
||||
}
|
||||
list := strings.Join(rows, "\n")
|
||||
|
||||
footer := renderPaginatorDots(p)
|
||||
if m.wifi.feedback != "" {
|
||||
footer = errorStyle().Render(m.wifi.feedback)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, fixed, list, footer)
|
||||
}
|
||||
|
||||
func (m Model) renderWifiRadioStatus() string {
|
||||
label := "Wi-Fi: "
|
||||
if m.wifi.radioOn {
|
||||
return label + successStyle().Bold(true).Render("On")
|
||||
}
|
||||
return label + errorStyle().Bold(true).Render("Off")
|
||||
}
|
||||
|
||||
func (m Model) renderCurrentWifiCard(width int) string {
|
||||
if m.wifi.current == nil {
|
||||
return ilovetui.S.Faint.Render("Not connected")
|
||||
}
|
||||
cur := m.wifi.current
|
||||
check := lipgloss.NewStyle().Foreground(ilovetui.S.Success).Render(icons.I.Check)
|
||||
title := lipgloss.NewStyle().Bold(true).Render(cur.SSID)
|
||||
line1 := check + title
|
||||
line2 := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render(
|
||||
fmt.Sprintf("%s · %d%%", securityLabel(cur.Security), cur.Signal))
|
||||
if m.wifi.ip != "" {
|
||||
line2 += lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render(" · " + m.wifi.ip)
|
||||
}
|
||||
return lipgloss.NewStyle().Width(width).Render(lipgloss.JoinVertical(lipgloss.Left, line1, line2))
|
||||
}
|
||||
|
||||
func (m Model) renderWifiRow(n network.WifiNetwork, selected bool) string {
|
||||
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||
subtleStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
|
||||
if selected {
|
||||
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
|
||||
}
|
||||
lock := ""
|
||||
if n.Secured() {
|
||||
lock = " " + icons.I.Lock
|
||||
}
|
||||
cursor := " "
|
||||
if selected {
|
||||
cursor = "> "
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s %s", cursor, textStyle.Render(n.SSID), lock, subtleStyle.Render(fmt.Sprintf("%d%%", n.Signal)))
|
||||
}
|
||||
|
||||
func (m Model) renderWifiPasswordPrompt() string {
|
||||
title := lipgloss.NewStyle().Bold(true).Render("Connect to " + m.wifi.connectSSID)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, "", m.wifi.password.View())
|
||||
}
|
||||
|
||||
func securityLabel(security string) string {
|
||||
if security == "" || security == "--" {
|
||||
return "Open"
|
||||
}
|
||||
return security
|
||||
}
|
||||
Reference in New Issue
Block a user