use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
use crate::tui::widgets::qr_popup::{centered_rect, render_qr_popup, render_text_popup};
pub const LOG_LEVELS: &[(&str, u8)] = &[
("ERROR", 1),
("WARN", 2),
("INFO", 3),
("DEBUG", 4),
("TRACE", 5),
];
#[derive(Debug, Clone, PartialEq)]
pub enum PushPopupStep {
Menu { selected: usize, active: usize },
AptoveForm { fields: [String; 2], field_idx: usize, error: Option<String> },
SelfManagedForm { fields: [String; 4], field_idx: usize, error: Option<String> },
}
#[derive(Debug, Clone, PartialEq)]
pub enum PopupKind {
QrCode,
Help,
LogLevel { selected: usize },
PushConfig { step: PushPopupStep },
}
const HELP_TEXT: &str = "\
/qr Show QR pairing code
/test-push Send a test push notification
/push Configure push notifications
/reconnect Restart the transport
/keep-alive Toggle prevent-sleep (on by default)
/log-level Change log verbosity
/clear-logs Clear the log view
/copy-logs Copy all logs to clipboard
/agent Change the AI agent
/help Show this help
/quit Exit the bridge
";
pub fn render_popup(
frame: &mut Frame,
kind: &PopupKind,
qr_string: &Option<String>,
) {
match kind {
PopupKind::QrCode => {
let qr = qr_string.as_deref().unwrap_or("No QR code available yet.\nStart the bridge first.");
render_qr_popup(frame, frame.area(), "Pairing QR Code (Esc to close)", qr);
}
PopupKind::Help => {
render_text_popup(frame, frame.area(), "Commands (Esc to close)", HELP_TEXT);
}
PopupKind::LogLevel { selected } => {
render_log_level_popup(frame, *selected);
}
PopupKind::PushConfig { step } => {
render_push_popup(frame, step);
}
}
}
const PUSH_DOC_URL: &str = "https://doc.aptove.com/bridge/push";
const PUSH_REGISTER_URL: &str = "https://dash.aptove.com";
const PUSH_MENU_LABELS: &[&str] = &[
"No Push (buffer messages, no notifications)",
"Aptove Push Service",
"Self Managed Push Service",
];
fn render_push_popup(frame: &mut Frame, step: &PushPopupStep) {
match step {
PushPopupStep::Menu { selected, active } => render_push_menu(frame, *selected, *active),
PushPopupStep::AptoveForm { fields, field_idx, error } => {
render_aptove_form(frame, fields, *field_idx, error.as_deref());
}
PushPopupStep::SelfManagedForm { fields, field_idx, error } => {
render_self_managed_form(frame, fields, *field_idx, error.as_deref());
}
}
}
fn render_push_menu(frame: &mut Frame, selected: usize, active: usize) {
let area = centered_rect(66, 55, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Push Notifications ")
.borders(Borders::ALL)
.style(Style::default().bg(Color::Black).fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
let items: Vec<ListItem> = PUSH_MENU_LABELS.iter().enumerate().map(|(i, &label)| {
let prefix = if i == selected { "> " } else { " " };
let suffix = if i == active { " [active]" } else { "" };
let style = if i == selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
ListItem::new(format!("{}{}{}", prefix, label, suffix)).style(style)
}).collect();
let list_area = Rect { y: inner.y + 1, height: 3, ..inner };
frame.render_widget(List::new(items), list_area);
let hint_y = inner.y + inner.height.saturating_sub(2);
frame.render_widget(
Paragraph::new("↑/↓ navigate Enter select Esc cancel")
.style(Style::default().fg(Color::DarkGray)),
Rect { y: hint_y, height: 1, ..inner },
);
let footer_y = inner.y + inner.height.saturating_sub(1);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("Docs: ", Style::default().fg(Color::DarkGray)),
Span::styled(PUSH_DOC_URL, Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED)),
])),
Rect { y: footer_y, height: 1, ..inner },
);
}
fn render_aptove_form(frame: &mut Frame, fields: &[String; 2], field_idx: usize, error: Option<&str>) {
let area = centered_rect(68, 62, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Aptove Push Service ")
.borders(Borders::ALL)
.style(Style::default().bg(Color::Black).fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("Register at: ", Style::default().fg(Color::DarkGray)),
Span::styled(PUSH_REGISTER_URL, Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED)),
])),
Rect { y: inner.y + 1, height: 1, ..inner },
);
let labels = ["Client ID:", "Client Secret:"];
for (i, &label) in labels.iter().enumerate() {
let y = inner.y + 3 + (i as u16) * 2;
push_field(frame, y, inner.x, inner.width, label, &fields[i], i == field_idx);
}
push_footer(frame, inner, error, "Tab next field Enter confirm Esc back");
}
fn render_self_managed_form(frame: &mut Frame, fields: &[String; 4], field_idx: usize, error: Option<&str>) {
let area = centered_rect(72, 72, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Self Managed Push Service ")
.borders(Borders::ALL)
.style(Style::default().bg(Color::Black).fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
let labels = ["Push Service URL:", "Token Service URL:", "Client ID:", "Client Secret:"];
for (i, &label) in labels.iter().enumerate() {
let y = inner.y + 1 + (i as u16) * 2;
push_field(frame, y, inner.x, inner.width, label, &fields[i], i == field_idx);
}
push_footer(frame, inner, error, "Tab next field Enter confirm Esc back");
}
fn push_field(frame: &mut Frame, y: u16, x: u16, width: u16, label: &str, value: &str, active: bool) {
let label_w = 20u16.min(width / 2);
let label_style = if active {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
frame.render_widget(
Paragraph::new(label).style(label_style),
Rect { x, y, width: label_w, height: 1 },
);
let cursor = if active { "█" } else { "" };
let val_style = if active {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::DarkGray)
};
frame.render_widget(
Paragraph::new(format!("[{}{}]", value, cursor)).style(val_style),
Rect { x: x + label_w, y, width: width.saturating_sub(label_w), height: 1 },
);
}
fn push_footer(frame: &mut Frame, inner: Rect, error: Option<&str>, hint: &str) {
if let Some(err) = error {
let y = inner.y + inner.height.saturating_sub(3);
frame.render_widget(
Paragraph::new(format!("Error: {}", err)).style(Style::default().fg(Color::Red)),
Rect { y, height: 1, ..inner },
);
}
let hint_y = inner.y + inner.height.saturating_sub(2);
frame.render_widget(
Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
Rect { y: hint_y, height: 1, ..inner },
);
let footer_y = inner.y + inner.height.saturating_sub(1);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("Docs: ", Style::default().fg(Color::DarkGray)),
Span::styled(PUSH_DOC_URL, Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED)),
])),
Rect { y: footer_y, height: 1, ..inner },
);
}
pub fn url_at(kind: &PopupKind, col: u16, row: u16, term: Rect) -> Option<&'static str> {
let on_row = |inner: Rect, y: u16| row == y && col >= inner.x && col < inner.x + inner.width;
let border_inner = |area: Rect| -> Rect {
Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
}
};
if let PopupKind::PushConfig { step } = kind {
match step {
PushPopupStep::Menu { .. } => {
let inner = border_inner(centered_rect(66, 55, term));
if on_row(inner, inner.y + inner.height.saturating_sub(1)) {
return Some(PUSH_DOC_URL);
}
}
PushPopupStep::AptoveForm { .. } => {
let inner = border_inner(centered_rect(68, 62, term));
if on_row(inner, inner.y + 1) {
return Some(PUSH_REGISTER_URL);
}
if on_row(inner, inner.y + inner.height.saturating_sub(1)) {
return Some(PUSH_DOC_URL);
}
}
PushPopupStep::SelfManagedForm { .. } => {
let inner = border_inner(centered_rect(72, 72, term));
if on_row(inner, inner.y + inner.height.saturating_sub(1)) {
return Some(PUSH_DOC_URL);
}
}
}
}
None
}
fn render_log_level_popup(frame: &mut Frame, selected: usize) {
let area = centered_rect(40, 50, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Log Level (↑/↓ select Enter confirm Esc cancel) ")
.borders(Borders::ALL)
.style(Style::default().bg(Color::Black).fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
let items: Vec<ListItem> = LOG_LEVELS.iter().enumerate().map(|(i, (name, _))| {
let label = format!(" {} ", name);
let style = if i == selected {
Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
ListItem::new(label).style(style)
}).collect();
let list_area = Rect {
y: inner.y + inner.height.saturating_sub(LOG_LEVELS.len() as u16) / 2,
height: LOG_LEVELS.len() as u16,
..inner
};
frame.render_widget(List::new(items), list_area);
}