notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git
src/panel.rs (5.9K)
1 //! A scrollable list of rows for the side panes: section headers, note
2 //! titles and indented line snippets, each optionally clickable. The
3 //! backlinks and outline pane on the right and the search pane on the left
4 //! all draw through this, so they hover, scroll and click alike.
5
6 use cce_ui::scene::layout::Rect;
7 use cce_ui::scene::paint::{PaintCtx, TextAttrs};
8 use cce_ui::widget::{Bounds, MouseScrollDelta, ScrollMotion};
9
10 use crate::reading::srgb_u8;
11
12 pub const ROW_H: f32 = 22.0;
13 const PAD: f32 = 6.0;
14 const INDENT: f32 = 14.0;
15
16 /// What a click on a row does.
17 #[derive(Clone, Debug, PartialEq)]
18 pub enum Action {
19 /// Open a note, optionally at a 0-based source line.
20 Open { path: String, line: Option<usize> },
21 /// Bring a 0-based line of the open note into view.
22 Line(usize),
23 }
24
25 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
26 pub enum Kind {
27 /// A section label ("Linked mentions (3)").
28 Header,
29 /// A note's name.
30 Title,
31 /// A line of text under a title, or an outline heading.
32 Line,
33 /// An explanatory line ("No backlinks").
34 Note,
35 }
36
37 #[derive(Clone, Debug, PartialEq)]
38 pub struct Item {
39 pub depth: usize,
40 pub text: String,
41 pub kind: Kind,
42 pub action: Option<Action>,
43 }
44
45 impl Item {
46 pub fn header(text: impl Into<String>) -> Item {
47 Item { depth: 0, text: text.into(), kind: Kind::Header, action: None }
48 }
49 pub fn note(text: impl Into<String>) -> Item {
50 Item { depth: 0, text: text.into(), kind: Kind::Note, action: None }
51 }
52 pub fn title(text: impl Into<String>, action: Action) -> Item {
53 Item { depth: 0, text: text.into(), kind: Kind::Title, action: Some(action) }
54 }
55 pub fn line(depth: usize, text: impl Into<String>, action: Action) -> Item {
56 Item { depth, text: text.into(), kind: Kind::Line, action: Some(action) }
57 }
58 }
59
60 #[derive(Default)]
61 pub struct Panel {
62 pub items: Vec<Item>,
63 pub scroll: f32,
64 motion: ScrollMotion,
65 pub hover: Option<usize>,
66 }
67
68 impl Panel {
69 /// Replace the rows. The scroll stays when the rows are an update of
70 /// the same content (a refresh), and resets when `reset` says the
71 /// content is for something else (another note, another query).
72 pub fn set(&mut self, items: Vec<Item>, reset: bool) {
73 self.items = items;
74 self.hover = None;
75 if reset {
76 self.scroll = 0.0;
77 self.motion = ScrollMotion::new();
78 }
79 }
80
81 fn max_scroll(&self, area: Rect) -> f32 {
82 (self.items.len() as f32 * ROW_H + 2.0 * PAD - area.height).max(0.0)
83 }
84
85 pub fn item_at(&self, area: Rect, x: f32, y: f32) -> Option<usize> {
86 if x < area.x || x > area.x + area.width || y < area.y || y > area.y + area.height {
87 return None;
88 }
89 let i = ((y - area.y - PAD + self.scroll) / ROW_H).floor();
90 if i < 0.0 {
91 return None;
92 }
93 let i = i as usize;
94 (i < self.items.len() && self.items[i].action.is_some()).then_some(i)
95 }
96
97 /// Update the hovered row; true when it changed.
98 pub fn hover_at(&mut self, area: Rect, x: f32, y: f32) -> bool {
99 let h = self.item_at(area, x, y);
100 let changed = h != self.hover;
101 self.hover = h;
102 changed
103 }
104
105 pub fn click(&self, area: Rect, x: f32, y: f32) -> Option<Action> {
106 self.item_at(area, x, y).and_then(|i| self.items[i].action.clone())
107 }
108
109 pub fn wheel(&mut self, delta: &MouseScrollDelta, area: Rect) -> bool {
110 let max = self.max_scroll(area);
111 self.motion.reconcile(0.0, self.scroll);
112 let moved = self.motion.apply(delta, (ROW_H, ROW_H), Bounds::max(0.0), Bounds::max(max));
113 self.scroll = self.motion.y.pos();
114 moved
115 }
116
117 /// Advance a wheel glide; true while it moves.
118 pub fn tick(&mut self, dt: f32, area: Rect) -> bool {
119 if !self.motion.is_animating() {
120 return false;
121 }
122 let max = self.max_scroll(area);
123 self.motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
124 self.scroll = self.motion.y.pos();
125 true
126 }
127
128 pub fn clamp(&mut self, area: Rect) {
129 let max = self.max_scroll(area);
130 if self.scroll > max {
131 self.scroll = max;
132 self.motion.y.jump_to(max);
133 }
134 }
135
136 pub fn paint(&self, pc: &mut PaintCtx, area: Rect) {
137 let (family, size) = cce_ui::layout::tree_font_parsed();
138 let small = (size * 0.9).round();
139 let body = "sans-serif".to_string();
140 pc.clip(area, |pc| {
141 for (i, item) in self.items.iter().enumerate() {
142 let y = area.y + PAD + i as f32 * ROW_H - self.scroll;
143 if y + ROW_H < area.y || y > area.y + area.height {
144 continue;
145 }
146 let r = Rect { x: area.x + 4.0, y, width: area.width - 8.0, height: ROW_H };
147 if self.hover == Some(i) {
148 pc.rounded_rect(r, 4.0, (true, true, true, true), [1.0, 1.0, 1.0, 0.05]);
149 }
150 let x = r.x + 6.0 + item.depth as f32 * INDENT;
151 let bounds = Some([x, r.y.max(area.y), r.x + r.width - 4.0, (r.y + r.height).min(area.y + area.height)]);
152 let (font, fsize, color, attrs) = match item.kind {
153 Kind::Header => (family.clone(), small, cce_ui::colors::TEXT_DIM, TextAttrs::default()),
154 Kind::Title => (family.clone(), size, cce_ui::colors::TEXT_FG, TextAttrs::default()),
155 Kind::Line => (body.clone(), small, cce_ui::colors::TEXT_FG, TextAttrs::default()),
156 Kind::Note => (body.clone(), small, cce_ui::colors::TEXT_DIM, TextAttrs { italic: true, weight: None, ..Default::default() }),
157 };
158 let ty = cce_ui::layout::align_text_y(r.y, r.height, fsize, 0.0);
159 pc.text_attrs(item.text.clone(), x, ty, fsize, srgb_u8(color), Some(font), bounds, attrs);
160 }
161 });
162 }
163 }