1#![forbid(unsafe_code)]
2
3use crate::borders::{BorderSet, BorderType};
29use crate::{Widget, apply_style, clear_text_area, draw_text_span};
30use ftui_core::geometry::Rect;
31use ftui_render::cell::{Cell, PackedRgba};
32use ftui_render::frame::Frame;
33use ftui_runtime::transparency::{Disclosure, DisclosureLevel, EvidenceDirection, TrafficLight};
34use ftui_style::Style;
35
36const GREEN_FG: PackedRgba = PackedRgba::rgb(0, 200, 0);
38const GREEN_BG: PackedRgba = PackedRgba::rgb(0, 60, 0);
39const YELLOW_FG: PackedRgba = PackedRgba::rgb(220, 200, 0);
40const YELLOW_BG: PackedRgba = PackedRgba::rgb(60, 50, 0);
41const RED_FG: PackedRgba = PackedRgba::rgb(220, 50, 50);
42const RED_BG: PackedRgba = PackedRgba::rgb(60, 10, 10);
43
44const EVIDENCE_SUPPORTING_FG: PackedRgba = PackedRgba::rgb(100, 200, 100);
45const EVIDENCE_OPPOSING_FG: PackedRgba = PackedRgba::rgb(200, 100, 100);
46const EVIDENCE_NEUTRAL_FG: PackedRgba = PackedRgba::rgb(160, 160, 160);
47const DETAIL_FG: PackedRgba = PackedRgba::rgb(140, 160, 180);
48const DIM_FG: PackedRgba = PackedRgba::rgb(120, 120, 120);
49
50#[derive(Debug, Clone)]
56pub struct DecisionCard<'a> {
57 disclosure: &'a Disclosure,
58 border_type: BorderType,
59 style: Style,
60 title_style: Style,
61}
62
63impl<'a> DecisionCard<'a> {
64 #[must_use]
66 pub fn new(disclosure: &'a Disclosure) -> Self {
67 Self {
68 disclosure,
69 border_type: BorderType::Rounded,
70 style: Style::default(),
71 title_style: Style::default().bold(),
72 }
73 }
74
75 #[must_use]
77 pub fn border_type(mut self, border_type: BorderType) -> Self {
78 self.border_type = border_type;
79 self
80 }
81
82 #[must_use]
84 pub fn style(mut self, style: Style) -> Self {
85 self.style = style;
86 self
87 }
88
89 #[must_use]
91 pub fn title_style(mut self, style: Style) -> Self {
92 self.title_style = style;
93 self
94 }
95
96 #[must_use]
98 pub fn min_height(&self) -> u16 {
99 let mut h: u16 = 3;
101 if self.disclosure.explanation.is_some() {
102 h += 1; }
104 if let Some(ref terms) = self.disclosure.evidence_terms
105 && !terms.is_empty()
106 {
107 h += 1; h += terms.len() as u16; }
110 if self.disclosure.bayesian_details.is_some() {
111 h += 2; }
113 h
114 }
115
116 fn signal_style(signal: TrafficLight) -> (Style, Style) {
117 let (fg, bg) = match signal {
118 TrafficLight::Green => (GREEN_FG, GREEN_BG),
119 TrafficLight::Yellow => (YELLOW_FG, YELLOW_BG),
120 TrafficLight::Red => (RED_FG, RED_BG),
121 };
122 let badge_style = Style::new().fg(fg).bg(bg).bold();
123 let border_style = Style::new().fg(fg);
124 (badge_style, border_style)
125 }
126
127 fn render_border(&self, area: Rect, frame: &mut Frame, border_style: Style) {
128 let deg = frame.buffer.degradation;
129 let set = if deg.use_unicode_borders() {
130 self.border_type.to_border_set()
131 } else {
132 BorderSet::ASCII
133 };
134
135 let border_cell = |c: char| -> Cell {
136 let mut cell = Cell::from_char(c);
137 apply_style(&mut cell, border_style);
138 cell
139 };
140
141 for x in area.x..area.right() {
143 frame
144 .buffer
145 .set_fast(x, area.y, border_cell(set.horizontal));
146 }
147 let bottom_y = area.bottom().saturating_sub(1);
149 for x in area.x..area.right() {
150 frame
151 .buffer
152 .set_fast(x, bottom_y, border_cell(set.horizontal));
153 }
154 for y in area.y..area.bottom() {
156 frame.buffer.set_fast(area.x, y, border_cell(set.vertical));
157 }
158 let right_x = area.right().saturating_sub(1);
160 for y in area.y..area.bottom() {
161 frame.buffer.set_fast(right_x, y, border_cell(set.vertical));
162 }
163 frame
165 .buffer
166 .set_fast(area.x, area.y, border_cell(set.top_left));
167 frame
168 .buffer
169 .set_fast(right_x, area.y, border_cell(set.top_right));
170 frame
171 .buffer
172 .set_fast(area.x, bottom_y, border_cell(set.bottom_left));
173 frame
174 .buffer
175 .set_fast(right_x, bottom_y, border_cell(set.bottom_right));
176 }
177
178 fn render_signal_row(&self, x: u16, y: u16, max_x: u16, frame: &mut Frame) {
179 let deg = frame.buffer.degradation;
180 let (badge_style, _) = Self::signal_style(self.disclosure.signal);
181 let badge_style = if deg.apply_styling() {
182 badge_style
183 } else {
184 Style::default()
185 };
186 let title_style = if deg.apply_styling() {
187 self.title_style
188 } else {
189 Style::default()
190 };
191 let label = self.disclosure.signal.label();
192
193 let badge_text = format!(" {label} ");
195 let mut cx = draw_text_span(frame, x, y, &badge_text, badge_style, max_x);
196
197 if cx < max_x {
199 cx = draw_text_span(frame, cx, y, " ", Style::default(), max_x);
200 }
201 cx = draw_text_span(
202 frame,
203 cx,
204 y,
205 &self.disclosure.action_label,
206 title_style,
207 max_x,
208 );
209 let _ = cx;
210 }
211
212 fn render_explanation(&self, x: u16, y: u16, max_x: u16, frame: &mut Frame) {
213 if let Some(ref explanation) = self.disclosure.explanation {
214 let style = if frame.buffer.degradation.apply_styling() {
215 Style::new().fg(DIM_FG)
216 } else {
217 Style::default()
218 };
219 draw_text_span(frame, x, y, explanation, style, max_x);
220 }
221 }
222
223 fn render_evidence(&self, x: u16, mut y: u16, max_x: u16, frame: &mut Frame) -> u16 {
224 let terms = match self.disclosure.evidence_terms {
225 Some(ref t) if !t.is_empty() => t,
226 _ => return y,
227 };
228
229 let apply_styling = frame.buffer.degradation.apply_styling();
230 let header_style = if apply_styling {
231 Style::new().fg(DETAIL_FG).bold()
232 } else {
233 Style::default()
234 };
235 draw_text_span(frame, x, y, "Evidence:", header_style, max_x);
236 y += 1;
237
238 for term in terms {
239 let (dir_char, dir_style) = match term.direction {
240 EvidenceDirection::Supporting => ('+', Style::new().fg(EVIDENCE_SUPPORTING_FG)),
241 EvidenceDirection::Opposing => ('-', Style::new().fg(EVIDENCE_OPPOSING_FG)),
242 EvidenceDirection::Neutral => ('~', Style::new().fg(EVIDENCE_NEUTRAL_FG)),
243 };
244 let dir_style = if apply_styling {
245 dir_style
246 } else {
247 Style::default()
248 };
249 let line = format!(" {dir_char} {}: BF={:.2}", term.label, term.bayes_factor);
250 draw_text_span(frame, x, y, &line, dir_style, max_x);
251 y += 1;
252 }
253 y
254 }
255
256 fn render_bayesian(&self, x: u16, y: u16, max_x: u16, frame: &mut Frame) {
257 let details = match self.disclosure.bayesian_details {
258 Some(ref d) => d,
259 None => return,
260 };
261
262 let deg = frame.buffer.degradation;
263 let style = if deg.apply_styling() {
264 Style::new().fg(DETAIL_FG)
265 } else {
266 Style::default()
267 };
268
269 let rule_style = if deg.apply_styling() {
271 Style::new().fg(DIM_FG)
272 } else {
273 Style::default()
274 };
275 let rule_len = (max_x.saturating_sub(x)) as usize;
276 let rule_ch = if deg.use_unicode_borders() {
277 '─'
278 } else {
279 '-'
280 };
281 let rule: String = std::iter::repeat_n(rule_ch, rule_len).collect();
282 draw_text_span(frame, x, y, &rule, rule_style, max_x);
283
284 let stats = format!(
286 "log_post={:.3} CI=[{:.3},{:.3}] loss={:.4} avoided={:.4}",
287 details.log_posterior,
288 details.confidence_interval.0,
289 details.confidence_interval.1,
290 details.expected_loss,
291 details.loss_avoided,
292 );
293 draw_text_span(frame, x, y + 1, &stats, style, max_x);
294 }
295}
296
297impl Widget for DecisionCard<'_> {
298 fn render(&self, area: Rect, frame: &mut Frame) {
299 if area.is_empty() {
300 return;
301 }
302
303 if area.width < 4 || area.height < 3 {
304 clear_text_area(frame, area, Style::default());
305 return;
306 }
307
308 let deg = frame.buffer.degradation;
309 if !deg.render_content() {
310 clear_text_area(frame, area, Style::default());
311 return;
312 }
313
314 let base_style = if deg.apply_styling() {
315 self.style
316 } else {
317 Style::default()
318 };
319 clear_text_area(frame, area, base_style);
320
321 let (_, border_style) = Self::signal_style(self.disclosure.signal);
323 let border_style = if deg.apply_styling() {
324 border_style
325 } else {
326 Style::default()
327 };
328
329 if deg.render_decorative() {
331 self.render_border(area, frame, border_style);
332 }
333
334 let inner_x = area.x.saturating_add(1);
336 let inner_max_x = area.right().saturating_sub(1);
337 let mut y = area.y.saturating_add(1);
338 let max_y = area.bottom().saturating_sub(1);
339
340 if y >= max_y || inner_x >= inner_max_x {
341 return;
342 }
343
344 self.render_signal_row(inner_x, y, inner_max_x, frame);
346 y += 1;
347
348 if y < max_y && self.disclosure.level >= DisclosureLevel::PlainEnglish {
350 self.render_explanation(inner_x, y, inner_max_x, frame);
351 if self.disclosure.explanation.is_some() {
352 y += 1;
353 }
354 }
355
356 if y < max_y && self.disclosure.level >= DisclosureLevel::EvidenceTerms {
358 y = self.render_evidence(inner_x, y, inner_max_x, frame);
359 }
360
361 if y + 1 < max_y && self.disclosure.level >= DisclosureLevel::FullBayesian {
363 self.render_bayesian(inner_x, y, inner_max_x, frame);
364 }
365 }
366
367 fn is_essential(&self) -> bool {
368 false
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use ftui_render::budget::DegradationLevel;
376 use ftui_render::cell::PackedRgba;
377 use ftui_render::grapheme_pool::GraphemePool;
378 use ftui_runtime::transparency::{BayesianDetails, DisclosureEvidence, EvidenceDirection};
379 use ftui_runtime::unified_evidence::DecisionDomain;
380
381 fn make_disclosure(level: DisclosureLevel) -> Disclosure {
382 let explanation = if level >= DisclosureLevel::PlainEnglish {
383 Some("Diff strategy: chose 'full_redraw' with high confidence.".to_string())
384 } else {
385 None
386 };
387
388 let evidence_terms = if level >= DisclosureLevel::EvidenceTerms {
389 Some(vec![
390 DisclosureEvidence {
391 label: "change_rate",
392 bayes_factor: 3.5,
393 direction: EvidenceDirection::Supporting,
394 },
395 DisclosureEvidence {
396 label: "frame_cost",
397 bayes_factor: 0.8,
398 direction: EvidenceDirection::Opposing,
399 },
400 DisclosureEvidence {
401 label: "stability",
402 bayes_factor: 1.0,
403 direction: EvidenceDirection::Neutral,
404 },
405 ])
406 } else {
407 None
408 };
409
410 let bayesian_details = if level >= DisclosureLevel::FullBayesian {
411 Some(BayesianDetails {
412 log_posterior: 2.0,
413 confidence_interval: (0.7, 0.95),
414 expected_loss: 0.1,
415 next_best_loss: 0.5,
416 loss_avoided: 0.4,
417 })
418 } else {
419 None
420 };
421
422 Disclosure {
423 domain: DecisionDomain::DiffStrategy,
424 level,
425 signal: TrafficLight::Green,
426 action_label: "full_redraw".to_string(),
427 explanation,
428 evidence_terms,
429 bayesian_details,
430 }
431 }
432
433 fn extract_row(frame: &Frame, y: u16, width: u16) -> String {
434 let mut row = String::new();
435 for x in 0..width {
436 if let Some(cell) = frame.buffer.get(x, y) {
437 if let Some(ch) = cell.content.as_char() {
438 row.push(ch);
439 } else {
440 row.push(' ');
441 }
442 }
443 }
444 row
445 }
446
447 #[test]
448 fn level_0_renders_badge_and_action() {
449 let disc = make_disclosure(DisclosureLevel::TrafficLight);
450 let mut pool = GraphemePool::new();
451 let mut frame = Frame::new(40, 5, &mut pool);
452 DecisionCard::new(&disc).render(Rect::new(0, 0, 40, 5), &mut frame);
453 let row1 = extract_row(&frame, 1, 40);
454 assert!(
455 row1.contains("OK"),
456 "should contain traffic light label: {row1}"
457 );
458 assert!(
459 row1.contains("full_redraw"),
460 "should contain action: {row1}"
461 );
462 }
463
464 #[test]
465 fn level_1_includes_explanation() {
466 let disc = make_disclosure(DisclosureLevel::PlainEnglish);
467 let mut pool = GraphemePool::new();
468 let mut frame = Frame::new(60, 6, &mut pool);
469 DecisionCard::new(&disc).render(Rect::new(0, 0, 60, 6), &mut frame);
470 let row2 = extract_row(&frame, 2, 60);
471 assert!(
472 row2.contains("Diff strategy"),
473 "should contain explanation: {row2}"
474 );
475 }
476
477 #[test]
478 fn level_2_includes_evidence() {
479 let disc = make_disclosure(DisclosureLevel::EvidenceTerms);
480 let mut pool = GraphemePool::new();
481 let mut frame = Frame::new(60, 10, &mut pool);
482 DecisionCard::new(&disc).render(Rect::new(0, 0, 60, 10), &mut frame);
483
484 let mut found_evidence = false;
485 let mut found_change_rate = false;
486 for y in 0..10 {
487 let row = extract_row(&frame, y, 60);
488 if row.contains("Evidence:") {
489 found_evidence = true;
490 }
491 if row.contains("change_rate") {
492 found_change_rate = true;
493 }
494 }
495 assert!(found_evidence, "should show Evidence header");
496 assert!(found_change_rate, "should show change_rate term");
497 }
498
499 #[test]
500 fn level_3_includes_bayesian() {
501 let disc = make_disclosure(DisclosureLevel::FullBayesian);
502 let mut pool = GraphemePool::new();
503 let mut frame = Frame::new(60, 12, &mut pool);
504 DecisionCard::new(&disc).render(Rect::new(0, 0, 60, 12), &mut frame);
505
506 let mut found_log_post = false;
507 for y in 0..12 {
508 let row = extract_row(&frame, y, 60);
509 if row.contains("log_post") {
510 found_log_post = true;
511 }
512 }
513 assert!(found_log_post, "should show log_post in Bayesian details");
514 }
515
516 #[test]
517 fn tiny_area_no_panic() {
518 let disc = make_disclosure(DisclosureLevel::FullBayesian);
519 let mut pool = GraphemePool::new();
520 let mut frame = Frame::new(3, 2, &mut pool);
522 DecisionCard::new(&disc).render(Rect::new(0, 0, 3, 2), &mut frame);
523 let mut frame = Frame::new(1, 1, &mut pool);
524 DecisionCard::new(&disc).render(Rect::new(0, 0, 1, 1), &mut frame);
525 }
526
527 #[test]
528 fn min_height_level_0() {
529 let disc = make_disclosure(DisclosureLevel::TrafficLight);
530 let card = DecisionCard::new(&disc);
531 assert_eq!(card.min_height(), 3); }
533
534 #[test]
535 fn min_height_level_3() {
536 let disc = make_disclosure(DisclosureLevel::FullBayesian);
537 let card = DecisionCard::new(&disc);
538 assert_eq!(card.min_height(), 10);
540 }
541
542 #[test]
543 fn signal_colors_differ() {
544 let (green_badge, green_border) = DecisionCard::signal_style(TrafficLight::Green);
545 let (yellow_badge, _) = DecisionCard::signal_style(TrafficLight::Yellow);
546 let (red_badge, red_border) = DecisionCard::signal_style(TrafficLight::Red);
547 assert_ne!(green_badge.fg, yellow_badge.fg);
548 assert_ne!(yellow_badge.fg, red_badge.fg);
549 assert_ne!(green_border.fg, red_border.fg);
550 }
551
552 #[test]
553 fn yellow_signal_shows_warn() {
554 let mut disc = make_disclosure(DisclosureLevel::TrafficLight);
555 disc.signal = TrafficLight::Yellow;
556 let mut pool = GraphemePool::new();
557 let mut frame = Frame::new(40, 5, &mut pool);
558 DecisionCard::new(&disc).render(Rect::new(0, 0, 40, 5), &mut frame);
559 let row1 = extract_row(&frame, 1, 40);
560 assert!(row1.contains("WARN"), "should contain WARN: {row1}");
561 }
562
563 #[test]
564 fn red_signal_shows_alert() {
565 let mut disc = make_disclosure(DisclosureLevel::TrafficLight);
566 disc.signal = TrafficLight::Red;
567 let mut pool = GraphemePool::new();
568 let mut frame = Frame::new(40, 5, &mut pool);
569 DecisionCard::new(&disc).render(Rect::new(0, 0, 40, 5), &mut frame);
570 let row1 = extract_row(&frame, 1, 40);
571 assert!(row1.contains("ALERT"), "should contain ALERT: {row1}");
572 }
573
574 #[test]
575 fn builder_methods() {
576 let disc = make_disclosure(DisclosureLevel::TrafficLight);
577 let card = DecisionCard::new(&disc)
578 .border_type(BorderType::Double)
579 .style(Style::new().bg(PackedRgba::rgb(10, 10, 10)))
580 .title_style(Style::new().bold());
581 let mut pool = GraphemePool::new();
583 let mut frame = Frame::new(40, 5, &mut pool);
584 card.render(Rect::new(0, 0, 40, 5), &mut frame);
585 }
586
587 #[test]
588 fn is_not_essential() {
589 let disc = make_disclosure(DisclosureLevel::TrafficLight);
590 let card = DecisionCard::new(&disc);
591 assert!(!card.is_essential());
592 }
593
594 #[test]
595 fn render_no_styling_drops_configured_and_signal_styles() {
596 let disclosure = make_disclosure(DisclosureLevel::EvidenceTerms);
597 let card = DecisionCard::new(&disclosure)
598 .style(Style::new().bg(PackedRgba::rgb(10, 20, 30)))
599 .title_style(Style::new().fg(PackedRgba::rgb(200, 0, 0)).bold());
600 let area = Rect::new(0, 0, 40, card.min_height());
601 let mut pool = GraphemePool::new();
602 let mut frame = Frame::new(40, area.height, &mut pool);
603 frame.buffer.degradation = DegradationLevel::NoStyling;
604
605 card.render(area, &mut frame);
606
607 let border = frame.buffer.get(0, 0).unwrap();
608 let border_default = Cell::from_char(border.content.as_char().unwrap());
609 assert_eq!(border.fg, border_default.fg);
610 assert_eq!(border.bg, border_default.bg);
611 assert_eq!(border.attrs, border_default.attrs);
612
613 let badge = frame.buffer.get(1, 1).unwrap();
614 let badge_default = Cell::from_char(' ');
615 assert_eq!(badge.content.as_char(), Some(' '));
616 assert_eq!(badge.fg, badge_default.fg);
617 assert_eq!(badge.bg, badge_default.bg);
618 assert_eq!(badge.attrs, badge_default.attrs);
619
620 let action = frame.buffer.get(6, 1).unwrap();
621 let action_default = Cell::from_char(action.content.as_char().unwrap());
622 assert_eq!(action.fg, action_default.fg);
623 assert_eq!(action.bg, action_default.bg);
624 assert_eq!(action.attrs, action_default.attrs);
625 }
626
627 #[test]
628 fn render_clears_gap_between_badge_and_action() {
629 let disclosure = make_disclosure(DisclosureLevel::TrafficLight);
630 let card = DecisionCard::new(&disclosure);
631 let area = Rect::new(0, 0, 40, 5);
632 let mut pool = GraphemePool::new();
633 let mut frame = Frame::new(40, 5, &mut pool);
634 frame.buffer.set_fast(5, 1, Cell::from_char('X'));
635
636 card.render(area, &mut frame);
637
638 assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some(' '));
639 }
640
641 #[test]
642 fn render_simple_borders_use_ascii_separator_rule() {
643 let disclosure = make_disclosure(DisclosureLevel::FullBayesian);
644 let card = DecisionCard::new(&disclosure);
645 let area = Rect::new(0, 0, 60, card.min_height());
646 let mut pool = GraphemePool::new();
647 let mut frame = Frame::new(60, area.height, &mut pool);
648 frame.buffer.degradation = DegradationLevel::SimpleBorders;
649
650 card.render(area, &mut frame);
651
652 let rule_y = area.y + area.height - 3;
653 assert_eq!(
654 frame.buffer.get(1, rule_y).unwrap().content.as_char(),
655 Some('-')
656 );
657 }
658
659 #[test]
660 fn render_skeleton_clears_previous_card() {
661 let disclosure = make_disclosure(DisclosureLevel::FullBayesian);
662 let card = DecisionCard::new(&disclosure);
663 let area = Rect::new(0, 0, 60, card.min_height());
664 let mut pool = GraphemePool::new();
665 let mut frame = Frame::new(60, area.height, &mut pool);
666
667 card.render(area, &mut frame);
668 frame.buffer.degradation = DegradationLevel::Skeleton;
669 card.render(area, &mut frame);
670
671 for y in 0..area.height {
672 for x in 0..area.width {
673 assert_eq!(frame.buffer.get(x, y).unwrap().content.as_char(), Some(' '));
674 }
675 }
676 }
677
678 #[test]
679 fn render_shorter_disclosure_clears_stale_rows() {
680 let full = make_disclosure(DisclosureLevel::FullBayesian);
681 let short = make_disclosure(DisclosureLevel::TrafficLight);
682 let area = Rect::new(0, 0, 60, DecisionCard::new(&full).min_height());
683 let mut pool = GraphemePool::new();
684 let mut frame = Frame::new(60, area.height, &mut pool);
685
686 DecisionCard::new(&full).render(area, &mut frame);
687 DecisionCard::new(&short).render(area, &mut frame);
688
689 for y in 4..area.height.saturating_sub(1) {
690 for x in 1..area.width.saturating_sub(1) {
691 assert_eq!(frame.buffer.get(x, y).unwrap().content.as_char(), Some(' '));
692 }
693 }
694 }
695}