use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use nmrs::{Network, NetworkManager, WifiSecurity};
use ratatui::{
DefaultTerminal, Frame,
buffer::Buffer,
layout::{Constraint, Flex, Layout, Rect},
style::{Style, Stylize},
text::Line,
widgets::{Block, BorderType, Clear, Paragraph, Row, Table, Widget},
};
use std::time::{Duration, Instant};
use tokio::task::JoinHandle;
pub struct WifiTUI {
exit: bool,
init_nm_handle: Option<JoinHandle<anyhow::Result<NetworkManager>>>,
networks_handle: Option<JoinHandle<anyhow::Result<Vec<Network>>>>,
connection_handle: Option<JoinHandle<anyhow::Result<()>>>,
scan_handle: Option<JoinHandle<anyhow::Result<()>>>,
networks: Vec<Network>,
nm: Option<NetworkManager>,
last_network_update: Instant,
selection_number: usize,
scanning: bool,
password: Option<String>,
password_network: Option<Network>,
action_ssid: Option<String>,
error: Option<String>,
}
impl WifiTUI {
pub fn new() -> Self {
let init_nm_handle = tokio::spawn(async {
let nm = NetworkManager::new().await?;
Ok(nm)
});
Self {
exit: false,
init_nm_handle: Some(init_nm_handle),
networks_handle: None,
connection_handle: None,
scan_handle: None,
networks: Vec::new(),
nm: None,
last_network_update: Instant::now() - Duration::from_millis(500),
selection_number: 0,
scanning: false,
password: None,
password_network: None,
action_ssid: None,
error: None,
}
}
pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
while !self.exit {
if let Some(handle) = &self.init_nm_handle {
if handle.is_finished() {
let handle = self.init_nm_handle.take().unwrap();
let nm = handle.await??;
let scan_nm = nm.clone();
self.scanning = true;
self.scan_handle = Some(tokio::spawn(async move {
scan_nm.scan_networks(None).await?;
tokio::time::sleep(Duration::from_secs(5)).await;
Ok(())
}));
self.nm = Some(nm);
}
}
if let Some(handle) = &self.scan_handle {
if handle.is_finished() {
let _ = self.scan_handle.take().unwrap().await;
self.scanning = false;
}
}
if let Some(handle) = &self.networks_handle {
if handle.is_finished() {
let handle = self.networks_handle.take().unwrap();
self.networks = handle.await??;
self.networks.sort_by(|a, b| a.ssid.cmp(&b.ssid));
if !self.networks.is_empty() {
self.selection_number = self.selection_number.min(self.networks.len() - 1);
} else {
self.selection_number = 0;
}
}
}
if let Some(handle) = &self.connection_handle {
if handle.is_finished() {
let handle = self.connection_handle.take().unwrap();
self.action_ssid = None;
self.error = handle.await?.err().map(|error| error.to_string());
}
}
if self.last_network_update.elapsed() >= Duration::from_millis(500)
&& self.networks_handle.is_none()
{
if let Some(nm) = &self.nm {
let nm = nm.clone();
self.networks_handle =
Some(tokio::spawn(
async move { Ok(nm.list_networks(None).await?) },
));
self.last_network_update = Instant::now();
}
}
terminal.draw(|frame| self.draw(frame))?;
self.handle_events().await?;
}
Ok(())
}
fn draw(&self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
async fn handle_events(&mut self) -> anyhow::Result<()> {
if event::poll(Duration::ZERO)? {
match event::read()? {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event).await;
}
_ => {}
}
} else {
tokio::time::sleep(Duration::from_millis(50)).await;
}
Ok(())
}
pub async fn handle_key_event(&mut self, key: KeyEvent) {
if let Some(password) = &mut self.password {
match key.code {
KeyCode::Esc => {
self.password = None;
self.password_network = None;
}
KeyCode::Enter => {
let password = std::mem::take(password);
self.password = None;
if let (Some(nm), Some(network)) = (&self.nm, self.password_network.take()) {
let nm = nm.clone();
let ssid = network.ssid.clone();
let interface =
Some(network.device.clone()).filter(|device| !device.is_empty());
self.error = None;
self.action_ssid = Some(ssid.clone());
self.connection_handle = Some(tokio::spawn(async move {
nm.connect(
&ssid,
interface.as_deref(),
WifiSecurity::WpaPsk {
psk: password.into(),
},
)
.await?;
Ok(())
}));
}
}
KeyCode::Backspace => {
password.pop();
}
KeyCode::Char(c) => {
password.push(c);
}
_ => {}
}
return;
}
match key.code {
KeyCode::Char('q') => {
self.exit = true;
}
KeyCode::Char('s') => {
if self.scanning {
return;
}
if let Some(nm) = &self.nm {
let nm = nm.clone();
self.scanning = true;
self.scan_handle = Some(tokio::spawn(async move {
nm.scan_networks(None).await?;
tokio::time::sleep(Duration::from_secs(5)).await;
Ok(())
}));
}
}
KeyCode::Char('f') => {
if self.connection_handle.is_some() {
return;
}
let Some(nm) = &self.nm else {
return;
};
let Some(network) = self.networks.get(self.selection_number) else {
return;
};
if !network.known {
return;
}
let nm = nm.clone();
let ssid = network.ssid.clone();
self.error = None;
self.connection_handle = Some(tokio::spawn(async move {
nm.forget(&ssid).await?;
Ok(())
}));
}
KeyCode::Char(' ') => {
if self.connection_handle.is_some() {
return;
}
let Some(nm) = &self.nm else {
return;
};
let Some(network) = self.networks.get(self.selection_number) else {
return;
};
let nm = nm.clone();
let network = network.clone();
let interface = Some(network.device.clone()).filter(|device| !device.is_empty());
self.error = None;
if network.is_active {
self.action_ssid = Some(network.ssid.clone());
self.connection_handle = Some(tokio::spawn(async move {
nm.disconnect(interface.as_deref()).await?;
Ok(())
}));
} else if network.known || !network.secured {
let ssid = network.ssid.clone();
self.action_ssid = Some(ssid.clone());
self.connection_handle = Some(tokio::spawn(async move {
nm.connect(&ssid, interface.as_deref(), WifiSecurity::Open)
.await?;
Ok(())
}));
} else if network.is_psk {
self.password = Some(String::new());
self.password_network = Some(network);
} else {
self.error = Some("Unsupported security type".to_string());
}
}
KeyCode::Up => match self.selection_number {
0 => {
self.selection_number = self.networks.len().saturating_sub(1);
}
_ => {
self.selection_number -= 1;
}
},
KeyCode::Down => match self.selection_number {
n if n + 1 >= self.networks.len() => {
self.selection_number = 0;
}
_ => {
self.selection_number += 1;
}
},
_ => {}
}
}
}
impl Widget for &WifiTUI {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let title = " WiFi ".black().on_cyan().into_centered_line();
let keybinds = Line::from(vec![
" Connect/Disconnect ".white(),
"<space>".cyan().bold(),
" Forget ".white(),
"<f>".cyan().bold(),
" Scan ".white(),
"<s>".cyan().bold(),
" Quit ".white(),
"<q> ".cyan().bold(),
]);
let nm_status = match self.networks.iter().find(|n| !n.device.is_empty()) {
Some(n) => Line::from(vec![
" Adapter: ".bold().cyan(),
n.device.clone().bold().yellow(),
" ".into(),
]),
None => Line::from(" Adapter ".bold().cyan()),
};
let action_status = if self.connection_handle.is_some() {
Line::from(" Working ".bold().yellow())
} else if self.scanning {
Line::from(" Scanning ".bold().green())
} else if let Some(error) = &self.error {
Line::from(format!(" {} ", error.chars().take(40).collect::<String>()).red())
} else {
Line::from("")
};
let mut rows = self
.networks
.iter()
.enumerate()
.map(|(i, n)| {
let security = if !n.secured {
"Open".green()
} else if n.is_psk {
"WPA-PSK".yellow()
} else if n.is_eap {
"WPA-EAP".yellow()
} else {
"Secured".yellow()
};
let mut row = Row::new([
Line::from(n.ssid.clone()).right_aligned().cyan(),
Line::from(security).left_aligned(),
Line::from(if n.known { "yes".green() } else { "no".red() }).right_aligned(),
Line::from(if self.action_ssid.as_ref() == Some(&n.ssid) {
if n.is_active {
"disconnecting".yellow()
} else {
"connecting".yellow()
}
} else if n.is_active {
"yes".green()
} else {
"no".red()
})
.left_aligned()
.red(),
])
.dim();
if i == self.selection_number {
row = row.style(Style::new().bold());
}
row
})
.collect::<Vec<_>>();
rows.insert(
0,
Row::new([
Line::from("Network".bold()).right_aligned(),
Line::from("Security".bold()).left_aligned(),
Line::from("Saved".bold()).right_aligned(),
Line::from("Connected".bold()).left_aligned(),
]),
);
let table = Table::new(
rows,
[
Constraint::Fill(4),
Constraint::Fill(2),
Constraint::Fill(1),
Constraint::Fill(4),
],
);
let block = Block::bordered()
.title(title)
.title_bottom(keybinds.centered())
.title_bottom(nm_status.left_aligned())
.title_bottom(action_status.right_aligned())
.border_type(BorderType::Double)
.border_style(Style::new().cyan());
table.block(block).render(area, buf);
if let Some(password) = &self.password {
let ssid = self
.password_network
.as_ref()
.map(|network| network.ssid.clone())
.unwrap_or_default();
let popup_width = 50.min(area.width);
let popup_height = 8.min(area.height);
let [popup_area] = Layout::vertical([Constraint::Length(popup_height)])
.flex(Flex::Center)
.areas(area);
let [popup_area] = Layout::horizontal([Constraint::Length(popup_width)])
.flex(Flex::Center)
.areas(popup_area);
let masked = "•".repeat(password.chars().count().min(30));
let popup_title = " WiFi Password ".black().on_cyan();
Clear.render(popup_area, buf);
Paragraph::new(vec![
Line::from(""),
Line::from(ssid.bold().cyan()).centered(),
Line::from(format!("Password: {masked}").yellow()).centered(),
Line::from(""),
Line::from(vec![
"Press ".white(),
"<enter>".cyan().bold(),
" to connect or ".white(),
"<esc>".cyan().bold(),
" to cancel.".white(),
])
.centered(),
Line::from(""),
])
.block(
Block::bordered()
.title(popup_title.into_centered_line())
.border_type(BorderType::Double)
.border_style(Style::new().cyan()),
)
.render(popup_area, buf);
}
}
}