1use std::time::Instant;
2
3use ratatui::Frame;
4use ratatui::layout::Rect;
5use ratatui::style::{Color, Modifier, Style};
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Block, Borders, Paragraph};
8
9use crate::app::{App, AprsMessageState, AprsMode, McpState, Pane};
10
11fn ago(then: Instant) -> String {
13 let secs = then.elapsed().as_secs();
14 if secs < 60 {
15 format!("{secs}s ago")
16 } else if secs < 3600 {
17 format!("{}m ago", secs / 60)
18 } else {
19 format!("{}h ago", secs / 3600)
20 }
21}
22
23fn fmt_lat(lat: f64) -> String {
25 let ns = if lat >= 0.0 { 'N' } else { 'S' };
26 format!("{:.3}\u{00b0}{ns}", lat.abs())
27}
28
29fn fmt_lon(lon: f64) -> String {
30 let ew = if lon >= 0.0 { 'E' } else { 'W' };
31 format!("{:.3}\u{00b0}{ew}", lon.abs())
32}
33
34fn kv_line(label: &str, value: String, value_color: Color) -> Line<'_> {
35 Line::from(vec![
36 Span::styled(
37 format!(" {label:<22}"),
38 Style::default().fg(Color::DarkGray),
39 ),
40 Span::styled(value, Style::default().fg(value_color)),
41 ])
42}
43
44pub(crate) fn render(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
45 match app.aprs_mode {
46 AprsMode::Active => render_live(app, frame, list_area, detail_area),
47 AprsMode::Inactive => render_mcp_config(app, frame, list_area, detail_area),
48 }
49}
50
51fn render_live(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
56 let station_count = app.aprs_stations.len();
58 let title = format!(" APRS Stations ({station_count} heard) ");
59
60 let block = Block::default()
61 .title(title)
62 .borders(Borders::ALL)
63 .border_style(super::border_style(app, Pane::Main));
64
65 let mut lines: Vec<Line<'_>> = Vec::new();
66
67 if app.aprs_stations.is_empty() {
68 lines.push(Line::from(Span::styled(
69 " Listening...",
70 Style::default().fg(Color::DarkGray),
71 )));
72 } else {
73 let visible_height = list_area.height.saturating_sub(2) as usize; let start = if app.aprs_station_index >= visible_height {
76 app.aprs_station_index - visible_height + 1
77 } else {
78 0
79 };
80 let end = (start + visible_height).min(station_count);
81
82 for (i, station) in app.aprs_stations[start..end].iter().enumerate() {
83 let idx = start + i;
84 let is_selected = idx == app.aprs_station_index;
85
86 let callsign = format!("{:<10}", station.callsign);
87 let pos = match (station.latitude, station.longitude) {
88 (Some(lat), Some(lon)) => format!("{} {}", fmt_lat(lat), fmt_lon(lon)),
89 _ => "No position".to_string(),
90 };
91 let time = ago(station.last_heard);
92
93 let style = if is_selected {
94 Style::default()
95 .fg(Color::Cyan)
96 .add_modifier(Modifier::BOLD)
97 } else {
98 Style::default().fg(Color::White)
99 };
100
101 let marker = if is_selected { ">" } else { " " };
102 lines.push(Line::from(Span::styled(
103 format!(" {marker} {callsign} {pos:<26} {time}"),
104 style,
105 )));
106 }
107 }
108
109 if let Some(ref buf) = app.aprs_compose {
111 lines.push(Line::from(""));
112 let target = app
113 .aprs_stations
114 .get(app.aprs_station_index)
115 .map_or("?", |s| s.callsign.as_str());
116 lines.push(Line::from(vec![
117 Span::styled(
118 format!(" Msg to {target}: "),
119 Style::default().fg(Color::Yellow),
120 ),
121 Span::styled(format!("{buf}_"), Style::default().fg(Color::White)),
122 ]));
123 }
124
125 lines.push(Line::from(""));
127 lines.push(Line::from(Span::styled(
128 " [j/k] Navigate [M] Message [b] Beacon [a] Stop APRS",
129 Style::default().fg(Color::DarkGray),
130 )));
131
132 frame.render_widget(Paragraph::new(lines).block(block), list_area);
133
134 let detail_block = Block::default()
136 .title(" Station Detail ")
137 .borders(Borders::ALL)
138 .border_style(super::border_style(app, Pane::Detail));
139
140 let mut detail_lines: Vec<Line<'_>> = Vec::new();
141
142 if let Some(station) = app.aprs_stations.get(app.aprs_station_index) {
143 detail_lines.push(Line::from(Span::styled(
144 format!(" {}", station.callsign),
145 Style::default()
146 .fg(Color::Cyan)
147 .add_modifier(Modifier::BOLD),
148 )));
149 detail_lines.push(Line::from(""));
150
151 match (station.latitude, station.longitude) {
153 (Some(lat), Some(lon)) => {
154 detail_lines.push(kv_line(
155 "Position",
156 format!("{} {}", fmt_lat(lat), fmt_lon(lon)),
157 Color::White,
158 ));
159 }
160 _ => {
161 detail_lines.push(kv_line("Position", "Unknown".into(), Color::DarkGray));
162 }
163 }
164
165 if let Some(speed) = station.speed_knots {
167 let mph = f64::from(speed) * 1.15078;
168 let course_str = station
169 .course_degrees
170 .map_or(String::new(), |c| format!(" heading {c}\u{00b0}"));
171 detail_lines.push(kv_line(
172 "Speed",
173 format!("{mph:.0} mph{course_str}"),
174 Color::White,
175 ));
176 }
177
178 detail_lines.push(kv_line("Last heard", ago(station.last_heard), Color::White));
180
181 detail_lines.push(kv_line(
183 "Packets",
184 station.packet_count.to_string(),
185 Color::White,
186 ));
187
188 if !station.last_path.is_empty() {
190 detail_lines.push(kv_line("Path", station.last_path.join(","), Color::White));
191 }
192
193 if let (Some(tbl), Some(code)) = (station.symbol_table, station.symbol_code) {
195 detail_lines.push(kv_line("Symbol", format!("{tbl}{code}"), Color::White));
196 }
197
198 if let Some(ref comment) = station.comment {
200 detail_lines.push(kv_line("Comment", comment.clone(), Color::White));
201 }
202
203 let pending = app
205 .aprs_messages
206 .iter()
207 .filter(|m| m.state == AprsMessageState::Pending)
208 .count();
209 let delivered = app
210 .aprs_messages
211 .iter()
212 .filter(|m| m.state == AprsMessageState::Delivered)
213 .count();
214
215 if !app.aprs_messages.is_empty() {
216 detail_lines.push(Line::from(""));
217 detail_lines.push(Line::from(Span::styled(
218 format!(" Messages ({pending} pending, {delivered} delivered):"),
219 Style::default().fg(Color::Yellow),
220 )));
221
222 for msg in app.aprs_messages.iter().rev().take(10) {
224 let (status_str, color) = match msg.state {
225 AprsMessageState::Pending => ("...", Color::Yellow),
226 AprsMessageState::Delivered => ("ack", Color::Green),
227 AprsMessageState::Rejected => ("rej", Color::Red),
228 AprsMessageState::Expired => ("exp", Color::Red),
229 };
230 detail_lines.push(Line::from(vec![
231 Span::styled(
232 format!(" -> {}: ", msg.addressee),
233 Style::default().fg(Color::DarkGray),
234 ),
235 Span::styled(msg.text.clone(), Style::default().fg(Color::White)),
236 Span::styled(format!(" [{status_str}]"), Style::default().fg(color)),
237 ]));
238 }
239 }
240 } else {
241 detail_lines.push(Line::from(Span::styled(
242 " No station selected",
243 Style::default().fg(Color::DarkGray),
244 )));
245 }
246
247 frame.render_widget(
248 Paragraph::new(detail_lines).block(detail_block),
249 detail_area,
250 );
251}
252
253fn render_mcp_config(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
258 let block = Block::default()
259 .title(" APRS ")
260 .borders(Borders::ALL)
261 .border_style(super::border_style(app, Pane::Main));
262
263 let detail_block = Block::default()
264 .title(" APRS Region Info ")
265 .borders(Borders::ALL)
266 .border_style(super::border_style(app, Pane::Detail));
267
268 let McpState::Loaded { ref image, .. } = app.mcp else {
269 frame.render_widget(
270 Paragraph::new(vec![
271 Line::from(" No MCP data loaded."),
272 Line::from(" Press [m] then [r] to read from radio."),
273 Line::from(""),
274 Line::from(Span::styled(
275 " Press [a] to enter live APRS mode.",
276 Style::default().fg(Color::Cyan),
277 )),
278 ])
279 .block(block),
280 list_area,
281 );
282 frame.render_widget(Paragraph::new("").block(detail_block), detail_area);
283 return;
284 };
285
286 let aprs = image.aprs();
287
288 let mut lines: Vec<Line<'_>> = Vec::new();
292
293 lines.push(Line::from(Span::styled(
294 " APRS Configuration",
295 Style::default().fg(Color::Yellow),
296 )));
297 lines.push(Line::from(""));
298
299 {
300 let cs = aprs.my_callsign();
301 let (disp, col) = if cs.is_empty() {
302 ("<not set>".into(), Color::DarkGray)
303 } else {
304 (cs, Color::Cyan)
305 };
306 lines.push(kv_line("My callsign", disp, col));
307 }
308
309 {
310 let interval = aprs.beacon_interval();
311 let (disp, col) = if interval == 0 {
312 ("Off".into(), Color::DarkGray)
313 } else {
314 (format!("{interval} s"), Color::White)
315 };
316 lines.push(kv_line("Beacon interval", disp, col));
317 }
318
319 lines.push(kv_line("Packet path", aprs.packet_path(), Color::White));
320
321 lines.push(Line::from(""));
322 lines.push(Line::from(Span::styled(
323 " Press [a] to enter live APRS mode.",
324 Style::default().fg(Color::Cyan),
325 )));
326
327 frame.render_widget(Paragraph::new(lines).block(block), list_area);
328
329 let mut detail_lines: Vec<Line<'_>> = Vec::new();
333
334 detail_lines.push(Line::from(Span::styled(
335 " APRS Memory Regions",
336 Style::default().fg(Color::Yellow),
337 )));
338 detail_lines.push(Line::from(""));
339
340 let region_info = [
341 ("Status header", "0x15100", "256 bytes"),
342 ("Data / settings", "0x15200", "~16 KB"),
343 ("Position data", "0x25100", "19,200 bytes (confirmed)"),
344 ];
345
346 for (name, offset, size) in region_info {
347 detail_lines.push(Line::from(vec![
348 Span::styled(
349 format!(" {name:<18}"),
350 Style::default().fg(Color::DarkGray),
351 ),
352 Span::styled(
353 format!("{offset} {size}"),
354 Style::default().fg(Color::White),
355 ),
356 ]));
357 }
358
359 detail_lines.push(Line::from(""));
360
361 let has_pos = aprs.has_position_data();
362 let (pos_str, pos_col) = if has_pos {
363 ("Yes", Color::Cyan)
364 } else {
365 ("No", Color::DarkGray)
366 };
367 detail_lines.push(Line::from(vec![
368 Span::styled(
369 " Position data present ",
370 Style::default().fg(Color::DarkGray),
371 ),
372 Span::styled(pos_str, Style::default().fg(pos_col)),
373 ]));
374
375 let region_sz = aprs.region_size();
376 detail_lines.push(Line::from(vec![
377 Span::styled(
378 " Total region size ",
379 Style::default().fg(Color::DarkGray),
380 ),
381 Span::styled(
382 format!("{region_sz} bytes"),
383 Style::default().fg(Color::White),
384 ),
385 ]));
386
387 frame.render_widget(
388 Paragraph::new(detail_lines).block(detail_block),
389 detail_area,
390 );
391}