1use std::sync::Arc;
2
3use kimun_core::NoteVault;
4use kimun_core::nfs::VaultPath;
5use ratatui::Frame;
6use ratatui::crossterm::event::{KeyCode, KeyEvent};
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
11
12use crate::components::event_state::EventState;
13use crate::components::events::{AppEvent, AppTx};
14use crate::components::file_list::{SortField, SortOrder};
15use crate::keys::action_shortcuts::ActionShortcuts;
16use crate::keys::{KeyBindings, key_event_to_combo};
17use crate::settings::themes::Theme;
18
19#[derive(Debug, Clone)]
25pub struct BacklinkEntry {
26 pub path: VaultPath,
27 pub title: String,
28 pub filename: String,
29 pub context: String,
31 pub full_text: Option<String>,
33}
34
35#[derive(Clone, Copy, PartialEq)]
40enum ExpandState {
41 Collapsed,
42 Context,
43 Full,
44}
45
46pub struct BacklinksPanel {
51 entries: Vec<BacklinkEntry>,
52 expand_states: Vec<ExpandState>,
53 list_state: ListState,
54 loading: bool,
55 current_note: VaultPath,
56 sort_field: SortField,
57 sort_order: SortOrder,
58 vault: Arc<NoteVault>,
59 key_bindings: KeyBindings,
60 content_scroll: usize,
62 content_scroll_max: usize,
64}
65
66impl BacklinksPanel {
67 pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings) -> Self {
68 Self {
69 entries: Vec::new(),
70 expand_states: Vec::new(),
71 list_state: ListState::default(),
72 loading: false,
73 current_note: VaultPath::empty(),
74 sort_field: SortField::Name,
75 sort_order: SortOrder::Ascending,
76 vault,
77 key_bindings,
78 content_scroll: 0,
79 content_scroll_max: 0,
80 }
81 }
82
83 fn is_full_expanded(&self) -> bool {
88 self.list_state
89 .selected()
90 .and_then(|i| self.expand_states.get(i))
91 .is_some_and(|s| *s == ExpandState::Full)
92 }
93
94 pub fn is_empty(&self) -> bool {
95 self.entries.is_empty()
96 }
97
98 pub fn selected_path(&self) -> Option<&VaultPath> {
99 self.list_state
100 .selected()
101 .and_then(|i| self.entries.get(i))
102 .map(|e| &e.path)
103 }
104
105 pub fn load(&mut self, note_path: VaultPath, tx: AppTx) {
111 self.entries.clear();
112 self.expand_states.clear();
113 self.list_state.select(None);
114 self.loading = true;
115 self.current_note = note_path.clone();
116 self.content_scroll = 0;
117 self.content_scroll_max = 0;
118
119 let vault = Arc::clone(&self.vault);
120 tokio::spawn(async move {
121 let entries = load_backlinks(&vault, ¬e_path).await;
122 let _ = tx.send(AppEvent::BacklinksLoaded(entries));
123 });
124 }
125
126 pub fn on_loaded(&mut self, entries: Vec<BacklinkEntry>) {
129 self.entries = entries;
130 self.apply_sort();
131 self.expand_states = vec![ExpandState::Collapsed; self.entries.len()];
132 self.loading = false;
133 if !self.entries.is_empty() {
134 self.list_state.select(Some(0));
135 }
136 }
137
138 pub fn apply_sort(&mut self) {
141 let field = self.sort_field;
142 let order = self.sort_order;
143
144 let mut indices: Vec<usize> = (0..self.entries.len()).collect();
146 indices.sort_by(|&a, &b| {
147 let cmp = match field {
148 SortField::Name => self.entries[a]
149 .filename
150 .to_lowercase()
151 .cmp(&self.entries[b].filename.to_lowercase()),
152 SortField::Title => self.entries[a]
153 .title
154 .to_lowercase()
155 .cmp(&self.entries[b].title.to_lowercase()),
156 };
157 match order {
158 SortOrder::Ascending => cmp,
159 SortOrder::Descending => cmp.reverse(),
160 }
161 });
162
163 let sorted_entries: Vec<BacklinkEntry> =
164 indices.iter().map(|&i| self.entries[i].clone()).collect();
165 let sorted_states: Vec<ExpandState> = if self.expand_states.len() == self.entries.len() {
166 indices.iter().map(|&i| self.expand_states[i]).collect()
167 } else {
168 vec![ExpandState::Collapsed; sorted_entries.len()]
169 };
170
171 self.entries = sorted_entries;
172 self.expand_states = sorted_states;
173 }
174
175 pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
178 if let Some(combo) = key_event_to_combo(key) {
180 match self.key_bindings.get_action(&combo) {
181 Some(ActionShortcuts::CycleSortField) => {
182 self.sort_field = self.sort_field.cycle();
183 self.apply_sort();
184 self.expand_states = vec![ExpandState::Collapsed; self.entries.len()];
185 return EventState::Consumed;
186 }
187 Some(ActionShortcuts::SortReverseOrder) => {
188 self.sort_order = self.sort_order.toggle();
189 self.apply_sort();
190 self.expand_states = vec![ExpandState::Collapsed; self.entries.len()];
191 return EventState::Consumed;
192 }
193 Some(ActionShortcuts::FollowLink) => {
196 if let Some(path) = self.selected_path().cloned() {
197 tx.send(AppEvent::OpenPath(path)).ok();
198 }
199 return EventState::Consumed;
200 }
201 _ => {}
202 }
203 }
204
205 match key.code {
206 KeyCode::Up => {
207 if self.is_full_expanded() {
208 self.content_scroll = self.content_scroll.saturating_sub(1);
209 } else {
210 self.move_selection(-1);
211 }
212 EventState::Consumed
213 }
214 KeyCode::Down => {
215 if self.is_full_expanded() {
216 self.content_scroll += 1;
218 } else {
219 self.move_selection(1);
220 }
221 EventState::Consumed
222 }
223 KeyCode::Enter => {
224 self.toggle_expand();
225 EventState::Consumed
226 }
227 KeyCode::Esc => EventState::NotConsumed,
228 _ => EventState::NotConsumed,
229 }
230 }
231
232 fn move_selection(&mut self, delta: i32) {
233 if self.entries.is_empty() {
234 return;
235 }
236 let current = self.list_state.selected().unwrap_or(0) as i32;
237 let next = (current + delta).clamp(0, self.entries.len() as i32 - 1) as usize;
238 self.list_state.select(Some(next));
239 }
240
241 fn toggle_expand(&mut self) {
242 let Some(idx) = self.list_state.selected() else {
243 return;
244 };
245 if idx >= self.expand_states.len() {
246 return;
247 }
248
249 match self.expand_states[idx] {
250 ExpandState::Collapsed => {
251 self.expand_states[idx] = ExpandState::Context;
252 }
253 ExpandState::Context => {
254 self.content_scroll = 0;
255 self.expand_states[idx] = ExpandState::Full;
256 }
257 ExpandState::Full => {
258 self.content_scroll = 0;
259 self.expand_states[idx] = ExpandState::Collapsed;
260 }
261 }
262 }
263
264 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
265 [
266 (ActionShortcuts::FocusSidebar, "\u{2190} editor"),
267 (ActionShortcuts::FollowLink, "open note"),
268 (ActionShortcuts::CycleSortField, "sort"),
269 ]
270 .iter()
271 .filter_map(|(action, label)| {
272 self.key_bindings
273 .first_combo_for(action)
274 .map(|k| (k, label.to_string()))
275 })
276 .collect()
277 }
278
279 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
282 let border_style = theme.border_style(focused);
283 let fg = theme.fg.to_ratatui();
284 let fg_muted = theme.fg_muted.to_ratatui();
285 let bg = theme.bg_panel.to_ratatui();
286
287 let sort_indicator = format!("{}{}", self.sort_field.label(), self.sort_order.label());
288 let title = format!("Backlinks ({}) {}", self.entries.len(), sort_indicator);
289
290 let outer = Block::default()
291 .title(title)
292 .borders(Borders::ALL)
293 .border_style(border_style)
294 .style(theme.panel_style());
295 let inner = outer.inner(rect);
296 f.render_widget(outer, rect);
297
298 if self.loading {
299 f.render_widget(
300 Paragraph::new(" Loading...").style(Style::default().fg(fg_muted).bg(bg)),
301 inner,
302 );
303 return;
304 }
305
306 if self.entries.is_empty() {
307 f.render_widget(
308 Paragraph::new(" No backlinks").style(Style::default().fg(fg_muted).bg(bg)),
309 inner,
310 );
311 return;
312 }
313
314 let selected = self.list_state.selected();
315 let selected_state = selected
316 .and_then(|i| self.expand_states.get(i).copied())
317 .unwrap_or(ExpandState::Collapsed);
318
319 if selected_state == ExpandState::Full {
321 if let Some(idx) = selected
322 && let Some(entry) = self.entries.get(idx)
323 {
324 let text = entry.full_text.as_deref().unwrap_or(&entry.context);
325
326 let title_display = if entry.title.is_empty() {
328 &entry.filename
329 } else {
330 &entry.title
331 };
332
333 let parts = Layout::default()
334 .direction(Direction::Vertical)
335 .constraints([
336 Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
340 .split(inner);
341
342 f.render_widget(
344 Paragraph::new(Line::from(vec![
345 Span::styled(
346 format!("\u{25BC} {} ", title_display),
347 Style::default()
348 .fg(theme.fg_selected.to_ratatui())
349 .bg(bg)
350 .add_modifier(Modifier::BOLD),
351 ),
352 Span::styled(
353 format!(" {}", entry.filename),
354 Style::default().fg(fg_muted).bg(bg),
355 ),
356 ]))
357 .style(Style::default().bg(bg)),
358 parts[0],
359 );
360
361 f.render_widget(
363 Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
364 .style(Style::default().fg(fg_muted).bg(bg)),
365 parts[1],
366 );
367
368 let indent = 2usize;
370 let wrap_width = parts[2].width.saturating_sub(indent as u16 + 1) as usize;
371 let target = self.current_note.get_clean_name().to_lowercase();
372
373 let mut lines = Vec::new();
374 for line in text.lines() {
375 let wrapped = wrap_line(line, wrap_width);
376 for wline in wrapped {
377 let spans = highlight_link(&wline, &target, fg_muted, bg, theme);
378 let mut indented =
379 vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
380 indented.extend(spans);
381 lines.push(Line::from(indented));
382 }
383 }
384
385 let total_lines = lines.len();
386 let viewport = parts[2].height as usize;
387 self.content_scroll_max = total_lines.saturating_sub(viewport);
388 self.content_scroll = self.content_scroll.min(self.content_scroll_max);
389
390 f.render_widget(
391 Paragraph::new(lines)
392 .scroll((self.content_scroll as u16, 0))
393 .style(Style::default().bg(bg)),
394 parts[2],
395 );
396 }
397 return;
398 }
399
400 let has_context = selected_state == ExpandState::Context;
402
403 let (list_area, divider_area, content_area) = if has_context {
404 let max_list = inner.height / 2;
405 let list_height = (self.entries.len() as u16).min(max_list).max(1);
406 let areas = Layout::default()
407 .direction(Direction::Vertical)
408 .constraints([
409 Constraint::Length(list_height),
410 Constraint::Length(1),
411 Constraint::Min(0),
412 ])
413 .split(inner);
414 (areas[0], Some(areas[1]), Some(areas[2]))
415 } else {
416 (inner, None, None)
417 };
418
419 let items: Vec<ListItem> = self
421 .entries
422 .iter()
423 .enumerate()
424 .map(|(i, entry)| {
425 let is_selected = selected == Some(i);
426 let title_style = if is_selected {
427 Style::default()
428 .fg(theme.fg_selected.to_ratatui())
429 .bg(theme.bg_selected.to_ratatui())
430 .add_modifier(Modifier::BOLD)
431 } else {
432 Style::default().fg(fg).bg(bg)
433 };
434 let title_display = if entry.title.is_empty() {
435 &entry.filename
436 } else {
437 &entry.title
438 };
439 let expand_marker = match self.expand_states.get(i) {
440 Some(ExpandState::Context) => "\u{25BC}",
441 _ => " ",
442 };
443 ListItem::new(Line::from(vec![
444 Span::styled(format!("{} {} ", expand_marker, title_display), title_style),
445 Span::styled(
446 format!(" {}", entry.filename),
447 Style::default().fg(fg_muted).bg(if is_selected {
448 theme.bg_selected.to_ratatui()
449 } else {
450 bg
451 }),
452 ),
453 ]))
454 })
455 .collect();
456
457 let list = List::new(items)
458 .style(Style::default().bg(bg))
459 .highlight_style(Style::default().bg(theme.bg_selected.to_ratatui()));
460
461 f.render_stateful_widget(list, list_area, &mut self.list_state);
462
463 if let Some(div) = divider_area {
465 f.render_widget(
466 Paragraph::new("\u{2500}".repeat(div.width as usize))
467 .style(Style::default().fg(fg_muted).bg(bg)),
468 div,
469 );
470 }
471
472 if let Some(area) = content_area
475 && let Some(idx) = selected
476 && let Some(entry) = self.entries.get(idx)
477 && selected_state == ExpandState::Context
478 {
479 let text = entry.full_text.as_deref().unwrap_or(&entry.context);
480 let indent = 2usize;
481 let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
482 let target = self.current_note.get_clean_name().to_lowercase();
483
484 let mut lines = Vec::new();
485
486 let mut link_line: Option<usize> = None;
488
489 for line in text.lines() {
490 let wrapped = wrap_line(line, wrap_width);
491 for wline in wrapped {
492 if link_line.is_none()
493 && (find_case_insensitive(&wline, &format!("[[{}", target)).is_some()
494 || find_case_insensitive(&wline, &format!("({})", target)).is_some())
495 {
496 link_line = Some(lines.len());
497 }
498 let spans = highlight_link(&wline, &target, fg_muted, bg, theme);
499 let mut indented =
500 vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
501 indented.extend(spans);
502 lines.push(Line::from(indented));
503 }
504 }
505
506 let viewport = area.height as usize;
510 let total = lines.len();
511 let link_pos = link_line.unwrap_or(0);
512 let lines_after_link = total.saturating_sub(link_pos);
513 let scroll_to = if lines_after_link <= viewport {
514 total.saturating_sub(viewport)
516 } else {
517 link_pos.saturating_sub(2)
519 } as u16;
520
521 f.render_widget(
522 Paragraph::new(lines)
523 .scroll((scroll_to, 0))
524 .style(Style::default().bg(bg)),
525 area,
526 );
527 }
528 }
529}
530
531async fn load_backlinks(vault: &NoteVault, note_path: &VaultPath) -> Vec<BacklinkEntry> {
538 let backlinks = match vault.get_backlinks(note_path).await {
539 Ok(bl) => bl,
540 Err(_) => return Vec::new(),
541 };
542
543 let target_name = note_path.get_clean_name();
544
545 let mut entries = Vec::with_capacity(backlinks.len());
546 for (entry_data, content_data) in backlinks {
547 let text = vault
548 .get_note_text(&entry_data.path)
549 .await
550 .unwrap_or_default();
551 let context = extract_context(&text, &target_name);
552 let (_parent, filename) = entry_data.path.get_parent_path();
553
554 entries.push(BacklinkEntry {
555 path: entry_data.path,
556 title: content_data.title,
557 filename,
558 context,
559 full_text: Some(text),
560 });
561 }
562
563 entries
564}
565
566fn extract_context(text: &str, target_name: &str) -> String {
577 let target_lower = target_name.to_lowercase();
578
579 let with_ext = VaultPath::note_path_from(&target_lower)
581 .to_string_with_ext()
582 .to_lowercase();
583 let filename_ext = with_ext.rsplit('/').next().unwrap_or(&with_ext);
585
586 let wikilink_full = format!("[[{}]]", target_lower);
588 let wikilink_partial = format!("[[{}", target_lower);
589 let md_link = format!("({})", target_lower);
590 let md_link_ext = format!("({})", filename_ext);
591
592 let paragraphs = split_paragraphs(text);
594
595 for para in ¶graphs {
596 let lower = para.to_lowercase();
597 if lower.contains(&wikilink_full)
598 || lower.contains(&wikilink_partial)
599 || lower.contains(&md_link)
600 || lower.contains(&md_link_ext)
601 {
602 return para.clone();
603 }
604 }
605
606 text.lines()
608 .find(|l| !l.trim().is_empty())
609 .unwrap_or("")
610 .to_string()
611}
612
613fn split_paragraphs(text: &str) -> Vec<String> {
616 let mut paragraphs = Vec::new();
617 let mut current: Vec<&str> = Vec::new();
618
619 for line in text.lines() {
620 if line.trim().is_empty() {
621 if !current.is_empty() {
622 paragraphs.push(current.join("\n"));
623 current.clear();
624 }
625 } else {
626 current.push(line);
627 }
628 }
629 if !current.is_empty() {
630 paragraphs.push(current.join("\n"));
631 }
632
633 paragraphs
634}
635
636fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
644 if max_width == 0 || line.chars().count() <= max_width {
645 return vec![line.to_string()];
646 }
647
648 let mut result = Vec::new();
649 let mut remaining = line;
650
651 while remaining.chars().count() > max_width {
652 let byte_limit = remaining
654 .char_indices()
655 .nth(max_width)
656 .map(|(i, _)| i)
657 .unwrap_or(remaining.len());
658
659 let break_at = remaining[..byte_limit]
661 .rfind(' ')
662 .map(|i| i + 1) .unwrap_or(byte_limit); result.push(remaining[..break_at].trim_end().to_string());
665 remaining = &remaining[break_at..];
666 }
667 if !remaining.is_empty() {
668 result.push(remaining.to_string());
669 }
670 result
671}
672
673fn find_case_insensitive(haystack: &str, needle: &str) -> Option<(usize, usize)> {
678 let needle_chars: Vec<char> = needle.chars().collect();
679 if needle_chars.is_empty() {
680 return None;
681 }
682 let hay_indices: Vec<(usize, char)> = haystack.char_indices().collect();
683 'outer: for start_idx in 0..hay_indices.len() {
684 if start_idx + needle_chars.len() > hay_indices.len() {
685 break;
686 }
687 for (j, &nc) in needle_chars.iter().enumerate() {
688 let hc = hay_indices[start_idx + j].1;
689 let mut h_lower = hc.to_lowercase();
691 let mut n_lower = nc.to_lowercase();
692 if h_lower.next() != n_lower.next() {
693 continue 'outer;
694 }
695 }
696 let byte_start = hay_indices[start_idx].0;
698 let byte_end = if start_idx + needle_chars.len() < hay_indices.len() {
699 hay_indices[start_idx + needle_chars.len()].0
700 } else {
701 haystack.len()
702 };
703 return Some((byte_start, byte_end));
704 }
705 None
706}
707
708fn highlight_link(
712 line: &str,
713 target: &str,
714 fg_muted: ratatui::style::Color,
715 bg: ratatui::style::Color,
716 theme: &Theme,
717) -> Vec<Span<'static>> {
718 let normal_style = Style::default().fg(fg_muted).bg(bg);
719 let bold_style = Style::default()
720 .fg(theme.accent.to_ratatui())
721 .bg(bg)
722 .add_modifier(Modifier::BOLD);
723
724 let with_ext = VaultPath::note_path_from(target)
726 .to_string_with_ext()
727 .to_lowercase();
728 let filename_ext = with_ext.rsplit('/').next().unwrap_or(&with_ext).to_string();
729
730 let needles = [
732 format!("[[{}]]", target),
733 format!("[[{}", target),
734 format!("({})", target),
735 format!("({})", filename_ext),
736 ];
737
738 let mut best_match: Option<(usize, usize)> = None; for needle in &needles {
741 if let Some((start, end)) = find_case_insensitive(line, needle)
742 && (best_match.is_none() || start < best_match.unwrap().0)
743 {
744 best_match = Some((start, end));
745 }
746 }
747
748 let Some((start, end)) = best_match else {
749 return vec![Span::styled(line.to_string(), normal_style)];
750 };
751
752 let mut spans = Vec::new();
753 if start > 0 {
754 spans.push(Span::styled(line[..start].to_string(), normal_style));
755 }
756 spans.push(Span::styled(line[start..end].to_string(), bold_style));
757 if end < line.len() {
758 spans.push(Span::styled(line[end..].to_string(), normal_style));
759 }
760 spans
761}
762
763#[cfg(test)]
768mod tests {
769 use super::*;
770
771 #[test]
772 fn extract_context_finds_wikilink_paragraph() {
773 let text = "\
774# Heading
775
776This is an intro paragraph.
777
778Here I reference [[my-note]] in some context
779that spans two lines.
780
781Another paragraph without links.";
782
783 let result = extract_context(text, "my-note");
784 assert!(result.contains("[[my-note]]"));
785 assert!(result.contains("that spans two lines"));
786 }
787
788 #[test]
789 fn extract_context_fallback_to_first_line() {
790 let text = "\
791# No links here
792
793Just a normal paragraph.";
794
795 let result = extract_context(text, "other-note");
796 assert_eq!(result, "# No links here");
797 }
798
799 #[test]
800 fn extract_context_finds_markdown_link() {
801 let text = "\
802# Title
803
804See [related](my-note.md) for details.
805
806Unrelated content.";
807
808 let result = extract_context(text, "my-note");
809 assert!(result.contains("(my-note.md)"));
810 }
811
812 #[test]
813 fn wrap_line_fits_within_width() {
814 let result = wrap_line("short", 20);
815 assert_eq!(result, vec!["short"]);
816 }
817
818 #[test]
819 fn wrap_line_breaks_at_word_boundary() {
820 let result = wrap_line("hello world foo bar", 12);
821 assert_eq!(result, vec!["hello world", "foo bar"]);
822 }
823
824 #[test]
825 fn wrap_line_hard_breaks_long_word() {
826 let result = wrap_line("abcdefghij", 5);
827 assert_eq!(result, vec!["abcde", "fghij"]);
828 }
829
830 #[test]
831 fn wrap_line_handles_multibyte_chars() {
832 let result = wrap_line("日本語テスト", 3);
834 assert_eq!(result, vec!["日本語", "テスト"]);
835 }
836
837 #[test]
838 fn wrap_line_empty_string() {
839 let result = wrap_line("", 10);
840 assert_eq!(result, vec![""]);
841 }
842
843 #[test]
844 fn highlight_link_bolds_wikilink() {
845 let spans = highlight_link(
846 "see [[my-note]] here",
847 "my-note",
848 ratatui::style::Color::Gray,
849 ratatui::style::Color::Black,
850 &crate::settings::themes::Theme::default(),
851 );
852 assert_eq!(spans.len(), 3);
853 assert_eq!(spans[0].content, "see ");
854 assert_eq!(spans[1].content, "[[my-note]]");
855 assert!(spans[1].style.add_modifier.contains(Modifier::BOLD));
856 assert_eq!(spans[2].content, " here");
857 }
858
859 #[test]
860 fn highlight_link_case_insensitive() {
861 let spans = highlight_link(
862 "See [[My-Note]] here",
863 "my-note",
864 ratatui::style::Color::Gray,
865 ratatui::style::Color::Black,
866 &crate::settings::themes::Theme::default(),
867 );
868 assert_eq!(spans.len(), 3);
869 assert_eq!(spans[1].content, "[[My-Note]]");
870 assert!(spans[1].style.add_modifier.contains(Modifier::BOLD));
871 }
872
873 #[test]
874 fn highlight_link_markdown_with_extension() {
875 let spans = highlight_link(
876 "see [link](my-note.md) here",
877 "my-note",
878 ratatui::style::Color::Gray,
879 ratatui::style::Color::Black,
880 &crate::settings::themes::Theme::default(),
881 );
882 assert_eq!(spans.len(), 3);
883 assert!(spans[1].content.contains("my-note.md"));
884 assert!(spans[1].style.add_modifier.contains(Modifier::BOLD));
885 }
886
887 #[test]
888 fn highlight_link_no_match_returns_single_span() {
889 let spans = highlight_link(
890 "nothing here",
891 "other",
892 ratatui::style::Color::Gray,
893 ratatui::style::Color::Black,
894 &crate::settings::themes::Theme::default(),
895 );
896 assert_eq!(spans.len(), 1);
897 }
898}