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, DStarMode, 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 kv_line(label: &str, value: String, value_color: Color) -> Line<'_> {
24 Line::from(vec![
25 Span::styled(
26 format!(" {label:<18}"),
27 Style::default().fg(Color::DarkGray),
28 ),
29 Span::styled(value, Style::default().fg(value_color)),
30 ])
31}
32
33fn fmt_callsign(cs: &str) -> String {
36 let trimmed = cs.trim();
37 if trimmed.is_empty() {
38 "<not set>".to_string()
39 } else {
40 trimmed.to_string()
41 }
42}
43
44pub(crate) fn render(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
45 match app.dstar_mode {
46 DStarMode::Active => render_gateway(app, frame, list_area, detail_area),
47 DStarMode::Inactive => render_cat_config(app, frame, list_area, detail_area),
48 }
49}
50
51fn render_cat_config(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
56 let block = Block::default()
57 .title(" D-STAR Configuration ")
58 .borders(Borders::ALL)
59 .border_style(super::border_style(app, Pane::Main));
60
61 let detail_block = Block::default()
62 .title(" Quick Actions ")
63 .borders(Borders::ALL)
64 .border_style(super::border_style(app, Pane::Detail));
65
66 let mut lines: Vec<Line<'_>> = Vec::new();
67
68 if let McpState::Loaded { ref image, .. } = app.mcp {
70 let my_cs = image.dstar().my_callsign();
71 let (disp, col) = if my_cs.is_empty() {
72 ("<not set>".into(), Color::DarkGray)
73 } else {
74 (my_cs, Color::Cyan)
75 };
76 lines.push(kv_line("MY Callsign", disp, col));
77 lines.push(Line::from(""));
78 }
79
80 let urcall_disp = fmt_callsign(&app.state.dstar_urcall);
82 let urcall_suffix = app.state.dstar_urcall_suffix.trim().to_string();
83 let urcall_full = if urcall_suffix.is_empty() {
84 urcall_disp.clone()
85 } else {
86 format!("{urcall_disp} {urcall_suffix}")
87 };
88 let urcall_color = if urcall_disp == "<not set>" {
89 Color::DarkGray
90 } else {
91 Color::Cyan
92 };
93 lines.push(kv_line("URCALL", urcall_full, urcall_color));
94
95 let rpt1_disp = fmt_callsign(&app.state.dstar_rpt1);
97 let rpt1_color = if rpt1_disp == "<not set>" {
98 Color::DarkGray
99 } else {
100 Color::White
101 };
102 lines.push(kv_line("RPT1", rpt1_disp, rpt1_color));
103
104 let rpt2_disp = fmt_callsign(&app.state.dstar_rpt2);
106 let rpt2_color = if rpt2_disp == "<not set>" {
107 Color::DarkGray
108 } else {
109 Color::White
110 };
111 lines.push(kv_line("RPT2", rpt2_disp, rpt2_color));
112
113 lines.push(Line::from(""));
114
115 let gw_str = app
117 .state
118 .dstar_gateway_mode
119 .map_or_else(|| "Unknown".to_string(), |g| format!("{g:?}"));
120 lines.push(kv_line("Gateway Mode", gw_str, Color::White));
121
122 let slot_str = app
124 .state
125 .dstar_slot
126 .map_or_else(|| "Unknown".to_string(), |s| format!("{}", s.as_u8()));
127 lines.push(kv_line("D-STAR Slot", slot_str, Color::White));
128
129 let cs_slot_str = app
131 .state
132 .dstar_callsign_slot
133 .map_or_else(|| "Unknown".to_string(), |s| format!("{}", s.as_u8()));
134 lines.push(kv_line("Callsign Slot", cs_slot_str, Color::White));
135
136 lines.push(Line::from(""));
138 if let Some(ref buf) = app.dstar_urcall_input {
139 lines.push(Line::from(vec![
140 Span::styled(" URCALL: ", Style::default().fg(Color::Yellow)),
141 Span::styled(format!("{buf}_"), Style::default().fg(Color::White)),
142 ]));
143 } else if let Some(ref buf) = app.dstar_reflector_input {
144 lines.push(Line::from(vec![
145 Span::styled(
146 " Reflector (e.g. REF030 C): ",
147 Style::default().fg(Color::Yellow),
148 ),
149 Span::styled(format!("{buf}_"), Style::default().fg(Color::White)),
150 ]));
151 } else {
152 lines.push(Line::from(Span::styled(
153 " [d] Enter Gateway Mode [u] Set URCALL",
154 Style::default().fg(Color::DarkGray),
155 )));
156 lines.push(Line::from(Span::styled(
157 " [r] Connect Reflector [U] Unlink Reflector",
158 Style::default().fg(Color::DarkGray),
159 )));
160 }
161
162 frame.render_widget(Paragraph::new(lines).block(block), list_area);
163
164 let mut detail_lines: Vec<Line<'_>> = Vec::new();
166
167 detail_lines.push(Line::from(Span::styled(
168 " D-STAR Quick Actions",
169 Style::default().fg(Color::Yellow),
170 )));
171 detail_lines.push(Line::from(""));
172
173 let actions = [
174 ("[C]", "CQ (set URCALL to CQCQCQ)"),
175 ("[r]", "Connect to reflector"),
176 ("[U]", "Unlink from reflector"),
177 ("[u]", "Set URCALL manually"),
178 ("[d]", "Enter gateway mode (MMDVM)"),
179 ];
180
181 for (key, desc) in actions {
182 detail_lines.push(Line::from(vec![
183 Span::styled(format!(" {key:<6}"), Style::default().fg(Color::Yellow)),
184 Span::styled(desc.to_string(), Style::default().fg(Color::White)),
185 ]));
186 }
187
188 if let McpState::Loaded { ref image, .. } = app.mcp {
190 let dstar = image.dstar();
191 let rpt_count = dstar.repeater_count();
192
193 detail_lines.push(Line::from(""));
194 detail_lines.push(Line::from(Span::styled(
195 " MCP D-STAR Data",
196 Style::default().fg(Color::Yellow),
197 )));
198 detail_lines.push(Line::from(""));
199 detail_lines.push(kv_line("Repeaters", format!("{rpt_count}"), Color::White));
200
201 let region_sz = dstar.region_size();
202 detail_lines.push(kv_line(
203 "Region Size",
204 format!("{region_sz} bytes"),
205 Color::White,
206 ));
207 }
208
209 frame.render_widget(
210 Paragraph::new(detail_lines).block(detail_block),
211 detail_area,
212 );
213}
214
215fn render_gateway(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
220 let count = app.dstar_last_heard.len();
222 let title = format!(" D-STAR Gateway ({count} heard) ");
223
224 let block = Block::default()
225 .title(title)
226 .borders(Borders::ALL)
227 .border_style(super::border_style(app, Pane::Main));
228
229 let mut lines: Vec<Line<'_>> = Vec::new();
230
231 if app.dstar_last_heard.is_empty() {
232 lines.push(Line::from(Span::styled(
233 " Listening...",
234 Style::default().fg(Color::DarkGray),
235 )));
236 } else {
237 let visible_height = list_area.height.saturating_sub(4) as usize;
238 let start = if app.dstar_last_heard_index >= visible_height {
239 app.dstar_last_heard_index - visible_height + 1
240 } else {
241 0
242 };
243 let end = (start + visible_height).min(count);
244
245 for (i, entry) in app.dstar_last_heard[start..end].iter().enumerate() {
246 let idx = start + i;
247 let is_selected = idx == app.dstar_last_heard_index;
248
249 let callsign = format!("{:<9}", entry.callsign.trim());
250 let dest = format!("{:<9}", entry.destination.trim());
251 let time = ago(entry.timestamp);
252
253 let style = if is_selected {
254 Style::default()
255 .fg(Color::Cyan)
256 .add_modifier(Modifier::BOLD)
257 } else {
258 Style::default().fg(Color::White)
259 };
260
261 let marker = if is_selected { ">" } else { " " };
262 lines.push(Line::from(Span::styled(
263 format!(" {marker} {callsign} -> {dest} {time}"),
264 style,
265 )));
266 }
267 }
268
269 lines.push(Line::from(""));
270 lines.push(Line::from(Span::styled(
271 " [j/k] Navigate [d] Exit Gateway Mode",
272 Style::default().fg(Color::DarkGray),
273 )));
274
275 frame.render_widget(Paragraph::new(lines).block(block), list_area);
276
277 let detail_block = Block::default()
279 .title(" Current Transmission ")
280 .borders(Borders::ALL)
281 .border_style(super::border_style(app, Pane::Detail));
282
283 let mut detail_lines: Vec<Line<'_>> = Vec::new();
284
285 if let Some(ref header) = app.dstar_rx_header {
286 let status_str = if app.dstar_rx_active {
287 "Receiving voice..."
288 } else {
289 "Idle"
290 };
291 let status_color = if app.dstar_rx_active {
292 Color::Green
293 } else {
294 Color::DarkGray
295 };
296
297 detail_lines.push(kv_line(
298 "From",
299 format!("{} {}", header.my_call.as_str(), header.my_suffix.as_str()),
300 Color::Cyan,
301 ));
302 detail_lines.push(kv_line(
303 "To",
304 header.ur_call.as_str().into_owned(),
305 Color::White,
306 ));
307 detail_lines.push(kv_line(
308 "RPT1",
309 header.rpt1.as_str().into_owned(),
310 Color::White,
311 ));
312 detail_lines.push(kv_line(
313 "RPT2",
314 header.rpt2.as_str().into_owned(),
315 Color::White,
316 ));
317 detail_lines.push(kv_line("Status", status_str.to_string(), status_color));
318
319 if let Some(ref text) = app.dstar_text_message {
320 detail_lines.push(Line::from(""));
321 detail_lines.push(kv_line("Text Message", text.clone(), Color::Yellow));
322 }
323 } else if let Some(entry) = app.dstar_last_heard.get(app.dstar_last_heard_index) {
324 detail_lines.push(Line::from(Span::styled(
326 format!(" {}", entry.callsign.trim()),
327 Style::default()
328 .fg(Color::Cyan)
329 .add_modifier(Modifier::BOLD),
330 )));
331 detail_lines.push(Line::from(""));
332
333 let suffix = entry.suffix.trim();
334 if !suffix.is_empty() {
335 detail_lines.push(kv_line("Suffix", suffix.to_string(), Color::White));
336 }
337 detail_lines.push(kv_line(
338 "Destination",
339 entry.destination.trim().to_string(),
340 Color::White,
341 ));
342 detail_lines.push(kv_line(
343 "RPT1",
344 entry.repeater1.trim().to_string(),
345 Color::White,
346 ));
347 detail_lines.push(kv_line(
348 "RPT2",
349 entry.repeater2.trim().to_string(),
350 Color::White,
351 ));
352 detail_lines.push(kv_line("Last heard", ago(entry.timestamp), Color::White));
353 } else {
354 detail_lines.push(Line::from(Span::styled(
355 " No transmission yet",
356 Style::default().fg(Color::DarkGray),
357 )));
358 }
359
360 frame.render_widget(
361 Paragraph::new(detail_lines).block(detail_block),
362 detail_area,
363 );
364}