| author | |
| committer | |
| log | f6222caecf1c286dee2a1746307bf90d715044cf |
| tree | 7879cedf26eef9e70c36a9f40086360dcbe19d0a |
| parent | 5e2a514da35d4f367f7084d85bdbe75af7584177 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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.55 files changed, 1108 insertions(+), 1108 deletions(-)
crates/snowbound/src/commands.rs+5-5| ... | @@ -1295,15 +1295,15 @@ impl State { | ... | @@ -1295,15 +1295,15 @@ impl State { |
| 1295 | Id::SearchResults => Status { | 1295 | Id::SearchResults => Status { |
| 1296 | enabled: !modal && session.is_some(), | 1296 | enabled: !modal && session.is_some(), |
| 1297 | checked: Some(matches!( | 1297 | checked: Some(matches!( |
| 1298 | self.search.pane, | 1298 | self.search.task_pane, |
| 1299 | Some(crate::pane::Pane::Search { .. }) | 1299 | Some(crate::task_pane::TaskPane::Search { .. }) |
| 1300 | )), | 1300 | )), |
| 1301 | }, | 1301 | }, |
| 1302 | Id::FindTags => Status { | 1302 | Id::FindTags => Status { |
| 1303 | enabled: !modal && session.is_some(), | 1303 | enabled: !modal && session.is_some(), |
| 1304 | checked: Some(matches!( | 1304 | checked: Some(matches!( |
| 1305 | self.search.pane, | 1305 | self.search.task_pane, |
| 1306 | Some(crate::pane::Pane::Tags { .. }) | 1306 | Some(crate::task_pane::TaskPane::Tags { .. }) |
| 1307 | )), | 1307 | )), |
| 1308 | }, | 1308 | }, |
| 1309 | Id::PagesMatchTheme => Status { | 1309 | Id::PagesMatchTheme => Status { |
| ... | @@ -1650,7 +1650,7 @@ impl State { | ... | @@ -1650,7 +1650,7 @@ impl State { |
| 1650 | return Ok(()); | 1650 | return Ok(()); |
| 1651 | } | 1651 | } |
| 1652 | Id::SearchResults | Id::FindTags => { | 1652 | Id::SearchResults | Id::FindTags => { |
| 1653 | self.toggle_pane(id == Id::FindTags); | 1653 | self.toggle_task_pane(id == Id::FindTags); |
| 1654 | return Ok(()); | 1654 | return Ok(()); |
| 1655 | } | 1655 | } |
| 1656 | Id::GoTo | Id::CommandPalette => { | 1656 | Id::GoTo | Id::CommandPalette => { |
crates/snowbound/src/main.rs+1-1| ... | @@ -48,7 +48,6 @@ mod menus; | ... | @@ -48,7 +48,6 @@ mod menus; |
| 48 | mod navigation; | 48 | mod navigation; |
| 49 | mod options; | 49 | mod options; |
| 50 | mod palette; | 50 | mod palette; |
| 51 | mod pane; | ||
| 52 | mod paste; | 51 | mod paste; |
| 53 | #[cfg_attr(target_os = "linux", path = "linux.rs")] | 52 | #[cfg_attr(target_os = "linux", path = "linux.rs")] |
| 54 | #[cfg_attr(target_os = "macos", path = "macos.rs")] | 53 | #[cfg_attr(target_os = "macos", path = "macos.rs")] |
| ... | @@ -88,6 +87,7 @@ mod surface; | ... | @@ -88,6 +87,7 @@ mod surface; |
| 88 | mod symbol; | 87 | mod symbol; |
| 89 | mod sync; | 88 | mod sync; |
| 90 | mod tags; | 89 | mod tags; |
| 90 | mod task_pane; | ||
| 91 | mod templates; | 91 | mod templates; |
| 92 | mod themes; | 92 | mod themes; |
| 93 | mod undo; | 93 | mod 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 | |||
| 5 | use crate::search::{Scope, marked}; | ||
| 6 | use crate::{State, Theme, art, page, platform}; | ||
| 7 | use canvas::gpu::tag_sources; | ||
| 8 | use canvas::outline::TagIcon; | ||
| 9 | use canvas::search::{Found, Tagged}; | ||
| 10 | use std::{error::Error, sync::atomic::Ordering}; | ||
| 11 | use ui::{Anchor, Axis, Flags, Id, Spec, Ui, fill, fit, px}; | ||
| 12 | use winit::keyboard::NamedKey; | ||
| 13 | |||
| 14 | /// The pane's width, and its lists' rows and group headings. | ||
| 15 | const WIDTH: f32 = 260.0; | ||
| 16 | const RESULT: f32 = 40.0; | ||
| 17 | const TAGGED: f32 = 24.0; | ||
| 18 | const HEADING: f32 = 22.0; | ||
| 19 | /// A control's width inside the pane. | ||
| 20 | const INSIDE: f32 = WIDTH - 2.0 * PAD; | ||
| 21 | const PAD: f32 = 8.0; | ||
| 22 | |||
| 23 | #[derive(Clone, Copy, Debug, PartialEq)] | ||
| 24 | pub 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)] | ||
| 41 | pub enum Sort { | ||
| 42 | Section, | ||
| 43 | Title, | ||
| 44 | Date, | ||
| 45 | } | ||
| 46 | |||
| 47 | impl 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)] | ||
| 61 | pub enum Group { | ||
| 62 | Tag, | ||
| 63 | Section, | ||
| 64 | Title, | ||
| 65 | Date, | ||
| 66 | Text, | ||
| 67 | } | ||
| 68 | |||
| 69 | impl 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)] | ||
| 91 | pub enum TagScope { | ||
| 92 | PageGroup, | ||
| 93 | Section, | ||
| 94 | SectionGroup, | ||
| 95 | Notebook, | ||
| 96 | All, | ||
| 97 | Today, | ||
| 98 | Yesterday, | ||
| 99 | ThisWeek, | ||
| 100 | LastWeek, | ||
| 101 | Older, | ||
| 102 | } | ||
| 103 | |||
| 104 | impl 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`. | ||
| 163 | fn 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. | ||
| 178 | fn 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 | |||
| 190 | const 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. | ||
| 206 | struct Grouped { | ||
| 207 | count: usize, | ||
| 208 | /// The rows starting groups, in order. | ||
| 209 | starts: Vec<usize>, | ||
| 210 | } | ||
| 211 | |||
| 212 | impl 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. | ||
| 231 | fn 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`. | ||
| 251 | fn 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 | |||
| 256 | fn 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. | ||
| 266 | fn 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. | ||
| 304 | fn 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. | ||
| 333 | pub(crate) fn field() -> Id { | ||
| 334 | Id::ROOT.child("pane-field") | ||
| 335 | } | ||
| 336 | |||
| 337 | fn 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. | ||
| 343 | fn 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 | |||
| 367 | fn 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. | ||
| 380 | fn 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 | |||
| 396 | impl 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. | ||
| 978 | fn 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. | ||
| 1045 | fn 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)] | ||
| 1079 | mod 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 @@ | ... | @@ -2,7 +2,7 @@ |
| 2 | //! results dropping down from it, and Find on This Page. The index is kept on a thread of | 2 | //! results dropping down from it, and Find on This Page. The index is kept on a thread of |
| 3 | //! its own, from the section files and the open section's replica. | 3 | //! its own, from the section files and the open section's replica. |
| 4 | 4 | ||
| 5 | use crate::pane::Pane; | 5 | use crate::task_pane::TaskPane; |
| 6 | use crate::{Command, Library, State, Theme, art, commands, lap, name, page}; | 6 | use crate::{Command, Library, State, Theme, art, commands, lap, name, page}; |
| 7 | use accesskit::Role; | 7 | use accesskit::Role; |
| 8 | use canvas::search::{Entry, Found, Index, PageMatch, Query, page_matches, paragraph_match}; | 8 | use canvas::search::{Entry, Found, Index, PageMatch, Query, page_matches, paragraph_match}; |
| ... | @@ -77,7 +77,7 @@ pub fn takes_text(focus: Option<Id>) -> bool { | ... | @@ -77,7 +77,7 @@ pub fn takes_text(focus: Option<Id>) -> bool { |
| 77 | [ | 77 | [ |
| 78 | Some(field()), | 78 | Some(field()), |
| 79 | Some(find_field()), | 79 | Some(find_field()), |
| 80 | Some(crate::pane::field()), | 80 | Some(crate::task_pane::field()), |
| 81 | ] | 81 | ] |
| 82 | .contains(&focus) | 82 | .contains(&focus) |
| 83 | } | 83 | } |
| ... | @@ -133,7 +133,7 @@ pub struct Search { | ... | @@ -133,7 +133,7 @@ pub struct Search { |
| 133 | anchor: Option<Anchor>, | 133 | anchor: Option<Anchor>, |
| 134 | /// The Search Results pane's field takes the keys once built. | 134 | /// The Search Results pane's field takes the keys once built. |
| 135 | pub(crate) claim: bool, | 135 | pub(crate) claim: bool, |
| 136 | pub(crate) pane: Option<Pane>, | 136 | pub(crate) task_pane: Option<TaskPane>, |
| 137 | } | 137 | } |
| 138 | 138 | ||
| 139 | impl Search { | 139 | impl Search { |
| ... | @@ -179,7 +179,7 @@ impl Search { | ... | @@ -179,7 +179,7 @@ impl Search { |
| 179 | open: false, | 179 | open: false, |
| 180 | anchor: None, | 180 | anchor: None, |
| 181 | claim: false, | 181 | claim: false, |
| 182 | pane: None, | 182 | task_pane: None, |
| 183 | } | 183 | } |
| 184 | } | 184 | } |
| 185 | 185 | ||
| ... | @@ -683,7 +683,7 @@ impl State { | ... | @@ -683,7 +683,7 @@ impl State { |
| 683 | self.search.current = None; | 683 | self.search.current = None; |
| 684 | self.ui.close_popup(results()); | 684 | self.ui.close_popup(results()); |
| 685 | self.ui.set_focus(Some(page())); | 685 | 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 { .. })) { |
| 687 | self.search.query.clear(); | 687 | self.search.query.clear(); |
| 688 | self.search.scope = self.search.default; | 688 | self.search.scope = self.search.default; |
| 689 | self.view.found.clear(); | 689 | 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 | |||
| 5 | use crate::search::{Scope, marked}; | ||
| 6 | use crate::{State, Theme, art, page, platform}; | ||
| 7 | use canvas::gpu::tag_sources; | ||
| 8 | use canvas::outline::TagIcon; | ||
| 9 | use canvas::search::{Found, Tagged}; | ||
| 10 | use std::{error::Error, sync::atomic::Ordering}; | ||
| 11 | use ui::{Anchor, Axis, Flags, Id, Spec, Ui, fill, fit, px}; | ||
| 12 | use winit::keyboard::NamedKey; | ||
| 13 | |||
| 14 | /// The pane's width, and its lists' rows and group headings. | ||
| 15 | const WIDTH: f32 = 260.0; | ||
| 16 | const RESULT: f32 = 40.0; | ||
| 17 | const TAGGED: f32 = 24.0; | ||
| 18 | const HEADING: f32 = 22.0; | ||
| 19 | /// A control's width inside the pane. | ||
| 20 | const INSIDE: f32 = WIDTH - 2.0 * PAD; | ||
| 21 | const PAD: f32 = 8.0; | ||
| 22 | |||
| 23 | #[derive(Clone, Copy, Debug, PartialEq)] | ||
| 24 | pub 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)] | ||
| 41 | pub enum Sort { | ||
| 42 | Section, | ||
| 43 | Title, | ||
| 44 | Date, | ||
| 45 | } | ||
| 46 | |||
| 47 | impl 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)] | ||
| 61 | pub enum Group { | ||
| 62 | Tag, | ||
| 63 | Section, | ||
| 64 | Title, | ||
| 65 | Date, | ||
| 66 | Text, | ||
| 67 | } | ||
| 68 | |||
| 69 | impl 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)] | ||
| 91 | pub enum TagScope { | ||
| 92 | PageGroup, | ||
| 93 | Section, | ||
| 94 | SectionGroup, | ||
| 95 | Notebook, | ||
| 96 | All, | ||
| 97 | Today, | ||
| 98 | Yesterday, | ||
| 99 | ThisWeek, | ||
| 100 | LastWeek, | ||
| 101 | Older, | ||
| 102 | } | ||
| 103 | |||
| 104 | impl 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`. | ||
| 163 | fn 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. | ||
| 178 | fn 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 | |||
| 190 | const 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. | ||
| 206 | struct Grouped { | ||
| 207 | count: usize, | ||
| 208 | /// The rows starting groups, in order. | ||
| 209 | starts: Vec<usize>, | ||
| 210 | } | ||
| 211 | |||
| 212 | impl 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. | ||
| 231 | fn 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`. | ||
| 251 | fn 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 | |||
| 256 | fn 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. | ||
| 266 | fn 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. | ||
| 304 | fn 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. | ||
| 333 | pub(crate) fn field() -> Id { | ||
| 334 | Id::ROOT.child("pane-field") | ||
| 335 | } | ||
| 336 | |||
| 337 | fn 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. | ||
| 343 | fn 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 | |||
| 367 | fn 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. | ||
| 380 | fn 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 | |||
| 396 | impl 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. | ||
| 978 | fn 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. | ||
| 1045 | fn 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)] | ||
| 1079 | mod 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 | } | ||