authorclover caruso <git@paperclover.net> 2026-10-16 01:50:45-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 02:24:41-07:00
logf6222caecf1c286dee2a1746307bf90d715044cf
tree7879cedf26eef9e70c36a9f40086360dcbe19d0a
parent5e2a514da35d4f367f7084d85bdbe75af7584177
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

refactor: name the task panes TaskPane

OneNote 2010 calls the Search Results, Tags Summary and Spelling panes beside the page task panes. Naming them so frees Pane for the page views a split window will hold: the module becomes task_pane.rs, the enum TaskPane, the Search field task_pane, and close_pane and toggle_pane say which pane they close and toggle. Nothing else changes. Assisted-by: claude-opus-5.5

5 files changed, 1108 insertions(+), 1108 deletions(-)

crates/snowbound/src/commands.rs+5-5
......@@ -1295,15 +1295,15 @@ impl State {
12951295 Id::SearchResults => Status {
12961296 enabled: !modal && session.is_some(),
12971297 checked: Some(matches!(
1298 self.search.pane,
1299 Some(crate::pane::Pane::Search { .. })
1298 self.search.task_pane,
1299 Some(crate::task_pane::TaskPane::Search { .. })
13001300 )),
13011301 },
13021302 Id::FindTags => Status {
13031303 enabled: !modal && session.is_some(),
13041304 checked: Some(matches!(
1305 self.search.pane,
1306 Some(crate::pane::Pane::Tags { .. })
1305 self.search.task_pane,
1306 Some(crate::task_pane::TaskPane::Tags { .. })
13071307 )),
13081308 },
13091309 Id::PagesMatchTheme => Status {
......@@ -1650,7 +1650,7 @@ impl State {
16501650 return Ok(());
16511651 }
16521652 Id::SearchResults | Id::FindTags => {
1653 self.toggle_pane(id == Id::FindTags);
1653 self.toggle_task_pane(id == Id::FindTags);
16541654 return Ok(());
16551655 }
16561656 Id::GoTo | Id::CommandPalette => {
crates/snowbound/src/main.rs+1-1
......@@ -48,7 +48,6 @@ mod menus;
4848mod navigation;
4949mod options;
5050mod palette;
51mod pane;
5251mod paste;
5352#[cfg_attr(target_os = "linux", path = "linux.rs")]
5453#[cfg_attr(target_os = "macos", path = "macos.rs")]
......@@ -88,6 +87,7 @@ mod surface;
8887mod symbol;
8988mod sync;
9089mod tags;
90mod task_pane;
9191mod templates;
9292mod themes;
9393mod undo;
crates/snowbound/src/pane.rs deleted-1097
......@@ -1,1097 +0,0 @@
1//! OneNote 2010's task panes beside the page: Page Search Results (Alt+O), listing the
2//! search's results sorted and grouped, the Tags Summary, listing tagged paragraphs, and
3//! Spelling (F7), walking the page's marked words.
4
5use crate::search::{Scope, marked};
6use crate::{State, Theme, art, page, platform};
7use canvas::gpu::tag_sources;
8use canvas::outline::TagIcon;
9use canvas::search::{Found, Tagged};
10use std::{error::Error, sync::atomic::Ordering};
11use ui::{Anchor, Axis, Flags, Id, Spec, Ui, fill, fit, px};
12use winit::keyboard::NamedKey;
13
14/// The pane's width, and its lists' rows and group headings.
15const WIDTH: f32 = 260.0;
16const RESULT: f32 = 40.0;
17const TAGGED: f32 = 24.0;
18const HEADING: f32 = 22.0;
19/// A control's width inside the pane.
20const INSIDE: f32 = WIDTH - 2.0 * PAD;
21const PAD: f32 = 8.0;
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub enum Pane {
25 Search {
26 sort: Sort,
27 descending: bool,
28 },
29 Tags {
30 group: Group,
31 unchecked: bool,
32 scope: TagScope,
33 },
34 /// The word is `State::correction`; `chosen` is the suggestion Change takes.
35 Spelling {
36 chosen: usize,
37 },
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Sort {
42 Section,
43 Title,
44 Date,
45}
46
47impl Sort {
48 const ALL: [Sort; 3] = [Sort::Section, Sort::Title, Sort::Date];
49
50 fn name(self) -> &'static str {
51 match self {
52 Sort::Section => "Sort by Section",
53 Sort::Title => "Sort by Title",
54 Sort::Date => "Sort by Date Modified",
55 }
56 }
57}
58
59/// What the Tags Summary groups its paragraphs by.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum Group {
62 Tag,
63 Section,
64 Title,
65 Date,
66 Text,
67}
68
69impl Group {
70 const ALL: [Group; 5] = [
71 Group::Tag,
72 Group::Section,
73 Group::Title,
74 Group::Date,
75 Group::Text,
76 ];
77
78 fn name(self) -> &'static str {
79 match self {
80 Group::Tag => "Tag name",
81 Group::Section => "Section",
82 Group::Title => "Title",
83 Group::Date => "Date",
84 Group::Text => "Note text",
85 }
86 }
87}
88
89/// Where the Tags Summary looks: part of the notebooks, or tags applied in a span of days.
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub enum TagScope {
92 PageGroup,
93 Section,
94 SectionGroup,
95 Notebook,
96 All,
97 Today,
98 Yesterday,
99 ThisWeek,
100 LastWeek,
101 Older,
102}
103
104impl TagScope {
105 const ALL: [TagScope; 10] = [
106 TagScope::PageGroup,
107 TagScope::Section,
108 TagScope::SectionGroup,
109 TagScope::Notebook,
110 TagScope::All,
111 TagScope::Today,
112 TagScope::Yesterday,
113 TagScope::ThisWeek,
114 TagScope::LastWeek,
115 TagScope::Older,
116 ];
117
118 /// Its name, which also heads the paragraphs tagged in its span when grouped by date.
119 fn name(self) -> &'static str {
120 match self {
121 TagScope::PageGroup => "This page group",
122 TagScope::Section => "This section",
123 TagScope::SectionGroup => "This section group",
124 TagScope::Notebook => "This notebook",
125 TagScope::All => "All notebooks",
126 TagScope::Today => "Today's notes",
127 TagScope::Yesterday => "Yesterday's notes",
128 TagScope::ThisWeek => "This week's notes",
129 TagScope::LastWeek => "Last week's notes",
130 TagScope::Older => "Older notes",
131 }
132 }
133
134 /// Whether a tag applied on local day `day` falls in the span, `today` being today.
135 fn spans(self, day: i64, today: i64) -> bool {
136 // OneNote's weeks start on Sunday; day 0 was a Thursday.
137 let week = today - (today + 4).rem_euclid(7);
138 match self {
139 TagScope::Today => day >= today,
140 TagScope::Yesterday => day == today - 1,
141 TagScope::ThisWeek => day >= week,
142 TagScope::LastWeek => (week - 7..week).contains(&day),
143 TagScope::Older => day < week - 7,
144 _ => true,
145 }
146 }
147
148 /// The span heading a tag applied on `day` when grouped by date.
149 fn of_day(day: i64, today: i64) -> TagScope {
150 [
151 TagScope::Today,
152 TagScope::Yesterday,
153 TagScope::ThisWeek,
154 TagScope::LastWeek,
155 ]
156 .into_iter()
157 .find(|span| span.spans(day, today))
158 .unwrap_or(TagScope::Older)
159 }
160}
161
162/// The local day, counted from 1970, of Time32 `time`.
163fn local_day(time: u64) -> i64 {
164 let unix = time as i64 + 315_532_800;
165 // SAFETY: `localtime_r` writes only the `tm` it is given.
166 #[cfg(unix)]
167 let offset = unsafe {
168 let mut tm: libc::tm = std::mem::zeroed();
169 libc::localtime_r(&unix, &mut tm);
170 tm.tm_gmtoff as i64
171 };
172 #[cfg(any(windows, target_arch = "wasm32"))]
173 let offset = crate::platform::utc_offset(unix);
174 (unix + offset).div_euclid(86_400)
175}
176
177/// Day `day` counted from 1970 as a year, month and day.
178fn civil(day: i64) -> (i64, u32, u32) {
179 let z = day + 719_468;
180 let era = z.div_euclid(146_097);
181 let doe = z.rem_euclid(146_097);
182 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
183 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
184 let mp = (5 * doy + 2) / 153;
185 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
186 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
187 (yoe + era * 400 + i64::from(month <= 2), month, day)
188}
189
190const MONTHS: [&str; 12] = [
191 "January",
192 "February",
193 "March",
194 "April",
195 "May",
196 "June",
197 "July",
198 "August",
199 "September",
200 "October",
201 "November",
202 "December",
203];
204
205/// Rows under group headings, each heading above the row starting its group.
206struct Grouped {
207 count: usize,
208 /// The rows starting groups, in order.
209 starts: Vec<usize>,
210}
211
212impl ui::Rows for Grouped {
213 fn count(&self) -> usize {
214 self.count
215 }
216
217 fn key(&self, index: usize) -> u64 {
218 index as u64
219 }
220
221 fn find(&self, key: u64) -> Option<usize> {
222 ((key as usize) < self.count).then_some(key as usize)
223 }
224
225 fn space_before(&self, index: usize) -> f32 {
226 self.starts.partition_point(|start| *start <= index) as f32 * HEADING
227 }
228}
229
230/// `items` in `order`, and each group's first row with its heading.
231fn grouped(order: &[usize], heading: impl Fn(usize) -> String) -> (Grouped, Vec<String>) {
232 let mut starts = Vec::new();
233 let mut headings: Vec<String> = Vec::new();
234 for (row, &item) in order.iter().enumerate() {
235 let name = heading(item);
236 if headings.last() != Some(&name) {
237 starts.push(row);
238 headings.push(name);
239 }
240 }
241 (
242 Grouped {
243 count: order.len(),
244 starts,
245 },
246 headings,
247 )
248}
249
250/// A section's name from its `Library::key`.
251fn section_name(key: &str) -> String {
252 let path = key.split_once('\n').map_or(key, |(_, path)| path);
253 crate::library::section_name(path, &None)
254}
255
256fn shown_title(title: &str) -> &str {
257 if title.trim().is_empty() {
258 "Untitled page"
259 } else {
260 title
261 }
262}
263
264/// The search's results in `sort`'s order, and their groups: sections, first letters of
265/// titles, or months.
266fn arrange(found: &[Found], sort: Sort, descending: bool) -> (Vec<usize>, Grouped, Vec<String>) {
267 let mut order: Vec<usize> = (0..found.len()).collect();
268 match sort {
269 Sort::Section => order.sort_by_cached_key(|&at| {
270 let found = &found[at];
271 (
272 section_name(&found.section).to_lowercase(),
273 found.section.clone(),
274 found.order,
275 )
276 }),
277 Sort::Title => {
278 order.sort_by_cached_key(|&at| shown_title(&found[at].title).to_lowercase());
279 }
280 Sort::Date => order.sort_by_key(|&at| found[at].modified),
281 }
282 if descending {
283 order.reverse();
284 }
285 let (rows, headings) = grouped(&order, |at| {
286 let found = &found[at];
287 match sort {
288 Sort::Section => section_name(&found.section),
289 Sort::Title => shown_title(&found.title)
290 .chars()
291 .next()
292 .map_or_else(String::new, |first| first.to_uppercase().collect()),
293 Sort::Date => {
294 let (year, month, _) = civil(local_day(found.modified));
295 format!("{}, {year}", MONTHS[month as usize - 1])
296 }
297 }
298 });
299 (order, rows, headings)
300}
301
302/// Tagged paragraphs in `group`'s order and their groups, each group's paragraphs in page
303/// order.
304fn arrange_tags(tagged: &[Tagged], group: Group) -> (Vec<usize>, Grouped, Vec<String>) {
305 let today = local_day(crate::search::now());
306 let heading = |at: usize| -> String {
307 let tagged = &tagged[at];
308 match group {
309 Group::Tag => tagged.name.clone(),
310 Group::Section => section_name(&tagged.section),
311 Group::Title => shown_title(&tagged.title).to_owned(),
312 Group::Date => TagScope::of_day(local_day(tagged.created), today)
313 .name()
314 .to_owned(),
315 Group::Text => Group::Text.name().to_owned(),
316 }
317 };
318 let mut order: Vec<usize> = (0..tagged.len()).collect();
319 match group {
320 Group::Date => order.sort_by_key(|&at| {
321 TagScope::ALL
322 .iter()
323 .position(|span| *span == TagScope::of_day(local_day(tagged[at].created), today))
324 }),
325 Group::Text => order.sort_by_cached_key(|&at| tagged[at].text.to_lowercase()),
326 _ => order.sort_by_cached_key(|&at| heading(at).to_lowercase()),
327 }
328 let (rows, headings) = grouped(&order, heading);
329 (order, rows, headings)
330}
331
332/// The field of the Search Results pane.
333pub(crate) fn field() -> Id {
334 Id::ROOT.child("pane-field")
335}
336
337fn popup(name: &str) -> Id {
338 Id::ROOT.child(("pane popup", name))
339}
340
341/// A combo box `label`led, `width` wide, showing `current` of `names`, returning the one
342/// chosen.
343fn choice(
344 ui: &mut Ui,
345 name: &str,
346 label: &str,
347 current: usize,
348 names: &[&str],
349 width: f32,
350) -> Option<usize> {
351 let combo = ui.id(name);
352 ui::shell::combo(ui, name, label, names[current], width, popup(name), true);
353 let items: Vec<_> = names
354 .iter()
355 .enumerate()
356 .map(|(index, text)| ui::popup::Item {
357 text,
358 checked: Some(index == current),
359 current: index == current,
360 ..Default::default()
361 })
362 .collect();
363 let anchor = Anchor::Below(combo);
364 ui::popup::menu(ui, popup(name), anchor, &items, None)
365}
366
367fn label(ui: &mut Ui, theme: &Theme, part: &str, text: &str) {
368 ui.leaf(
369 part,
370 Spec {
371 size: [fill(), px(20.0)],
372 text: Some(text),
373 color: Some(theme.text_dim),
374 ..Spec::default()
375 },
376 );
377}
378
379/// A group's heading, floating above the row starting it.
380fn heading(ui: &mut Ui, theme: &Theme, text: &str) {
381 ui.leaf(
382 "heading",
383 Spec {
384 flags: Flags::FLOAT,
385 size: [fill(), px(HEADING)],
386 position: [0.0, -HEADING],
387 text: Some(text),
388 bold: true,
389 color: Some(theme.text),
390 pad: [4.0, 0.0],
391 ..Spec::default()
392 },
393 );
394}
395
396impl State {
397 /// Opens the Search Results pane on the search, as Alt+O does.
398 pub(crate) fn open_search_pane(&mut self) {
399 if !matches!(self.search.pane, Some(Pane::Search { .. })) {
400 self.search.pane = Some(Pane::Search {
401 sort: Sort::Date,
402 descending: true,
403 });
404 }
405 self.search.open = false;
406 self.search.finding = false;
407 self.search.claim = true;
408 self.ui.close_popup(crate::search::results());
409 self.sync_index(true, Vec::new());
410 }
411
412 /// Opens or closes a pane, as its command does.
413 pub(crate) fn toggle_pane(&mut self, tags: bool) {
414 match self.search.pane {
415 Some(Pane::Tags { .. }) if tags => self.close_pane(),
416 Some(Pane::Search { .. }) if !tags => self.close_pane(),
417 _ if tags => {
418 self.close_pane();
419 self.search.pane = Some(Pane::Tags {
420 group: Group::Tag,
421 unchecked: false,
422 scope: TagScope::Notebook,
423 });
424 self.sync_index(true, Vec::new());
425 }
426 _ => self.open_search_pane(),
427 }
428 }
429
430 /// Closes the pane; the Search Results pane takes its search, and the marks, with it.
431 fn close_pane(&mut self) {
432 self.correction = None;
433 if matches!(self.search.pane.take(), Some(Pane::Search { .. })) {
434 self.end_search();
435 }
436 }
437
438 /// Opens the Spelling pane on the next marked word, as F7 does.
439 pub(crate) fn open_spelling_pane(&mut self) {
440 if !matches!(self.search.pane, Some(Pane::Spelling { .. })) {
441 self.close_pane();
442 }
443 self.next_word();
444 }
445
446 /// Shows the next marked word in the Spelling pane; past the last, the check is complete.
447 fn next_word(&mut self) {
448 match self.view.next_correction() {
449 Ok(Some((response, correction))) => {
450 self.respond(response);
451 self.correction = Some(correction);
452 self.search.pane = Some(Pane::Spelling { chosen: 0 });
453 }
454 Ok(None) => {
455 self.close_pane();
456 platform::alert("The spelling check is complete.", "");
457 }
458 Err(error) => eprintln!("{error}"),
459 }
460 }
461
462 fn spelling_pane(&mut self, theme: &Theme, mut chosen: usize) {
463 let Some(correction) = &self.correction else {
464 return;
465 };
466 let ui = &mut self.ui;
467 label(
468 ui,
469 theme,
470 "kind",
471 if correction.repeated {
472 "Repeated word:"
473 } else {
474 "Current spelling:"
475 },
476 );
477 ui.leaf(
478 "word",
479 Spec {
480 size: [fill(), px(ui::shell::TOOL)],
481 text: Some(&correction.word),
482 bold: true,
483 fill: Some(theme.base),
484 border: Some(theme.chip),
485 radius: 4.0,
486 pad: [6.0, 0.0],
487 ..Spec::default()
488 },
489 );
490 ui.open(
491 "actions",
492 Spec {
493 size: [fill(), px(theme.font_size * 2.0)],
494 gap: 4.0,
495 ..Spec::default()
496 },
497 );
498 let [first, second] = if correction.repeated {
499 ["Delete", "Ignore"]
500 } else {
501 ["Ignore", "Add to Dictionary"]
502 };
503 let pressed = [first, second].map(|text| ui::button(ui, text, text).clicked);
504 ui.close();
505 let mut change = false;
506 if !correction.repeated {
507 label(ui, theme, "suggestions label", "Suggestions:");
508 ui.open(
509 "suggestions",
510 Spec {
511 axis: Axis::Y,
512 size: [fill(), px(TAGGED * 5.0 + 2.0)],
513 fill: Some(theme.base),
514 border: Some(theme.chip),
515 radius: 4.0,
516 pad: [1.0, 1.0],
517 role: Some(accesskit::Role::List),
518 ..Spec::default()
519 },
520 );
521 for (index, suggestion) in correction.suggestions.iter().enumerate() {
522 let row = ui.leaf(
523 index,
524 Spec {
525 flags: Flags::CLICKABLE,
526 size: [fill(), px(TAGGED)],
527 text: Some(suggestion),
528 fill: (index == chosen).then(|| theme.hover()),
529 hover_fill: Some(theme.hover()),
530 pad: [6.0, 0.0],
531 role: Some(accesskit::Role::ListItem),
532 ..Spec::default()
533 },
534 );
535 if row.clicked {
536 chosen = index;
537 change = row.unit != draw::edit::SelectionUnit::Grapheme;
538 }
539 }
540 if correction.suggestions.is_empty() {
541 label(ui, theme, "none", "(No Spelling Suggestions)");
542 }
543 ui.close();
544 if !correction.suggestions.is_empty() {
545 change |= ui::button(ui, "change", "Change").clicked;
546 }
547 }
548 self.search.pane = Some(Pane::Spelling { chosen });
549 let Some(correction) = self
550 .correction
551 .take_if(|_| change || pressed.contains(&true))
552 else {
553 return;
554 };
555 let spelling = self.view.spelling.clone();
556 let text = match (correction.repeated, pressed) {
557 (_, _) if change => correction.suggestions.get(chosen).map(String::as_str),
558 (true, [true, _]) => Some(""),
559 (false, [false, true]) => {
560 spelling.inspect(|spelling| spelling.learn(&correction.word));
561 None
562 }
563 _ => {
564 spelling.inspect(|spelling| spelling.ignore(&correction.word));
565 None
566 }
567 };
568 if let Some(text) = text {
569 match self.view.correct(&correction, text) {
570 Ok(response) => self.respond(response),
571 Err(error) => eprintln!("{error}"),
572 }
573 }
574 self.next_word();
575 }
576
577 /// The open pane at the window's right, easing open and closed.
578 pub(crate) fn task_pane(&mut self, theme: &Theme) {
579 let id = self.ui.id("task pane");
580 let width = self.ui.animate(
581 id,
582 if self.search.pane.is_some() {
583 WIDTH
584 } else {
585 0.0
586 },
587 );
588 self.ui.open(
589 "task pane",
590 Spec {
591 flags: Flags::CLIP,
592 size: [px(width), fill()],
593 fill: Some(theme.strip),
594 ..Spec::default()
595 },
596 );
597 // The content keeps its width as the pane eases, so its labels never reflow.
598 self.ui.open(
599 "content",
600 Spec {
601 axis: Axis::Y,
602 size: [px(WIDTH), fill()],
603 pad: [PAD, 4.0],
604 gap: 4.0,
605 ..Spec::default()
606 },
607 );
608 if let Some(pane) = self.search.pane {
609 self.ui.open(
610 "header",
611 Spec {
612 size: [fill(), px(ui::shell::TOOL)],
613 ..Spec::default()
614 },
615 );
616 self.ui.leaf(
617 "title",
618 Spec {
619 size: [fill(), px(ui::shell::TOOL)],
620 text: Some(match pane {
621 Pane::Search { .. } => "Search Results",
622 Pane::Tags { .. } => "Tags Summary",
623 Pane::Spelling { .. } => "Spelling",
624 }),
625 bold: true,
626 ..Spec::default()
627 },
628 );
629 if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, None)
630 .clicked
631 {
632 self.close_pane();
633 }
634 let close = self.ui.id("close");
635 crate::name(&mut self.ui, close, "Close");
636 self.ui.close();
637 match self.search.pane {
638 Some(Pane::Search { sort, descending }) => {
639 if let Err(error) = self.search_pane(theme, sort, descending) {
640 eprintln!("{error}");
641 }
642 }
643 Some(Pane::Tags {
644 group,
645 unchecked,
646 scope,
647 }) => self.tags_pane(theme, group, unchecked, scope),
648 Some(Pane::Spelling { chosen }) => self.spelling_pane(theme, chosen),
649 None => {}
650 }
651 }
652 self.ui.close();
653 self.ui.close();
654 }
655
656 fn search_pane(
657 &mut self,
658 theme: &Theme,
659 mut sort: Sort,
660 mut descending: bool,
661 ) -> Result<(), Box<dyn Error>> {
662 self.refresh_results()?;
663 let keys = ui::popup::navigation(
664 &mut self.ui,
665 &[field()],
666 &[
667 NamedKey::ArrowUp,
668 NamedKey::ArrowDown,
669 NamedKey::PageUp,
670 NamedKey::PageDown,
671 NamedKey::Enter,
672 NamedKey::Escape,
673 ],
674 );
675 if keys.contains(&NamedKey::Escape) {
676 self.ui.set_focus(Some(page()));
677 }
678 let border = if self.ui.focused() == Some(field()) {
679 theme.accent
680 } else {
681 theme.chip
682 };
683 ui::text_field(
684 &mut self.ui,
685 field(),
686 &mut self.search.query,
687 "Search",
688 Spec {
689 size: [fill(), px(ui::shell::TOOL)],
690 fill: Some(theme.base),
691 border: Some(border),
692 radius: 4.0,
693 pad: [6.0, 0.0],
694 role: Some(accesskit::Role::SearchInput),
695 ..Spec::default()
696 },
697 );
698 crate::name(&mut self.ui, field(), "Search");
699 if std::mem::take(&mut self.search.claim) {
700 self.ui.set_focus(Some(field()));
701 }
702 let scopes = Scope::ALL.map(|scope| format!("Search {}", scope.name()));
703 let names = scopes.each_ref().map(String::as_str);
704 let current = Scope::ALL
705 .iter()
706 .position(|scope| *scope == self.search.scope)
707 .unwrap_or_default();
708 if let Some(index) = choice(
709 &mut self.ui,
710 "scope",
711 "Search scope",
712 current,
713 &names,
714 INSIDE,
715 ) {
716 self.search.scope = Scope::ALL[index];
717 }
718 self.ui.open(
719 "sort",
720 Spec {
721 size: [fill(), px(ui::shell::TOOL)],
722 gap: 2.0,
723 ..Spec::default()
724 },
725 );
726 let names = Sort::ALL.map(Sort::name);
727 let current = Sort::ALL
728 .iter()
729 .position(|listed| *listed == sort)
730 .unwrap_or_default();
731 let width = INSIDE - ui::shell::TOOL - 2.0;
732 if let Some(index) = choice(&mut self.ui, "by", "Sort by", current, &names, width) {
733 sort = Sort::ALL[index];
734 // Dates start newest first, names from the start of the alphabet.
735 descending = sort == Sort::Date;
736 }
737 let arrow = if descending {
738 art::SORT_DESCENDING
739 } else {
740 art::SORT_ASCENDING
741 };
742 if ui::shell::tool_button(&mut self.ui, "direction", arrow, theme.text, None).clicked {
743 descending = !descending;
744 }
745 let tip = if descending {
746 "Sort ascending"
747 } else {
748 "Sort descending"
749 };
750 ui::popup::tooltip(&mut self.ui, tip, "", None);
751 self.ui.close();
752 self.search.pane = Some(Pane::Search { sort, descending });
753 let found = &self.search.found;
754 if found.is_empty() {
755 let status = if self.search.query.trim().is_empty() {
756 ""
757 } else if self.search.pending.load(Ordering::Relaxed) > 0 {
758 "Searching…"
759 } else {
760 "No matches"
761 };
762 label(&mut self.ui, theme, "status", status);
763 return Ok(());
764 }
765 let (order, rows, headings) = arrange(found, sort, descending);
766 let dates: Vec<String> = order
767 .iter()
768 .map(|&at| {
769 let (year, month, day) = civil(local_day(found[at].modified));
770 format!("{month}/{day}/{year}")
771 })
772 .collect();
773 let before = self.search.selected;
774 let list = Id::ROOT.child("pane results");
775 let clicked = ui::list(
776 &mut self.ui,
777 list,
778 Spec {
779 size: [fill(), fill()],
780 fill: Some(theme.base),
781 border: Some(theme.chip),
782 radius: 4.0,
783 role: Some(accesskit::Role::ListBox),
784 ..Spec::default()
785 },
786 ui::List {
787 rows: &rows,
788 row: RESULT,
789 keys: &keys,
790 hover_selects: false,
791 },
792 &mut self.search.selected,
793 |ui, row| {
794 if let Some(node) = ui.access(list.child(row.key)) {
795 node.set_role(accesskit::Role::ListBoxOption);
796 node.set_selected(row.selected);
797 }
798 if let Ok(group) = rows.starts.binary_search(&row.index) {
799 heading(ui, theme, &headings[group]);
800 }
801 result_row(
802 ui,
803 theme,
804 &found[order[row.index]],
805 &dates[row.index],
806 row.selected,
807 );
808 },
809 );
810 if let Some(key) = self.search.selected
811 && let Some(node) = self.ui.access(field())
812 {
813 node.set_active_descendant(list.child(key).node());
814 }
815 let moved = before != self.search.selected && !keys.contains(&NamedKey::Enter);
816 let chosen = clicked.or_else(|| {
817 self.search
818 .selected
819 .map(|key| key as usize)
820 .filter(|_| moved || keys.contains(&NamedKey::Enter))
821 });
822 if let Some(row) = chosen {
823 self.search.selected = Some(row as u64);
824 let found = &self.search.found[order[row]];
825 self.reveal(found.section.clone(), found.space, None)?;
826 if clicked.is_some() || keys.contains(&NamedKey::Enter) {
827 self.ui.set_focus(Some(page()));
828 }
829 }
830 Ok(())
831 }
832
833 fn tags_pane(
834 &mut self,
835 theme: &Theme,
836 mut group: Group,
837 mut unchecked: bool,
838 mut scope: TagScope,
839 ) {
840 let status = if self.search.pending.load(Ordering::Relaxed) > 0 {
841 "Searching…"
842 } else {
843 "Search completed"
844 };
845 label(&mut self.ui, theme, "status", status);
846 label(&mut self.ui, theme, "group by", "Group tags by:");
847 let names = Group::ALL.map(Group::name);
848 let current = Group::ALL
849 .iter()
850 .position(|listed| *listed == group)
851 .unwrap_or_default();
852 if let Some(index) = choice(
853 &mut self.ui,
854 "group",
855 "Group tags by",
856 current,
857 &names,
858 INSIDE,
859 ) {
860 group = Group::ALL[index];
861 }
862 if ui::check_box(
863 &mut self.ui,
864 "unchecked",
865 "Show only unchecked items",
866 unchecked,
867 )
868 .clicked
869 {
870 unchecked = !unchecked;
871 }
872 let tagged = self.tagged(scope, unchecked);
873 let (order, rows, headings) = arrange_tags(&tagged, group);
874 let mut selected = None;
875 let list = Id::ROOT.child("pane tags");
876 let clicked = ui::list(
877 &mut self.ui,
878 list,
879 Spec {
880 size: [fill(), fill()],
881 fill: Some(theme.base),
882 border: Some(theme.chip),
883 radius: 4.0,
884 role: Some(accesskit::Role::List),
885 ..Spec::default()
886 },
887 ui::List {
888 rows: &rows,
889 row: TAGGED,
890 keys: &[],
891 hover_selects: false,
892 },
893 &mut selected,
894 |ui, row| {
895 if let Some(node) = ui.access(list.child(row.key)) {
896 node.set_role(accesskit::Role::ListItem);
897 }
898 if let Ok(at) = rows.starts.binary_search(&row.index) {
899 heading(ui, theme, &headings[at]);
900 }
901 tagged_row(ui, theme, &tagged[order[row.index]]);
902 },
903 );
904 label(&mut self.ui, theme, "search", "Search:");
905 let names = TagScope::ALL.map(TagScope::name);
906 let current = TagScope::ALL
907 .iter()
908 .position(|listed| *listed == scope)
909 .unwrap_or_default();
910 if let Some(index) = choice(&mut self.ui, "tag scope", "Search", current, &names, INSIDE) {
911 scope = TagScope::ALL[index];
912 }
913 self.search.pane = Some(Pane::Tags {
914 group,
915 unchecked,
916 scope,
917 });
918 if let Some(row) = clicked {
919 let tagged = &tagged[order[row]];
920 if let Err(error) =
921 self.reveal(tagged.section.clone(), tagged.space, Some(tagged.paragraph))
922 {
923 eprintln!("{error}");
924 }
925 self.ui.set_focus(Some(page()));
926 }
927 }
928
929 /// The tagged paragraphs `scope` takes in, only those not checked off when `unchecked`.
930 fn tagged(&self, scope: TagScope, unchecked: bool) -> Vec<Tagged> {
931 let session = self.session.as_ref();
932 let open = session.map(crate::Session::key);
933 // The open page with its subpages, or the page it is a subpage of with its others.
934 let group: Vec<_> = session
935 .and_then(|session| {
936 let pages = &session.pages;
937 let at = pages
938 .iter()
939 .position(|(space, ..)| *space == session.space)?;
940 let base = pages.iter().map(|(.., level)| *level).min()?;
941 let start = pages[..=at]
942 .iter()
943 .rposition(|(.., level)| *level == base)?;
944 let end = pages[start + 1..]
945 .iter()
946 .position(|(.., level)| *level == base)
947 .map_or(pages.len(), |after| start + 1 + after);
948 Some(pages[start..end].iter().map(|(space, ..)| *space).collect())
949 })
950 .unwrap_or_default();
951 let place = match scope {
952 TagScope::Section => Scope::Section,
953 TagScope::SectionGroup => Scope::Group,
954 TagScope::Notebook => Scope::Notebook,
955 _ => Scope::All,
956 };
957 let today = local_day(crate::search::now());
958 let index = self
959 .search
960 .index
961 .lock()
962 .unwrap_or_else(|error| error.into_inner());
963 index
964 .tagged(|entry| match scope {
965 TagScope::PageGroup => {
966 Some(&entry.section) == open.as_ref() && group.contains(&entry.space)
967 }
968 _ => self.in_scope(place, &entry.section),
969 })
970 .into_iter()
971 .filter(|tagged| scope.spans(local_day(tagged.created), today))
972 .filter(|tagged| !(unchecked && tagged.checked))
973 .collect()
974 }
975}
976
977/// A result: its page's icon, title and date, and the snippet around its first match.
978fn result_row(ui: &mut Ui, theme: &Theme, found: &Found, date: &str, selected: bool) {
979 ui.open(
980 "result",
981 Spec {
982 axis: Axis::Y,
983 size: [fill(), fill()],
984 fill: selected.then(|| theme.hover()),
985 hover_fill: Some(theme.hover()),
986 radius: 4.0,
987 pad: [4.0, 2.0],
988 ..Spec::default()
989 },
990 );
991 ui.open(
992 "top",
993 Spec {
994 size: [fill(), px(18.0)],
995 gap: 4.0,
996 ..Spec::default()
997 },
998 );
999 ui.leaf(
1000 "icon",
1001 Spec {
1002 size: [px(16.0), px(18.0)],
1003 icon: Some(art::PAGE),
1004 color: Some(theme.text_dim),
1005 ..Spec::default()
1006 },
1007 );
1008 marked(
1009 ui,
1010 "title",
1011 shown_title(&found.title),
1012 &found.title_hits,
1013 Spec {
1014 size: [fill(), px(18.0)],
1015 color: Some(theme.text),
1016 ..Spec::default()
1017 },
1018 );
1019 ui.leaf(
1020 "date",
1021 Spec {
1022 size: [fit(), px(18.0)],
1023 text: Some(date),
1024 color: Some(theme.text_dim),
1025 ..Spec::default()
1026 },
1027 );
1028 ui.close();
1029 marked(
1030 ui,
1031 "snippet",
1032 &found.snippet,
1033 &found.snippet_hits,
1034 Spec {
1035 size: [fill(), px(18.0)],
1036 color: Some(theme.text_dim),
1037 pad: [20.0, 0.0],
1038 ..Spec::default()
1039 },
1040 );
1041 ui.close();
1042}
1043
1044/// A tagged paragraph: its tag's symbol and its text.
1045fn tagged_row(ui: &mut Ui, theme: &Theme, tagged: &Tagged) {
1046 ui.open(
1047 "tagged",
1048 Spec {
1049 size: [fill(), fill()],
1050 hover_fill: Some(theme.hover()),
1051 radius: 4.0,
1052 pad: [4.0, 0.0],
1053 gap: 6.0,
1054 ..Spec::default()
1055 },
1056 );
1057 ui.leaf(
1058 "icon",
1059 Spec {
1060 size: [px(16.0), px(TAGGED)],
1061 icon: TagIcon::of(tagged.shape, tagged.checked).map(tag_sources),
1062 color: Some([1.0; 4]),
1063 ..Spec::default()
1064 },
1065 );
1066 ui.leaf(
1067 "text",
1068 Spec {
1069 size: [fill(), px(TAGGED)],
1070 text: Some(&tagged.text),
1071 overflow: ui::Overflow::Ellipsis,
1072 ..Spec::default()
1073 },
1074 );
1075 ui.close();
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[test]
1083 fn days_read_as_the_calendar_and_weeks_start_on_sunday() {
1084 assert_eq!(civil(0), (1970, 1, 1));
1085 assert_eq!(civil(20_725), (2026, 9, 29));
1086 assert_eq!(civil(-1), (1969, 12, 31));
1087 // Tuesday, September 29, 2026: its week began on Sunday the 27th.
1088 let today = 20_725;
1089 assert_eq!(TagScope::of_day(today, today), TagScope::Today);
1090 assert_eq!(TagScope::of_day(today - 1, today), TagScope::Yesterday);
1091 assert_eq!(TagScope::of_day(today - 2, today), TagScope::ThisWeek);
1092 assert_eq!(TagScope::of_day(today - 3, today), TagScope::LastWeek);
1093 assert_eq!(TagScope::of_day(today - 9, today), TagScope::LastWeek);
1094 assert_eq!(TagScope::of_day(today - 10, today), TagScope::Older);
1095 assert!(TagScope::ThisWeek.spans(today, today));
1096 }
1097}
crates/snowbound/src/search.rs+5-5
......@@ -2,7 +2,7 @@
22//! results dropping down from it, and Find on This Page. The index is kept on a thread of
33//! its own, from the section files and the open section's replica.
44
5use crate::pane::Pane;
5use crate::task_pane::TaskPane;
66use crate::{Command, Library, State, Theme, art, commands, lap, name, page};
77use accesskit::Role;
88use canvas::search::{Entry, Found, Index, PageMatch, Query, page_matches, paragraph_match};
......@@ -77,7 +77,7 @@ pub fn takes_text(focus: Option<Id>) -> bool {
7777 [
7878 Some(field()),
7979 Some(find_field()),
80 Some(crate::pane::field()),
80 Some(crate::task_pane::field()),
8181 ]
8282 .contains(&focus)
8383}
......@@ -133,7 +133,7 @@ pub struct Search {
133133 anchor: Option<Anchor>,
134134 /// The Search Results pane's field takes the keys once built.
135135 pub(crate) claim: bool,
136 pub(crate) pane: Option<Pane>,
136 pub(crate) task_pane: Option<TaskPane>,
137137}
138138
139139impl Search {
......@@ -179,7 +179,7 @@ impl Search {
179179 open: false,
180180 anchor: None,
181181 claim: false,
182 pane: None,
182 task_pane: None,
183183 }
184184 }
185185
......@@ -683,7 +683,7 @@ impl State {
683683 self.search.current = None;
684684 self.ui.close_popup(results());
685685 self.ui.set_focus(Some(page()));
686 if !matches!(self.search.pane, Some(Pane::Search { .. })) {
686 if !matches!(self.search.task_pane, Some(TaskPane::Search { .. })) {
687687 self.search.query.clear();
688688 self.search.scope = self.search.default;
689689 self.view.found.clear();
crates/snowbound/src/task_pane.rs created+1097
......@@ -0,0 +1,1097 @@
1//! OneNote 2010's task panes beside the page: Page Search Results (Alt+O), listing the
2//! search's results sorted and grouped, the Tags Summary, listing tagged paragraphs, and
3//! Spelling (F7), walking the page's marked words.
4
5use crate::search::{Scope, marked};
6use crate::{State, Theme, art, page, platform};
7use canvas::gpu::tag_sources;
8use canvas::outline::TagIcon;
9use canvas::search::{Found, Tagged};
10use std::{error::Error, sync::atomic::Ordering};
11use ui::{Anchor, Axis, Flags, Id, Spec, Ui, fill, fit, px};
12use winit::keyboard::NamedKey;
13
14/// The pane's width, and its lists' rows and group headings.
15const WIDTH: f32 = 260.0;
16const RESULT: f32 = 40.0;
17const TAGGED: f32 = 24.0;
18const HEADING: f32 = 22.0;
19/// A control's width inside the pane.
20const INSIDE: f32 = WIDTH - 2.0 * PAD;
21const PAD: f32 = 8.0;
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub enum TaskPane {
25 Search {
26 sort: Sort,
27 descending: bool,
28 },
29 Tags {
30 group: Group,
31 unchecked: bool,
32 scope: TagScope,
33 },
34 /// The word is `State::correction`; `chosen` is the suggestion Change takes.
35 Spelling {
36 chosen: usize,
37 },
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Sort {
42 Section,
43 Title,
44 Date,
45}
46
47impl Sort {
48 const ALL: [Sort; 3] = [Sort::Section, Sort::Title, Sort::Date];
49
50 fn name(self) -> &'static str {
51 match self {
52 Sort::Section => "Sort by Section",
53 Sort::Title => "Sort by Title",
54 Sort::Date => "Sort by Date Modified",
55 }
56 }
57}
58
59/// What the Tags Summary groups its paragraphs by.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum Group {
62 Tag,
63 Section,
64 Title,
65 Date,
66 Text,
67}
68
69impl Group {
70 const ALL: [Group; 5] = [
71 Group::Tag,
72 Group::Section,
73 Group::Title,
74 Group::Date,
75 Group::Text,
76 ];
77
78 fn name(self) -> &'static str {
79 match self {
80 Group::Tag => "Tag name",
81 Group::Section => "Section",
82 Group::Title => "Title",
83 Group::Date => "Date",
84 Group::Text => "Note text",
85 }
86 }
87}
88
89/// Where the Tags Summary looks: part of the notebooks, or tags applied in a span of days.
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub enum TagScope {
92 PageGroup,
93 Section,
94 SectionGroup,
95 Notebook,
96 All,
97 Today,
98 Yesterday,
99 ThisWeek,
100 LastWeek,
101 Older,
102}
103
104impl TagScope {
105 const ALL: [TagScope; 10] = [
106 TagScope::PageGroup,
107 TagScope::Section,
108 TagScope::SectionGroup,
109 TagScope::Notebook,
110 TagScope::All,
111 TagScope::Today,
112 TagScope::Yesterday,
113 TagScope::ThisWeek,
114 TagScope::LastWeek,
115 TagScope::Older,
116 ];
117
118 /// Its name, which also heads the paragraphs tagged in its span when grouped by date.
119 fn name(self) -> &'static str {
120 match self {
121 TagScope::PageGroup => "This page group",
122 TagScope::Section => "This section",
123 TagScope::SectionGroup => "This section group",
124 TagScope::Notebook => "This notebook",
125 TagScope::All => "All notebooks",
126 TagScope::Today => "Today's notes",
127 TagScope::Yesterday => "Yesterday's notes",
128 TagScope::ThisWeek => "This week's notes",
129 TagScope::LastWeek => "Last week's notes",
130 TagScope::Older => "Older notes",
131 }
132 }
133
134 /// Whether a tag applied on local day `day` falls in the span, `today` being today.
135 fn spans(self, day: i64, today: i64) -> bool {
136 // OneNote's weeks start on Sunday; day 0 was a Thursday.
137 let week = today - (today + 4).rem_euclid(7);
138 match self {
139 TagScope::Today => day >= today,
140 TagScope::Yesterday => day == today - 1,
141 TagScope::ThisWeek => day >= week,
142 TagScope::LastWeek => (week - 7..week).contains(&day),
143 TagScope::Older => day < week - 7,
144 _ => true,
145 }
146 }
147
148 /// The span heading a tag applied on `day` when grouped by date.
149 fn of_day(day: i64, today: i64) -> TagScope {
150 [
151 TagScope::Today,
152 TagScope::Yesterday,
153 TagScope::ThisWeek,
154 TagScope::LastWeek,
155 ]
156 .into_iter()
157 .find(|span| span.spans(day, today))
158 .unwrap_or(TagScope::Older)
159 }
160}
161
162/// The local day, counted from 1970, of Time32 `time`.
163fn local_day(time: u64) -> i64 {
164 let unix = time as i64 + 315_532_800;
165 // SAFETY: `localtime_r` writes only the `tm` it is given.
166 #[cfg(unix)]
167 let offset = unsafe {
168 let mut tm: libc::tm = std::mem::zeroed();
169 libc::localtime_r(&unix, &mut tm);
170 tm.tm_gmtoff as i64
171 };
172 #[cfg(any(windows, target_arch = "wasm32"))]
173 let offset = crate::platform::utc_offset(unix);
174 (unix + offset).div_euclid(86_400)
175}
176
177/// Day `day` counted from 1970 as a year, month and day.
178fn civil(day: i64) -> (i64, u32, u32) {
179 let z = day + 719_468;
180 let era = z.div_euclid(146_097);
181 let doe = z.rem_euclid(146_097);
182 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
183 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
184 let mp = (5 * doy + 2) / 153;
185 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
186 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
187 (yoe + era * 400 + i64::from(month <= 2), month, day)
188}
189
190const MONTHS: [&str; 12] = [
191 "January",
192 "February",
193 "March",
194 "April",
195 "May",
196 "June",
197 "July",
198 "August",
199 "September",
200 "October",
201 "November",
202 "December",
203];
204
205/// Rows under group headings, each heading above the row starting its group.
206struct Grouped {
207 count: usize,
208 /// The rows starting groups, in order.
209 starts: Vec<usize>,
210}
211
212impl ui::Rows for Grouped {
213 fn count(&self) -> usize {
214 self.count
215 }
216
217 fn key(&self, index: usize) -> u64 {
218 index as u64
219 }
220
221 fn find(&self, key: u64) -> Option<usize> {
222 ((key as usize) < self.count).then_some(key as usize)
223 }
224
225 fn space_before(&self, index: usize) -> f32 {
226 self.starts.partition_point(|start| *start <= index) as f32 * HEADING
227 }
228}
229
230/// `items` in `order`, and each group's first row with its heading.
231fn grouped(order: &[usize], heading: impl Fn(usize) -> String) -> (Grouped, Vec<String>) {
232 let mut starts = Vec::new();
233 let mut headings: Vec<String> = Vec::new();
234 for (row, &item) in order.iter().enumerate() {
235 let name = heading(item);
236 if headings.last() != Some(&name) {
237 starts.push(row);
238 headings.push(name);
239 }
240 }
241 (
242 Grouped {
243 count: order.len(),
244 starts,
245 },
246 headings,
247 )
248}
249
250/// A section's name from its `Library::key`.
251fn section_name(key: &str) -> String {
252 let path = key.split_once('\n').map_or(key, |(_, path)| path);
253 crate::library::section_name(path, &None)
254}
255
256fn shown_title(title: &str) -> &str {
257 if title.trim().is_empty() {
258 "Untitled page"
259 } else {
260 title
261 }
262}
263
264/// The search's results in `sort`'s order, and their groups: sections, first letters of
265/// titles, or months.
266fn arrange(found: &[Found], sort: Sort, descending: bool) -> (Vec<usize>, Grouped, Vec<String>) {
267 let mut order: Vec<usize> = (0..found.len()).collect();
268 match sort {
269 Sort::Section => order.sort_by_cached_key(|&at| {
270 let found = &found[at];
271 (
272 section_name(&found.section).to_lowercase(),
273 found.section.clone(),
274 found.order,
275 )
276 }),
277 Sort::Title => {
278 order.sort_by_cached_key(|&at| shown_title(&found[at].title).to_lowercase());
279 }
280 Sort::Date => order.sort_by_key(|&at| found[at].modified),
281 }
282 if descending {
283 order.reverse();
284 }
285 let (rows, headings) = grouped(&order, |at| {
286 let found = &found[at];
287 match sort {
288 Sort::Section => section_name(&found.section),
289 Sort::Title => shown_title(&found.title)
290 .chars()
291 .next()
292 .map_or_else(String::new, |first| first.to_uppercase().collect()),
293 Sort::Date => {
294 let (year, month, _) = civil(local_day(found.modified));
295 format!("{}, {year}", MONTHS[month as usize - 1])
296 }
297 }
298 });
299 (order, rows, headings)
300}
301
302/// Tagged paragraphs in `group`'s order and their groups, each group's paragraphs in page
303/// order.
304fn arrange_tags(tagged: &[Tagged], group: Group) -> (Vec<usize>, Grouped, Vec<String>) {
305 let today = local_day(crate::search::now());
306 let heading = |at: usize| -> String {
307 let tagged = &tagged[at];
308 match group {
309 Group::Tag => tagged.name.clone(),
310 Group::Section => section_name(&tagged.section),
311 Group::Title => shown_title(&tagged.title).to_owned(),
312 Group::Date => TagScope::of_day(local_day(tagged.created), today)
313 .name()
314 .to_owned(),
315 Group::Text => Group::Text.name().to_owned(),
316 }
317 };
318 let mut order: Vec<usize> = (0..tagged.len()).collect();
319 match group {
320 Group::Date => order.sort_by_key(|&at| {
321 TagScope::ALL
322 .iter()
323 .position(|span| *span == TagScope::of_day(local_day(tagged[at].created), today))
324 }),
325 Group::Text => order.sort_by_cached_key(|&at| tagged[at].text.to_lowercase()),
326 _ => order.sort_by_cached_key(|&at| heading(at).to_lowercase()),
327 }
328 let (rows, headings) = grouped(&order, heading);
329 (order, rows, headings)
330}
331
332/// The field of the Search Results pane.
333pub(crate) fn field() -> Id {
334 Id::ROOT.child("pane-field")
335}
336
337fn popup(name: &str) -> Id {
338 Id::ROOT.child(("pane popup", name))
339}
340
341/// A combo box `label`led, `width` wide, showing `current` of `names`, returning the one
342/// chosen.
343fn choice(
344 ui: &mut Ui,
345 name: &str,
346 label: &str,
347 current: usize,
348 names: &[&str],
349 width: f32,
350) -> Option<usize> {
351 let combo = ui.id(name);
352 ui::shell::combo(ui, name, label, names[current], width, popup(name), true);
353 let items: Vec<_> = names
354 .iter()
355 .enumerate()
356 .map(|(index, text)| ui::popup::Item {
357 text,
358 checked: Some(index == current),
359 current: index == current,
360 ..Default::default()
361 })
362 .collect();
363 let anchor = Anchor::Below(combo);
364 ui::popup::menu(ui, popup(name), anchor, &items, None)
365}
366
367fn label(ui: &mut Ui, theme: &Theme, part: &str, text: &str) {
368 ui.leaf(
369 part,
370 Spec {
371 size: [fill(), px(20.0)],
372 text: Some(text),
373 color: Some(theme.text_dim),
374 ..Spec::default()
375 },
376 );
377}
378
379/// A group's heading, floating above the row starting it.
380fn heading(ui: &mut Ui, theme: &Theme, text: &str) {
381 ui.leaf(
382 "heading",
383 Spec {
384 flags: Flags::FLOAT,
385 size: [fill(), px(HEADING)],
386 position: [0.0, -HEADING],
387 text: Some(text),
388 bold: true,
389 color: Some(theme.text),
390 pad: [4.0, 0.0],
391 ..Spec::default()
392 },
393 );
394}
395
396impl State {
397 /// Opens the Search Results pane on the search, as Alt+O does.
398 pub(crate) fn open_search_pane(&mut self) {
399 if !matches!(self.search.task_pane, Some(TaskPane::Search { .. })) {
400 self.search.task_pane = Some(TaskPane::Search {
401 sort: Sort::Date,
402 descending: true,
403 });
404 }
405 self.search.open = false;
406 self.search.finding = false;
407 self.search.claim = true;
408 self.ui.close_popup(crate::search::results());
409 self.sync_index(true, Vec::new());
410 }
411
412 /// Opens or closes a pane, as its command does.
413 pub(crate) fn toggle_task_pane(&mut self, tags: bool) {
414 match self.search.task_pane {
415 Some(TaskPane::Tags { .. }) if tags => self.close_task_pane(),
416 Some(TaskPane::Search { .. }) if !tags => self.close_task_pane(),
417 _ if tags => {
418 self.close_task_pane();
419 self.search.task_pane = Some(TaskPane::Tags {
420 group: Group::Tag,
421 unchecked: false,
422 scope: TagScope::Notebook,
423 });
424 self.sync_index(true, Vec::new());
425 }
426 _ => self.open_search_pane(),
427 }
428 }
429
430 /// Closes the pane; the Search Results pane takes its search, and the marks, with it.
431 fn close_task_pane(&mut self) {
432 self.correction = None;
433 if matches!(self.search.task_pane.take(), Some(TaskPane::Search { .. })) {
434 self.end_search();
435 }
436 }
437
438 /// Opens the Spelling pane on the next marked word, as F7 does.
439 pub(crate) fn open_spelling_pane(&mut self) {
440 if !matches!(self.search.task_pane, Some(TaskPane::Spelling { .. })) {
441 self.close_task_pane();
442 }
443 self.next_word();
444 }
445
446 /// Shows the next marked word in the Spelling pane; past the last, the check is complete.
447 fn next_word(&mut self) {
448 match self.view.next_correction() {
449 Ok(Some((response, correction))) => {
450 self.respond(response);
451 self.correction = Some(correction);
452 self.search.task_pane = Some(TaskPane::Spelling { chosen: 0 });
453 }
454 Ok(None) => {
455 self.close_task_pane();
456 platform::alert("The spelling check is complete.", "");
457 }
458 Err(error) => eprintln!("{error}"),
459 }
460 }
461
462 fn spelling_pane(&mut self, theme: &Theme, mut chosen: usize) {
463 let Some(correction) = &self.correction else {
464 return;
465 };
466 let ui = &mut self.ui;
467 label(
468 ui,
469 theme,
470 "kind",
471 if correction.repeated {
472 "Repeated word:"
473 } else {
474 "Current spelling:"
475 },
476 );
477 ui.leaf(
478 "word",
479 Spec {
480 size: [fill(), px(ui::shell::TOOL)],
481 text: Some(&correction.word),
482 bold: true,
483 fill: Some(theme.base),
484 border: Some(theme.chip),
485 radius: 4.0,
486 pad: [6.0, 0.0],
487 ..Spec::default()
488 },
489 );
490 ui.open(
491 "actions",
492 Spec {
493 size: [fill(), px(theme.font_size * 2.0)],
494 gap: 4.0,
495 ..Spec::default()
496 },
497 );
498 let [first, second] = if correction.repeated {
499 ["Delete", "Ignore"]
500 } else {
501 ["Ignore", "Add to Dictionary"]
502 };
503 let pressed = [first, second].map(|text| ui::button(ui, text, text).clicked);
504 ui.close();
505 let mut change = false;
506 if !correction.repeated {
507 label(ui, theme, "suggestions label", "Suggestions:");
508 ui.open(
509 "suggestions",
510 Spec {
511 axis: Axis::Y,
512 size: [fill(), px(TAGGED * 5.0 + 2.0)],
513 fill: Some(theme.base),
514 border: Some(theme.chip),
515 radius: 4.0,
516 pad: [1.0, 1.0],
517 role: Some(accesskit::Role::List),
518 ..Spec::default()
519 },
520 );
521 for (index, suggestion) in correction.suggestions.iter().enumerate() {
522 let row = ui.leaf(
523 index,
524 Spec {
525 flags: Flags::CLICKABLE,
526 size: [fill(), px(TAGGED)],
527 text: Some(suggestion),
528 fill: (index == chosen).then(|| theme.hover()),
529 hover_fill: Some(theme.hover()),
530 pad: [6.0, 0.0],
531 role: Some(accesskit::Role::ListItem),
532 ..Spec::default()
533 },
534 );
535 if row.clicked {
536 chosen = index;
537 change = row.unit != draw::edit::SelectionUnit::Grapheme;
538 }
539 }
540 if correction.suggestions.is_empty() {
541 label(ui, theme, "none", "(No Spelling Suggestions)");
542 }
543 ui.close();
544 if !correction.suggestions.is_empty() {
545 change |= ui::button(ui, "change", "Change").clicked;
546 }
547 }
548 self.search.task_pane = Some(TaskPane::Spelling { chosen });
549 let Some(correction) = self
550 .correction
551 .take_if(|_| change || pressed.contains(&true))
552 else {
553 return;
554 };
555 let spelling = self.view.spelling.clone();
556 let text = match (correction.repeated, pressed) {
557 (_, _) if change => correction.suggestions.get(chosen).map(String::as_str),
558 (true, [true, _]) => Some(""),
559 (false, [false, true]) => {
560 spelling.inspect(|spelling| spelling.learn(&correction.word));
561 None
562 }
563 _ => {
564 spelling.inspect(|spelling| spelling.ignore(&correction.word));
565 None
566 }
567 };
568 if let Some(text) = text {
569 match self.view.correct(&correction, text) {
570 Ok(response) => self.respond(response),
571 Err(error) => eprintln!("{error}"),
572 }
573 }
574 self.next_word();
575 }
576
577 /// The open pane at the window's right, easing open and closed.
578 pub(crate) fn task_pane(&mut self, theme: &Theme) {
579 let id = self.ui.id("task pane");
580 let width = self.ui.animate(
581 id,
582 if self.search.task_pane.is_some() {
583 WIDTH
584 } else {
585 0.0
586 },
587 );
588 self.ui.open(
589 "task pane",
590 Spec {
591 flags: Flags::CLIP,
592 size: [px(width), fill()],
593 fill: Some(theme.strip),
594 ..Spec::default()
595 },
596 );
597 // The content keeps its width as the pane eases, so its labels never reflow.
598 self.ui.open(
599 "content",
600 Spec {
601 axis: Axis::Y,
602 size: [px(WIDTH), fill()],
603 pad: [PAD, 4.0],
604 gap: 4.0,
605 ..Spec::default()
606 },
607 );
608 if let Some(pane) = self.search.task_pane {
609 self.ui.open(
610 "header",
611 Spec {
612 size: [fill(), px(ui::shell::TOOL)],
613 ..Spec::default()
614 },
615 );
616 self.ui.leaf(
617 "title",
618 Spec {
619 size: [fill(), px(ui::shell::TOOL)],
620 text: Some(match pane {
621 TaskPane::Search { .. } => "Search Results",
622 TaskPane::Tags { .. } => "Tags Summary",
623 TaskPane::Spelling { .. } => "Spelling",
624 }),
625 bold: true,
626 ..Spec::default()
627 },
628 );
629 if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, None)
630 .clicked
631 {
632 self.close_task_pane();
633 }
634 let close = self.ui.id("close");
635 crate::name(&mut self.ui, close, "Close");
636 self.ui.close();
637 match self.search.task_pane {
638 Some(TaskPane::Search { sort, descending }) => {
639 if let Err(error) = self.search_pane(theme, sort, descending) {
640 eprintln!("{error}");
641 }
642 }
643 Some(TaskPane::Tags {
644 group,
645 unchecked,
646 scope,
647 }) => self.tags_pane(theme, group, unchecked, scope),
648 Some(TaskPane::Spelling { chosen }) => self.spelling_pane(theme, chosen),
649 None => {}
650 }
651 }
652 self.ui.close();
653 self.ui.close();
654 }
655
656 fn search_pane(
657 &mut self,
658 theme: &Theme,
659 mut sort: Sort,
660 mut descending: bool,
661 ) -> Result<(), Box<dyn Error>> {
662 self.refresh_results()?;
663 let keys = ui::popup::navigation(
664 &mut self.ui,
665 &[field()],
666 &[
667 NamedKey::ArrowUp,
668 NamedKey::ArrowDown,
669 NamedKey::PageUp,
670 NamedKey::PageDown,
671 NamedKey::Enter,
672 NamedKey::Escape,
673 ],
674 );
675 if keys.contains(&NamedKey::Escape) {
676 self.ui.set_focus(Some(page()));
677 }
678 let border = if self.ui.focused() == Some(field()) {
679 theme.accent
680 } else {
681 theme.chip
682 };
683 ui::text_field(
684 &mut self.ui,
685 field(),
686 &mut self.search.query,
687 "Search",
688 Spec {
689 size: [fill(), px(ui::shell::TOOL)],
690 fill: Some(theme.base),
691 border: Some(border),
692 radius: 4.0,
693 pad: [6.0, 0.0],
694 role: Some(accesskit::Role::SearchInput),
695 ..Spec::default()
696 },
697 );
698 crate::name(&mut self.ui, field(), "Search");
699 if std::mem::take(&mut self.search.claim) {
700 self.ui.set_focus(Some(field()));
701 }
702 let scopes = Scope::ALL.map(|scope| format!("Search {}", scope.name()));
703 let names = scopes.each_ref().map(String::as_str);
704 let current = Scope::ALL
705 .iter()
706 .position(|scope| *scope == self.search.scope)
707 .unwrap_or_default();
708 if let Some(index) = choice(
709 &mut self.ui,
710 "scope",
711 "Search scope",
712 current,
713 &names,
714 INSIDE,
715 ) {
716 self.search.scope = Scope::ALL[index];
717 }
718 self.ui.open(
719 "sort",
720 Spec {
721 size: [fill(), px(ui::shell::TOOL)],
722 gap: 2.0,
723 ..Spec::default()
724 },
725 );
726 let names = Sort::ALL.map(Sort::name);
727 let current = Sort::ALL
728 .iter()
729 .position(|listed| *listed == sort)
730 .unwrap_or_default();
731 let width = INSIDE - ui::shell::TOOL - 2.0;
732 if let Some(index) = choice(&mut self.ui, "by", "Sort by", current, &names, width) {
733 sort = Sort::ALL[index];
734 // Dates start newest first, names from the start of the alphabet.
735 descending = sort == Sort::Date;
736 }
737 let arrow = if descending {
738 art::SORT_DESCENDING
739 } else {
740 art::SORT_ASCENDING
741 };
742 if ui::shell::tool_button(&mut self.ui, "direction", arrow, theme.text, None).clicked {
743 descending = !descending;
744 }
745 let tip = if descending {
746 "Sort ascending"
747 } else {
748 "Sort descending"
749 };
750 ui::popup::tooltip(&mut self.ui, tip, "", None);
751 self.ui.close();
752 self.search.task_pane = Some(TaskPane::Search { sort, descending });
753 let found = &self.search.found;
754 if found.is_empty() {
755 let status = if self.search.query.trim().is_empty() {
756 ""
757 } else if self.search.pending.load(Ordering::Relaxed) > 0 {
758 "Searching…"
759 } else {
760 "No matches"
761 };
762 label(&mut self.ui, theme, "status", status);
763 return Ok(());
764 }
765 let (order, rows, headings) = arrange(found, sort, descending);
766 let dates: Vec<String> = order
767 .iter()
768 .map(|&at| {
769 let (year, month, day) = civil(local_day(found[at].modified));
770 format!("{month}/{day}/{year}")
771 })
772 .collect();
773 let before = self.search.selected;
774 let list = Id::ROOT.child("pane results");
775 let clicked = ui::list(
776 &mut self.ui,
777 list,
778 Spec {
779 size: [fill(), fill()],
780 fill: Some(theme.base),
781 border: Some(theme.chip),
782 radius: 4.0,
783 role: Some(accesskit::Role::ListBox),
784 ..Spec::default()
785 },
786 ui::List {
787 rows: &rows,
788 row: RESULT,
789 keys: &keys,
790 hover_selects: false,
791 },
792 &mut self.search.selected,
793 |ui, row| {
794 if let Some(node) = ui.access(list.child(row.key)) {
795 node.set_role(accesskit::Role::ListBoxOption);
796 node.set_selected(row.selected);
797 }
798 if let Ok(group) = rows.starts.binary_search(&row.index) {
799 heading(ui, theme, &headings[group]);
800 }
801 result_row(
802 ui,
803 theme,
804 &found[order[row.index]],
805 &dates[row.index],
806 row.selected,
807 );
808 },
809 );
810 if let Some(key) = self.search.selected
811 && let Some(node) = self.ui.access(field())
812 {
813 node.set_active_descendant(list.child(key).node());
814 }
815 let moved = before != self.search.selected && !keys.contains(&NamedKey::Enter);
816 let chosen = clicked.or_else(|| {
817 self.search
818 .selected
819 .map(|key| key as usize)
820 .filter(|_| moved || keys.contains(&NamedKey::Enter))
821 });
822 if let Some(row) = chosen {
823 self.search.selected = Some(row as u64);
824 let found = &self.search.found[order[row]];
825 self.reveal(found.section.clone(), found.space, None)?;
826 if clicked.is_some() || keys.contains(&NamedKey::Enter) {
827 self.ui.set_focus(Some(page()));
828 }
829 }
830 Ok(())
831 }
832
833 fn tags_pane(
834 &mut self,
835 theme: &Theme,
836 mut group: Group,
837 mut unchecked: bool,
838 mut scope: TagScope,
839 ) {
840 let status = if self.search.pending.load(Ordering::Relaxed) > 0 {
841 "Searching…"
842 } else {
843 "Search completed"
844 };
845 label(&mut self.ui, theme, "status", status);
846 label(&mut self.ui, theme, "group by", "Group tags by:");
847 let names = Group::ALL.map(Group::name);
848 let current = Group::ALL
849 .iter()
850 .position(|listed| *listed == group)
851 .unwrap_or_default();
852 if let Some(index) = choice(
853 &mut self.ui,
854 "group",
855 "Group tags by",
856 current,
857 &names,
858 INSIDE,
859 ) {
860 group = Group::ALL[index];
861 }
862 if ui::check_box(
863 &mut self.ui,
864 "unchecked",
865 "Show only unchecked items",
866 unchecked,
867 )
868 .clicked
869 {
870 unchecked = !unchecked;
871 }
872 let tagged = self.tagged(scope, unchecked);
873 let (order, rows, headings) = arrange_tags(&tagged, group);
874 let mut selected = None;
875 let list = Id::ROOT.child("pane tags");
876 let clicked = ui::list(
877 &mut self.ui,
878 list,
879 Spec {
880 size: [fill(), fill()],
881 fill: Some(theme.base),
882 border: Some(theme.chip),
883 radius: 4.0,
884 role: Some(accesskit::Role::List),
885 ..Spec::default()
886 },
887 ui::List {
888 rows: &rows,
889 row: TAGGED,
890 keys: &[],
891 hover_selects: false,
892 },
893 &mut selected,
894 |ui, row| {
895 if let Some(node) = ui.access(list.child(row.key)) {
896 node.set_role(accesskit::Role::ListItem);
897 }
898 if let Ok(at) = rows.starts.binary_search(&row.index) {
899 heading(ui, theme, &headings[at]);
900 }
901 tagged_row(ui, theme, &tagged[order[row.index]]);
902 },
903 );
904 label(&mut self.ui, theme, "search", "Search:");
905 let names = TagScope::ALL.map(TagScope::name);
906 let current = TagScope::ALL
907 .iter()
908 .position(|listed| *listed == scope)
909 .unwrap_or_default();
910 if let Some(index) = choice(&mut self.ui, "tag scope", "Search", current, &names, INSIDE) {
911 scope = TagScope::ALL[index];
912 }
913 self.search.task_pane = Some(TaskPane::Tags {
914 group,
915 unchecked,
916 scope,
917 });
918 if let Some(row) = clicked {
919 let tagged = &tagged[order[row]];
920 if let Err(error) =
921 self.reveal(tagged.section.clone(), tagged.space, Some(tagged.paragraph))
922 {
923 eprintln!("{error}");
924 }
925 self.ui.set_focus(Some(page()));
926 }
927 }
928
929 /// The tagged paragraphs `scope` takes in, only those not checked off when `unchecked`.
930 fn tagged(&self, scope: TagScope, unchecked: bool) -> Vec<Tagged> {
931 let session = self.session.as_ref();
932 let open = session.map(crate::Session::key);
933 // The open page with its subpages, or the page it is a subpage of with its others.
934 let group: Vec<_> = session
935 .and_then(|session| {
936 let pages = &session.pages;
937 let at = pages
938 .iter()
939 .position(|(space, ..)| *space == session.space)?;
940 let base = pages.iter().map(|(.., level)| *level).min()?;
941 let start = pages[..=at]
942 .iter()
943 .rposition(|(.., level)| *level == base)?;
944 let end = pages[start + 1..]
945 .iter()
946 .position(|(.., level)| *level == base)
947 .map_or(pages.len(), |after| start + 1 + after);
948 Some(pages[start..end].iter().map(|(space, ..)| *space).collect())
949 })
950 .unwrap_or_default();
951 let place = match scope {
952 TagScope::Section => Scope::Section,
953 TagScope::SectionGroup => Scope::Group,
954 TagScope::Notebook => Scope::Notebook,
955 _ => Scope::All,
956 };
957 let today = local_day(crate::search::now());
958 let index = self
959 .search
960 .index
961 .lock()
962 .unwrap_or_else(|error| error.into_inner());
963 index
964 .tagged(|entry| match scope {
965 TagScope::PageGroup => {
966 Some(&entry.section) == open.as_ref() && group.contains(&entry.space)
967 }
968 _ => self.in_scope(place, &entry.section),
969 })
970 .into_iter()
971 .filter(|tagged| scope.spans(local_day(tagged.created), today))
972 .filter(|tagged| !(unchecked && tagged.checked))
973 .collect()
974 }
975}
976
977/// A result: its page's icon, title and date, and the snippet around its first match.
978fn result_row(ui: &mut Ui, theme: &Theme, found: &Found, date: &str, selected: bool) {
979 ui.open(
980 "result",
981 Spec {
982 axis: Axis::Y,
983 size: [fill(), fill()],
984 fill: selected.then(|| theme.hover()),
985 hover_fill: Some(theme.hover()),
986 radius: 4.0,
987 pad: [4.0, 2.0],
988 ..Spec::default()
989 },
990 );
991 ui.open(
992 "top",
993 Spec {
994 size: [fill(), px(18.0)],
995 gap: 4.0,
996 ..Spec::default()
997 },
998 );
999 ui.leaf(
1000 "icon",
1001 Spec {
1002 size: [px(16.0), px(18.0)],
1003 icon: Some(art::PAGE),
1004 color: Some(theme.text_dim),
1005 ..Spec::default()
1006 },
1007 );
1008 marked(
1009 ui,
1010 "title",
1011 shown_title(&found.title),
1012 &found.title_hits,
1013 Spec {
1014 size: [fill(), px(18.0)],
1015 color: Some(theme.text),
1016 ..Spec::default()
1017 },
1018 );
1019 ui.leaf(
1020 "date",
1021 Spec {
1022 size: [fit(), px(18.0)],
1023 text: Some(date),
1024 color: Some(theme.text_dim),
1025 ..Spec::default()
1026 },
1027 );
1028 ui.close();
1029 marked(
1030 ui,
1031 "snippet",
1032 &found.snippet,
1033 &found.snippet_hits,
1034 Spec {
1035 size: [fill(), px(18.0)],
1036 color: Some(theme.text_dim),
1037 pad: [20.0, 0.0],
1038 ..Spec::default()
1039 },
1040 );
1041 ui.close();
1042}
1043
1044/// A tagged paragraph: its tag's symbol and its text.
1045fn tagged_row(ui: &mut Ui, theme: &Theme, tagged: &Tagged) {
1046 ui.open(
1047 "tagged",
1048 Spec {
1049 size: [fill(), fill()],
1050 hover_fill: Some(theme.hover()),
1051 radius: 4.0,
1052 pad: [4.0, 0.0],
1053 gap: 6.0,
1054 ..Spec::default()
1055 },
1056 );
1057 ui.leaf(
1058 "icon",
1059 Spec {
1060 size: [px(16.0), px(TAGGED)],
1061 icon: TagIcon::of(tagged.shape, tagged.checked).map(tag_sources),
1062 color: Some([1.0; 4]),
1063 ..Spec::default()
1064 },
1065 );
1066 ui.leaf(
1067 "text",
1068 Spec {
1069 size: [fill(), px(TAGGED)],
1070 text: Some(&tagged.text),
1071 overflow: ui::Overflow::Ellipsis,
1072 ..Spec::default()
1073 },
1074 );
1075 ui.close();
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[test]
1083 fn days_read_as_the_calendar_and_weeks_start_on_sunday() {
1084 assert_eq!(civil(0), (1970, 1, 1));
1085 assert_eq!(civil(20_725), (2026, 9, 29));
1086 assert_eq!(civil(-1), (1969, 12, 31));
1087 // Tuesday, September 29, 2026: its week began on Sunday the 27th.
1088 let today = 20_725;
1089 assert_eq!(TagScope::of_day(today, today), TagScope::Today);
1090 assert_eq!(TagScope::of_day(today - 1, today), TagScope::Yesterday);
1091 assert_eq!(TagScope::of_day(today - 2, today), TagScope::ThisWeek);
1092 assert_eq!(TagScope::of_day(today - 3, today), TagScope::LastWeek);
1093 assert_eq!(TagScope::of_day(today - 9, today), TagScope::LastWeek);
1094 assert_eq!(TagScope::of_day(today - 10, today), TagScope::Older);
1095 assert!(TagScope::ThisWeek.spans(today, today));
1096 }
1097}