Skip to main content

kimun_notes/components/
drawer_views.rs

1//! The phase-03 drawer views: **TAGS**, **LINKS**, and **OUTLINE** — each a
2//! thin adapter (`ListPanelSpec` + a `RowSource`) of the shared
3//! [`QueryListPanel`] body, over core's vault API. Rebuilt on demand
4//! (`refresh`) — the same engine-per-context pattern the sidebar uses per
5//! directory.
6
7use std::collections::HashSet;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use kimun_core::NoteVault;
12use kimun_core::nfs::VaultPath;
13use kimun_core::note::LinkType;
14use ratatui::Frame;
15use ratatui::crossterm::event::KeyCode;
16use ratatui::layout::{Constraint, Direction, Layout, Rect};
17use ratatui::style::{Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{ListItem, Paragraph};
20
21use crate::components::event_state::EventState;
22use crate::components::events::{AppEvent, AppTx, FileOp, InputEvent};
23use crate::components::panel::panel_block;
24use crate::components::query_list_panel::{ListPanelSpec, QueryListPanel};
25use crate::components::rich_row::RichRow;
26use crate::components::search_list::{Emit, RowSource, SearchRow};
27use crate::settings::icons::Icons;
28use crate::settings::themes::Theme;
29
30// ---------------------------------------------------------------------------
31// TAGS
32// ---------------------------------------------------------------------------
33
34#[derive(Clone)]
35pub struct TagEntry {
36    pub label: String,
37    pub count: usize,
38}
39
40impl SearchRow for TagEntry {
41    fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
42        let aqua = Style::default().fg(theme.aqua.to_ratatui());
43        RichRow::new("#", self.label.clone())
44            .glyph_style(aqua)
45            .title_style(aqua)
46            .meta(self.count.to_string())
47            .into_list_item(theme)
48    }
49
50    fn match_text(&self) -> Option<&str> {
51        Some(&self.label)
52    }
53
54    fn visual_height(&self) -> u16 {
55        1
56    }
57}
58
59struct TagSource {
60    vault: Arc<NoteVault>,
61}
62
63#[async_trait]
64impl RowSource<TagEntry> for TagSource {
65    async fn load(&self, _query: &str, emit: Emit<TagEntry>) {
66        let mut rows: Vec<TagEntry> = self
67            .vault
68            .label_counts()
69            .await
70            .unwrap_or_default()
71            .into_iter()
72            .map(|(label, count)| TagEntry { label, count })
73            .collect();
74        // Most-used first; ties alphabetical (counts come in alphabetical).
75        rows.sort_by_key(|r| std::cmp::Reverse(r.count));
76        emit.replace(rows);
77    }
78
79    fn reload_on_query(&self) -> bool {
80        false // load once; the local fuzzy filter narrows the set
81    }
82}
83
84/// Spec: Enter / click runs the tag's query in the FIND drawer.
85pub struct TagsSpec;
86
87impl ListPanelSpec for TagsSpec {
88    type Row = TagEntry;
89    const TITLE: &'static str = "Tags";
90
91    fn submit(row: &TagEntry, tx: &AppTx) {
92        tx.send(AppEvent::RunTagQuery(row.label.clone())).ok();
93    }
94
95    fn hints() -> Vec<(String, String)> {
96        vec![("Enter".into(), "Run tag query".into())]
97    }
98}
99
100/// The TAGS drawer: every `#tag` in the vault with its note count.
101pub struct TagsPanel {
102    vault: Arc<NoteVault>,
103    body: QueryListPanel<TagsSpec>,
104}
105
106impl TagsPanel {
107    pub fn new(vault: Arc<NoteVault>, icons: Icons) -> Self {
108        Self {
109            vault,
110            body: QueryListPanel::new(icons),
111        }
112    }
113
114    /// (Re)load the tag list. Called when the view is opened.
115    pub fn refresh(&mut self, tx: &AppTx) {
116        self.body.set_source(
117            TagSource {
118                vault: self.vault.clone(),
119            },
120            tx,
121        );
122    }
123
124    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
125        self.body.hint_shortcuts()
126    }
127
128    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
129        self.body.handle_input(event, tx)
130    }
131
132    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
133        self.body.render(f, rect, theme, focused);
134    }
135}
136
137// ---------------------------------------------------------------------------
138// LINKS
139// ---------------------------------------------------------------------------
140
141#[derive(Clone, Copy, PartialEq, Eq, Debug)]
142pub enum LinksTab {
143    Backlinks,
144    Outgoing,
145    Unlinked,
146}
147
148impl LinksTab {
149    /// The sub-view order, single source for cycling and the tab bar.
150    pub const ORDER: [LinksTab; 3] = [LinksTab::Backlinks, LinksTab::Outgoing, LinksTab::Unlinked];
151
152    /// The tab `steps` away in [`Self::ORDER`], wrapping.
153    fn cycled(self, steps: isize) -> LinksTab {
154        let n = Self::ORDER.len() as isize;
155        let i = Self::ORDER.iter().position(|t| *t == self).unwrap_or(0) as isize;
156        Self::ORDER[((i + steps).rem_euclid(n)) as usize]
157    }
158
159    fn label(self) -> &'static str {
160        match self {
161            LinksTab::Backlinks => "backlinks",
162            LinksTab::Outgoing => "outgoing",
163            LinksTab::Unlinked => "unlinked",
164        }
165    }
166}
167
168#[derive(Clone)]
169pub struct LinkEntry {
170    pub path: VaultPath,
171    pub title: String,
172    pub filename: String,
173}
174
175impl LinkEntry {
176    fn from_path(path: VaultPath) -> Self {
177        let title = path.get_clean_name();
178        let (_, filename) = path.get_parent_path();
179        Self {
180            path,
181            title,
182            filename,
183        }
184    }
185}
186
187impl SearchRow for LinkEntry {
188    fn to_list_item(&self, theme: &Theme, icons: &Icons, _selected: bool) -> ListItem<'static> {
189        let title = if self.title.is_empty() {
190            self.filename.clone()
191        } else {
192            self.title.clone()
193        };
194        RichRow::new(icons.note, title)
195            .filename(self.filename.clone())
196            .into_list_item(theme)
197    }
198
199    fn match_text(&self) -> Option<&str> {
200        Some(&self.filename)
201    }
202
203    fn visual_height(&self) -> u16 {
204        2
205    }
206}
207
208struct LinksSource {
209    vault: Arc<NoteVault>,
210    note: VaultPath,
211    tab: LinksTab,
212}
213
214#[async_trait]
215impl RowSource<LinkEntry> for LinksSource {
216    async fn load(&self, _query: &str, emit: Emit<LinkEntry>) {
217        if self.note.is_root_or_empty() {
218            emit.replace(Vec::new());
219            return;
220        }
221        let entries = match self.tab {
222            LinksTab::Backlinks => self
223                .vault
224                .get_backlinks(&self.note)
225                .await
226                .unwrap_or_default()
227                .into_iter()
228                .map(|(entry, content)| {
229                    let (_, filename) = entry.path.get_parent_path();
230                    LinkEntry {
231                        path: entry.path,
232                        title: content.title,
233                        filename,
234                    }
235                })
236                .collect(),
237            LinksTab::Outgoing => {
238                let links = self
239                    .vault
240                    .get_markdown_and_links(&self.note)
241                    .await
242                    .map(|md| md.links)
243                    .unwrap_or_default();
244                let mut seen = HashSet::new();
245                links
246                    .into_iter()
247                    .filter_map(|link| match link.ltype {
248                        LinkType::Note(path) => seen
249                            .insert(path.clone())
250                            .then(|| LinkEntry::from_path(path)),
251                        _ => None,
252                    })
253                    .collect()
254            }
255            LinksTab::Unlinked => {
256                // Notes whose body mentions this note's name as plain text
257                // but does not link to it: text-search the clean name, then
258                // subtract the linking notes and the note itself.
259                let name = self.note.get_clean_name();
260                if name.is_empty() {
261                    emit.replace(Vec::new());
262                    return;
263                }
264                // Quote the name so multi-word names search as one literal
265                // phrase, not an AND of words. Fetch both sets concurrently.
266                let (backlinks, mentions) = tokio::join!(
267                    self.vault.get_backlinks(&self.note),
268                    self.vault.search_notes(kimun_core::quote_query_term(&name))
269                );
270                let linked: HashSet<VaultPath> = backlinks
271                    .unwrap_or_default()
272                    .into_iter()
273                    .map(|(entry, _)| entry.path)
274                    .collect();
275                mentions
276                    .unwrap_or_default()
277                    .into_iter()
278                    // `is_like`: `self.note` may be relative while `entry.path` is
279                    // index-absolute (adr/0021), so `==` would fail to exclude the
280                    // open note from its own unlinked-mentions list.
281                    .filter(|(entry, _)| {
282                        !entry.path.is_like(&self.note) && !linked.contains(&entry.path)
283                    })
284                    .map(|(entry, content)| {
285                        let (_, filename) = entry.path.get_parent_path();
286                        LinkEntry {
287                            path: entry.path,
288                            title: content.title,
289                            filename,
290                        }
291                    })
292                    .collect()
293            }
294        };
295        emit.replace(entries);
296    }
297
298    fn reload_on_query(&self) -> bool {
299        false
300    }
301}
302
303/// Spec: Enter / click opens the entry; rows are real notes, so right-click
304/// opens the file-ops menu. No filter input — `b/o/u` are sub-view keys.
305pub struct LinksSpec;
306
307impl ListPanelSpec for LinksSpec {
308    type Row = LinkEntry;
309    const TITLE: &'static str = "Links";
310    const HAS_FILTER: bool = false;
311
312    fn submit(row: &LinkEntry, tx: &AppTx) {
313        tx.send(AppEvent::open(row.path.clone())).ok();
314    }
315
316    fn context_event(row: &LinkEntry) -> Option<AppEvent> {
317        Some(AppEvent::FileOp(FileOp::ShowMenu(row.path.clone())))
318    }
319
320    fn hints() -> Vec<(String, String)> {
321        vec![
322            ("b/o/u".into(), "Sub-view".into()),
323            ("Enter".into(), "Open".into()),
324        ]
325    }
326}
327
328/// The LINKS drawer for the open note: backlinks / outgoing / unlinked
329/// mentions as sub-tabs (`b` / `o` / `u`, or ←/→) over the shared body.
330pub struct LinksPanel {
331    vault: Arc<NoteVault>,
332    note: VaultPath,
333    tab: LinksTab,
334    body: QueryListPanel<LinksSpec>,
335    /// Screen cell each sub-view tab was drawn into on the last render —
336    /// click-to-switch hit-test (keyboard ↔ mouse parity, spec §10).
337    tab_cells: Vec<(LinksTab, Rect)>,
338}
339
340impl LinksPanel {
341    pub fn new(vault: Arc<NoteVault>, icons: Icons) -> Self {
342        Self {
343            vault,
344            note: VaultPath::empty(),
345            tab: LinksTab::Backlinks,
346            body: QueryListPanel::new(icons),
347            tab_cells: Vec::new(),
348        }
349    }
350
351    pub fn set_note(&mut self, note: VaultPath, tx: &AppTx) {
352        if note != self.note || !self.body.is_loaded() {
353            self.note = note;
354            self.refresh(tx);
355        }
356    }
357
358    pub fn tab(&self) -> LinksTab {
359        self.tab
360    }
361
362    /// Switch to `tab`, used by leader paths (`l b/o/u`).
363    pub fn show_tab(&mut self, tab: LinksTab, tx: &AppTx) {
364        self.set_tab(tab, tx);
365    }
366
367    fn set_tab(&mut self, tab: LinksTab, tx: &AppTx) {
368        if tab != self.tab {
369            self.tab = tab;
370            self.refresh(tx);
371        }
372    }
373
374    fn refresh(&mut self, tx: &AppTx) {
375        self.body.set_source(
376            LinksSource {
377                vault: self.vault.clone(),
378                note: self.note.clone(),
379                tab: self.tab,
380            },
381            tx,
382        );
383    }
384
385    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
386        self.body.hint_shortcuts()
387    }
388
389    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
390        // Tab-bar concerns first (sub-view keys / tab clicks); the rest is
391        // the shared body's.
392        match event {
393            InputEvent::Key(key) => match key.code {
394                KeyCode::Char('b') => {
395                    self.set_tab(LinksTab::Backlinks, tx);
396                    return EventState::Consumed;
397                }
398                KeyCode::Char('o') => {
399                    self.set_tab(LinksTab::Outgoing, tx);
400                    return EventState::Consumed;
401                }
402                KeyCode::Char('u') => {
403                    self.set_tab(LinksTab::Unlinked, tx);
404                    return EventState::Consumed;
405                }
406                KeyCode::Left => {
407                    self.set_tab(self.tab.cycled(-1), tx);
408                    return EventState::Consumed;
409                }
410                KeyCode::Right => {
411                    self.set_tab(self.tab.cycled(1), tx);
412                    return EventState::Consumed;
413                }
414                _ => {}
415            },
416            InputEvent::Mouse(mouse) => {
417                // A click on the tab bar switches the sub-view.
418                if matches!(
419                    mouse.kind,
420                    ratatui::crossterm::event::MouseEventKind::Down(
421                        ratatui::crossterm::event::MouseButton::Left
422                    )
423                ) && let Some(tab) = self
424                    .tab_cells
425                    .iter()
426                    .find(|(_, r)| {
427                        r.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
428                    })
429                    .map(|(t, _)| *t)
430                {
431                    self.set_tab(tab, tx);
432                    return EventState::Consumed;
433                }
434            }
435            _ => {}
436        }
437        self.body.handle_input(event, tx)
438    }
439
440    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
441        let block = panel_block("Links", theme, focused);
442        let inner = block.inner(rect);
443        f.render_widget(block, rect);
444        let rows = Layout::default()
445            .direction(Direction::Vertical)
446            .constraints([Constraint::Length(1), Constraint::Min(0)])
447            .split(inner);
448
449        // Sub-view tab bar: the active tab pops; each tab's cell is recorded
450        // so a click switches to it.
451        self.tab_cells.clear();
452        let mut spans = Vec::new();
453        let mut x = rows[0].x;
454        for (i, tab) in LinksTab::ORDER.into_iter().enumerate() {
455            if i > 0 {
456                spans.push(Span::styled(
457                    " · ",
458                    Style::default().fg(theme.gray.to_ratatui()),
459                ));
460                x += 3;
461            }
462            let style = if tab == self.tab {
463                Style::default()
464                    .fg(theme.aqua.to_ratatui())
465                    .add_modifier(Modifier::BOLD)
466            } else {
467                Style::default().fg(theme.gray.to_ratatui())
468            };
469            let w = tab.label().len() as u16; // labels are ASCII
470            if x < rows[0].right() {
471                self.tab_cells
472                    .push((tab, Rect::new(x, rows[0].y, w.min(rows[0].right() - x), 1)));
473            }
474            spans.push(Span::styled(tab.label(), style));
475            x += w;
476        }
477        f.render_widget(Paragraph::new(Line::from(spans)), rows[0]);
478
479        self.body.render_in(f, rows[1], rect, theme, focused);
480    }
481}
482
483// ---------------------------------------------------------------------------
484// OUTLINE
485// ---------------------------------------------------------------------------
486
487#[derive(Clone)]
488pub struct OutlineEntry {
489    pub heading: String,
490    /// 1-based heading depth (H1 = 1).
491    pub depth: usize,
492}
493
494impl SearchRow for OutlineEntry {
495    fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
496        let indent = "  ".repeat(self.depth.saturating_sub(1));
497        RichRow::new(format!("{indent}≡"), self.heading.clone())
498            .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
499            .into_list_item(theme)
500    }
501
502    fn match_text(&self) -> Option<&str> {
503        Some(&self.heading)
504    }
505
506    fn visual_height(&self) -> u16 {
507        1
508    }
509}
510
511struct OutlineSource {
512    vault: Arc<NoteVault>,
513    note: VaultPath,
514}
515
516#[async_trait]
517impl RowSource<OutlineEntry> for OutlineSource {
518    async fn load(&self, _query: &str, emit: Emit<OutlineEntry>) {
519        if self.note.is_root_or_empty() {
520            emit.replace(Vec::new());
521            return;
522        }
523        // Read the note and take the heading hierarchy from its content
524        // chunks (document order). Each chunk's breadcrumb is the heading
525        // path to it; the innermost part is the chunk's own heading.
526        let Ok(details) = self.vault.load_note(&self.note).await else {
527            emit.replace(Vec::new());
528            return;
529        };
530        // One chunk per heading section (core contract), in document order;
531        // a headingless preamble chunk has an empty breadcrumb and is skipped.
532        let entries: Vec<OutlineEntry> = details
533            .get_content_chunks()
534            .into_iter()
535            .filter_map(|chunk| {
536                let depth = chunk.breadcrumb_parts().count();
537                chunk.breadcrumb_last().map(|heading| OutlineEntry {
538                    heading: heading.to_string(),
539                    depth,
540                })
541            })
542            .collect();
543        emit.replace(entries);
544    }
545
546    fn reload_on_query(&self) -> bool {
547        false
548    }
549}
550
551/// Spec: Enter / click jumps the editor to the heading.
552pub struct OutlineSpec;
553
554impl ListPanelSpec for OutlineSpec {
555    type Row = OutlineEntry;
556    const TITLE: &'static str = "Outline";
557
558    fn submit(row: &OutlineEntry, tx: &AppTx) {
559        tx.send(AppEvent::JumpToHeading(row.heading.clone())).ok();
560    }
561
562    fn hints() -> Vec<(String, String)> {
563        vec![("Enter".into(), "Jump to heading".into())]
564    }
565}
566
567/// The OUTLINE drawer: the open note's headings as an indented tree.
568pub struct OutlinePanel {
569    vault: Arc<NoteVault>,
570    note: VaultPath,
571    body: QueryListPanel<OutlineSpec>,
572}
573
574impl OutlinePanel {
575    pub fn new(vault: Arc<NoteVault>, icons: Icons) -> Self {
576        Self {
577            vault,
578            note: VaultPath::empty(),
579            body: QueryListPanel::new(icons),
580        }
581    }
582
583    pub fn set_note(&mut self, note: VaultPath, tx: &AppTx) {
584        if note != self.note || !self.body.is_loaded() {
585            self.note = note;
586            self.refresh(tx);
587        }
588    }
589
590    /// Re-read the headings (e.g. after the buffer was saved).
591    pub fn refresh(&mut self, tx: &AppTx) {
592        self.body.set_source(
593            OutlineSource {
594                vault: self.vault.clone(),
595                note: self.note.clone(),
596            },
597            tx,
598        );
599    }
600
601    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
602        self.body.hint_shortcuts()
603    }
604
605    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
606        self.body.handle_input(event, tx)
607    }
608
609    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
610        self.body.render(f, rect, theme, focused);
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::test_support::temp_vault;
618
619    use crate::components::search_list::SearchList;
620
621    /// Poll a panel's list until the async load lands.
622    async fn drain<R: SearchRow + Clone + Send + Sync + 'static>(list: &mut SearchList<R>) {
623        for _ in 0..50 {
624            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
625            list.poll();
626        }
627    }
628
629    #[tokio::test(flavor = "multi_thread")]
630    async fn tags_panel_lists_label_counts() {
631        let vault = temp_vault("tags-panel").await;
632        vault.validate_and_init().await.unwrap();
633        vault
634            .save_note(&VaultPath::note_path_from("a"), "x #alpha #beta")
635            .await
636            .unwrap();
637        vault
638            .save_note(&VaultPath::note_path_from("b"), "y #alpha")
639            .await
640            .unwrap();
641
642        let mut panel = TagsPanel::new(vault, Icons::new(false));
643        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
644        panel.refresh(&tx);
645        drain(panel.body.list_mut().unwrap()).await;
646
647        let rows = panel.body.list().unwrap().visible_rows();
648        let labels: Vec<(&str, usize)> = rows.iter().map(|r| (r.label.as_str(), r.count)).collect();
649        // Most-used first.
650        assert_eq!(labels, vec![("alpha", 2), ("beta", 1)]);
651    }
652
653    #[tokio::test(flavor = "multi_thread")]
654    async fn links_panel_tabs_track_note() {
655        let vault = temp_vault("links-panel").await;
656        vault.validate_and_init().await.unwrap();
657        // projectx is linked from linker, mentioned (no link) in mentions.
658        vault
659            .save_note(&VaultPath::note_path_from("projectx"), "the note body")
660            .await
661            .unwrap();
662        vault
663            .save_note(
664                &VaultPath::note_path_from("linker"),
665                "links to [[projectx]] here",
666            )
667            .await
668            .unwrap();
669        vault
670            .save_note(
671                &VaultPath::note_path_from("mentions"),
672                "talks about projectx without linking",
673            )
674            .await
675            .unwrap();
676
677        let mut panel = LinksPanel::new(vault, Icons::new(false));
678        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
679
680        // Backlinks of projectx → linker.
681        panel.set_note(VaultPath::note_path_from("projectx"), &tx);
682        drain(panel.body.list_mut().unwrap()).await;
683        let names: Vec<&str> = panel
684            .body
685            .list()
686            .unwrap()
687            .visible_rows()
688            .iter()
689            .map(|r| r.filename.as_str())
690            .collect();
691        assert_eq!(names, vec!["linker.md"], "backlinks tab");
692
693        // Outgoing of linker → projectx.
694        panel.set_note(VaultPath::note_path_from("linker"), &tx);
695        panel.set_tab(LinksTab::Outgoing, &tx);
696        drain(panel.body.list_mut().unwrap()).await;
697        let names: Vec<&str> = panel
698            .body
699            .list()
700            .unwrap()
701            .visible_rows()
702            .iter()
703            .map(|r| r.filename.as_str())
704            .collect();
705        assert_eq!(names, vec!["projectx.md"], "outgoing tab");
706
707        // Unlinked mentions of projectx → mentions (linker is excluded).
708        panel.set_note(VaultPath::note_path_from("projectx"), &tx);
709        panel.set_tab(LinksTab::Unlinked, &tx);
710        drain(panel.body.list_mut().unwrap()).await;
711        let names: Vec<&str> = panel
712            .body
713            .list()
714            .unwrap()
715            .visible_rows()
716            .iter()
717            .map(|r| r.filename.as_str())
718            .collect();
719        assert!(
720            names.contains(&"mentions.md") && !names.contains(&"linker.md"),
721            "unlinked tab: got {names:?}"
722        );
723    }
724
725    #[tokio::test(flavor = "multi_thread")]
726    async fn outline_panel_lists_headings_in_order() {
727        let vault = temp_vault("outline-panel").await;
728        vault.validate_and_init().await.unwrap();
729        vault
730            .save_note(
731                &VaultPath::note_path_from("doc"),
732                "# Top\nintro\n## Sub One\nbody\n## Sub Two\nmore\n# Second\nend\n",
733            )
734            .await
735            .unwrap();
736
737        let mut panel = OutlinePanel::new(vault, Icons::new(false));
738        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
739        panel.set_note(VaultPath::note_path_from("doc"), &tx);
740        drain(panel.body.list_mut().unwrap()).await;
741
742        let rows = panel.body.list().unwrap().visible_rows();
743        let headings: Vec<(&str, usize)> =
744            rows.iter().map(|r| (r.heading.as_str(), r.depth)).collect();
745        assert_eq!(
746            headings,
747            vec![("Top", 1), ("Sub One", 2), ("Sub Two", 2), ("Second", 1)]
748        );
749    }
750}