use crate::{
app::{App, WifiApPromptField},
domain::common::WifiFocus,
domain::wifi::WifiDeviceInfo,
};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Cell, Clear, Paragraph, Row, Table},
};
pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(7),
Constraint::Min(6),
Constraint::Length(5),
])
.split(area);
render_known_networks(app, frame, chunks[0]);
render_new_networks(app, frame, chunks[1]);
render_device(app, frame, chunks[2]);
if app.show_wifi_details {
render_details_popup(app, frame);
}
if app.wifi_ap_prompt_open {
render_wifi_ap_popup(app, frame);
}
if app.wifi_share_popup.is_some() {
render_wifi_share_popup(app, frame);
}
if app.wifi_passphrase_prompt_ssid.is_some() {
render_wifi_passphrase_popup(app, frame);
}
if app.hidden_connect_prompt {
render_hidden_connect_popup(app, frame);
}
}
fn render_known_networks(app: &mut App, frame: &mut Frame, area: Rect) {
if app.wifi_access_point_active() {
render_access_point_status(app, frame, area);
return;
}
let focused = app.wifi_focus == WifiFocus::KnownNetworks;
let title = if app.wifi_connect_active() {
" Known Networks (Connecting) ".to_string()
} else {
" Known Networks ".to_string()
};
let mut rows: Vec<Row> = app
.wifi
.known_networks
.iter()
.map(|n| {
Row::new(vec![
Cell::from(if n.connected { "ó°–©" } else { "" }),
Cell::from(n.ssid.clone()),
Cell::from(n.security.clone()),
Cell::from(
n.hidden
.map(|v| if v { "Yes" } else { "No" })
.unwrap_or("-"),
),
Cell::from(
n.autoconnect
.map(|v| if v { "Yes" } else { "No" })
.unwrap_or("-"),
),
Cell::from(n.signal.clone()),
])
})
.collect();
if app.show_unavailable_known_networks {
for n in &app.wifi.unavailable_known_networks {
rows.push(
Row::new(vec![
Cell::from(""),
Cell::from(n.ssid.clone()),
Cell::from(n.security.clone()),
Cell::from(
n.hidden
.map(|v| if v { "Yes" } else { "No" })
.unwrap_or("-"),
),
Cell::from(
n.autoconnect
.map(|v| if v { "Yes" } else { "No" })
.unwrap_or("-"),
),
Cell::from("-"),
])
.style(secondary_row_style()),
);
}
}
let table = Table::new(
rows,
[
Constraint::Length(2),
Constraint::Length(28),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(14),
Constraint::Length(10),
],
)
.header(
Row::new(vec![
"",
"Name",
"Security",
"Hidden",
"Auto Connect",
"Signal",
])
.style(Style::default().fg(Color::Yellow).bold())
.bottom_margin(1),
)
.block(section_block(&title, focused))
.row_highlight_style(if focused {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
});
frame.render_stateful_widget(table, area, &mut app.wifi_known_state);
}
fn render_new_networks(app: &mut App, frame: &mut Frame, area: Rect) {
if app.wifi_access_point_active() {
render_access_point_clients(app, frame, area);
return;
}
let focused = app.wifi_focus == WifiFocus::NewNetworks;
let title = if app.wifi_scanning_active() {
" New Networks (Scanning) ".to_string()
} else if app.wifi_connect_active() {
" New Networks (Connecting) ".to_string()
} else {
" New Networks ".to_string()
};
let mut rows: Vec<Row> = app
.wifi
.new_networks
.iter()
.map(|n| {
Row::new(vec![
Cell::from(n.ssid.clone()),
Cell::from(n.security.clone()),
Cell::from(n.signal.clone()),
])
})
.collect();
if app.show_hidden_networks {
for n in &app.wifi.hidden_networks {
rows.push(
Row::new(vec![
Cell::from(n.ssid.clone()),
Cell::from(n.security.clone()),
Cell::from(n.signal.clone()),
])
.style(secondary_row_style()),
);
}
}
if rows.is_empty() {
rows.push(Row::new(vec![
Cell::from("- no new networks -").style(secondary_text_style()),
Cell::from(""),
Cell::from(""),
]));
}
let table = Table::new(
rows,
[
Constraint::Length(34),
Constraint::Length(12),
Constraint::Length(10),
],
)
.header(
Row::new(vec!["Name", "Security", "Signal"])
.style(Style::default().fg(Color::Yellow).bold())
.bottom_margin(1),
)
.block(section_block(&title, focused))
.row_highlight_style(if focused {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
});
frame.render_stateful_widget(table, area, &mut app.wifi_new_state);
}
fn render_access_point_status(app: &mut App, frame: &mut Frame, area: Rect) {
let focused = app.wifi_focus == WifiFocus::KnownNetworks;
let title = if app.wifi_ap_pending {
" Access Point (Updating) "
} else {
" Access Point "
};
let rows = vec![Row::new(vec![
Cell::from(
app.wifi
.access_point_ssid
.clone()
.unwrap_or_else(|| "-".to_string()),
),
Cell::from(
app.wifi
.access_point_iface
.clone()
.or_else(|| app.wifi.device.as_ref().map(|device| device.iface.clone()))
.unwrap_or_else(|| "-".to_string()),
),
Cell::from(
app.wifi
.device
.as_ref()
.map(|device| device.state.clone())
.unwrap_or_else(|| "-".to_string()),
),
Cell::from(
app.wifi
.device
.as_ref()
.map(|device| device.frequency.clone())
.unwrap_or_else(|| "-".to_string()),
),
Cell::from(
app.wifi
.device
.as_ref()
.map(|device| device.security.clone())
.unwrap_or_else(|| "-".to_string()),
),
])];
let table = Table::new(
rows,
[
Constraint::Length(24),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(14),
Constraint::Length(14),
],
)
.header(
Row::new(vec!["SSID", "Interface", "State", "Frequency", "Security"])
.style(Style::default().fg(Color::Yellow).bold())
.bottom_margin(1),
)
.block(section_block(title, focused))
.row_highlight_style(if focused {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
});
frame.render_stateful_widget(table, area, &mut app.wifi_known_state);
}
fn render_access_point_clients(app: &mut App, frame: &mut Frame, area: Rect) {
let focused = app.wifi_focus == WifiFocus::NewNetworks;
let title = " Connected Devices ";
let mut rows: Vec<Row> = app
.wifi
.access_point_clients
.iter()
.map(|client| Row::new(vec![Cell::from(client.clone())]))
.collect();
if rows.is_empty() {
rows.push(Row::new(vec![
Cell::from("- no connected devices -").style(secondary_text_style()),
]));
}
let table = Table::new(rows, [Constraint::Min(20)])
.header(
Row::new(vec!["Address"])
.style(Style::default().fg(Color::Yellow).bold())
.bottom_margin(1),
)
.block(section_block(title, focused))
.row_highlight_style(if focused {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
});
frame.render_stateful_widget(table, area, &mut app.wifi_new_state);
}
fn render_device(app: &mut App, frame: &mut Frame, area: Rect) {
let focused = app.wifi_focus == WifiFocus::Adapter;
let dev = app.wifi.device.clone().unwrap_or_else(|| WifiDeviceInfo {
iface: app
.wifi
.ifaces
.first()
.cloned()
.unwrap_or_else(|| "-".to_string()),
mode: "station".to_string(),
powered: "-".to_string(),
state: "-".to_string(),
scanning: "-".to_string(),
frequency: "-".to_string(),
security: "-".to_string(),
});
let rows = vec![Row::new(vec![
Cell::from(dev.iface),
Cell::from(dev.mode),
Cell::from(dev.powered),
Cell::from(dev.state),
Cell::from(dev.scanning),
Cell::from(dev.frequency),
Cell::from(dev.security),
])];
let table = Table::new(
rows,
[
Constraint::Percentage(14),
Constraint::Percentage(11),
Constraint::Percentage(11),
Constraint::Percentage(14),
Constraint::Percentage(14),
Constraint::Percentage(16),
Constraint::Percentage(20),
],
)
.header(
Row::new(vec![
"Name",
"Mode",
"Powered",
"State",
"Scanning",
"Frequency",
"Security",
])
.style(Style::default().fg(Color::Yellow).bold())
.bottom_margin(1),
)
.column_spacing(1)
.block(section_block(" Device ", focused))
.row_highlight_style(if focused {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
});
frame.render_stateful_widget(table, area, &mut app.wifi_adapter_state);
}
fn section_block(title: &str, focused: bool) -> Block<'_> {
let border = if focused { Color::Green } else { Color::White };
let border_type = if focused {
BorderType::Thick
} else {
BorderType::Plain
};
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(border_type)
.border_style(Style::default().fg(border))
}
fn secondary_row_style() -> Style {
Style::default().fg(Color::Gray)
}
fn secondary_text_style() -> Style {
Style::default().fg(Color::Gray).bold()
}
fn render_hidden_connect_popup(app: &App, frame: &mut Frame) {
let area = centered_rect(58, 28, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Connect Hidden Network ")
.borders(Borders::ALL)
.border_type(BorderType::Thick)
.border_style(Style::default().fg(Color::Blue));
let inner = block.inner(area);
frame.render_widget(block, area);
let content = vec![
Line::from("Enter hidden SSID"),
Line::from(""),
Line::from(vec![
Span::from("SSID: ").bold(),
Span::from(app.hidden_ssid_input.clone()),
]),
Line::from(""),
Line::from(vec![
Span::from("Enter").bold(),
Span::from(" connect"),
Span::from(" | "),
Span::from("Esc").bold(),
Span::from(" cancel"),
]),
];
frame.render_widget(Paragraph::new(content), inner);
}
fn render_wifi_passphrase_popup(app: &App, frame: &mut Frame) {
let Some(ssid) = app.wifi_passphrase_prompt_ssid.clone() else {
return;
};
let area = centered_rect(62, 38, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Wi-Fi Passphrase ")
.borders(Borders::ALL)
.border_type(BorderType::Thick)
.border_style(Style::default().fg(Color::Blue));
let inner = block.inner(area);
frame.render_widget(block, area);
let passphrase = if app.wifi_passphrase_visible {
app.wifi_passphrase_input.clone()
} else {
"*".repeat(app.wifi_passphrase_input.chars().count())
};
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(1),
Constraint::Length(8),
Constraint::Fill(1),
])
.split(inner);
let content_area = outer[1];
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(1),
])
.split(content_area);
let label_area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(42),
Constraint::Fill(1),
])
.split(chunks[0])[1];
let field_area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(42),
Constraint::Fill(1),
])
.split(chunks[2])[1];
let content = vec![Line::from(vec![
Span::from("SSID: ").bold(),
Span::from(ssid).fg(Color::Cyan),
])];
frame.render_widget(Paragraph::new(content), label_area);
frame.render_widget(
Paragraph::new(Line::from("Passphrase:").style(Style::default().bold())),
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(42),
Constraint::Fill(1),
])
.split(chunks[1])[1],
);
let field_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.border_type(BorderType::Rounded);
let field_inner = field_block.inner(field_area);
frame.render_widget(field_block, field_area);
frame.render_widget(
Paragraph::new(Line::from(Span::from(passphrase))).alignment(Alignment::Center),
field_inner,
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::from("⇥").bold(),
Span::from(" show/hide"),
Span::from(" | "),
Span::from("↵").bold(),
Span::from(" connect"),
Span::from(" | "),
Span::from("Esc").bold(),
Span::from(" cancel"),
]))
.alignment(Alignment::Center),
chunks[3],
);
}
fn render_wifi_ap_popup(app: &App, frame: &mut Frame) {
let area = centered_rect(70, 54, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Start Access Point ")
.borders(Borders::ALL)
.border_type(BorderType::Thick)
.border_style(Style::default().fg(Color::Blue));
let inner = block.inner(area);
frame.render_widget(block, area);
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(1),
Constraint::Length(15),
Constraint::Fill(1),
])
.split(inner);
let content_area = outer[1];
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(4),
])
.split(content_area);
let ssid_area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(42),
Constraint::Fill(1),
])
.split(rows[1])[1];
let passphrase_area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(42),
Constraint::Fill(1),
])
.split(rows[3])[1];
frame.render_widget(
Paragraph::new(Line::from("SSID:").style(Style::default().bold())),
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(42),
Constraint::Fill(1),
])
.split(rows[0])[1],
);
frame.render_widget(
Paragraph::new(Line::from("Password:").style(Style::default().bold())),
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(42),
Constraint::Fill(1),
])
.split(rows[2])[1],
);
let ssid_block = Block::default()
.borders(Borders::ALL)
.border_style(
Style::default().fg(if app.wifi_ap_prompt_field == WifiApPromptField::Ssid {
Color::Cyan
} else {
Color::Gray
}),
)
.border_type(BorderType::Rounded);
let ssid_inner = ssid_block.inner(ssid_area);
frame.render_widget(ssid_block, ssid_area);
frame.render_widget(
Paragraph::new(Line::from(app.wifi_ap_ssid_input.as_str())),
ssid_inner,
);
let passphrase_value = if app.wifi_ap_passphrase_visible {
app.wifi_ap_passphrase_input.clone()
} else {
"*".repeat(app.wifi_ap_passphrase_input.chars().count())
};
let passphrase_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(
if app.wifi_ap_prompt_field == WifiApPromptField::Passphrase {
Color::Cyan
} else {
Color::Gray
},
))
.border_type(BorderType::Rounded);
let passphrase_inner = passphrase_block.inner(passphrase_area);
frame.render_widget(passphrase_block, passphrase_area);
frame.render_widget(
Paragraph::new(Line::from(passphrase_value)),
passphrase_inner,
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::from("↑/↓").bold(),
Span::from(" field"),
Span::from(" | "),
Span::from("⇥").bold(),
Span::from(" show/hide"),
Span::from(" | "),
Span::from("↵").bold(),
Span::from(" start AP"),
Span::from(" | "),
Span::from("Esc").bold(),
Span::from(" cancel"),
]))
.alignment(Alignment::Center),
rows[4],
);
frame.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from("Hotspot support depends on the Wi-Fi adapter and driver.")
.style(Style::default().fg(Color::Yellow)),
Line::from(
"Some adapters can scan and connect normally but still fail in access point mode.",
)
.style(Style::default().fg(Color::Gray)),
Line::from(
"For DHCP in AP mode, iwd should enable [General] EnableNetworkConfiguration=true.",
)
.style(Style::default().fg(Color::Gray)),
])
.alignment(Alignment::Left)
.wrap(ratatui::widgets::Wrap { trim: true }),
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(56),
Constraint::Fill(1),
])
.split(rows[5])[1],
);
}
fn render_wifi_share_popup(app: &App, frame: &mut Frame) {
let Some(share) = &app.wifi_share_popup else {
return;
};
let area = centered_rect(72, 82, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Share Wi-Fi ")
.borders(Borders::ALL)
.border_type(BorderType::Thick)
.border_style(Style::default().fg(Color::Blue));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(12),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.margin(1)
.split(inner);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::from("SSID: ").bold(),
Span::from(share.ssid.as_str()).fg(Color::Cyan),
]))
.alignment(Alignment::Center),
rows[0],
);
frame.render_widget(
Paragraph::new(share.qr_text.as_str()).alignment(Alignment::Center),
rows[1],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::from("Passphrase: ").bold(),
Span::from(share.passphrase.as_str())
.fg(Color::White)
.bg(Color::DarkGray),
]))
.alignment(Alignment::Center),
rows[2],
);
frame.render_widget(
Paragraph::new("Scan the QR code or type the password on another device.")
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Gray)),
rows[3],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::from("Esc").bold(),
Span::from(" close"),
]))
.alignment(Alignment::Center),
rows[4],
);
}
fn render_details_popup(app: &App, frame: &mut Frame) {
let area = centered_rect(78, 70, frame.area());
frame.render_widget(Clear, area);
let title = app
.wifi
.ifaces
.first()
.map(|i| format!(" Wi-Fi Details ({i}) "))
.unwrap_or_else(|| " Wi-Fi Details ".to_string());
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Thick)
.border_style(Style::default().fg(Color::Blue));
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = if let Some(details) = &app.wifi_iface_details {
let mut lines = vec![
Line::from(vec![
Span::from("Connected SSID: ").bold(),
Span::from(
app.wifi
.connected_ssid
.clone()
.unwrap_or_else(|| "Not connected".to_string()),
)
.fg(Color::Cyan),
]),
Line::from(""),
Line::from(vec![
Span::from("State: ").bold(),
Span::from(details.operstate.clone()),
]),
Line::from(vec![
Span::from("Carrier: ").bold(),
Span::from(
details
.carrier
.map(|c| if c { "1" } else { "0" })
.unwrap_or("?"),
),
]),
Line::from(vec![
Span::from("Speed: ").bold(),
Span::from(
details
.speed_mbps
.map(|s| format!("{s} Mb/s"))
.unwrap_or_else(|| "-".to_string()),
),
]),
Line::from(vec![
Span::from("MAC: ").bold(),
Span::from(details.mac.clone().unwrap_or_else(|| "-".to_string())),
]),
Line::from(vec![
Span::from("Gateway v4: ").bold(),
Span::from(
details
.gateway_v4
.clone()
.unwrap_or_else(|| "-".to_string()),
),
]),
Line::from(""),
Line::from(Span::from("IPv4").bold()),
];
if details.ipv4.is_empty() {
lines.push(Line::from(" -"));
} else {
for ip in &details.ipv4 {
lines.push(Line::from(format!(" {ip}")));
}
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::from("i").bold(),
Span::from(" close details"),
]));
lines
} else {
vec![
Line::from("No Wi-Fi interface details available."),
Line::from(""),
Line::from("Make sure a physical Wi-Fi adapter is present."),
Line::from(""),
Line::from(vec![Span::from("i").bold(), Span::from(" close details")]),
]
};
let paragraph = Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: true });
frame.render_widget(paragraph, inner);
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}