Files
RedBear-OS/local/recipes/system/redbear-netctl-console/source/src/ui.rs
T
2026-04-16 12:44:35 +01:00

347 lines
10 KiB
Rust

use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
use crate::app::{App, Field, Focus};
use crate::backend::{ConsoleBackend, SecurityKind};
struct Palette {
title: Color,
accent: Color,
selected: Color,
success: Color,
danger: Color,
muted: Color,
}
const PALETTE: Palette = Palette {
title: Color::Cyan,
accent: Color::Yellow,
selected: Color::LightYellow,
success: Color::Green,
danger: Color::Red,
muted: Color::DarkGray,
};
pub fn render<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(18),
Constraint::Length(5),
])
.split(frame.area());
render_header(frame, app, layout[0]);
render_body(frame, app, layout[1]);
render_footer(frame, app, layout[2]);
if app.editor.is_some() {
render_editor(frame, app);
}
}
fn render_header<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, area: Rect) {
let mut status_spans = vec![Span::styled(
" Red Bear Wi-Fi Console ",
Style::default()
.fg(PALETTE.title)
.add_modifier(Modifier::BOLD),
)];
if let Some(active) = &app.active_profile {
status_spans.push(Span::raw(" "));
status_spans.push(Span::styled(
format!("active={active}"),
Style::default().fg(PALETTE.success),
));
}
status_spans.push(Span::raw(" "));
status_spans.push(Span::styled(
format!("interface={}", app.status.interface),
Style::default().fg(PALETTE.accent),
));
if app.dirty {
status_spans.push(Span::raw(" "));
status_spans.push(Span::styled(
"unsaved changes",
Style::default().fg(PALETTE.danger),
));
}
let paragraph = Paragraph::new(vec![Line::from(status_spans)])
.block(Block::default().borders(Borders::ALL));
frame.render_widget(paragraph, area);
}
fn render_body<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, area: Rect) {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(28),
Constraint::Length(34),
Constraint::Min(34),
])
.split(area);
render_profiles(frame, app, columns[0]);
let middle = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(12), Constraint::Min(6)])
.split(columns[1]);
render_status(frame, app, middle[0]);
render_scan(frame, app, middle[1]);
render_editor_fields(frame, app, columns[2]);
}
fn render_profiles<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, area: Rect) {
let items = if app.profiles.is_empty() {
vec![ListItem::new("No Wi-Fi profiles yet")]
} else {
app.profiles
.iter()
.enumerate()
.map(|(index, name)| {
let mut line = name.clone();
if app.active_profile.as_deref() == Some(name.as_str()) {
line = format!("* {line}");
}
let style = if index == app.selected_profile {
selected_style(app.focus == Focus::Profiles)
} else {
Style::default()
};
ListItem::new(line).style(style)
})
.collect::<Vec<_>>()
};
frame.render_widget(
List::new(items).block(
Block::default()
.title("Profiles")
.borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Profiles)),
),
area,
);
}
fn render_status<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, area: Rect) {
let lines = vec![
kv_line("Address", &app.status.address),
kv_line("Status", &app.status.status),
kv_line("Link", &app.status.link_state),
kv_line("Firmware", &app.status.firmware_status),
kv_line("Transport", &app.status.transport_status),
kv_line("Init", &app.status.transport_init_status),
kv_line("Activation", &app.status.activation_status),
kv_line("Connect", &app.status.connect_result),
kv_line("Disconnect", &app.status.disconnect_result),
kv_line("Last error", &app.status.last_error),
];
frame.render_widget(
Paragraph::new(lines)
.block(Block::default().title("Live Status").borders(Borders::ALL))
.wrap(Wrap { trim: false }),
area,
);
}
fn render_scan<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, area: Rect) {
let items = if app.scans.is_empty() {
vec![ListItem::new("Press r to scan the selected interface")]
} else {
app.scans
.iter()
.enumerate()
.map(|(index, scan)| {
let style = if index == app.selected_scan {
selected_style(app.focus == Focus::Scan)
} else {
Style::default()
};
ListItem::new(scan.label()).style(style)
})
.collect::<Vec<_>>()
};
frame.render_widget(
List::new(items).block(
Block::default()
.title("Scan Results")
.borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Scan)),
),
area,
);
}
fn render_editor_fields<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, area: Rect) {
let selected = app.selected_field();
let rows = app
.visible_fields()
.into_iter()
.map(|field| render_field_line(app, field, field == selected))
.collect::<Vec<_>>();
frame.render_widget(
Paragraph::new(rows)
.block(
Block::default()
.title("Profile Draft")
.borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Fields)),
)
.wrap(Wrap { trim: false }),
area,
);
}
fn render_footer<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, area: Rect) {
let message_style = if app.message.starts_with("Error:") {
Style::default().fg(PALETTE.danger)
} else {
Style::default()
};
let security_note = match app.draft.security {
SecurityKind::Open => "open network selected",
SecurityKind::Wpa2Psk => "wpa2-psk key required",
};
let lines = vec![
Line::from(vec![
Span::styled("Message: ", Style::default().fg(PALETTE.accent)),
Span::styled(app.message.clone(), message_style),
]),
Line::from(vec![
Span::styled("Keys: ", Style::default().fg(PALETTE.accent)),
Span::raw(
"Tab focus Enter load/apply/edit r scan s save a activate c connect d disconnect n new q quit",
),
]),
Line::from(vec![
Span::styled("Hints: ", Style::default().fg(PALETTE.accent)),
Span::styled(security_note, Style::default().fg(PALETTE.muted)),
Span::raw(""),
Span::styled(
"connect uses /scheme/wifictl plus /etc/netctl persistence",
Style::default().fg(PALETTE.muted),
),
]),
];
frame.render_widget(
Paragraph::new(lines)
.block(Block::default().title("Console Flow").borders(Borders::ALL))
.wrap(Wrap { trim: false }),
area,
);
}
fn render_editor<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>) {
let Some(editor) = &app.editor else {
return;
};
let area = centered_rect(frame.area(), 72, 22);
let lines = vec![
Line::from(vec![Span::styled(
format!("Editing {}", editor.field.label()),
Style::default()
.fg(PALETTE.title)
.add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(editor.buffer.clone()),
Line::from(""),
Line::from(vec![Span::styled(
"Enter saves • Esc cancels • Backspace deletes",
Style::default().fg(PALETTE.muted),
)]),
];
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(lines)
.block(Block::default().title("Field Editor").borders(Borders::ALL))
.wrap(Wrap { trim: false }),
area,
);
}
fn render_field_line<B: ConsoleBackend>(
app: &App<B>,
field: Field,
selected: bool,
) -> Line<'static> {
let label_style = if selected && app.focus == Focus::Fields {
Style::default()
.fg(PALETTE.selected)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(PALETTE.accent)
};
let marker = if selected { ">" } else { " " };
Line::from(vec![
Span::styled(format!("{marker} {:<12}", field.label()), label_style),
Span::raw(app.field_value(field)),
])
}
fn kv_line(label: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label:<10} "), Style::default().fg(PALETTE.accent)),
Span::raw(value.to_string()),
])
}
fn border_style(active: bool) -> Style {
if active {
Style::default().fg(PALETTE.selected)
} else {
Style::default()
}
}
fn selected_style(active: bool) -> Style {
if active {
Style::default()
.fg(Color::Black)
.bg(PALETTE.selected)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(PALETTE.selected)
}
}
fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - height_percent) / 2),
Constraint::Percentage(height_percent),
Constraint::Percentage((100 - height_percent) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_percent) / 2),
Constraint::Percentage(width_percent),
Constraint::Percentage((100 - width_percent) / 2),
])
.split(vertical[1])[1]
}