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
+118
View File
@@ -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}
}
}
+273
View File
@@ -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
}
+165
View File
@@ -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))
}
+63
View File
@@ -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)
}
+286
View File
@@ -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
}
+64
View File
@@ -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
}
+109
View File
@@ -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()
}
+44
View File
@@ -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())
}
+22
View File
@@ -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)
}
+63
View File
@@ -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
}
+115
View File
@@ -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
}
+82
View File
@@ -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."),
)
}
+139
View File
@@ -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))
}
+296
View File
@@ -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
}