1use ratatui::{
2 buffer::Buffer,
3 layout::{Constraint, Layout, Rect},
4 style::{Color, Modifier, Style},
5 symbols,
6 text::{Line, Span},
7 widgets::{Block, Borders, Paragraph, Widget, Wrap},
8};
9
10#[cfg(test)]
11use super::app::CodeExample;
12use super::app::{App, ConventionItem};
13
14pub fn render(frame: &mut ratatui::Frame, app: &App) {
15 let area = frame.area();
16
17 let has_examples = app
18 .current()
19 .map(|c| !c.examples.is_empty())
20 .unwrap_or(false);
21
22 let has_filter = app.search_mode || app.filter_locked;
23
24 if let Some(convention) = app.current() {
25 let card = ConventionCard {
26 convention,
27 current: if has_filter {
28 app.filtered_current_index()
29 } else {
30 app.current_index
31 },
32 total: if has_filter {
33 app.filtered_total()
34 } else {
35 app.total()
36 },
37 review_complete: app.review_complete,
38 has_examples,
39 search_mode: app.search_mode,
40 search_query: &app.search_query,
41 filter_locked: app.filter_locked,
42 no_match: false,
43 };
44 card.render(area, frame.buffer_mut());
45 } else if !app.conventions.is_empty()
46 && app.filtered_indices.is_empty()
47 && (app.search_mode || app.filter_locked)
48 {
49 let card = ConventionCard {
50 convention: &app.conventions[0],
51 current: 0,
52 total: 0,
53 review_complete: false,
54 has_examples: false,
55 search_mode: app.search_mode,
56 search_query: &app.search_query,
57 filter_locked: app.filter_locked,
58 no_match: true,
59 };
60 card.render(area, frame.buffer_mut());
61 } else {
62 Paragraph::new("No convention to display").render(area, frame.buffer_mut());
63 }
64}
65
66pub struct ConventionCard<'a> {
67 pub convention: &'a ConventionItem,
68 pub current: usize,
69 pub total: usize,
70 pub review_complete: bool,
71 has_examples: bool,
72 search_mode: bool,
73 search_query: &'a str,
74 filter_locked: bool,
75 no_match: bool,
76}
77
78impl ConventionCard<'_> {
79 fn example_title(&self) -> String {
86 if !self.has_examples {
87 return "── (no usage examples) ".to_owned();
88 }
89 let Some(example) = self.convention.examples.get(self.convention.example_index) else {
90 debug_assert!(
98 false,
99 "example_index {} out of bounds (have {} examples) — \
100 cursor was not reset after the convention list changed",
101 self.convention.example_index,
102 self.convention.examples.len(),
103 );
104 return format!(
105 "── (example index {}/{} out of range) ",
106 self.convention.example_index + 1,
107 self.convention.examples.len(),
108 );
109 };
110 let example_num = self.convention.example_index + 1;
111 let examples_count = self.convention.examples.len();
112 if example.file.is_empty() && example.line == 0 {
118 return if examples_count > 1 {
119 format!("── Summary ({example_num}/{examples_count}) ")
120 } else {
121 "── Summary ".to_owned()
122 };
123 }
124 let file_display = shorten_path(&example.file);
125 let line = example.line;
126 if examples_count > 1 {
127 format!("── Example ({example_num}/{examples_count}): (\u{2026}{file_display}:{line}) ")
128 } else {
129 format!("── Example: (\u{2026}{file_display}:{line}) ")
130 }
131 }
132}
133
134impl Widget for ConventionCard<'_> {
135 fn render(self, area: Rect, buf: &mut Buffer) {
136 let border_style = Style::default().fg(Color::Blue);
137 let divider_set = symbols::border::Set {
138 top_left: "├",
139 top_right: "┤",
140 ..symbols::border::PLAIN
141 };
142 let outer_block = Block::default()
143 .borders(Borders::ALL)
144 .title("── Seshat Convention Review ")
145 .style(Style::default().fg(Color::Cyan))
146 .border_style(border_style);
147 let inner = outer_block.inner(area);
148 outer_block.render(area, buf);
149
150 if self.no_match {
151 Paragraph::new(" No matching conventions")
152 .style(
153 Style::default()
154 .fg(Color::Yellow)
155 .add_modifier(Modifier::BOLD),
156 )
157 .render(inner, buf);
158 render_key_bindings(
159 buf,
160 Rect {
161 x: area.x,
162 y: area.height.saturating_sub(1),
163 width: area.width,
164 height: 1,
165 },
166 0,
167 );
168 return;
169 }
170
171 let has_search_bar = self.search_mode;
172
173 let [header_height, info_height] = if self.filter_locked {
175 [Constraint::Length(2), Constraint::Length(2)]
176 } else {
177 [Constraint::Length(1), Constraint::Length(3)]
178 };
179
180 let constraints: Vec<Constraint> = {
181 let mut v = vec![header_height, Constraint::Length(1), info_height];
182 v.push(Constraint::Min(2));
183 v.push(Constraint::Length(1));
184 v.push(Constraint::Length(1));
185 v
186 };
187
188 let areas = Layout::vertical(&constraints).split(inner);
189 let header_area = areas[0];
190 let div1_area = areas[1];
191 let info_area = areas[2];
192 let example_area = areas[3];
193 let div2_area = areas[4];
194 let ctrl_area = areas[5];
195
196 if self.filter_locked {
198 let filter_text = format!(" [filter: '{}']", self.search_query);
199 Paragraph::new(filter_text)
200 .style(
201 Style::default()
202 .fg(Color::Cyan)
203 .add_modifier(Modifier::BOLD),
204 )
205 .render(header_area, buf);
206
207 let desc_text = format!(
208 " {}/{}: {}",
209 self.current + 1,
210 self.total,
211 self.convention.description
212 );
213 Paragraph::new(desc_text)
214 .style(
215 Style::default()
216 .fg(Color::White)
217 .add_modifier(Modifier::BOLD),
218 )
219 .wrap(Wrap { trim: false })
220 .render(
221 Rect {
222 y: header_area.y + 1,
223 height: 1,
224 ..header_area
225 },
226 buf,
227 );
228 } else {
229 let desc_text = format!(
230 " {}/{}: {}",
231 self.current + 1,
232 self.total,
233 self.convention.description
234 );
235 Paragraph::new(desc_text)
236 .style(
237 Style::default()
238 .fg(Color::White)
239 .add_modifier(Modifier::BOLD),
240 )
241 .wrap(Wrap { trim: false })
242 .render(header_area, buf);
243 }
244
245 Block::default()
247 .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
248 .border_set(divider_set)
249 .border_style(border_style)
250 .render(
251 Rect {
252 x: area.x,
253 y: div1_area.y,
254 width: area.width,
255 height: 1,
256 },
257 buf,
258 );
259
260 let weight_display = match self.convention.weight.as_str() {
262 "rule" => "Rule",
263 "strong" => "Strong",
264 "moderate" => "Moderate",
265 "weak" => "Weak",
266 "info" => "Info",
267 other => other,
268 };
269 let nature_display = match self.convention.nature.as_str() {
270 "convention" => "Convention",
271 "observation" => "Observation",
272 other => other,
273 };
274
275 let meta = Line::from(vec![
276 Span::raw(" "),
277 Span::styled(
278 format!("Nature: {nature_display}"),
279 Style::default().fg(Color::Green),
280 ),
281 Span::raw(" "),
282 Span::styled(
283 format!("Confidence: {}%", self.convention.confidence_pct),
284 Style::default().fg(Color::Yellow),
285 ),
286 Span::raw(" "),
287 Span::styled(
288 format!("Weight: {weight_display}"),
289 Style::default().fg(Color::Magenta),
290 ),
291 ]);
292
293 let adoption = format!(
294 " Found in: {}/{} files ({}% adoption)",
295 self.convention.adoption_count,
296 self.convention.total_count,
297 self.convention.adoption_rate_pct
298 );
299
300 Paragraph::new(vec![meta, Line::from(adoption), Line::default()]).render(info_area, buf);
301
302 let example_title = self.example_title();
305 Block::default()
306 .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
307 .border_set(divider_set)
308 .border_style(border_style)
309 .title(Span::styled(example_title, border_style))
310 .render(
311 Rect {
312 x: area.x,
313 y: example_area.y,
314 width: area.width,
315 height: 1,
316 },
317 buf,
318 );
319
320 let code_area = Rect {
322 y: example_area.y + 1,
323 height: example_area.height.saturating_sub(1),
324 ..example_area
325 };
326
327 if self.has_examples {
328 if let Some(example) = self.convention.examples.get(self.convention.example_index) {
329 let max_lines = code_area.height as usize;
330 let max_chars = code_area.width.saturating_sub(8).max(1) as usize;
331
332 let is_composite = example.file.is_empty() && example.line == 0;
333
334 let snippet_lines: Vec<Line> = if is_composite {
335 example
339 .snippet
340 .lines()
341 .take(max_lines)
342 .map(|line_text| {
343 Line::from(Span::styled(
344 truncate_str(line_text, max_chars),
345 Style::default().fg(Color::Cyan),
346 ))
347 })
348 .collect()
349 } else {
350 let snippet_start = if example.snippet_start_line > 0 {
351 example.snippet_start_line
352 } else {
353 example.line
354 };
355 example
356 .snippet
357 .lines()
358 .take(max_lines)
359 .enumerate()
360 .map(|(i, line_text)| {
361 let line_num = snippet_start + i as u32;
362 let is_highlight = line_num >= example.line
363 && line_num <= example.end_line.max(example.line);
364 let display = truncate_str(line_text, max_chars);
365 let text_style = if is_highlight {
366 Style::default()
367 .fg(Color::Green)
368 .add_modifier(Modifier::BOLD)
369 } else {
370 Style::default().fg(Color::Yellow)
371 };
372 Line::from(vec![
373 Span::styled(
374 format!("{:>5} ", line_num),
375 Style::default().fg(Color::DarkGray),
376 ),
377 Span::styled(display, text_style),
378 ])
379 })
380 .collect()
381 };
382
383 if snippet_lines.is_empty() {
384 Paragraph::new("(no snippet available)")
385 .style(Style::default().fg(Color::DarkGray))
386 .render(code_area, buf);
387 } else {
388 Paragraph::new(snippet_lines).render(code_area, buf);
389 }
390 }
391 } else {
392 Paragraph::new("(no usage examples found for this convention)")
393 .style(Style::default().fg(Color::DarkGray))
394 .render(code_area, buf);
395 }
396
397 Block::default()
399 .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
400 .border_set(divider_set)
401 .border_style(border_style)
402 .render(
403 Rect {
404 x: area.x,
405 y: div2_area.y,
406 width: area.width,
407 height: 1,
408 },
409 buf,
410 );
411
412 let examples_count = self.convention.examples.len();
413
414 if has_search_bar {
415 let hint = "Press Enter to keep or Esc to clear";
416 let prompt = format!(" Filter: {}", self.search_query);
417 let prompt_width = prompt.chars().count();
418 let hint_width = hint.chars().count();
419 let gap = ctrl_area.width as usize;
420
421 if prompt_width + hint_width + 2 < gap {
422 let pad = gap - prompt_width - hint_width - 2;
423 let full = format!("{prompt}{}{hint}", " ".repeat(pad));
424 Paragraph::new(full)
425 .style(Style::default().fg(Color::Yellow))
426 .render(ctrl_area, buf);
427 } else {
428 Paragraph::new(prompt)
429 .style(Style::default().fg(Color::Yellow))
430 .render(ctrl_area, buf);
431 }
432
433 let cursor_pos = 10 + self.search_query.len();
434 if cursor_pos < ctrl_area.width as usize {
435 if let Some(c) = buf.cell_mut((ctrl_area.x + cursor_pos as u16, ctrl_area.y)) {
436 c.set_style(
437 Style::default()
438 .fg(Color::Cyan)
439 .add_modifier(Modifier::REVERSED),
440 );
441 }
442 }
443 } else {
444 render_key_bindings(buf, ctrl_area, examples_count);
445 }
446 }
447}
448
449fn truncate_str(s: &str, max_len: usize) -> String {
450 if s.chars().count() <= max_len {
451 return s.to_owned();
452 }
453 let truncated: String = s.chars().take(max_len).collect();
454 format!("{}\u{2026}", truncated)
455}
456
457fn shorten_path(path: &str) -> String {
458 let parts: Vec<&str> = path.split('/').collect();
459 if parts.len() <= 4 {
460 return path.to_owned();
461 }
462 let tail = &parts[parts.len() - 4..];
463 format!("\u{2026}/{}", tail.join("/"))
464}
465
466fn render_key_bindings(buf: &mut Buffer, area: Rect, examples_count: usize) {
467 let inner_width = area.width as usize;
468
469 let mut parts: Vec<(&str, Style)> = vec![
470 (
471 " [y] Confirm",
472 Style::default()
473 .fg(Color::Green)
474 .add_modifier(Modifier::BOLD),
475 ),
476 (
477 "[n] Reject",
478 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
479 ),
480 (
481 "[p] Partial",
482 Style::default()
483 .fg(Color::Yellow)
484 .add_modifier(Modifier::BOLD),
485 ),
486 (
487 "[s] Skip",
488 Style::default()
489 .fg(Color::Blue)
490 .add_modifier(Modifier::BOLD),
491 ),
492 (
493 "[\u{2191}\u{2193}/jk] Navigate",
494 Style::default()
495 .fg(Color::White)
496 .add_modifier(Modifier::BOLD),
497 ),
498 ];
499
500 if examples_count > 1 {
501 parts.push((
502 "[\u{2190}\u{2192}/ad] Examples",
503 Style::default()
504 .fg(Color::White)
505 .add_modifier(Modifier::BOLD),
506 ));
507 }
508
509 parts.push((
510 "[q/Esc] Finish",
511 Style::default()
512 .fg(Color::Magenta)
513 .add_modifier(Modifier::BOLD),
514 ));
515
516 let mut spans = Vec::new();
517 for (text, style) in &parts {
518 if !spans.is_empty() {
519 spans.push(Span::raw(" "));
520 }
521 spans.push(Span::styled(text.to_string(), *style));
522 }
523
524 let rendered_text: String = Line::from(spans.clone()).to_string();
525 if rendered_text.chars().count() > inner_width {
526 let take = inner_width.saturating_sub(3);
527 let truncated: String = rendered_text.chars().take(take).collect();
528 spans = vec![Span::styled(truncated + "...", parts.last().unwrap().1)];
529 }
530
531 Paragraph::new(Line::from(spans)).render(area, buf);
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 #[test]
539 fn shorten_path_keeps_short_paths() {
540 assert_eq!(shorten_path("src/main.rs"), "src/main.rs");
541 assert_eq!(shorten_path("a/b/c/d.rs"), "a/b/c/d.rs");
542 }
543
544 #[test]
545 fn shorten_path_truncates_long_paths() {
546 let result = shorten_path("very/long/path/that/has/many/segments/file.rs");
547 assert!(result.starts_with("\u{2026}/"));
548 assert!(result.contains("file.rs"));
549 }
550
551 #[test]
552 fn shorten_path_exact_four_parts_not_truncated() {
553 assert_eq!(shorten_path("a/b/c/d"), "a/b/c/d");
554 }
555
556 #[test]
557 fn shorten_path_five_parts_truncated() {
558 let result = shorten_path("a/b/c/d/e.rs");
559 assert!(result.starts_with("\u{2026}/"));
560 assert!(result.contains("d/e.rs"));
561 }
562
563 #[test]
564 fn layout_constraints_produce_valid_areas() {
565 let area = Rect::new(0, 0, 120, 40);
566 let areas: [Rect; 6] = Layout::vertical([
567 Constraint::Length(1),
568 Constraint::Length(1),
569 Constraint::Length(2),
570 Constraint::Min(2),
571 Constraint::Length(1),
572 Constraint::Length(1),
573 ])
574 .areas(area);
575
576 assert!(areas[3].height >= 2);
577 assert_eq!(areas[5].height, 1);
578 }
579
580 #[test]
581 fn layout_with_examples_provides_six_areas() {
582 let inner = Rect::new(0, 0, 120, 30);
583 let areas: [Rect; 6] = Layout::vertical([
584 Constraint::Length(1),
585 Constraint::Length(1),
586 Constraint::Length(2),
587 Constraint::Min(2),
588 Constraint::Length(1),
589 Constraint::Length(1),
590 ])
591 .areas(inner);
592
593 assert_eq!(areas.len(), 6);
594 assert!(areas[3].height >= 2);
595 }
596
597 #[test]
598 fn layout_without_examples_provides_zero_height_for_code() {
599 let inner = Rect::new(0, 0, 120, 30);
600 let areas: [Rect; 6] = Layout::vertical([
601 Constraint::Length(1),
602 Constraint::Length(1),
603 Constraint::Length(2),
604 Constraint::Length(0),
605 Constraint::Length(1),
606 Constraint::Length(1),
607 ])
608 .areas(inner);
609
610 assert_eq!(areas.len(), 6);
611 assert_eq!(areas[3].height, 0);
612 }
613
614 #[test]
615 fn progress_title_format_single_digit() {
616 let total_width = 9.to_string().len().max(1);
617 let title = format!(
618 " Seshat Convention Review {:>width$}/{:<width$} ",
619 1,
620 9,
621 width = total_width
622 );
623 assert!(title.contains("1/9"));
624 }
625
626 #[test]
627 fn progress_title_format_double_digit() {
628 let total_width = 10.to_string().len().max(1);
629 let title = format!(
630 " Seshat Convention Review {:>width$}/{:<width$} ",
631 5,
632 10,
633 width = total_width
634 );
635 assert!(title.contains(" 5/10"));
636 }
637
638 #[test]
639 fn progress_title_format_triple_digit() {
640 let total_width = 100.to_string().len().max(1);
641 let title = format!(
642 " Seshat Convention Review {:>width$}/{:<width$} ",
643 50,
644 100,
645 width = total_width
646 );
647 assert!(title.contains(" 50/100"));
648 }
649
650 #[test]
651 fn truncate_str_short_string_no_change() {
652 assert_eq!(truncate_str("hello", 10), "hello");
653 }
654
655 #[test]
656 fn truncate_str_long_string_truncates() {
657 let result = truncate_str("hello world", 7);
658 assert!(result.ends_with("\u{2026}"));
659 assert_eq!(result.chars().count(), 8); }
661
662 #[test]
663 fn truncate_str_empty_string() {
664 assert_eq!(truncate_str("", 0), "");
665 assert_eq!(truncate_str("", 5), "");
666 }
667
668 #[test]
669 fn truncate_str_exact_length() {
670 assert_eq!(truncate_str("abc", 3), "abc");
671 }
672
673 #[test]
674 fn render_key_bindings_single_example() {
675 let mut buf = Buffer::empty(Rect::new(0, 0, 120, 1));
676 render_key_bindings(&mut buf, Rect::new(0, 0, 120, 1), 1);
677 let text = buf.content().iter().map(|c| c.symbol()).collect::<String>();
678 assert!(text.contains("[y] Confirm"));
679 assert!(text.contains("[n] Reject"));
680 assert!(!text.contains("Examples"));
681 }
682
683 #[test]
684 fn render_key_bindings_multiple_examples() {
685 let mut buf = Buffer::empty(Rect::new(0, 0, 120, 1));
686 render_key_bindings(&mut buf, Rect::new(0, 0, 120, 1), 3);
687 let text = buf.content().iter().map(|c| c.symbol()).collect::<String>();
688 assert!(text.contains("Examples"));
689 }
690
691 #[test]
692 fn render_key_bindings_zero_examples() {
693 let mut buf = Buffer::empty(Rect::new(0, 0, 120, 1));
694 render_key_bindings(&mut buf, Rect::new(0, 0, 120, 1), 0);
695 let text = buf.content().iter().map(|c| c.symbol()).collect::<String>();
696 assert!(!text.contains("Examples"));
697 }
698
699 fn make_conv_item(desc: &str, examples: Vec<CodeExample>) -> ConventionItem {
700 use std::hash::{Hash, Hasher};
701 let mut hasher = std::collections::hash_map::DefaultHasher::default();
702 desc.hash(&mut hasher);
703 let hash = hasher.finish();
704
705 ConventionItem {
706 node_id: 1,
707 description: desc.to_owned(),
708 nature: "convention".to_owned(),
709 weight: "strong".to_owned(),
710 confidence_pct: 85,
711 adoption_count: 10,
712 total_count: 12,
713 adoption_rate_pct: 83,
714 trend: "stable".to_owned(),
715 source: "auto".to_owned(),
716 examples,
717 snapshot_hash: hash,
718 example_index: 0,
719 description_hash: None,
720 }
721 }
722
723 #[test]
724 fn convention_card_fills_buffer() {
725 let examples = vec![CodeExample {
726 file: "src/main.rs".to_owned(),
727 line: 10,
728 end_line: 12,
729 snippet: "fn main() {\n println!(\"hi\");\n}".to_owned(),
730 snippet_start_line: 10,
731 }];
732 let item = make_conv_item("Use snake_case", examples);
733 let card = ConventionCard {
734 convention: &item,
735 current: 0,
736 total: 1,
737 review_complete: false,
738 has_examples: true,
739 search_mode: false,
740 search_query: "",
741 filter_locked: false,
742 no_match: false,
743 };
744 let mut buf = Buffer::empty(Rect::new(0, 0, 120, 30));
745 card.render(Rect::new(0, 0, 120, 30), &mut buf);
746 let text = buf.content().iter().map(|c| c.symbol()).collect::<String>();
747 assert!(text.contains("Use snake_case"));
748 assert!(text.contains("Nature: Convention"));
749 assert!(text.contains("Confidence: 85%"));
750 }
751
752 #[test]
753 fn convention_card_no_examples_fills_buffer() {
754 let item = make_conv_item("Use camelCase", vec![]);
755 let card = ConventionCard {
756 convention: &item,
757 current: 0,
758 total: 1,
759 review_complete: true,
760 has_examples: false,
761 search_mode: false,
762 search_query: "",
763 filter_locked: false,
764 no_match: false,
765 };
766 let mut buf = Buffer::empty(Rect::new(0, 0, 120, 30));
767 card.render(Rect::new(0, 0, 120, 30), &mut buf);
768 let text = buf.content().iter().map(|c| c.symbol()).collect::<String>();
769 assert!(text.contains("Use camelCase"));
770 }
771
772 #[test]
778 fn example_title_distinguishes_no_examples_from_oob_cursor() {
779 let item_empty = make_conv_item("X", vec![]);
781 let card_empty = ConventionCard {
782 convention: &item_empty,
783 current: 0,
784 total: 1,
785 review_complete: false,
786 has_examples: false,
787 search_mode: false,
788 search_query: "",
789 filter_locked: false,
790 no_match: false,
791 };
792 assert!(card_empty.example_title().contains("no usage examples"));
793 }
794
795 #[test]
810 fn example_title_oob_index_does_not_silently_render_no_examples() {
811 let mut item = make_conv_item(
812 "X",
813 vec![CodeExample {
814 file: "a.rs".to_owned(),
815 line: 1,
816 end_line: 1,
817 snippet: "x".to_owned(),
818 snippet_start_line: 1,
819 }],
820 );
821 item.example_index = 5; let card = ConventionCard {
823 convention: &item,
824 current: 0,
825 total: 1,
826 review_complete: false,
827 has_examples: true,
828 search_mode: false,
829 search_query: "",
830 filter_locked: false,
831 no_match: false,
832 };
833 let result =
834 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| card.example_title()));
835
836 if cfg!(debug_assertions) {
837 assert!(
838 result.is_err(),
839 "debug build must panic via debug_assert! when example_index is OOB",
840 );
841 } else {
842 let title = result.expect("release build must not panic on OOB index");
843 assert!(
844 title.contains("out of range"),
845 "release build must surface a distinct OOB title; got {title:?}",
846 );
847 assert!(
850 !title.contains("no usage examples"),
851 "OOB index must not render the empty-state title; got {title:?}",
852 );
853 }
854 }
855
856 #[test]
857 fn convention_card_tiny_area_does_not_panic() {
858 let examples = vec![CodeExample {
859 file: "src/lib.rs".to_owned(),
860 line: 1,
861 end_line: 1,
862 snippet: "pub fn add(a: i32, b: i32) -> i32 { a + b }".to_owned(),
863 snippet_start_line: 1,
864 }];
865 let item = make_conv_item("Prefer explicit types", examples);
866 let card = ConventionCard {
867 convention: &item,
868 current: 0,
869 total: 1,
870 review_complete: false,
871 has_examples: true,
872 search_mode: false,
873 search_query: "",
874 filter_locked: false,
875 no_match: false,
876 };
877 let mut buf = Buffer::empty(Rect::new(0, 0, 10, 3));
878 card.render(Rect::new(0, 0, 10, 3), &mut buf);
879 }
880}