authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 11:27:28-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 16:21:47-07:00
log04cf1f351630be2d74efa40ded0af99c0f440f8f
tree3e2d98809c7937d41ac1a26825b9010db5d49b1e
parentbb5a7aff22be6cf47f808f3d65e597869ba30486
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: notebook search, Options, faster tab switches, and a split test suite

Search All Notebooks (⌘E) with OneNote's scopes, results and snippets, and Find on This Page (⌘F), on one engine shared with iOS. An Options dialog with OneNote's pages: color scheme, dark pages, user name, cache location. Page layout for a switched-to page runs on the loader thread and settings are only written when the notebook changes, so no tab switch exceeds a 165 Hz frame. The default test run keeps every correctness test plus a deterministic smoke slice of each sweep; SNOWBOUND_SWEEP runs the sweeps in full with shifted seeds, and tools/soak.sh runs sweeps and fuzz targets indefinitely. Seeded identities and generous wall-clock bounds remove the known flaky tests. Snow Leopard port tooling lives under platform/snow-leopard. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5

93 files changed, 6096 insertions(+), 494 deletions(-)

.gitignore+1
...@@ -7,6 +7,7 @@ __pycache__/...@@ -7,6 +7,7 @@ __pycache__/
7/fuzz/corpus/7/fuzz/corpus/
8/fuzz/artifacts/8/fuzz/artifacts/
9/fuzz/coverage/9/fuzz/coverage/
10/soak/
10/tools/w7/payload/vendor/11/tools/w7/payload/vendor/
11/resources/12/resources/
12/.env13/.env
Cargo.toml+5
...@@ -14,3 +14,8 @@ wgpu = "30.0.1"...@@ -14,3 +14,8 @@ wgpu = "30.0.1"
14# unoptimised dependencies alone drop frames.14# unoptimised dependencies alone drop frames.
15[profile.dev.package."*"]15[profile.dev.package."*"]
16opt-level = 316opt-level = 3
17
18# Tests replay thousands of commits and seals through the store, which unoptimised takes most
19# of the suite's time.
20[profile.test.package.onestore]
21opt-level = 1
apps/ios/Snowbound/Lists.swift+1-1
...@@ -32,7 +32,7 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele...@@ -32,7 +32,7 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele
32 UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in32 UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in
33 self?.connect()33 self?.connect()
34 },34 },
35 UIAction(title: "Change Your Name…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in35 UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in
36 guard let self else { return }36 guard let self else { return }
37 Author.ask(from: self) {}37 Author.ask(from: self) {}
38 },38 },
apps/ios/Snowbound/Notebooks.swift+6-5
...@@ -24,7 +24,7 @@ func titleDate(_ date: Date = Date()) -> (date: String, time: String) {...@@ -24,7 +24,7 @@ func titleDate(_ date: Date = Date()) -> (date: String, time: String) {
24 return (day, formatter.string(from: date))24 return (day, formatter.string(from: date))
25}25}
2626
27/// Who edits name, as OneNote names the Office user; asked once.27/// The user name edits are stored under, OneNote's Personalize setting; asked once.
28enum Author {28enum Author {
29 private static let key = "author"29 private static let key = "author"
3030
...@@ -33,18 +33,19 @@ enum Author {...@@ -33,18 +33,19 @@ enum Author {
33 set { UserDefaults.standard.set(newValue, forKey: key) }33 set { UserDefaults.standard.set(newValue, forKey: key) }
34 }34 }
3535
36 /// Asks for the name OneNote shows beside changes, then runs `then`.36 /// Asks for the user name OneNote shows beside changes, then runs `then`.
37 static func ask(from controller: UIViewController, then: @escaping () -> Void) {37 static func ask(from controller: UIViewController, then: @escaping () -> Void) {
38 let alert = UIAlertController(38 let alert = UIAlertController(
39 title: "What’s Your Name?",39 title: "Personalize",
40 message: "OneNote shows it beside the pages and changes you make.", preferredStyle: .alert)40 message: "OneNote shows your user name beside the pages and changes you make.",
41 preferredStyle: .alert)
41 let save = UIAlertAction(title: "Continue", style: .default) { [weak alert] _ in42 let save = UIAlertAction(title: "Continue", style: .default) { [weak alert] _ in
42 name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces)43 name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces)
43 then()44 then()
44 }45 }
45 alert.addTextField { field in46 alert.addTextField { field in
46 field.text = name47 field.text = name
47 field.placeholder = "Name"48 field.placeholder = "User name"
48 field.textContentType = .name49 field.textContentType = .name
49 field.autocapitalizationType = .words50 field.autocapitalizationType = .words
50 save.isEnabled = !(field.text ?? "").isEmpty51 save.isEnabled = !(field.text ?? "").isEmpty
corpus/search/README.md created+27
...@@ -0,0 +1,27 @@
1# Search
2
3OneNote 2010's search, observed on the Win7 VM (2026-09-27) with `notebook` open, a copy of
4`docs/sample-notebook/Personal`. `crates/canvas/tests/search.rs` checks the shared engine
5against it.
6
7- The box beside the section tabs reads "Search All Notebooks (Ctrl+E)". Its arrow opens
8 "Search In:" with "Find on This Page (Ctrl+F)", "This Section", "This Section Group",
9 "This Notebook", "All Notebooks" (checked) and "Set This Scope as Default"; a scope not set
10 as default returns to it for the next search.
11- Typing `tom` with This Notebook lists, under "Finished: This Notebook (change)" and "Find
12 on page: Ctrl+F", "Title contains: tom (1)" (Tomatoes) and "Body contains: tom (4)"
13 (Weeknight dal, Spring planting plan, Harvest log 2025, Seed inventory), each with its
14 notebook and section, "(Personal/Garden)". Words match word starts: `tom` finds
15 "Tomatoes", `sungold` finds "‘Sungold’".
16- `sungold tom` lists only "Body contains: sungold tom (4)": Tomatoes, Spring planting plan,
17 Harvest log 2025, Seed inventory. A page matches when every word is in its title or text;
18 "Title contains" needs every word in the title.
19- Down arrow selects the next result and shows its page with every match highlighted in
20 yellow. Esc closes the list and empties the box; the page keeps its first match selected.
21- `zzq` shows "No matches: All Notebooks".
22- Ctrl+F on Spring planting plan turns the box into "Find on page"; `bed` shows "Match 1 of
23 6" with previous and next arrows ("Previous Match (Shift+F3)"), the current match
24 selected and the others yellow. Enter goes to the next. `zzq` shows "No matches. Try
25 Ctrl+E".
26- The Search Results pane (Alt+O) lists each page under its date with the paragraph around
27 its first match, "…" where the paragraph is cut.
corpus/search/notebook/Garden.one created
Binary files /dev/null and b/corpus/search/notebook/Garden.one differ
corpus/search/notebook/Home.one created
Binary files /dev/null and b/corpus/search/notebook/Home.one differ
corpus/search/notebook/Journal.one created
Binary files /dev/null and b/corpus/search/notebook/Journal.one differ
corpus/search/notebook/Kitchen.one created
Binary files /dev/null and b/corpus/search/notebook/Kitchen.one differ
corpus/search/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/search/notebook/Open Notebook.onetoc2 differ
corpus/search/notebook/Reading.one created
Binary files /dev/null and b/corpus/search/notebook/Reading.one differ
corpus/search/notebook/Trips.one created
Binary files /dev/null and b/corpus/search/notebook/Trips.one differ
crates/canvas/src/editor.rs+61-61
...@@ -415,6 +415,64 @@ impl TextOutline {...@@ -415,6 +415,64 @@ impl TextOutline {
415 .join("\n")415 .join("\n")
416 }416 }
417417
418 /// Highlight rectangles of `selection` in the outline, in its coordinates.
419 pub fn range_rects(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
420 let [anchor, focus] = selection.positions;
421 if anchor == focus {
422 return Ok(Vec::new());
423 }
424 let (start, end, affinities) = if anchor < focus {
425 (anchor, focus, selection.affinities)
426 } else {
427 (
428 focus,
429 anchor,
430 [selection.affinities[1], selection.affinities[0]],
431 )
432 };
433 let mut rectangles = Vec::new();
434 for (visible, (index, paragraph)) in self
435 .layouts()
436 .enumerate()
437 .filter(|(_, (index, _))| *index >= start.paragraph && *index <= end.paragraph)
438 {
439 let source = self.document.paragraph(index).unwrap();
440 let first = if index == start.paragraph {
441 paragraph.cursor(start.offset, affinities[0])?
442 } else {
443 paragraph.cursor(0, Affinity::Downstream)?
444 };
445 let last = if index == end.paragraph {
446 paragraph.cursor(end.offset, affinities[1])?
447 } else {
448 paragraph.cursor(
449 source.utf16_offset(source.text().len())?,
450 Affinity::Upstream,
451 )?
452 };
453 let mut local = paragraph
454 .text
455 .selection(ParagraphSelection::new(first, last));
456 if index < end.paragraph {
457 local.push(paragraph.text.caret(last, 4.0));
458 }
459 for mut rect in local {
460 rect.x0 += f64::from(paragraph.origin[0]);
461 rect.x1 += f64::from(paragraph.origin[0]);
462 rect.y0 += f64::from(paragraph.origin[1]);
463 rect.y1 += f64::from(paragraph.origin[1]);
464 if let Some(cell) = self.shaped.paragraph_cell(visible) {
465 let Some(clipped) = cell.clip(rect) else {
466 continue;
467 };
468 rect = clipped;
469 }
470 rectangles.push(rect);
471 }
472 }
473 Ok(rectangles)
474 }
475
418 /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the476 /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the
419 /// next shown one.477 /// next shown one.
420 pub fn utf16_offset(&self, position: TextPosition) -> Result<u32, EditError> {478 pub fn utf16_offset(&self, position: TextPosition) -> Result<u32, EditError> {
...@@ -2445,12 +2503,13 @@ impl CanvasEditor {...@@ -2445,12 +2503,13 @@ impl CanvasEditor {
2445 }2503 }
24462504
2447 pub fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> {2505 pub fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
2448 self.range_rects(self.active_outline().selection)2506 self.active_outline()
2507 .range_rects(self.active_outline().selection)
2449 }2508 }
24502509
2451 pub fn marked_rects(&self) -> Result<Vec<BoundingBox>, EditError> {2510 pub fn marked_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
2452 match &self.composition {2511 match &self.composition {
2453 Some(composition) => self.range_rects(Selection {2512 Some(composition) => self.active_outline().range_rects(Selection {
2454 positions: [composition.range.start, composition.range.end],2513 positions: [composition.range.start, composition.range.end],
2455 affinities: [Affinity::Downstream, Affinity::Upstream],2514 affinities: [Affinity::Downstream, Affinity::Upstream],
2456 }),2515 }),
...@@ -2458,65 +2517,6 @@ impl CanvasEditor {...@@ -2458,65 +2517,6 @@ impl CanvasEditor {
2458 }2517 }
2459 }2518 }
24602519
2461 /// Highlight rectangles of `selection` in the active outline, in its coordinates.
2462 pub fn range_rects(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
2463 let [anchor, focus] = selection.positions;
2464 if anchor == focus {
2465 return Ok(Vec::new());
2466 }
2467 let (start, end, affinities) = if anchor < focus {
2468 (anchor, focus, selection.affinities)
2469 } else {
2470 (
2471 focus,
2472 anchor,
2473 [selection.affinities[1], selection.affinities[0]],
2474 )
2475 };
2476 let mut rectangles = Vec::new();
2477 for (visible, (index, paragraph)) in self
2478 .active_outline()
2479 .layouts()
2480 .enumerate()
2481 .filter(|(_, (index, _))| *index >= start.paragraph && *index <= end.paragraph)
2482 {
2483 let source = self.active_outline().document.paragraph(index).unwrap();
2484 let first = if index == start.paragraph {
2485 paragraph.cursor(start.offset, affinities[0])?
2486 } else {
2487 paragraph.cursor(0, Affinity::Downstream)?
2488 };
2489 let last = if index == end.paragraph {
2490 paragraph.cursor(end.offset, affinities[1])?
2491 } else {
2492 paragraph.cursor(
2493 source.utf16_offset(source.text().len())?,
2494 Affinity::Upstream,
2495 )?
2496 };
2497 let mut local = paragraph
2498 .text
2499 .selection(ParagraphSelection::new(first, last));
2500 if index < end.paragraph {
2501 local.push(paragraph.text.caret(last, 4.0));
2502 }
2503 for mut rect in local {
2504 rect.x0 += f64::from(paragraph.origin[0]);
2505 rect.x1 += f64::from(paragraph.origin[0]);
2506 rect.y0 += f64::from(paragraph.origin[1]);
2507 rect.y1 += f64::from(paragraph.origin[1]);
2508 if let Some(cell) = self.active_outline().shaped.paragraph_cell(visible) {
2509 let Some(clipped) = cell.clip(rect) else {
2510 continue;
2511 };
2512 rect = clipped;
2513 }
2514 rectangles.push(rect);
2515 }
2516 }
2517 Ok(rectangles)
2518 }
2519
2520 pub fn replace(2520 pub fn replace(
2521 &mut self,2521 &mut self,
2522 engine: &mut TextEngine,2522 engine: &mut TextEngine,
crates/canvas/src/interaction/mod.rs+29-1
...@@ -104,7 +104,7 @@ pub struct TextColors {...@@ -104,7 +104,7 @@ pub struct TextColors {
104/// How to paint an outline: the caret's opacity, the view scale, document points per104/// How to paint an outline: the caret's opacity, the view scale, document points per
105/// logical pixel, and the text colours.105/// logical pixel, and the text colours.
106#[derive(Clone, Copy)]106#[derive(Clone, Copy)]
107struct Paint {107struct Paint<'a> {
108 caret: f32,108 caret: f32,
109 scale: f32,109 scale: f32,
110 pixel: f32,110 pixel: f32,
...@@ -114,6 +114,8 @@ struct Paint {...@@ -114,6 +114,8 @@ struct Paint {
114 visible: [f32; 2],114 visible: [f32; 2],
115 /// Whether the active outline shows its frame and grips.115 /// Whether the active outline shows its frame and grips.
116 chrome: bool,116 chrome: bool,
117 /// Search matches, marked as OneNote marks them.
118 found: &'a [crate::search::PageMatch],
117}119}
118120
119enum Drag {121enum Drag {
...@@ -202,6 +204,8 @@ pub struct PageView {...@@ -202,6 +204,8 @@ pub struct PageView {
202 /// A platform scroll view owns the viewport: changes neither clamp it nor reveal the204 /// A platform scroll view owns the viewport: changes neither clamp it nor reveal the
203 /// caret, which the host does knowing its bars and keyboard.205 /// caret, which the host does knowing its bars and keyboard.
204 pub host_viewport: bool,206 pub host_viewport: bool,
207 /// Matches of the search shown on the page, marked under their text.
208 pub found: Vec<crate::search::PageMatch>,
205 /// The caret's opacity in its blink.209 /// The caret's opacity in its blink.
206 caret: f32,210 caret: f32,
207 /// When the caret last moved, which restarts its blink.211 /// When the caret last moved, which restarts its blink.
...@@ -260,6 +264,7 @@ impl PageView {...@@ -260,6 +264,7 @@ impl PageView {
260 focused: true,264 focused: true,
261 touch: false,265 touch: false,
262 host_viewport: false,266 host_viewport: false,
267 found: Vec::new(),
263 caret: 1.0,268 caret: 1.0,
264 blink_from: Instant::now(),269 blink_from: Instant::now(),
265 };270 };
...@@ -273,6 +278,7 @@ impl PageView {...@@ -273,6 +278,7 @@ impl PageView {
273 self.scene = scene;278 self.scene = scene;
274 self.drag = None;279 self.drag = None;
275 self.object_focus = None;280 self.object_focus = None;
281 self.found.clear();
276 }282 }
277283
278 /// Shows the stored page after a change made elsewhere in place of the one shown,284 /// Shows the stored page after a change made elsewhere in place of the one shown,
...@@ -730,6 +736,7 @@ impl PageView {...@@ -730,6 +736,7 @@ impl PageView {
730 visible: [0.0, self.viewport.size[1] as f32]736 visible: [0.0, self.viewport.size[1] as f32]
731 .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale),737 .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale),
732 chrome: !self.touch || self.focused,738 chrome: !self.touch || self.focused,
739 found: &self.found,
733 },740 },
734 )741 )
735 }742 }
...@@ -1920,6 +1927,7 @@ fn append_outline<'a>(...@@ -1920,6 +1927,7 @@ fn append_outline<'a>(
1920 pixel,1927 pixel,
1921 colors,1928 colors,
1922 visible,1929 visible,
1930 found,
1923 ..1931 ..
1924 } = paint;1932 } = paint;
1925 let rows = [visible[0] - y, visible[1] - y];1933 let rows = [visible[0] - y, visible[1] - y];
...@@ -1929,6 +1937,26 @@ fn append_outline<'a>(...@@ -1929,6 +1937,26 @@ fn append_outline<'a>(
1929 outline1937 outline
1930 .shaped()1938 .shaped()
1931 .append_background_primitives(primitives, origin, rows, colors.paper);1939 .append_background_primitives(primitives, origin, rows, colors.paper);
1940 // OneNote's search highlight: yellow, deepened to gold under light text on dark paper.
1941 let [red, green, blue, _] = colors.paper.color;
1942 let marker = if red + green + blue < 1.5 {
1943 draw::srgb(0x7a, 0x62, 0x00)
1944 } else {
1945 draw::srgb(0xff, 0xe6, 0x00)
1946 };
1947 for (_, selection) in found.iter().filter(|(id, _)| *id == outline.id) {
1948 for rect in outline.range_rects(*selection)? {
1949 primitives.push(Primitive::Rect {
1950 rect: [
1951 rect.x0 as f32 + x,
1952 rect.y0 as f32 + y,
1953 rect.x1 as f32 + x,
1954 rect.y1 as f32 + y,
1955 ],
1956 color: marker,
1957 });
1958 }
1959 }
1932 if let Some(editor) = editor {1960 if let Some(editor) = editor {
1933 for rect in editor.selection_rects()? {1961 for rect in editor.selection_rects()? {
1934 primitives.push(Primitive::Rect {1962 primitives.push(Primitive::Rect {
crates/canvas/src/interaction/tests.rs+4-1
...@@ -13,7 +13,7 @@ const COLORS: TextColors = TextColors {...@@ -13,7 +13,7 @@ const COLORS: TextColors = TextColors {
13};13};
1414
15/// Paint for a view at `scale` device pixels per point on a display of `display_scale`.15/// Paint for a view at `scale` device pixels per point on a display of `display_scale`.
16pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint {16pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint<'static> {
17 Paint {17 Paint {
18 caret: f32::from(u8::from(show_caret)),18 caret: f32::from(u8::from(show_caret)),
19 scale,19 scale,
...@@ -21,6 +21,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint {...@@ -21,6 +21,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint {
21 colors: COLORS,21 colors: COLORS,
22 visible: [f32::NEG_INFINITY, f32::INFINITY],22 visible: [f32::NEG_INFINITY, f32::INFINITY],
23 chrome: true,23 chrome: true,
24 found: &[],
24 }25 }
25}26}
2627
...@@ -833,6 +834,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {...@@ -833,6 +834,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
833 colors: COLORS,834 colors: COLORS,
834 visible: [f32::NEG_INFINITY, f32::INFINITY],835 visible: [f32::NEG_INFINITY, f32::INFINITY],
835 chrome: true,836 chrome: true,
837 found: &[],
836 },838 },
837 &mut primitives,839 &mut primitives,
838 )840 )
...@@ -905,6 +907,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {...@@ -905,6 +907,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {
905 colors: COLORS,907 colors: COLORS,
906 visible: [f32::NEG_INFINITY, f32::INFINITY],908 visible: [f32::NEG_INFINITY, f32::INFINITY],
907 chrome: true,909 chrome: true,
910 found: &[],
908 },911 },
909 &mut primitives,912 &mut primitives,
910 )913 )
crates/canvas/src/layout.rs+19-2
...@@ -4,7 +4,7 @@ use parley::{...@@ -4,7 +4,7 @@ use parley::{
4 FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, OverflowWrap,4 FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, OverflowWrap,
5 PositionedLayoutItem, StyleProperty,5 PositionedLayoutItem, StyleProperty,
6 editing::{Cursor, Selection},6 editing::{Cursor, Selection},
7 fontique::{Blob, FontInfo},7 fontique::{Blob, FontInfo, SourceCache},
8};8};
9use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId};9use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId};
10use std::{10use std::{
...@@ -26,6 +26,7 @@ pub struct TextEngine {...@@ -26,6 +26,7 @@ pub struct TextEngine {
26}26}
2727
28/// A font laid out in place of a family.28/// A font laid out in place of a family.
29#[derive(Clone)]
29pub struct Substitute {30pub struct Substitute {
30 pub name: &'static str,31 pub name: &'static str,
31 /// Its faces as registered for the family.32 /// Its faces as registered for the family.
...@@ -132,7 +133,11 @@ impl Default for TextEngine {...@@ -132,7 +133,11 @@ impl Default for TextEngine {
132 /// An engine that lays out each bundled family in its substitute where it is missing.133 /// An engine that lays out each bundled family in its substitute where it is missing.
133 fn default() -> Self {134 fn default() -> Self {
134 let mut engine = Self {135 let mut engine = Self {
135 fonts: FontContext::default(),136 // Clones share loaded font files, so glyphs one lays out draw from the same cache.
137 fonts: FontContext {
138 source_cache: SourceCache::new_shared(),
139 ..FontContext::default()
140 },
136 context: LayoutContext::default(),141 context: LayoutContext::default(),
137 arial_substitutes: BTreeSet::new(),142 arial_substitutes: BTreeSet::new(),
138 substitutes: BTreeMap::new(),143 substitutes: BTreeMap::new(),
...@@ -150,6 +155,18 @@ impl Default for TextEngine {...@@ -150,6 +155,18 @@ impl Default for TextEngine {
150 }155 }
151}156}
152157
158/// Another engine with the same fonts and substitutes, to lay out on another thread.
159impl Clone for TextEngine {
160 fn clone(&self) -> Self {
161 Self {
162 fonts: self.fonts.clone(),
163 context: LayoutContext::default(),
164 arial_substitutes: self.arial_substitutes.clone(),
165 substitutes: self.substitutes.clone(),
166 }
167 }
168}
169
153impl TextEngine {170impl TextEngine {
154 /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as171 /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as
155 /// Courier New before creating layouts, in place of the bundled substitute.172 /// Courier New before creating layouts, in place of the bundled substitute.
crates/canvas/src/search.rs+30-17
...@@ -110,7 +110,7 @@ fn fold_char(character: char, out: &mut String) {...@@ -110,7 +110,7 @@ fn fold_char(character: char, out: &mut String) {
110 if character.is_ascii() {110 if character.is_ascii() {
111 out.push(match character {111 out.push(match character {
112 '\n' => '\n',112 '\n' => '\n',
113 space if space.is_ascii_whitespace() => ' ',113 space if space.is_whitespace() => ' ',
114 other => other.to_ascii_lowercase(),114 other => other.to_ascii_lowercase(),
115 });115 });
116 return;116 return;
...@@ -215,6 +215,22 @@ pub struct Entry {...@@ -215,6 +215,22 @@ pub struct Entry {
215 folded_text: String,215 folded_text: String,
216}216}
217217
218impl Entry {
219 /// Page `space` of `section` as `page` shows it.
220 pub fn new(section: &str, space: ExGuid, page: &Page, modified: u64) -> Self {
221 let text = page_text(page);
222 Self {
223 section: section.to_owned(),
224 space,
225 folded_title: fold(&page.title),
226 folded_text: fold(&text),
227 title: page.title.clone(),
228 modified,
229 text,
230 }
231 }
232}
233
218/// A page matching a query.234/// A page matching a query.
219#[derive(Clone, Debug, PartialEq)]235#[derive(Clone, Debug, PartialEq)]
220pub struct Found {236pub struct Found {
...@@ -245,22 +261,12 @@ impl Index {...@@ -245,22 +261,12 @@ impl Index {
245 self.entries.is_empty()261 self.entries.is_empty()
246 }262 }
247263
248 /// Adds or replaces page `space` of `section` as `page` shows it.264 /// Adds `entry`, replacing the page it names.
249 pub fn set(&mut self, section: &str, space: ExGuid, page: &Page, modified: u64) {265 pub fn set(&mut self, entry: Entry) {
250 let text = page_text(page);
251 let entry = Entry {
252 section: section.to_owned(),
253 space,
254 folded_title: fold(&page.title),
255 folded_text: fold(&text),
256 title: page.title.clone(),
257 modified,
258 text,
259 };
260 match self266 match self
261 .entries267 .entries
262 .iter_mut()268 .iter_mut()
263 .find(|entry| entry.space == space && entry.section == section)269 .find(|old| old.space == entry.space && old.section == entry.section)
264 {270 {
265 Some(old) => *old = entry,271 Some(old) => *old = entry,
266 None => self.entries.push(entry),272 None => self.entries.push(entry),
...@@ -323,7 +329,10 @@ impl Index {...@@ -323,7 +329,10 @@ impl Index {
323fn snippet(entry: &Entry, query: &Query) -> (String, Vec<Range<usize>>) {329fn snippet(entry: &Entry, query: &Query) -> (String, Vec<Range<usize>>) {
324 let first = query.hits(&entry.folded_text).first().map(|hit| hit.start);330 let first = query.hits(&entry.folded_text).first().map(|hit| hit.start);
325 let line = first.map_or(0, |at| {331 let line = first.map_or(0, |at| {
326 entry.folded_text[..at].bytes().filter(|byte| *byte == b'\n').count()332 entry.folded_text[..at]
333 .bytes()
334 .filter(|byte| *byte == b'\n')
335 .count()
327 });336 });
328 let text = entry.text.split('\n').nth(line).unwrap_or_default();337 let text = entry.text.split('\n').nth(line).unwrap_or_default();
329 let hits = query.find(text);338 let hits = query.find(text);
...@@ -335,12 +344,16 @@ fn snippet(entry: &Entry, query: &Query) -> (String, Vec<Range<usize>>) {...@@ -335,12 +344,16 @@ fn snippet(entry: &Entry, query: &Query) -> (String, Vec<Range<usize>>) {
335 let mut end = (start + SNIPPET).min(chars.len());344 let mut end = (start + SNIPPET).min(chars.len());
336 // Cut ends fall back to the nearest space, so no word shows in part.345 // Cut ends fall back to the nearest space, so no word shows in part.
337 if start > 0346 if start > 0
338 && let Some(space) = chars[start..at].iter().position(|(_, char)| char.is_whitespace())347 && let Some(space) = chars[start..at]
348 .iter()
349 .position(|(_, char)| char.is_whitespace())
339 {350 {
340 start += space + 1;351 start += space + 1;
341 }352 }
342 if end < chars.len()353 if end < chars.len()
343 && let Some(space) = chars[at..end].iter().rposition(|(_, char)| char.is_whitespace())354 && let Some(space) = chars[at..end]
355 .iter()
356 .rposition(|(_, char)| char.is_whitespace())
344 {357 {
345 end = at + space;358 end = at + space;
346 }359 }
crates/canvas/tests/ops_differential.rs+12-8
...@@ -2,7 +2,8 @@...@@ -2,7 +2,8 @@
2//! oracle (`op::predict`) predicts from them, read back so once sealed, and hold the editor's2//! oracle (`op::predict`) predicts from them, read back so once sealed, and hold the editor's
3//! page or what it lowered whole stores: every editor operation alone and in random sequences with undo and redo, on every page of the corpus sections3//! page or what it lowered whole stores: every editor operation alone and in random sequences with undo and redo, on every page of the corpus sections
4//! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural4//! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural
5//! probe section, which `structural_roundtrip` sweeps, takes minutes).5//! probe section, which `structural_roundtrip` sweeps, takes minutes). The smoke slice takes
6//! the first pages of each section.
67
7use canvas::{8use canvas::{
8 document::TextPosition,9 document::TextPosition,
...@@ -18,6 +19,9 @@ use onestore::{...@@ -18,6 +19,9 @@ use onestore::{
18};19};
19use std::{collections::BTreeMap, path::Path};20use std::{collections::BTreeMap, path::Path};
2021
22#[path = "../../onestore/tests/support/sweep.rs"]
23mod sweep;
24
21const SECTIONS: [&str; 15] = [25const SECTIONS: [&str; 15] = [
22 "corpus/outline-edit/before/notebook/synthetic.one",26 "corpus/outline-edit/before/notebook/synthetic.one",
23 "corpus/paragraph-edit/before/notebook/synthetic.one",27 "corpus/paragraph-edit/before/notebook/synthetic.one",
...@@ -634,12 +638,12 @@ fn sweep(...@@ -634,12 +638,12 @@ fn sweep(
634 random: &mut Random,638 random: &mut Random,
635 engine: &mut TextEngine,639 engine: &mut TextEngine,
636 tally: &mut Tally,640 tally: &mut Tally,
637 sequences: usize,641 pages: usize,
638) {642) {
639 let image = std::fs::read(path).unwrap();643 let image = std::fs::read(path).unwrap();
640 let arena = Arena::default();644 let arena = Arena::default();
641 let mut section = Section::open(&arena, image.clone()).unwrap();645 let mut section = Section::open(&arena, image.clone()).unwrap();
642 for (space, title, _) in section.pages().unwrap() {646 for (space, title, _) in section.pages().unwrap().into_iter().take(pages) {
643 let Ok(page) = section.page(space) else {647 let Ok(page) = section.page(space) else {
644 continue;648 continue;
645 };649 };
...@@ -650,7 +654,7 @@ fn sweep(...@@ -650,7 +654,7 @@ fn sweep(
650 .iter()654 .iter()
651 .map(|kind| vec![*kind])655 .map(|kind| vec![*kind])
652 .chain(KINDS.iter().map(|kind| vec![*kind, Kind::Undo, Kind::Redo]))656 .chain(KINDS.iter().map(|kind| vec![*kind, Kind::Undo, Kind::Redo]))
653 .chain((0..sequences).map(|_| {657 .chain((0..4).map(|_| {
654 (0..3 + random.below(8))658 (0..3 + random.below(8))
655 .map(|_| KINDS[random.below(KINDS.len())])659 .map(|_| KINDS[random.below(KINDS.len())])
656 .collect()660 .collect()
...@@ -695,14 +699,14 @@ fn editor_ops_store_the_editor_s_page() {...@@ -695,14 +699,14 @@ fn editor_ops_store_the_editor_s_page() {
695 if let Some(extra) = std::env::var_os("OPS_SWEEP_SECTIONS") {699 if let Some(extra) = std::env::var_os("OPS_SWEEP_SECTIONS") {
696 paths.extend(std::env::split_paths(&extra));700 paths.extend(std::env::split_paths(&extra));
697 }701 }
698 let seed = std::env::var("OPS_SWEEP_SEED").map_or(1, |seed| seed.parse().unwrap());702 let full = sweep::full();
699 let sequences = std::env::var("OPS_SWEEP_SEQUENCES").map_or(4, |n| n.parse().unwrap());703 let pages = if full.is_some() { usize::MAX } else { 2 };
700 let mut random = Random(seed);704 let mut random = Random(1 + full.unwrap_or(0));
701 let mut engine = TextEngine::default();705 let mut engine = TextEngine::default();
702 let mut tally = Tally::default();706 let mut tally = Tally::default();
703 for path in &paths {707 for path in &paths {
704 let start = std::time::Instant::now();708 let start = std::time::Instant::now();
705 sweep(path, &mut random, &mut engine, &mut tally, sequences);709 sweep(path, &mut random, &mut engine, &mut tally, pages);
706 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {710 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
707 eprintln!("TIME {:?} {}", start.elapsed(), path.display());711 eprintln!("TIME {:?} {}", start.elapsed(), path.display());
708 }712 }
crates/canvas/tests/search.rs created+216
...@@ -0,0 +1,216 @@
1//! The search engine against OneNote 2010's own results on the sample notebook
2//! (`corpus/search`), and its folding, word starts, ranking and snippets.
3
4use canvas::{
5 document::TextDocument,
6 editor::CanvasEditor,
7 layout::TextEngine,
8 search::{Entry, Index, Query, fold, page_matches},
9};
10use onestore::{
11 ExGuid, RevisionIndex, Store,
12 document::{Document, Format},
13 page::{Page, text::Paragraph},
14};
15use std::path::Path;
16
17/// Each page of the corpus notebook's section `name`.
18fn section(name: &str) -> Vec<(ExGuid, Page)> {
19 let file = Path::new(env!("CARGO_MANIFEST_DIR"))
20 .join("../../corpus/search/notebook")
21 .join(format!("{name}.one"));
22 let bytes = std::fs::read(file).unwrap();
23 let store = Store::parse(&bytes).unwrap();
24 let index = RevisionIndex::parse(&store).unwrap();
25 let document = Document::parse(&index).unwrap();
26 document
27 .pages()
28 .unwrap()
29 .into_iter()
30 .map(|(space, id)| {
31 let page = Page::from_revision(document.active(space).unwrap(), id).unwrap();
32 (space, page)
33 })
34 .collect()
35}
36
37fn notebook() -> Index {
38 let mut index = Index::default();
39 for name in ["Garden", "Home", "Journal", "Kitchen", "Reading", "Trips"] {
40 for (space, page) in section(name) {
41 index.set(Entry::new(name, space, &page, 0));
42 }
43 }
44 index
45}
46
47fn titles(found: &[canvas::search::Found], in_title: bool) -> Vec<&str> {
48 let mut titles: Vec<&str> = found
49 .iter()
50 .filter(|found| found.in_title == in_title)
51 .map(|found| found.title.as_str())
52 .collect();
53 titles.sort();
54 titles
55}
56
57#[test]
58fn a_notebook_search_finds_what_onenote_finds() {
59 let index = notebook();
60 let found = index.search(&Query::new("tom"), |_| true);
61 assert_eq!(titles(&found, true), ["Tomatoes"]);
62 assert_eq!(
63 titles(&found, false),
64 [
65 "Harvest log 2025",
66 "Seed inventory",
67 "Spring planting plan",
68 "Weeknight dal"
69 ]
70 );
71 assert!(found[0].in_title, "title hits come first");
72 let found = index.search(&Query::new("sungold tom"), |_| true);
73 assert_eq!(titles(&found, true), Vec::<&str>::new());
74 assert_eq!(
75 titles(&found, false),
76 [
77 "Harvest log 2025",
78 "Seed inventory",
79 "Spring planting plan",
80 "Tomatoes"
81 ]
82 );
83 assert!(index.search(&Query::new("zzq"), |_| true).is_empty());
84 let garden = index.search(&Query::new("tom"), |section| section == "Kitchen");
85 assert_eq!(titles(&garden, false), ["Weeknight dal"]);
86}
87
88#[test]
89fn snippets_show_the_first_match_marked() {
90 let index = notebook();
91 let found = index.search(&Query::new("lentils"), |_| true);
92 let dal = found
93 .iter()
94 .find(|found| found.title == "Weeknight dal")
95 .unwrap();
96 assert!(dal.snippet.contains("lentils"), "{:?}", dal.snippet);
97 let [hit] = dal.snippet_hits.as_slice() else {
98 panic!("{:?}", dal.snippet_hits)
99 };
100 assert_eq!(dal.snippet[hit.clone()].to_lowercase(), "lentils");
101 assert!(!dal.in_title && dal.title_hits.is_empty());
102}
103
104#[test]
105fn find_on_page_counts_what_onenote_counts() {
106 let (_, page) = section("Garden")
107 .into_iter()
108 .find(|(_, page)| page.title == "Spring planting plan")
109 .unwrap();
110 let mut engine = TextEngine::default();
111 let editor = CanvasEditor::from_page(page, &mut engine).unwrap();
112 let matches = page_matches(&editor, &Query::new("bed"));
113 assert_eq!(matches.len(), 6, "OneNote shows Match 1 of 6");
114 for (outline, selection) in &matches {
115 let outline = editor
116 .outlines()
117 .iter()
118 .find(|candidate| candidate.id == *outline)
119 .unwrap();
120 let [start, end] = selection.positions;
121 let paragraph = outline
122 .document()
123 .paragraphs()
124 .nth(start.paragraph)
125 .unwrap();
126 let text: String = paragraph
127 .text()
128 .encode_utf16()
129 .skip(start.offset as usize)
130 .take((end.offset - start.offset) as usize)
131 .map(|unit| char::from_u32(u32::from(unit)).unwrap())
132 .collect();
133 assert_eq!(text.to_lowercase(), "bed");
134 }
135 assert!(page_matches(&editor, &Query::new("zzq")).is_empty());
136}
137
138#[test]
139fn folding_ignores_case_and_diacritics() {
140 assert_eq!(fold("Crème BRÛLÉE"), "creme brulee");
141 assert_eq!(fold("Straße Ærø don’t"), "strasse aero don't");
142 assert_eq!(fold("a\u{a0}b\u{b}c\nd"), "a b c\nd");
143}
144
145/// A page titled `title` holding one paragraph of `text`.
146fn page(engine: &mut TextEngine, title: &str, text: &str) -> Page {
147 let document =
148 TextDocument::new(vec![Paragraph::new(text.to_owned(), Format::default())]).unwrap();
149 let mut page = CanvasEditor::new(engine, document, 480.0)
150 .unwrap()
151 .page()
152 .unwrap();
153 page.title = title.to_owned();
154 page
155}
156
157#[test]
158fn words_match_word_starts_and_pages_rank_by_title_then_recency() {
159 let mut engine = TextEngine::default();
160 let mut index = Index::default();
161 let space = |n: u32| ExGuid {
162 guid: [n as u8; 16],
163 n,
164 };
165 index.set(Entry::new(
166 "a",
167 space(1),
168 &page(&mut engine, "Notes", "café au lait at the bottom"),
169 30,
170 ));
171 index.set(Entry::new(
172 "a",
173 space(2),
174 &page(&mut engine, "Café list", "nothing else"),
175 10,
176 ));
177 index.set(Entry::new(
178 "b",
179 space(3),
180 &page(&mut engine, "Old", "CAFE menu"),
181 20,
182 ));
183 index.set(Entry::new(
184 "b",
185 space(4),
186 &page(&mut engine, "東京", "今日は東京に行く"),
187 5,
188 ));
189 let order = |index: &Index, query: &str| -> Vec<String> {
190 index
191 .search(&Query::new(query), |_| true)
192 .into_iter()
193 .map(|found| found.title)
194 .collect()
195 };
196 assert_eq!(order(&index, "cafe"), ["Café list", "Notes", "Old"]);
197 assert_eq!(
198 order(&index, "tom"),
199 Vec::<String>::new(),
200 "only word starts match"
201 );
202 assert_eq!(order(&index, "\"au lait\""), ["Notes"]);
203 assert_eq!(order(&index, "\"lait au\""), Vec::<String>::new());
204 assert_eq!(order(&index, "京に"), ["東京"]);
205 // Replacing a page, then dropping a section.
206 index.set(Entry::new(
207 "a",
208 space(1),
209 &page(&mut engine, "Notes", "tea"),
210 40,
211 ));
212 assert_eq!(order(&index, "cafe"), ["Café list", "Old"]);
213 index.retain(|entry| entry.section != "b");
214 assert_eq!(order(&index, "cafe"), ["Café list"]);
215 assert_eq!(index.len(), 2);
216}
crates/canvas/tests/structural_roundtrip.rs+17-6
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1//! Every structural edit the editor makes saves as the ops the editor recorded and rereads1//! Every structural edit the editor makes saves as the ops the editor recorded and rereads
2//! as the model it saved: random Enter, Backspace, Delete, Tab,2//! as the model it saved: random Enter, Backspace, Delete, Tab,
3//! Shift+Tab, list and paste edits, alone and several to a save, on every outline of the probe3//! Shift+Tab, list and paste edits, alone and several to a save, on every outline of the probe
4//! section and of any sections named in `CANVAS_SWEEP_SECTIONS` (`:`-separated paths).4//! section and of any sections named in `CANVAS_SWEEP_SECTIONS` (`:`-separated paths). The
5//! smoke slice takes the first pages of each section.
56
6use canvas::{7use canvas::{
7 document::TextPosition,8 document::TextPosition,
...@@ -15,6 +16,9 @@ use onestore::{...@@ -15,6 +16,9 @@ use onestore::{
15};16};
16use std::{collections::BTreeMap, path::Path};17use std::{collections::BTreeMap, path::Path};
1718
19#[path = "../../onestore/tests/support/sweep.rs"]
20mod sweep;
21
18#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]22#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
19enum Kind {23enum Kind {
20 EnterStart,24 EnterStart,
...@@ -289,9 +293,15 @@ struct Tally {...@@ -289,9 +293,15 @@ struct Tally {
289 failures: BTreeMap<String, Vec<String>>,293 failures: BTreeMap<String, Vec<String>>,
290}294}
291295
292fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut Tally) {296fn sweep(
297 path: &Path,
298 random: &mut Random,
299 engine: &mut TextEngine,
300 tally: &mut Tally,
301 count: usize,
302) {
293 let section = std::fs::read(path).unwrap();303 let section = std::fs::read(path).unwrap();
294 for (space, page) in pages(&section) {304 for (space, page) in pages(&section).into_iter().take(count) {
295 let Ok(editor) = CanvasEditor::from_page(page.clone(), engine) else {305 let Ok(editor) = CanvasEditor::from_page(page.clone(), engine) else {
296 continue;306 continue;
297 };307 };
...@@ -378,12 +388,13 @@ fn structural_edits_reread_as_saved() {...@@ -378,12 +388,13 @@ fn structural_edits_reread_as_saved() {
378 if let Some(extra) = std::env::var_os("CANVAS_SWEEP_SECTIONS") {388 if let Some(extra) = std::env::var_os("CANVAS_SWEEP_SECTIONS") {
379 paths.extend(std::env::split_paths(&extra));389 paths.extend(std::env::split_paths(&extra));
380 }390 }
381 let seed = std::env::var("CANVAS_SWEEP_SEED").map_or(1, |seed| seed.parse().unwrap());391 let full = sweep::full();
382 let mut random = Random(seed);392 let count = if full.is_some() { usize::MAX } else { 24 };
393 let mut random = Random(1 + full.unwrap_or(0));
383 let mut engine = TextEngine::default();394 let mut engine = TextEngine::default();
384 let mut tally = Tally::default();395 let mut tally = Tally::default();
385 for path in &paths {396 for path in &paths {
386 sweep(path, &mut random, &mut engine, &mut tally);397 sweep(path, &mut random, &mut engine, &mut tally, count);
387 }398 }
388 let failed: usize = tally.failures.values().map(Vec::len).sum();399 let failed: usize = tally.failures.values().map(Vec::len).sum();
389 let mut report = String::new();400 let mut report = String::new();
crates/mobile/src/lib.rs+7-18
...@@ -413,26 +413,15 @@ impl Canvas {...@@ -413,26 +413,15 @@ impl Canvas {
413 Ok(true)413 Ok(true)
414 }414 }
415415
416 /// Selects the first place `query` occurs on the page, ignoring case.416 /// Selects the first match of `query` on the page, as the search that found it matches.
417 fn find(&mut self, query: &str) -> Result<bool> {417 fn find(&mut self, query: &str) -> Result<bool> {
418 let lower: Vec<u16> = query.to_lowercase().encode_utf16().collect();418 let query = canvas::search::Query::new(query);
419 if lower.is_empty() {419 let Some(&(id, selection)) = canvas::search::page_matches(&self.page.editor, &query).first()
420 return Ok(false);420 else {
421 }
422 let found = self.page.editor.outlines().iter().find_map(|outline| {
423 let text: Vec<u16> = outline.shown_text().to_lowercase().encode_utf16().collect();
424 let at = text
425 .windows(lower.len())
426 .position(|window| window == lower)?;
427 // Lowercasing can change the length; the match is then only found in place.
428 (text.len() == outline.shown_text().encode_utf16().count())
429 .then_some((outline.id, at as u32))
430 });
431 let Some((id, at)) = found else {
432 return Ok(false);421 return Ok(false);
433 };422 };
434 self.page.editor.focus_outline(id)?;423 self.page.editor.focus_outline(id)?;
435 self.select([at, at + lower.len() as u32])?;424 self.page.editor.select(selection)?;
436 Ok(true)425 Ok(true)
437 }426 }
438427
...@@ -447,7 +436,7 @@ impl Canvas {...@@ -447,7 +436,7 @@ impl Canvas {
447 outline436 outline
448 .document()437 .document()
449 .paragraphs()438 .paragraphs()
450 .map(library::shown)439 .map(canvas::search::shown)
451 .collect::<Vec<_>>()440 .collect::<Vec<_>>()
452 .join("\n")441 .join("\n")
453 })442 })
...@@ -511,7 +500,7 @@ impl Canvas {...@@ -511,7 +500,7 @@ impl Canvas {
511500
512 /// The selection rectangles of a range, one or more per line.501 /// The selection rectangles of a range, one or more per line.
513 fn range_rects(&self, range: [u32; 2]) -> Result<Vec<[f32; 4]>> {502 fn range_rects(&self, range: [u32; 2]) -> Result<Vec<[f32; 4]>> {
514 let rects = self.page.editor.range_rects(Selection {503 let rects = self.active().range_rects(Selection {
515 positions: self.positions(range)?,504 positions: self.positions(range)?,
516 affinities: [Affinity::Downstream, Affinity::Upstream],505 affinities: [Affinity::Downstream, Affinity::Upstream],
517 })?;506 })?;
crates/mobile/src/library.rs+65-141
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3//! cache, which stores each edit at once and publishes it in the background.3//! cache, which stores each edit at once and publishes it in the background.
44
5use crate::{Result, owned, report, string};5use crate::{Result, owned, report, string};
6use canvas::search::{Entry, Index, Query};
6use notebook::{7use notebook::{
7 Remote, Replica,8 Remote, Replica,
8 discover::{Folder, SectionState},9 discover::{Folder, SectionState},
...@@ -10,11 +11,9 @@ use notebook::{...@@ -10,11 +11,9 @@ use notebook::{
10 smb::{Client, Credentials},11 smb::{Client, Credentials},
11};12};
12use onestore::{13use onestore::{
13 CommitError, CommitState, ExGuid, PageCreation, PageEdit, RevisionIndex, Stamp, Store,14 CommitError, CommitState, ExGuid, PageCreation, PageEdit, Stamp, Transaction,
14 Transaction,
15 document::Document,
16 op::{Edit, Op, SectionOp},15 op::{Edit, Op, SectionOp},
17 page::{Page, PageObject, PageParagraph, ParagraphContent},16 page::Page,
18};17};
19use std::{18use std::{
20 collections::{BTreeMap, HashMap},19 collections::{BTreeMap, HashMap},
...@@ -172,11 +171,10 @@ fn files(folder: &Folder, files: &mut BTreeMap<String, String>) {...@@ -172,11 +171,10 @@ fn files(folder: &Folder, files: &mut BTreeMap<String, String>) {
172 }171 }
173}172}
174173
175/// Page texts of a section as last read, and when its file was last checked.174/// When a section's file was last checked for changes, and how it was then.
176struct Indexed {175struct Checked {
177 checked: Instant,176 at: Instant,
178 stamp: Vec<u8>,177 stamp: Vec<u8>,
179 pages: Vec<(ExGuid, String, String)>,
180}178}
181179
182pub struct Library {180pub struct Library {
...@@ -184,7 +182,7 @@ pub struct Library {...@@ -184,7 +182,7 @@ pub struct Library {
184 notebook: Mutex<Option<Notebook>>,182 notebook: Mutex<Option<Notebook>>,
185 place: Place,183 place: Place,
186 cache: PathBuf,184 cache: PathBuf,
187 index: Mutex<HashMap<String, Indexed>>,185 index: Mutex<(HashMap<String, Checked>, Index)>,
188}186}
189187
190#[derive(serde::Serialize, serde::Deserialize)]188#[derive(serde::Serialize, serde::Deserialize)]
...@@ -665,65 +663,6 @@ const CHANGED: u32 = 2;...@@ -665,65 +663,6 @@ const CHANGED: u32 = 2;
665/// The section refused an edit; the page shown returns to what is stored.663/// The section refused an edit; the page shown returns to what is stored.
666const REJECTED: u32 = 4;664const REJECTED: u32 = 4;
667665
668/// The text a paragraph shows, without hidden field codes.
669pub(crate) fn shown(paragraph: &onestore::page::text::Paragraph) -> String {
670 let mut start = 0;
671 paragraph
672 .spans()
673 .iter()
674 .filter_map(|span| {
675 let run = &paragraph.text()[start..span.end];
676 start = span.end;
677 (span.format.hidden != Some(true)).then_some(run)
678 })
679 .collect()
680}
681
682/// A page's text, paragraph by paragraph, the title first.
683pub(crate) fn page_text(page: &Page) -> String {
684 fn walk(paragraphs: &[PageParagraph], out: &mut Vec<String>) {
685 for paragraph in paragraphs {
686 match &paragraph.content {
687 ParagraphContent::Text(text) => out.push(shown(&text.text)),
688 ParagraphContent::Table(table) => {
689 for cell in table.rows.iter().flat_map(|row| &row.cells) {
690 walk(&cell.paragraphs, out);
691 }
692 }
693 _ => {}
694 }
695 }
696 }
697 let mut out = Vec::new();
698 for object in &page.objects {
699 match object {
700 PageObject::Title(title) => {
701 for outline in &title.outlines {
702 walk(&outline.paragraphs, &mut out);
703 }
704 }
705 PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out),
706 _ => {}
707 }
708 }
709 out.join("\n")
710}
711
712/// Each page of a section file with its title and text.
713fn section_texts(bytes: &[u8]) -> Result<Vec<(ExGuid, String, String)>> {
714 let store = Store::parse(bytes)?;
715 let index = RevisionIndex::parse(&store)?;
716 let document = Document::parse(&index)?;
717 let mut pages = Vec::new();
718 for (space, id) in document.pages()? {
719 let revision = document.active(space)?;
720 let (title, _) = Page::heading(revision, id);
721 let page = Page::from_revision(revision, id)?;
722 pages.push((space, title, page_text(&page)));
723 }
724 Ok(pages)
725}
726
727#[derive(serde::Serialize)]666#[derive(serde::Serialize)]
728pub(crate) struct Found {667pub(crate) struct Found {
729 /// The section's catalog path.668 /// The section's catalog path.
...@@ -734,81 +673,63 @@ pub(crate) struct Found {...@@ -734,81 +673,63 @@ pub(crate) struct Found {
734 pub(crate) snippet: String,673 pub(crate) snippet: String,
735}674}
736675
737/// Up to 60 characters either side of the first place `lower` occurs in `text`, on its line.
738fn snippet(text: &str, lower: &str) -> Option<String> {
739 let line = text
740 .lines()
741 .find(|line| line.to_lowercase().contains(lower))?;
742 let chars: Vec<char> = line.chars().collect();
743 let folded: Vec<char> = line.to_lowercase().chars().collect();
744 let needle: Vec<char> = lower.chars().collect();
745 // Lowercasing can change a line's length; then the match is shown from the line start.
746 let at = (folded.len() == chars.len())
747 .then(|| {
748 folded
749 .windows(needle.len())
750 .position(|window| window == needle)
751 })
752 .flatten()
753 .unwrap_or(0);
754 let start = at.saturating_sub(60);
755 let end = (at + needle.len() + 60).min(chars.len());
756 let mut text: String = chars[start..end].iter().collect();
757 if start > 0 {
758 text.insert(0, '…');
759 }
760 if end < chars.len() {
761 text.push('…');
762 }
763 Some(text)
764}
765
766impl Library {676impl Library {
767 /// Pages of the notebook whose title or text holds `query`, ignoring case: the open677 /// Pages of the notebook holding every word of `query` in their title or text, as the
768 /// section as its edits leave it, others as stored, reread when their file changed.678 /// desktop searches: the open section as its edits leave it, others as stored, read
679 /// again when their file changed.
769 pub(crate) fn search(&self, open: Option<(&str, &Section)>, query: &str) -> Result<Vec<Found>> {680 pub(crate) fn search(&self, open: Option<(&str, &Section)>, query: &str) -> Result<Vec<Found>> {
770 let lower = query.to_lowercase();681 let tabs: Vec<String> = self
771 let mut found = Vec::new();682 .tabs()?
772 for tab in self.tabs()?.into_iter().filter(|tab| tab.readable) {683 .into_iter()
773 let pages = match open.filter(|(path, _)| *path == tab.path) {684 .filter(|tab| tab.readable)
685 .map(|tab| tab.path)
686 .collect();
687 let mut indexed = self.index.lock().unwrap_or_else(|error| error.into_inner());
688 let (checked, index) = &mut *indexed;
689 for path in &tabs {
690 match open.filter(|(open, _)| open == path) {
774 Some((_, section)) => {691 Some((_, section)) => {
775 let section = &section.shared.section;692 let section = &section.shared.section;
776 section693 let mut entries = Vec::new();
777 .pages()?694 for (space, ..) in section.pages()? {
778 .into_iter()695 let modified = index.get(path, space).map_or(0, |entry| entry.modified);
779 .map(|(space, title, _)| {696 entries.push(Entry::new(path, space, &section.page(space)?, modified));
780 Ok((space, title, page_text(&section.page(space)?)))697 }
781 })698 index.retain(|entry| entry.section != *path);
782 .collect::<Result<Vec<_>>>()?699 entries.into_iter().for_each(|entry| index.set(entry));
700 checked.remove(path);
783 }701 }
784 None => match report(self.indexed(&tab.path)) {702 None => {
785 Some(pages) => pages,703 report(self.reindex(path, checked, index));
786 None => continue,
787 },
788 };
789 for (space, title, text) in pages {
790 let snippet = snippet(&text, &lower);
791 if title.to_lowercase().contains(&lower) || snippet.is_some() {
792 found.push(Found {
793 section: tab.path.clone(),
794 page: space.to_string(),
795 title: title.replace(|char: char| char.is_control(), " "),
796 snippet: snippet.unwrap_or_default(),
797 });
798 }704 }
799 }705 }
800 }706 }
801 Ok(found)707 index.retain(|entry| tabs.contains(&entry.section));
708 Ok(index
709 .search(&Query::new(query), |_| true)
710 .into_iter()
711 .map(|found| Found {
712 section: found.section,
713 page: found.space.to_string(),
714 title: found.title.replace(|char: char| char.is_control(), " "),
715 snippet: found.snippet,
716 })
717 .collect())
802 }718 }
803719
804 /// The pages of the section at catalog `path` with their texts, read again only when720 /// Reads the section at catalog `path` into `index` again when its file changed, and
805 /// the file changed, and checked at most every 30 seconds.721 /// checks at most every 30 seconds.
806 fn indexed(&self, path: &str) -> Result<Vec<(ExGuid, String, String)>> {722 fn reindex(
807 let mut index = self.index.lock().unwrap_or_else(|error| error.into_inner());723 &self,
808 if let Some(indexed) = index.get(path)724 path: &str,
809 && indexed.checked.elapsed() < Duration::from_secs(30)725 checked: &mut HashMap<String, Checked>,
726 index: &mut Index,
727 ) -> Result<()> {
728 if checked
729 .get(path)
730 .is_some_and(|known| known.at.elapsed() < Duration::from_secs(30))
810 {731 {
811 return Ok(indexed.pages.clone());732 return Ok(());
812 }733 }
813 let (stamp, read): (Vec<u8>, Reader) = match &self.place {734 let (stamp, read): (Vec<u8>, Reader) = match &self.place {
814 Place::Share { root, .. } => {735 Place::Share { root, .. } => {
...@@ -829,19 +750,22 @@ impl Library {...@@ -829,19 +750,22 @@ impl Library {
829 Place::Folder(root) => local(root.join(path))?,750 Place::Folder(root) => local(root.join(path))?,
830 Place::File(file) => local(file.clone())?,751 Place::File(file) => local(file.clone())?,
831 };752 };
832 let pages = match index.remove(path) {753 if checked.get(path).is_none_or(|known| known.stamp != stamp) {
833 Some(indexed) if indexed.stamp == stamp => indexed.pages,754 let pages = session::stored_pages(&read()?)?;
834 _ => section_texts(&read()?)?,755 index.retain(|entry| entry.section != path);
835 };756 for stored in pages {
836 index.insert(757 let modified = stored.modified.map_or(0, u64::from);
758 index.set(Entry::new(path, stored.space, &stored.page, modified));
759 }
760 }
761 checked.insert(
837 path.to_owned(),762 path.to_owned(),
838 Indexed {763 Checked {
839 checked: Instant::now(),764 at: Instant::now(),
840 stamp,765 stamp,
841 pages: pages.clone(),
842 },766 },
843 );767 );
844 Ok(pages)768 Ok(())
845 }769 }
846}770}
847771
crates/mobile/src/tests.rs+7-7
...@@ -214,7 +214,7 @@ fn undo_restores_typing() {...@@ -214,7 +214,7 @@ fn undo_restores_typing() {
214fn stored_text(file: &Path, space: ExGuid) -> String {214fn stored_text(file: &Path, space: ExGuid) -> String {
215 let arena = onestore::Arena::default();215 let arena = onestore::Arena::default();
216 let section = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();216 let section = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
217 library::page_text(&section.page(space).unwrap())217 canvas::search::page_text(&section.page(space).unwrap())
218}218}
219219
220#[test]220#[test]
...@@ -245,7 +245,7 @@ fn typing_is_stored_in_the_cache_and_published_to_the_file() {...@@ -245,7 +245,7 @@ fn typing_is_stored_in_the_cache_and_published_to_the_file() {
245 let reopened = open(&library, &file.to_string_lossy());245 let reopened = open(&library, &file.to_string_lossy());
246 let (page, read_only) = reopened.shared.page(space).unwrap();246 let (page, read_only) = reopened.shared.page(space).unwrap();
247 assert!(!read_only);247 assert!(!read_only);
248 assert!(library::page_text(&page).contains("Saved Collapsed parent"));248 assert!(canvas::search::page_text(&page).contains("Saved Collapsed parent"));
249}249}
250250
251#[test]251#[test]
...@@ -347,7 +347,7 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() {...@@ -347,7 +347,7 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() {
347 let mut canvas = canvas(&section, "Paragraph controls");347 let mut canvas = canvas(&section, "Paragraph controls");
348 assert!(canvas.find("collapsed PARENT").unwrap());348 assert!(canvas.find("collapsed PARENT").unwrap());
349 let [start, end] = canvas.selection().unwrap();349 let [start, end] = canvas.selection().unwrap();
350 assert_eq!(canvas.text([start, end]).to_lowercase(), "collapsed parent");350 assert_eq!(canvas.text([start, end]).to_lowercase(), "collapsed");
351 assert_eq!(canvas.format_bits().unwrap() & 1, 0);351 assert_eq!(canvas.format_bits().unwrap() & 1, 0);
352 assert!(canvas.format(0).unwrap());352 assert!(canvas.format(0).unwrap());
353 assert_eq!(canvas.format_bits().unwrap() & 1, 1);353 assert_eq!(canvas.format_bits().unwrap() & 1, 1);
...@@ -494,7 +494,7 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {...@@ -494,7 +494,7 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {
494 .unwrap();494 .unwrap();
495 let arena = onestore::Arena::default();495 let arena = onestore::Arena::default();
496 let stored = onestore::Section::open(&arena, published).unwrap();496 let stored = onestore::Section::open(&arena, published).unwrap();
497 assert!(library::page_text(&stored.page(canvas.space).unwrap()).contains("Shared "));497 assert!(canvas::search::page_text(&stored.page(canvas.space).unwrap()).contains("Shared "));
498 let shared = Arc::clone(&section.shared);498 let shared = Arc::clone(&section.shared);
499 drop((canvas, section, library));499 drop((canvas, section, library));
500 Arc::into_inner(shared).unwrap().section.close().unwrap();500 Arc::into_inner(shared).unwrap().section.close().unwrap();
...@@ -517,7 +517,7 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {...@@ -517,7 +517,7 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {
517 .page(space(&reopened, "Paragraph controls"))517 .page(space(&reopened, "Paragraph controls"))
518 .unwrap()518 .unwrap()
519 .0;519 .0;
520 assert!(library::page_text(&page).contains("Shared "));520 assert!(canvas::search::page_text(&page).contains("Shared "));
521}521}
522522
523/// Paths the test coordinator was asked for, with whether to write.523/// Paths the test coordinator was asked for, with whether to write.
...@@ -584,7 +584,7 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {...@@ -584,7 +584,7 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {
584 }584 }
585 )));585 )));
586 section.shared.apply(edit).unwrap();586 section.shared.apply(edit).unwrap();
587 let stored = library::page_text(&section.shared.page(canvas.space).unwrap().0);587 let stored = section.shared.page(canvas.space).unwrap().0.date_text();
588 assert!(stored.contains("Friday, January 2, 2026"), "{stored}");588 assert_eq!(stored.unwrap()[0], "Friday, January 2, 2026");
589 assert_ne!(canvas.page.editor.date().unwrap().timestamp(), before);589 assert_ne!(canvas.page.editor.date().unwrap().timestamp(), before);
590}590}
crates/notebook/README.md+4
...@@ -267,6 +267,10 @@ section, so a link follows its page when the section is renamed or moved and...@@ -267,6 +267,10 @@ section, so a link follows its page when the section is renamed or moved and
267when the page itself was moved to another section. Other URLs and unknown267when the page itself was moved to another section. Other URLs and unknown
268targets are `None`.268targets are `None`.
269269
270`Notebook::read_section(path)` reads a section file as stored, without opening a
271replica, and `session::stored_pages(image)` builds its pages with each page's
272`LastModifiedTime`: the app's search indexes the sections it has not opened this way.
273
270With the optional `protected` feature, `Notebook::unlock(path, password)`274With the optional `protected` feature, `Notebook::unlock(path, password)`
271reads a `Locked` section as `Unlocked { pages, .. }`, and275reads a `Locked` section as `Unlocked { pages, .. }`, and
272`Notebook::apply_unlocked(path, password, &mut unlocked, author, edit)`276`Notebook::apply_unlocked(path, password, &mut unlocked, author, edit)`
crates/notebook/src/session.rs+34
...@@ -746,6 +746,12 @@ impl Notebook {...@@ -746,6 +746,12 @@ impl Notebook {
746 Ok(None)746 Ok(None)
747 }747 }
748748
749 /// The section at catalog `path` as its file stores it, without the edits a replica
750 /// may hold (`stored_pages` reads it).
751 pub fn read_section(&self, path: &str) -> Result<Vec<u8>> {
752 self.storage.read(&self.section_path(path)?.path)
753 }
754
749 /// The pages of a password-protected section: nothing is cached, and the decoded755 /// The pages of a password-protected section: nothing is cached, and the decoded
750 /// buffers go when the pages have been built.756 /// buffers go when the pages have been built.
751 #[cfg(feature = "protected")]757 #[cfg(feature = "protected")]
...@@ -828,6 +834,34 @@ impl Notebook {...@@ -828,6 +834,34 @@ impl Notebook {
828 }834 }
829}835}
830836
837/// A page as a section file stores it.
838pub struct StoredPage {
839 pub space: ExGuid,
840 pub page: Page,
841 /// The page's `LastModifiedTime`, Time32 seconds since 1980.
842 pub modified: Option<u32>,
843}
844
845/// The pages of the section file `image` holds, in section order; pages the model cannot
846/// build are left out.
847pub fn stored_pages(image: &[u8]) -> Result<Vec<StoredPage>> {
848 let store = Store::parse(image)?;
849 let index = RevisionIndex::parse(&store)?;
850 let document = Document::parse(&index)?;
851 Ok(document
852 .pages()?
853 .into_iter()
854 .filter_map(|(space, id)| {
855 let revision = document.active(space).ok()?;
856 Some(StoredPage {
857 space,
858 page: Page::from_revision(revision, id).ok()?,
859 modified: revision.nodes.get(&id).and_then(|node| node.modified),
860 })
861 })
862 .collect())
863}
864
831/// The colours OneNote 2010 gives a folder's first sixteen new sections, COLORREF, in the865/// The colours OneNote 2010 gives a folder's first sixteen new sections, COLORREF, in the
832/// order it gives them (`corpus/notebook-management/native/section-colors`: sixteen sections866/// order it gives them (`corpus/notebook-management/native/section-colors`: sixteen sections
833/// made with New Section in a new notebook).867/// made with New Section in a new notebook).
crates/notebook/tests/assets.rs+4-2
...@@ -207,7 +207,9 @@ fn download_network_wait_does_not_block_local_edits() {...@@ -207,7 +207,9 @@ fn download_network_wait_does_not_block_local_edits() {
207 }207 }
208 fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> {208 fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> {
209 self.entered.send(()).unwrap();209 self.entered.send(()).unwrap();
210 self.released.recv_timeout(Duration::from_secs(5)).unwrap();210 self.released
211 .recv_timeout(Duration::from_secs(120))
212 .unwrap();
211 Ok(self.bytes.clone())213 Ok(self.bytes.clone())
212 }214 }
213 }215 }
...@@ -227,7 +229,7 @@ fn download_network_wait_does_not_block_local_edits() {...@@ -227,7 +229,7 @@ fn download_network_wait_does_not_block_local_edits() {
227 .fetch_asset(&mut source, "synthetic.one", &name, 1024)229 .fetch_asset(&mut source, "synthetic.one", &name, 1024)
228 .unwrap()230 .unwrap()
229 });231 });
230 waiting.recv_timeout(Duration::from_secs(5)).unwrap();232 waiting.recv_timeout(Duration::from_secs(120)).unwrap();
231 let text = first_text(&snapshot(&cache));233 let text = first_text(&snapshot(&cache));
232 let id = model_ops::save(&cache, text, |page| {234 let id = model_ops::save(&cache, text, |page| {
233 model_ops::replace_text(page, text, 0..0, "during download ")235 model_ops::replace_text(page, text, 0..0, "during download ")
crates/notebook/tests/cache.rs+7-8
...@@ -19,6 +19,8 @@ use std::{...@@ -19,6 +19,8 @@ use std::{
19mod model_ops;19mod model_ops;
20#[path = "support/server.rs"]20#[path = "support/server.rs"]
21mod server;21mod server;
22#[path = "../../onestore/tests/support/sweep.rs"]
23mod sweep;
22use server::snapshot;24use server::snapshot;
2325
24fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {26fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {
...@@ -377,7 +379,8 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {...@@ -377,7 +379,8 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
377 let source = onestore::create_section("model.one", &text, "Fixture").unwrap();379 let source = onestore::create_section("model.one", &text, "Fixture").unwrap();
378 let mut replica = Replica::create(&path, &source).unwrap();380 let mut replica = Replica::create(&path, &source).unwrap();
379 let (_, object, _) = target(&source);381 let (_, object, _) = target(&source);
380 let mut random = 911_u64;382 let steps = sweep::seeds(0..512, 64);
383 let mut random = 911 + steps.start;
381 let mut next = || {384 let mut next = || {
382 random ^= random << 13;385 random ^= random << 13;
383 random ^= random >> 7;386 random ^= random >> 7;
...@@ -385,7 +388,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {...@@ -385,7 +388,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
385 random388 random
386 };389 };
387 let mut acknowledged = Vec::new();390 let mut acknowledged = Vec::new();
388 for step in 0..512 {391 for step in steps.clone() {
389 let boundaries: Vec<_> = text392 let boundaries: Vec<_> = text
390 .char_indices()393 .char_indices()
391 .map(|(at, _)| at)394 .map(|(at, _)| at)
...@@ -412,11 +415,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {...@@ -412,11 +415,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
412 None => assert_eq!(text, expected),415 None => assert_eq!(text, expected),
413 }416 }
414 text = expected;417 text = expected;
415 assert_eq!(418 assert_eq!(target(&snapshot(&replica)).2, text, "step {step}");
416 target(&snapshot(&replica)).2,
417 text,
418 "seed 911, step {step}"
419 );
420 if step % 37 == 0 {419 if step % 37 == 0 {
421 let pending = replica.pending().unwrap();420 let pending = replica.pending().unwrap();
422 drop(replica);421 drop(replica);
...@@ -426,7 +425,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {...@@ -426,7 +425,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
426 }425 }
427 }426 }
428 assert!(427 assert!(
429 acknowledged.len() > 256,428 acknowledged.len() as u64 * 2 > steps.end - steps.start,
430 "Most random edits change the text"429 "Most random edits change the text"
431 );430 );
432 assert_eq!(431 assert_eq!(
crates/notebook/tests/session.rs+3-3
...@@ -630,13 +630,13 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {...@@ -630,13 +630,13 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {
630 1024 * 1024,630 1024 * 1024,
631 move || {631 move || {
632 entered.send(()).unwrap();632 entered.send(()).unwrap();
633 stalled.recv_timeout(Duration::from_secs(10)).unwrap();633 stalled.recv_timeout(Duration::from_secs(120)).unwrap();
634 Err(std::io::ErrorKind::TimedOut.into())634 Err(std::io::ErrorKind::TimedOut.into())
635 },635 },
636 || {},636 || {},
637 )637 )
638 .unwrap();638 .unwrap();
639 connecting.recv_timeout(Duration::from_secs(5)).unwrap();639 connecting.recv_timeout(Duration::from_secs(120)).unwrap();
640 let space = section.pages().unwrap()[0].0;640 let space = section.pages().unwrap()[0].0;
641 let before = section.page(space).unwrap();641 let before = section.page(space).unwrap();
642 let after = edited(&before, "Saved during connection ");642 let after = edited(&before, "Saved during connection ");
...@@ -646,7 +646,7 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {...@@ -646,7 +646,7 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {
646 drop(section);646 drop(section);
647 dropped.send(()).unwrap();647 dropped.send(()).unwrap();
648 });648 });
649 finished.recv_timeout(Duration::from_secs(2)).unwrap();649 finished.recv_timeout(Duration::from_secs(60)).unwrap();
650 assert!(matches!(650 assert!(matches!(
651 notebook::Replica::open(&cache),651 notebook::Replica::open(&cache),
652 Err(notebook::Error::Database(error))652 Err(notebook::Error::Database(error))
crates/notebook/tests/sync.rs+9-5
...@@ -9,6 +9,8 @@ mod server;...@@ -9,6 +9,8 @@ mod server;
9use server::*;9use server::*;
10#[path = "support/model_ops.rs"]10#[path = "support/model_ops.rs"]
11mod model_ops;11mod model_ops;
12#[path = "../../onestore/tests/support/sweep.rs"]
13mod sweep;
1214
13/// Saves a replacement of `range` in the page holding `text`.15/// Saves a replacement of `range` in the page holding `text`.
14fn save(cache: &Replica, text: ExGuid, range: Range<u32>, replacement: &str) -> Option<u64> {16fn save(cache: &Replica, text: ExGuid, range: Range<u32>, replacement: &str) -> Option<u64> {
...@@ -444,7 +446,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio...@@ -444,7 +446,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
444 if self.phase == phase {446 if self.phase == phase {
445 self.entered.send(()).unwrap();447 self.entered.send(()).unwrap();
446 self.resume448 self.resume
447 .recv_timeout(std::time::Duration::from_secs(5))449 .recv_timeout(std::time::Duration::from_secs(120))
448 .unwrap();450 .unwrap();
449 }451 }
450 }452 }
...@@ -502,7 +504,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio...@@ -502,7 +504,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
502 paused.server504 paused.server
503 });505 });
504 entered_rx506 entered_rx
505 .recv_timeout(std::time::Duration::from_secs(5))507 .recv_timeout(std::time::Duration::from_secs(120))
506 .unwrap();508 .unwrap();
507 assert!(509 assert!(
508 matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock)510 matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock)
...@@ -537,8 +539,9 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio...@@ -537,8 +539,9 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
537 .map(|handle| handle.join().unwrap())539 .map(|handle| handle.join().unwrap())
538 .collect();540 .collect();
539 assert_eq!(ids.len(), 12, "every local edit is its own");541 assert_eq!(ids.len(), 12, "every local edit is its own");
542 // Well inside the paused remote's wait, which edits queued behind it would outlast.
540 assert!(543 assert!(
541 started.elapsed() < std::time::Duration::from_secs(2),544 started.elapsed() < std::time::Duration::from_secs(60),
542 "Local edits waited for remote {phase}"545 "Local edits waited for remote {phase}"
543 );546 );
544 resume_tx.send(()).unwrap();547 resume_tx.send(()).unwrap();
...@@ -671,8 +674,9 @@ fn a_failed_rebase_leaves_the_queue_as_it_was() {...@@ -671,8 +674,9 @@ fn a_failed_rebase_leaves_the_queue_as_it_was() {
671fn seeded_conflicts_keep_the_remote_text_and_later_edits_publish_once() {674fn seeded_conflicts_keep_the_remote_text_and_later_edits_publish_once() {
672 let dir = tempfile::tempdir().unwrap();675 let dir = tempfile::tempdir().unwrap();
673 let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect();676 let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect();
674 let mut seed = 911_u64;677 let cases = sweep::seeds(0..64, 16);
675 for case in 0..64 {678 let mut seed = 911 + cases.start;
679 for case in cases {
676 seed ^= seed << 13;680 seed ^= seed << 13;
677 seed ^= seed >> 7;681 seed ^= seed >> 7;
678 seed ^= seed << 17;682 seed ^= seed << 17;
crates/notebook/tests/sync/tree.rs+3-2
...@@ -12,8 +12,9 @@ fn save_page(cache: &Replica, space: ExGuid, edit: impl FnOnce(&mut Page)) -> Op...@@ -12,8 +12,9 @@ fn save_page(cache: &Replica, space: ExGuid, edit: impl FnOnce(&mut Page)) -> Op
1212
13#[test]13#[test]
14fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() {14fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() {
15 let mut random = 1940_u64;15 let runs = sweep::seeds(0..24, 1);
16 for _ in 0..24 {16 let mut random = 1940 + runs.start;
17 for _ in runs {
17 let input: Vec<_> = (0..256)18 let input: Vec<_> = (0..256)
18 .map(|_| {19 .map(|_| {
19 random ^= random << 13;20 random ^= random << 13;
crates/notebook/tests/sync_formatting.rs+5-2
...@@ -16,6 +16,8 @@ mod server;...@@ -16,6 +16,8 @@ mod server;
16use server::{Fault, Server, conflicted, pages, snapshot};16use server::{Fault, Server, conflicted, pages, snapshot};
17#[path = "support/model_ops.rs"]17#[path = "support/model_ops.rs"]
18mod model_ops;18mod model_ops;
19#[path = "../../onestore/tests/support/sweep.rs"]
20mod sweep;
19use model_ops::{21use model_ops::{
20 AUTHOR, insert_after, insert_outline, page_of, paragraph_with, replace_text, restyle, save,22 AUTHOR, insert_after, insert_outline, page_of, paragraph_with, replace_text, restyle, save,
21};23};
...@@ -263,7 +265,8 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone...@@ -263,7 +265,8 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone
263 .map(|id| u32::try_from(text_of(&page, *id).encode_utf16().count()).unwrap())265 .map(|id| u32::try_from(text_of(&page, *id).encode_utf16().count()).unwrap())
264 .collect();266 .collect();
265 let (mut published, mut conflicts) = (0, 0);267 let (mut published, mut conflicts) = (0, 0);
266 for seed in 1_u64..=64 {268 let seeds = sweep::seeds(1..65, 16);
269 for seed in seeds.clone() {
267 let mut state = seed;270 let mut state = seed;
268 let mut next = || {271 let mut next = || {
269 state = state272 state = state
...@@ -341,7 +344,7 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone...@@ -341,7 +344,7 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone
341 assert_eq!(snapshot(&cache), server.durable, "seed {seed}");344 assert_eq!(snapshot(&cache), server.durable, "seed {seed}");
342 }345 }
343 assert!(346 assert!(
344 published >= 16,347 published * 4 >= seeds.end - seeds.start,
345 "{published} published, {conflicts} conflicts"348 "{published} published, {conflicts} conflicts"
346 );349 );
347}350}
crates/notebook/tests/sync_outline.rs+2
...@@ -23,6 +23,8 @@ use model_ops::{...@@ -23,6 +23,8 @@ use model_ops::{
23};23};
24#[path = "support/model_schedule.rs"]24#[path = "support/model_schedule.rs"]
25mod model_schedule;25mod model_schedule;
26#[path = "../../onestore/tests/support/sweep.rs"]
27mod sweep;
26#[path = "sync/tree.rs"]28#[path = "sync/tree.rs"]
27mod tree;29mod tree;
2830
crates/notebook/tests/sync_page.rs+3-1
...@@ -22,10 +22,12 @@ mod model_ops;...@@ -22,10 +22,12 @@ mod model_ops;
22#[path = "support/page_schedule.rs"]22#[path = "support/page_schedule.rs"]
23mod page_schedule;23mod page_schedule;
24use page_schedule::body_outline;24use page_schedule::body_outline;
25#[path = "../../onestore/tests/support/sweep.rs"]
26mod sweep;
2527
26#[test]28#[test]
27fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() {29fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() {
28 for seed in 0..24_u64 {30 for seed in sweep::seeds(0..24, 1) {
29 let mut random = seed + 1956;31 let mut random = seed + 1956;
30 let mut input = Vec::new();32 let mut input = Vec::new();
31 for step in 0..48 {33 for step in 0..48 {
crates/notebook/tests/sync_worker.rs+30-33
...@@ -19,6 +19,10 @@ use std::{...@@ -19,6 +19,10 @@ use std::{
19 time::{Duration, Instant},19 time::{Duration, Instant},
20};20};
2121
22/// How long a wait may take on a loaded machine before it fails: far short of the hourly
23/// poll the waits rule out.
24const PATIENCE: Duration = Duration::from_secs(120);
25
22#[derive(Clone)]26#[derive(Clone)]
23struct Shared(Arc<Mutex<Server>>);27struct Shared(Arc<Mutex<Server>>);
2428
...@@ -130,7 +134,7 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() {...@@ -130,7 +134,7 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() {
130 .unwrap();134 .unwrap();
131 let mut errors = 0;135 let mut errors = 0;
132 let published = loop {136 let published = loop {
133 match observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() {137 match observed_rx.recv_timeout(PATIENCE).unwrap() {
134 Err(_) => errors += 1,138 Err(_) => errors += 1,
135 Ok(Some((actual, status @ EditStatus::Published { .. }))) => {139 Ok(Some((actual, status @ EditStatus::Published { .. }))) => {
136 assert_eq!(actual, id);140 assert_eq!(actual, id);
...@@ -183,15 +187,12 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {...@@ -183,15 +187,12 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
183 .unwrap();187 .unwrap();
184 if first {188 if first {
185 first = false;189 first = false;
186 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();190 resume_rx.recv_timeout(PATIENCE).unwrap();
187 }191 }
188 },192 },
189 )193 )
190 .unwrap();194 .unwrap();
191 assert_eq!(195 assert_eq!(observed_rx.recv_timeout(PATIENCE).unwrap(), None);
192 observed_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
193 None
194 );
195 let started = Instant::now();196 let started = Instant::now();
196 let queued = std::thread::scope(|scope| {197 let queued = std::thread::scope(|scope| {
197 (0..12)198 (0..12)
...@@ -219,12 +220,12 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {...@@ -219,12 +220,12 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
219 });220 });
220 resume_tx.send(()).unwrap();221 resume_tx.send(()).unwrap();
221 while !cache.pending().unwrap().is_empty() {222 while !cache.pending().unwrap().is_empty() {
222 if let Some((_, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() {223 if let Some((_, status)) = observed_rx.recv_timeout(PATIENCE).unwrap() {
223 assert!(matches!(status, EditStatus::Published { .. }), "{status:?}");224 assert!(matches!(status, EditStatus::Published { .. }), "{status:?}");
224 }225 }
225 }226 }
226 assert!(227 assert!(
227 started.elapsed() < Duration::from_secs(5),228 started.elapsed() < PATIENCE,
228 "Edits waited for the hourly poll"229 "Edits waited for the hourly poll"
229 );230 );
230 worker.stop().unwrap();231 worker.stop().unwrap();
...@@ -262,7 +263,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove...@@ -262,7 +263,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove
262 }263 }
263 fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> {264 fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> {
264 self.entered.send(()).unwrap();265 self.entered.send(()).unwrap();
265 self.resume.recv_timeout(Duration::from_secs(5)).unwrap();266 self.resume.recv_timeout(PATIENCE).unwrap();
266 self.shared.publish(transaction)267 self.shared.publish(transaction)
267 }268 }
268 fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> {269 fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> {
...@@ -293,10 +294,10 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove...@@ -293,10 +294,10 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove
293 |_| {},294 |_| {},
294 )295 )
295 .unwrap();296 .unwrap();
296 entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();297 entered_rx.recv_timeout(PATIENCE).unwrap();
297 let stopped = Instant::now();298 let stopped = Instant::now();
298 drop(worker);299 drop(worker);
299 assert!(stopped.elapsed() < Duration::from_millis(500));300 assert!(stopped.elapsed() < PATIENCE);
300 let shared = Shared(Arc::clone(&server));301 let shared = Shared(Arc::clone(&server));
301 let start = cache.start_sync(302 let start = cache.start_sync(
302 Duration::from_secs(3600),303 Duration::from_secs(3600),
...@@ -315,7 +316,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove...@@ -315,7 +316,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove
315 if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) => {}316 if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) => {}
316 Err(error) => panic!("{error}"),317 Err(error) => panic!("{error}"),
317 }318 }
318 assert!(stopped.elapsed() < Duration::from_secs(5));319 assert!(stopped.elapsed() < PATIENCE);
319 std::thread::sleep(Duration::from_millis(1));320 std::thread::sleep(Duration::from_millis(1));
320 });321 });
321 assert!(matches!(322 assert!(matches!(
...@@ -335,7 +336,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove...@@ -335,7 +336,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove
335 )336 )
336 .unwrap();337 .unwrap();
337 assert!(338 assert!(
338 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)339 matches!(rx.recv_timeout(PATIENCE).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
339 );340 );
340 worker.stop().unwrap();341 worker.stop().unwrap();
341 let server = server.lock().unwrap();342 let server = server.lock().unwrap();
...@@ -369,7 +370,7 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication()...@@ -369,7 +370,7 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication()
369 },370 },
370 )371 )
371 .unwrap();372 .unwrap();
372 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());373 assert!(rx.recv_timeout(PATIENCE).unwrap());
373 assert!(matches!(worker.stop(), Err(Error::Database(_))));374 assert!(matches!(worker.stop(), Err(Error::Database(_))));
374 assert!(rx.try_iter().next().is_none());375 assert!(rx.try_iter().next().is_none());
375 assert_eq!(server.lock().unwrap().publications, 0);376 assert_eq!(server.lock().unwrap().publications, 0);
...@@ -405,13 +406,9 @@ fn polling_reports_an_uncertain_attempt_once_per_remote_change_without_replay()...@@ -405,13 +406,9 @@ fn polling_reports_an_uncertain_attempt_once_per_remote_change_without_replay()
405 },406 },
406 )407 )
407 .unwrap();408 .unwrap();
408 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err());409 assert!(rx.recv_timeout(PATIENCE).unwrap().is_err());
409 for round in 0..3 {410 for round in 0..3 {
410 let (actual, status) = rx411 let (actual, status) = rx.recv_timeout(PATIENCE).unwrap().unwrap().unwrap();
411 .recv_timeout(Duration::from_secs(5))
412 .unwrap()
413 .unwrap()
414 .unwrap();
415 assert_eq!(actual, id);412 assert_eq!(actual, id);
416 assert!(413 assert!(
417 matches!(status, EditStatus::AwaitingConfirmation { .. }),414 matches!(status, EditStatus::AwaitingConfirmation { .. }),
...@@ -450,9 +447,9 @@ fn reachability_notification_retries_without_waiting_for_the_poll() {...@@ -450,9 +447,9 @@ fn reachability_notification_retries_without_waiting_for_the_poll() {
450 },447 },
451 )448 )
452 .unwrap();449 .unwrap();
453 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());450 assert!(rx.recv_timeout(PATIENCE).unwrap());
454 worker.wake();451 worker.wake();
455 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());452 assert!(rx.recv_timeout(PATIENCE).unwrap());
456 worker.stop().unwrap();453 worker.stop().unwrap();
457 assert!(rx.try_iter().next().is_none());454 assert!(rx.try_iter().next().is_none());
458}455}
...@@ -472,7 +469,7 @@ fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() {...@@ -472,7 +469,7 @@ fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() {
472 Duration::from_secs(3600),469 Duration::from_secs(3600),
473 move || {470 move || {
474 entered_tx.send(()).unwrap();471 entered_tx.send(()).unwrap();
475 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();472 resume_rx.recv_timeout(PATIENCE).unwrap();
476 Ok(shared.clone())473 Ok(shared.clone())
477 },474 },
478 move |_| {475 move |_| {
...@@ -480,18 +477,18 @@ fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() {...@@ -480,18 +477,18 @@ fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() {
480 },477 },
481 )478 )
482 .unwrap();479 .unwrap();
483 entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();480 entered_rx.recv_timeout(PATIENCE).unwrap();
484 drop(worker);481 drop(worker);
485 let weak = Arc::downgrade(&cache);482 let weak = Arc::downgrade(&cache);
486 drop(cache);483 drop(cache);
487 resume_tx.send(()).unwrap();484 resume_tx.send(()).unwrap();
488 let started = Instant::now();485 let started = Instant::now();
489 while weak.upgrade().is_some() {486 while weak.upgrade().is_some() {
490 assert!(started.elapsed() < Duration::from_secs(5));487 assert!(started.elapsed() < PATIENCE);
491 std::thread::sleep(Duration::from_millis(1));488 std::thread::sleep(Duration::from_millis(1));
492 }489 }
493 assert_eq!(490 assert_eq!(
494 rx.recv_timeout(Duration::from_secs(5)),491 rx.recv_timeout(PATIENCE),
495 Err(mpsc::RecvTimeoutError::Disconnected)492 Err(mpsc::RecvTimeoutError::Disconnected)
496 );493 );
497}494}
...@@ -519,7 +516,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {...@@ -519,7 +516,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {
519 },516 },
520 )517 )
521 .unwrap();518 .unwrap();
522 rx.recv_timeout(Duration::from_secs(5)).unwrap();519 rx.recv_timeout(PATIENCE).unwrap();
523 assert!(matches!(worker.stop(), Err(Error::Io(_))));520 assert!(matches!(worker.stop(), Err(Error::Io(_))));
524 let (tx, rx) = mpsc::channel();521 let (tx, rx) = mpsc::channel();
525 let worker = cache522 let worker = cache
...@@ -531,7 +528,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {...@@ -531,7 +528,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {
531 },528 },
532 )529 )
533 .unwrap();530 .unwrap();
534 assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), None);531 assert_eq!(rx.recv_timeout(PATIENCE).unwrap(), None);
535 worker.stop().unwrap();532 worker.stop().unwrap();
536 assert_eq!(snapshot(&cache), source);533 assert_eq!(snapshot(&cache), source);
537}534}
...@@ -559,7 +556,7 @@ fn a_conflict_publishes_its_conflict_page_in_one_worker_step() {...@@ -559,7 +556,7 @@ fn a_conflict_publishes_its_conflict_page_in_one_worker_step() {
559 )556 )
560 .unwrap();557 .unwrap();
561 assert!(558 assert!(
562 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual > id)559 matches!(rx.recv_timeout(PATIENCE).unwrap(), Some((actual, EditStatus::Published { .. })) if actual > id)
563 );560 );
564 worker.stop().unwrap();561 worker.stop().unwrap();
565 assert!(cache.pending().unwrap().is_empty());562 assert!(cache.pending().unwrap().is_empty());
...@@ -640,10 +637,10 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() {...@@ -640,10 +637,10 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() {
640 )637 )
641 .unwrap();638 .unwrap();
642 for _ in 0..4 {639 for _ in 0..4 {
643 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err());640 assert!(rx.recv_timeout(PATIENCE).unwrap().is_err());
644 }641 }
645 assert!(642 assert!(
646 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)643 matches!(rx.recv_timeout(PATIENCE).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
647 );644 );
648 worker.stop().unwrap();645 worker.stop().unwrap();
649 assert_eq!(text(&snapshot(&cache)).2, "L abc");646 assert_eq!(text(&snapshot(&cache)).2, "L abc");
...@@ -706,10 +703,10 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() {...@@ -706,10 +703,10 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() {
706 },703 },
707 )704 )
708 .unwrap();705 .unwrap();
709 let (second, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap();706 let (second, status) = rx.recv_timeout(PATIENCE).unwrap();
710 assert_eq!(status, None);707 assert_eq!(status, None);
711 for expected in [first, second] {708 for expected in [first, second] {
712 let (id, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap();709 let (id, status) = rx.recv_timeout(PATIENCE).unwrap();
713 assert_eq!(id, expected);710 assert_eq!(id, expected);
714 assert!(matches!(status, Some(EditStatus::Published { .. })));711 assert!(matches!(status, Some(EditStatus::Published { .. })));
715 }712 }
crates/onestore/src/active.rs+2
...@@ -499,6 +499,8 @@ pub(crate) mod tests {...@@ -499,6 +499,8 @@ pub(crate) mod tests {
499499
500 #[test]500 #[test]
501 fn writes_leave_the_page_that_appending_and_reading_their_revisions_does() {501 fn writes_leave_the_page_that_appending_and_reading_their_revisions_does() {
502 // Seeded identities: the writes pick nodes in identity order.
503 crate::write::GUIDS.set(Some(1 << 61));
502 // Past 512 revisions, so the chain checkpoints once.504 // Past 512 revisions, so the chain checkpoints once.
503 check_writes(505 check_writes(
504 include_bytes!(506 include_bytes!(
crates/onestore/src/create.rs+13
...@@ -661,3 +661,16 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result...@@ -661,3 +661,16 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result
661 RevisionIndex::parse(&store)?.validate_current()?;661 RevisionIndex::parse(&store)?.validate_current()?;
662 Ok(output)662 Ok(output)
663}663}
664
665#[cfg(test)]
666mod tests {
667 #[test]
668 fn initials_take_each_word_first_letter() {
669 // OneNote 2010 stores "snow" with "S" in the native corpus.
670 assert_eq!(super::initials("snow"), "S");
671 assert_eq!(super::initials("Clover"), "C");
672 assert_eq!(super::initials(" Snowbound Test "), "ST");
673 assert_eq!(super::initials("émile zola"), "ÉZ");
674 assert_eq!(super::initials(""), "");
675 }
676}
crates/onestore/src/lib.rs+3
...@@ -24,6 +24,9 @@ mod revisions;...@@ -24,6 +24,9 @@ mod revisions;
24mod section;24mod section;
25mod snapshot;25mod snapshot;
26mod store;26mod store;
27#[cfg(test)]
28#[path = "../tests/support/sweep.rs"]
29mod sweep;
27mod toc;30mod toc;
28mod tree;31mod tree;
29mod write;32mod write;
crates/onestore/src/op/tests.rs+4-6
...@@ -1225,11 +1225,10 @@ pub(super) fn first_difference(expected: &Page, actual: &Page) -> String {...@@ -1225,11 +1225,10 @@ pub(super) fn first_difference(expected: &Page, actual: &Page) -> String {
1225fn lowered_edits_store_the_edited_page() {1225fn lowered_edits_store_the_edited_page() {
1226 let mut tally: BTreeMap<(Family, String), usize> = BTreeMap::new();1226 let mut tally: BTreeMap<(Family, String), usize> = BTreeMap::new();
1227 let mut mismatches = Vec::new();1227 let mut mismatches = Vec::new();
1228 let seeds = std::env::var("OP_SEEDS").map_or(3, |s| s.parse().unwrap());
1229 for (name, source) in SOURCES {1228 for (name, source) in SOURCES {
1230 for (p, space) in pages(source).into_iter().enumerate().take(4) {1229 for (p, space) in pages(source).into_iter().enumerate().take(4) {
1231 for family in FAMILIES {1230 for family in FAMILIES {
1232 for seed in 0..seeds {1231 for seed in crate::sweep::seeds(0..3, 1) {
1233 let mut rng = Rng(seed * 7919 + p as u64 * 131 + family as u64);1232 let mut rng = Rng(seed * 7919 + p as u64 * 131 + family as u64);
1234 let mut after = read(source, space);1233 let mut after = read(source, space);
1235 GUIDS.set(Some(1 << 60 | seed << 32 | (family as u64) << 20));1234 GUIDS.set(Some(1 << 60 | seed << 32 | (family as u64) << 20));
...@@ -1621,12 +1620,12 @@ fn random_object_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {...@@ -1621,12 +1620,12 @@ fn random_object_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
1621/// the same pages.1620/// the same pages.
1622#[test]1621#[test]
1623fn random_ops_read_back_as_the_model_predicts() {1622fn random_ops_read_back_as_the_model_predicts() {
1624 let steps = std::env::var("OP_STEPS").map_or(60, |s| s.parse().unwrap());1623 let steps = 60;
1625 let (mut applied, mut refused, mut unseen) = (0, 0, 0);1624 let (mut applied, mut refused, mut unseen) = (0, 0, 0);
1626 let mut mismatches: Vec<String> = Vec::new();1625 let mut mismatches: Vec<String> = Vec::new();
1627 for (name, source) in SOURCES {1626 for (name, source) in SOURCES {
1628 let spaces = pages(source);1627 let spaces = pages(source);
1629 for seed in 0..2u64 {1628 for seed in crate::sweep::seeds(0..2, 2) {
1630 let arena = Arena::default();1629 let arena = Arena::default();
1631 let mut section = Section::open(&arena, source.to_vec()).unwrap();1630 let mut section = Section::open(&arena, source.to_vec()).unwrap();
1632 let mut rng = Rng(seed * 104_729 + name.len() as u64);1631 let mut rng = Rng(seed * 104_729 + name.len() as u64);
...@@ -1952,11 +1951,10 @@ fn a_range_lowers_as_its_page_does() {...@@ -1952,11 +1951,10 @@ fn a_range_lowers_as_its_page_does() {
1952 Family::ParagraphPicture, Family::Attachment, Family::Equation,1951 Family::ParagraphPicture, Family::Attachment, Family::Equation,
1953 ];1952 ];
1954 let (mut same, mut total, mut smaller) = (0, 0, 0);1953 let (mut same, mut total, mut smaller) = (0, 0, 0);
1955 let seeds = std::env::var("OP_SEEDS").map_or(3, |s| s.parse().unwrap());
1956 for (name, source) in SOURCES {1954 for (name, source) in SOURCES {
1957 for (p, space) in pages(source).into_iter().enumerate().take(4) {1955 for (p, space) in pages(source).into_iter().enumerate().take(4) {
1958 for family in families {1956 for family in families {
1959 for seed in 0..seeds {1957 for seed in crate::sweep::seeds(0..3, 1) {
1960 let mut rng = Rng(seed * 7919 + p as u64 * 131 + family as u64);1958 let mut rng = Rng(seed * 7919 + p as u64 * 131 + family as u64);
1961 let before = read(source, space);1959 let before = read(source, space);
1962 let mut after = before.clone();1960 let mut after = before.clone();
crates/onestore/src/section.rs+5-2
...@@ -928,7 +928,10 @@ mod tests {...@@ -928,7 +928,10 @@ mod tests {
928 differential(source, seed as u64, 60, 1, 2);928 differential(source, seed as u64, 60, 1, 2);
929 differential(source, seed as u64 + 100, 60, 5, 2);929 differential(source, seed as u64 + 100, 60, 5, 2);
930 }930 }
931 let created = crate::create_section("model.one", "First", "Author").unwrap();931 // Seeded identities: the writes pick nodes in identity order.
932 let created = seeded(7, || {
933 crate::create_section("model.one", "First", "Author").unwrap()
934 });
932 differential(&created, 7, 60, 3, 1);935 differential(&created, 7, 60, 3, 1);
933 }936 }
934937
...@@ -942,7 +945,7 @@ mod tests {...@@ -942,7 +945,7 @@ mod tests {
942 include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"),945 include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"),
943 include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"),946 include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"),
944 ];947 ];
945 for seed in 4..=8 {948 for seed in crate::sweep::seeds(4..9, 5) {
946 for source in corpus {949 for source in corpus {
947 differential(source, seed, 30, 12, 3);950 differential(source, seed, 30, 12, 3);
948 }951 }
crates/onestore/tests/commit.rs+98
...@@ -209,3 +209,101 @@ fn bounded_file_reads_reject_partial_images_and_release_the_owner() {...@@ -209,3 +209,101 @@ fn bounded_file_reads_reject_partial_images_and_release_the_owner() {
209 assert_eq!(fs::read(&path).unwrap(), bytes);209 assert_eq!(fs::read(&path).unwrap(), bytes);
210 fs::remove_file(path).unwrap();210 fs::remove_file(path).unwrap();
211}211}
212
213/// Zeroes the chunk reference of the file node at `offset`, as OneNote's garbage
214/// collection leaves a node whose target it freed.
215fn free_reference(bytes: &mut [u8], offset: usize) {
216 let header = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
217 let stp = [8, 4, 2, 4][(header >> 23 & 3) as usize];
218 let cb = [4, 8, 1, 2][(header >> 25 & 3) as usize];
219 bytes[offset + 4..offset + 4 + stp + cb].fill(0);
220}
221
222#[test]
223fn garbage_collected_references_read_as_absent_and_accept_appends() {
224 let source = fs::read("../../corpus/page-lifecycle/removal/features/native/after/notebook/Lifecycle.one")
225 .unwrap();
226 let store = Store::parse(&source).unwrap();
227 let index = RevisionIndex::parse(&store).unwrap();
228 let document = Document::parse(&index).unwrap();
229 let pages = document.pages().unwrap();
230 let live = |space: &ExGuid| *space == index.root || pages.iter().any(|(page, _)| page == space);
231 let payloads = |spaces: &mut dyn Iterator<Item = &ExGuid>| {
232 let mut guids = std::collections::BTreeSet::new();
233 for space in spaces {
234 for rid in index.spaces[space].revisions.keys() {
235 for object in index.resolve(*space, *rid).unwrap().objects.values() {
236 if let Some(onestore::FileDataReference::Internal(guid)) =
237 object.file_reference().unwrap()
238 {
239 guids.insert(guid);
240 }
241 }
242 }
243 }
244 guids
245 };
246 let deleted: Vec<_> = index.spaces.keys().filter(|space| !live(space)).copied().collect();
247 let referenced = payloads(&mut index.spaces.keys().filter(|space| live(space)));
248 let orphans: Vec<[u8; 16]> = store
249 .lists
250 .values()
251 .flat_map(|list| &list.nodes)
252 .filter(|node| node.id == 0x94)
253 .map(|node| node.payload[..16].try_into().unwrap())
254 .filter(|guid| !referenced.contains(guid))
255 .collect();
256 assert!(!deleted.is_empty() && !orphans.is_empty());
257 let mut collected = source.clone();
258 for node in store.lists.values().flat_map(|list| &list.nodes) {
259 let tail = |width: usize| &node.payload[node.payload.len() - width..];
260 let freed = match node.id {
261 8 => deleted.iter().any(|space| {
262 tail(20) == [space.guid.as_slice(), &space.n.to_le_bytes()].concat()
263 }),
264 0x94 => orphans.iter().any(|guid| node.payload[..16] == *guid),
265 _ => false,
266 };
267 if freed {
268 free_reference(&mut collected, node.offset);
269 }
270 }
271 let store = Store::parse(&collected).unwrap();
272 let index = RevisionIndex::parse(&store).unwrap();
273 index.validate_current().unwrap();
274 assert!(deleted.iter().all(|space| !index.spaces.contains_key(space)));
275 assert_eq!(Document::parse(&index).unwrap().pages().unwrap(), pages);
276 for guid in &orphans {
277 assert_eq!(
278 store.file_data(*guid).unwrap_err().message,
279 "File-data object is not declared"
280 );
281 }
282
283 let arena = Arena::default();
284 let (space, object, transaction) = document
285 .spaces
286 .iter()
287 .flat_map(|(space, view)| {
288 let nodes = view.active().map(|revision| &revision.nodes);
289 nodes.into_iter().flatten().filter_map(|(id, node)| match &node.kind {
290 Kind::RichText { text, .. } => Some((*space, *id, text.encode_utf16().count())),
291 _ => None,
292 })
293 })
294 .find_map(|(space, object, length)| {
295 let mut section = Section::open(&arena, collected.clone()).unwrap();
296 let op = PageOp::Text {
297 text: object,
298 range: 0..length as u32,
299 with: "After collection".into(),
300 };
301 let edit = Edit { at: 133_700_000_000_000_000, ops: vec![Op::Page { space, op }] };
302 section.apply("Author", &edit).ok()?;
303 Some((space, object, section.seal().unwrap().unwrap()))
304 })
305 .unwrap();
306 let mut written = collected.clone();
307 transaction.apply(&mut written).unwrap();
308 assert_eq!(text_of(&written, space, object), "After collection");
309}
crates/onestore/tests/formatting.rs+3-1
...@@ -4,6 +4,8 @@ mod ops;...@@ -4,6 +4,8 @@ mod ops;
4mod current;4mod current;
5#[path = "support/disk.rs"]5#[path = "support/disk.rs"]
6mod disk;6mod disk;
7#[path = "support/sweep.rs"]
8mod sweep;
7use onestore::{9use onestore::{
8 ExGuid, RevisionIndex, Store, TextAttribute as A,10 ExGuid, RevisionIndex, Store, TextAttribute as A,
9 document::{Document, Kind},11 document::{Document, Kind},
...@@ -49,7 +51,7 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() {...@@ -49,7 +51,7 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() {
49 let text = "abcdefgh 東京 🦀 café\rSecond\tline";51 let text = "abcdefgh 東京 🦀 café\rSecond\tline";
50 let original = onestore::create_section("format.one", text, "Author").unwrap();52 let original = onestore::create_section("format.one", text, "Author").unwrap();
51 let (sid, id) = target(&original);53 let (sid, id) = target(&original);
52 for seed in 1..=16_u64 {54 for seed in sweep::seeds(1..17, 16) {
53 let mut rng = seed;55 let mut rng = seed;
54 let mut source = original.clone();56 let mut source = original.clone();
55 let mut expected = characters(&source, sid, id);57 let mut expected = characters(&source, sid, id);
crates/onestore/tests/insertion.rs+3-1
...@@ -6,6 +6,8 @@ mod current;...@@ -6,6 +6,8 @@ mod current;
6mod disk;6mod disk;
7#[path = "support/ops.rs"]7#[path = "support/ops.rs"]
8mod ops;8mod ops;
9#[path = "support/sweep.rs"]
10mod sweep;
911
10use onestore::{12use onestore::{
11 ExGuid, RevisionIndex, Store, TextAttribute as A,13 ExGuid, RevisionIndex, Store, TextAttribute as A,
...@@ -105,7 +107,7 @@ fn atomic_formatted_insertions_match_a_character_model_and_preserve_history() {...@@ -105,7 +107,7 @@ fn atomic_formatted_insertions_match_a_character_model_and_preserve_history() {
105 .collect();107 .collect();
106 let old_store = Store::parse(&source).unwrap();108 let old_store = Store::parse(&source).unwrap();
107 let old_index = RevisionIndex::parse(&old_store).unwrap();109 let old_index = RevisionIndex::parse(&old_store).unwrap();
108 for seed in 1..=48_u64 {110 for seed in sweep::seeds(1..49, 48) {
109 let mut random = seed;111 let mut random = seed;
110 let mut expected: Vec<_> = text.chars().map(|c| (c, default.clone())).collect();112 let mut expected: Vec<_> = text.chars().map(|c| (c, default.clone())).collect();
111 let mut formats = Vec::new();113 let mut formats = Vec::new();
crates/onestore/tests/outline.rs+3-1
...@@ -11,6 +11,8 @@ mod current;...@@ -11,6 +11,8 @@ mod current;
11mod disk;11mod disk;
12#[path = "support/ops.rs"]12#[path = "support/ops.rs"]
13mod ops;13mod ops;
14#[path = "support/sweep.rs"]
15mod sweep;
1416
15const SOURCE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");17const SOURCE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
1618
...@@ -464,7 +466,7 @@ fn repeated_geometry_changes_and_expansion_match_an_independent_model() {...@@ -464,7 +466,7 @@ fn repeated_geometry_changes_and_expansion_match_an_independent_model() {
464 let text = view.nodes[&paragraph].content[0];466 let text = view.nodes[&paragraph].content[0];
465 let (add, _, _) = ops::new_outline(144.0, 36.0, "Second");467 let (add, _, _) = ops::new_outline(144.0, 36.0, "Second");
466 let original = ops::page_op(&original, sid, add).unwrap().image;468 let original = ops::page_op(&original, sid, add).unwrap().image;
467 for seed in 1..=16_u64 {469 for seed in sweep::seeds(1..17, 16) {
468 let mut rng = seed;470 let mut rng = seed;
469 let mut source = original.clone();471 let mut source = original.clone();
470 let mut layout = serde_json::to_value(&view.nodes[&outline].layout).unwrap();472 let mut layout = serde_json::to_value(&view.nodes[&outline].layout).unwrap();
crates/onestore/tests/page_deletion.rs+10-2
...@@ -276,7 +276,13 @@ fn tombstones_and_first_page_promotion_survive_each_storage_interruption() {...@@ -276,7 +276,13 @@ fn tombstones_and_first_page_promotion_survive_each_storage_interruption() {
276 old.keys().collect::<Vec<_>>(),276 old.keys().collect::<Vec<_>>(),
277 new.keys().collect::<Vec<_>>()277 new.keys().collect::<Vec<_>>()
278 );278 );
279 for write_limit in [1, 17, 4096] {279 // Byte-at-a-time writes tear the commit at every byte, a full sweep's matrix.
280 let limits: &[usize] = if sweep::full().is_some() {
281 &[1, 17, 4096]
282 } else {
283 &[17, 4096]
284 };
285 for &write_limit in limits {
280 let mut complete = disk::Disk {286 let mut complete = disk::Disk {
281 visible: source.to_vec(),287 visible: source.to_vec(),
282 durable: source.to_vec(),288 durable: source.to_vec(),
...@@ -360,6 +366,8 @@ fn stale_removal_cannot_delete_a_newer_page_edit() {...@@ -360,6 +366,8 @@ fn stale_removal_cannot_delete_a_newer_page_edit() {
360366
361#[path = "support/page_schedule.rs"]367#[path = "support/page_schedule.rs"]
362mod page_schedule;368mod page_schedule;
369#[path = "support/sweep.rs"]
370mod sweep;
363371
364#[test]372#[test]
365fn twelve_client_page_schedules_include_removal_empty_sections_and_stale_edits() {373fn twelve_client_page_schedules_include_removal_empty_sections_and_stale_edits() {
...@@ -367,7 +375,7 @@ fn twelve_client_page_schedules_include_removal_empty_sections_and_stale_edits()...@@ -367,7 +375,7 @@ fn twelve_client_page_schedules_include_removal_empty_sections_and_stale_edits()
367 0, 192, 0, 128, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2, 18, 0, 0, 0, 0, 0, 0, 3, 192, 0,375 0, 192, 0, 128, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2, 18, 0, 0, 0, 0, 0, 0, 3, 192, 0,
368 128, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0,376 128, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0,
369 ]);377 ]);
370 for seed in 1_u64..=32 {378 for seed in sweep::seeds(1..33, 2) {
371 let mut random = seed * 2003;379 let mut random = seed * 2003;
372 let mut input = [0; 192];380 let mut input = [0; 192];
373 for byte in &mut input {381 for byte in &mut input {
crates/onestore/tests/page_movement.rs+9-1
...@@ -11,6 +11,8 @@ use onestore::{...@@ -11,6 +11,8 @@ use onestore::{
11mod current;11mod current;
12#[path = "support/disk.rs"]12#[path = "support/disk.rs"]
13mod disk;13mod disk;
14#[path = "support/sweep.rs"]
15mod sweep;
1416
15const SOURCE: &[u8] =17const SOURCE: &[u8] =
16 include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");18 include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
...@@ -426,7 +428,13 @@ fn grouped_page_levels_and_membership_survive_each_storage_interruption() {...@@ -426,7 +428,13 @@ fn grouped_page_levels_and_membership_survive_each_storage_interruption() {
426 let old = current::current(source);428 let old = current::current(source);
427 let new = current::current(prepared.as_bytes());429 let new = current::current(prepared.as_bytes());
428 assert_eq!(new.len(), old.len());430 assert_eq!(new.len(), old.len());
429 for write_limit in [1, 17, 4096] {431 // Byte-at-a-time writes tear the commit at every byte, a full sweep's matrix.
432 let limits: &[usize] = if sweep::full().is_some() {
433 &[1, 17, 4096]
434 } else {
435 &[17, 4096]
436 };
437 for &write_limit in limits {
430 let mut complete = disk::Disk {438 let mut complete = disk::Disk {
431 visible: source.to_vec(),439 visible: source.to_vec(),
432 durable: source.to_vec(),440 durable: source.to_vec(),
crates/onestore/tests/support/sweep.rs created+20
...@@ -0,0 +1,20 @@
1//! How far seeded sweeps reach. By default each runs its smoke slice; `SNOWBOUND_SWEEP=n`
2//! runs every sweep in full with its seeds shifted by `n` (`0` for the seeds as written), and
3//! a failure replays under the same value.
4#![allow(dead_code)]
5
6use std::ops::Range;
7
8/// The shift `SNOWBOUND_SWEEP` asks full sweeps to run with.
9pub fn full() -> Option<u64> {
10 let shift = std::env::var("SNOWBOUND_SWEEP").ok()?;
11 Some(shift.parse().expect("SNOWBOUND_SWEEP is a seed shift"))
12}
13
14/// The seeds of `all` a sweep runs: its first `smoke`, or all of them shifted.
15pub fn seeds(all: Range<u64>, smoke: u64) -> Range<u64> {
16 match full() {
17 None => all.start..all.end.min(all.start + smoke),
18 Some(shift) => all.start + shift..all.end + shift,
19 }
20}
crates/onestore/tests/tree.rs+5-2
...@@ -11,6 +11,8 @@ mod current;...@@ -11,6 +11,8 @@ mod current;
11mod disk;11mod disk;
12#[path = "support/ops.rs"]12#[path = "support/ops.rs"]
13mod ops;13mod ops;
14#[path = "support/sweep.rs"]
15mod sweep;
14#[path = "support/tree_model.rs"]16#[path = "support/tree_model.rs"]
15mod tree_model;17mod tree_model;
1618
...@@ -27,8 +29,9 @@ const FIXTURES: [(&[u8], &[u8]); 2] = [...@@ -27,8 +29,9 @@ const FIXTURES: [(&[u8], &[u8]); 2] = [
2729
28#[test]30#[test]
29fn twelve_client_tree_schedules_preserve_content_and_indentation() {31fn twelve_client_tree_schedules_preserve_content_and_indentation() {
30 let mut random = 1932_u64;32 let runs = sweep::seeds(0..80, 4);
31 for _ in 0..80 {33 let mut random = 1932 + runs.start;
34 for _ in runs {
32 let input: Vec<_> = (0..241)35 let input: Vec<_> = (0..241)
33 .map(|_| {36 .map(|_| {
34 random ^= random << 13;37 random ^= random << 13;
crates/snowbound/Cargo.toml+1-1
...@@ -24,7 +24,7 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r...@@ -24,7 +24,7 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r
24[target.'cfg(target_os = "macos")'.dependencies]24[target.'cfg(target_os = "macos")'.dependencies]
25libc = "0.2"25libc = "0.2"
26objc2 = "0.5.2"26objc2 = "0.5.2"
27objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] }27objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] }
28objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }28objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }
2929
30[target.'cfg(target_os = "linux")'.dependencies]30[target.'cfg(target_os = "linux")'.dependencies]
crates/snowbound/assets/icons/options.svg created+5
...@@ -0,0 +1,5 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 1.5V3.5M8 12.5V14.5M1.5 8H3.5M12.5 8H14.5M3.4 3.4L4.8 4.8M11.2 11.2L12.6 12.6M3.4 12.6L4.8 11.2M11.2 4.8L12.6 3.4" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
3 <circle cx="8" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.25"/>
4 <circle cx="8" cy="8" r="1.25" fill="none" stroke="currentColor" stroke-width="1.25"/>
5</svg>
crates/snowbound/assets/icons/page.svg created+3
...@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4 2.25H9.25L12 5V13.75H4ZM9.25 2.25V5H12" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/>
3</svg>
crates/snowbound/examples/search_probe.rs created+76
...@@ -0,0 +1,76 @@
1//! Times the search index as the app builds and queries it: `search_probe NOTEBOOK_FOLDER
2//! [QUERY]...` reads every section file, indexes its pages, runs each query over the whole
3//! notebook, and finds each query on the largest page.
4
5use canvas::{
6 editor::CanvasEditor,
7 layout::TextEngine,
8 search::{Entry, Index, Query, page_matches},
9};
10use std::time::Instant;
11
12fn main() -> Result<(), Box<dyn std::error::Error>> {
13 let mut args = std::env::args().skip(1);
14 let root = args.next().ok_or("Name a notebook folder")?;
15 let queries: Vec<String> = args.collect();
16 let mut files: Vec<_> = std::fs::read_dir(&root)?
17 .flatten()
18 .map(|entry| entry.path())
19 .filter(|path| path.extension().is_some_and(|extension| extension == "one"))
20 .collect();
21 files.sort();
22 let mut index = Index::default();
23 let (mut read, mut built, mut text) = (0.0, 0.0, 0);
24 let mut largest = None;
25 for file in &files {
26 let start = Instant::now();
27 let pages = notebook::session::stored_pages(&onestore::read_file(file)?)?;
28 read += start.elapsed().as_secs_f64();
29 let start = Instant::now();
30 let key = file.display().to_string();
31 for stored in pages {
32 let entry = Entry::new(&key, stored.space, &stored.page, 0);
33 let length = canvas::search::page_text(&stored.page).len();
34 text += length;
35 if largest.as_ref().is_none_or(|(size, _)| length > *size) {
36 largest = Some((length, stored.page));
37 }
38 index.set(entry);
39 }
40 built += start.elapsed().as_secs_f64();
41 }
42 println!(
43 "{} sections, {} pages, {} KB of text: read {:.0} ms, indexed {:.1} ms",
44 files.len(),
45 index.len(),
46 text / 1024,
47 read * 1e3,
48 built * 1e3
49 );
50 let (_, page) = largest.ok_or("No pages")?;
51 let start = Instant::now();
52 let entry = Entry::new("again", onestore::ExGuid::default(), &page, 0);
53 println!(
54 "largest page, {:?}: indexed again in {:.2} ms",
55 page.title,
56 start.elapsed().as_secs_f64() * 1e3
57 );
58 drop(entry);
59 let mut engine = TextEngine::default();
60 let editor = CanvasEditor::from_page(page, &mut engine)?;
61 for query in &queries {
62 let parsed = Query::new(query);
63 let start = Instant::now();
64 let found = index.search(&parsed, |_| true);
65 let searched = start.elapsed().as_secs_f64() * 1e3;
66 let start = Instant::now();
67 let matches = page_matches(&editor, &parsed);
68 let found_on_page = start.elapsed().as_secs_f64() * 1e3;
69 println!(
70 "{query:?}: {} pages in {searched:.2} ms; {} matches on the largest page in {found_on_page:.2} ms",
71 found.len(),
72 matches.len()
73 );
74 }
75 Ok(())
76}
crates/snowbound/src/art.rs+2
...@@ -26,7 +26,9 @@ pub const ITALIC: &[&str] = art!("icons/italic");...@@ -26,7 +26,9 @@ pub const ITALIC: &[&str] = art!("icons/italic");
26pub const LINK: &[&str] = art!("icons/link");26pub const LINK: &[&str] = art!("icons/link");
27pub const NOTEBOOK: &[&str] = art!("icons/notebook");27pub const NOTEBOOK: &[&str] = art!("icons/notebook");
28pub const NUMBERING: &[&str] = art!("icons/numbering");28pub const NUMBERING: &[&str] = art!("icons/numbering");
29pub const OPTIONS: &[&str] = art!("icons/options");
29pub const OUTDENT: &[&str] = art!("icons/outdent");30pub const OUTDENT: &[&str] = art!("icons/outdent");
31pub const PAGE: &[&str] = art!("icons/page");
30pub const PICTURE: &[&str] = art!("icons/picture");32pub const PICTURE: &[&str] = art!("icons/picture");
31pub const PLUS: &[&str] = art!("icons/plus");33pub const PLUS: &[&str] = art!("icons/plus");
32pub const REDO: &[&str] = art!("icons/redo");34pub const REDO: &[&str] = art!("icons/redo");
crates/snowbound/src/conflict_render.rs+1-1
...@@ -58,7 +58,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {...@@ -58,7 +58,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {
58 ..Spec::default()58 ..Spec::default()
59 },59 },
60 );60 );
61 let rows = page_rows(ui, &theme, &section, session, "");61 let rows = page_rows(ui, &theme, &section, session, &HashSet::new());
62 commands.extend(rows.clicked.map(Command::OpenPage));62 commands.extend(rows.clicked.map(Command::OpenPage));
63 ui.close();63 ui.close();
64 ui.close();64 ui.close();
crates/snowbound/src/linux.rs+10
...@@ -163,6 +163,9 @@ pub fn zoom(window: &Window) {...@@ -163,6 +163,9 @@ pub fn zoom(window: &Window) {
163/// Winit delivers typed text with its key events on Linux.163/// Winit delivers typed text with its key events on Linux.
164pub fn install_text_input(_: &Window) {}164pub fn install_text_input(_: &Window) {}
165165
166/// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu.
167pub fn install_menu() {}
168
166pub fn clear_marked_text(_: &Window) {}169pub fn clear_marked_text(_: &Window) {}
167170
168pub fn configure_presentation(_: &wgpu::Surface<'_>) {}171pub fn configure_presentation(_: &wgpu::Surface<'_>) {}
...@@ -540,6 +543,13 @@ fn dialog<const Z: usize, const K: usize>(...@@ -540,6 +543,13 @@ fn dialog<const Z: usize, const K: usize>(
540 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))543 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))
541}544}
542545
546/// Opens `folder` in the file manager.
547pub fn reveal(folder: &std::path::Path) {
548 if let Err(error) = Command::new("xdg-open").arg(folder).spawn() {
549 eprintln!("Cannot open {}: {error}", folder.display());
550 }
551}
552
543/// Asks whether to go ahead with `action`; false when no tool can ask.553/// Asks whether to go ahead with `action`; false when no tool can ask.
544pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {554pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
545 let status = Command::new("zenity")555 let status = Command::new("zenity")
crates/snowbound/src/macos.rs+37
...@@ -10,6 +10,7 @@ use objc2::{...@@ -10,6 +10,7 @@ use objc2::{
10use objc2_app_kit::{10use objc2_app_kit::{
11 NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor,11 NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor,
12 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventType,12 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventType,
13 NSMenuItem,
13};14};
14use objc2_foundation::{15use objc2_foundation::{
15 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,16 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
...@@ -634,6 +635,11 @@ declare_class!(...@@ -634,6 +635,11 @@ declare_class!(
634 if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); }635 if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); }
635 }636 }
636637
638 #[method(showOptions:)]
639 fn show_options(&self, _sender: Option<&AnyObject>) {
640 if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Options); }
641 }
642
637 // NSAlert consumes Escape before it reaches cancelOperation:.643 // NSAlert consumes Escape before it reaches cancelOperation:.
638 #[method(sendEvent:)]644 #[method(sendEvent:)]
639 fn send_event(&self, event: &NSEvent) {645 fn send_event(&self, event: &NSEvent) {
...@@ -670,6 +676,37 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo...@@ -670,6 +676,37 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo
670 Ok(event_loop)676 Ok(event_loop)
671}677}
672678
679/// Adds Settings… to the application menu Winit made, below About as macOS places it.
680pub fn install_menu() {
681 let mtm = MainThreadMarker::new().expect("Menus belong to the main thread");
682 let app = NSApplication::sharedApplication(mtm);
683 // Winit's menu is plain AppKit objects on the main thread.
684 unsafe {
685 let Some(menu) = app
686 .mainMenu()
687 .and_then(|bar| bar.itemAtIndex(0))
688 .and_then(|item| item.submenu())
689 else {
690 return;
691 };
692 let settings = NSMenuItem::initWithTitle_action_keyEquivalent(
693 mtm.alloc(),
694 &NSString::from_str("Settings…"),
695 Some(sel!(showOptions:)),
696 &NSString::from_str(","),
697 );
698 menu.insertItem_atIndex(&settings, 2);
699 menu.insertItem_atIndex(&NSMenuItem::separatorItem(mtm), 3);
700 }
701}
702
703/// Opens `folder` in Finder.
704pub fn reveal(folder: &std::path::Path) {
705 if let Err(error) = std::process::Command::new("open").arg(folder).spawn() {
706 eprintln!("Cannot open {}: {error}", folder.display());
707 }
708}
709
673/// Asks whether to go ahead with `action`, offering `cancel` first.710/// Asks whether to go ahead with `action`, offering `cancel` first.
674pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {711pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
675 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");712 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
crates/snowbound/src/main.rs+165-129
...@@ -5,10 +5,12 @@ mod library;...@@ -5,10 +5,12 @@ mod library;
5mod manage;5mod manage;
6mod meeting;6mod meeting;
7mod menus;7mod menus;
8mod options;
8#[cfg_attr(target_os = "linux", path = "linux.rs")]9#[cfg_attr(target_os = "linux", path = "linux.rs")]
9#[cfg_attr(target_os = "macos", path = "macos.rs")]10#[cfg_attr(target_os = "macos", path = "macos.rs")]
10mod platform;11mod platform;
11mod screenshot;12mod screenshot;
13mod search;
12mod settings;14mod settings;
13mod sidebar;15mod sidebar;
14mod templates;16mod templates;
...@@ -35,7 +37,7 @@ use std::{...@@ -35,7 +37,7 @@ use std::{
35 collections::{HashMap, HashSet},37 collections::{HashMap, HashSet},
36 error::Error,38 error::Error,
37 path::{Path, PathBuf},39 path::{Path, PathBuf},
38 sync::{Arc, mpsc},40 sync::{Arc, Mutex, mpsc},
39 time::Instant,41 time::Instant,
40};42};
41use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};43use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};
...@@ -102,6 +104,9 @@ enum UserEvent {...@@ -102,6 +104,9 @@ enum UserEvent {
102 Replay(Replay),104 Replay(Replay),
103 /// A page background finished rasterizing on its worker thread.105 /// A page background finished rasterizing on its worker thread.
104 Redraw,106 Redraw,
107 /// The app menu's Settings item was chosen.
108 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
109 Options,
105}110}
106111
107/// Asks the event loop for a frame from any thread.112/// Asks the event loop for a frame from any thread.
...@@ -316,8 +321,13 @@ enum Shown {...@@ -316,8 +321,13 @@ enum Shown {
316 Page(ExGuid),321 Page(ExGuid),
317}322}
318323
319/// A loader thread's request number and what it read.324/// A loader thread's request number and what it read, its page laid out.
320type Read = (u64, Result<Loaded, String>);325type Read = (u64, Result<Laid, String>);
326
327enum Laid {
328 Page(Box<Opening>),
329 Library(Arc<Library>, String),
330}
321331
322/// The newest page read, laid out and waiting for the pictures it shows first, so it332/// The newest page read, laid out and waiting for the pictures it shows first, so it
323/// never appears without them.333/// never appears without them.
...@@ -367,12 +377,13 @@ enum Command {...@@ -367,12 +377,13 @@ enum Command {
367377
368struct State {378struct State {
369 window: Arc<Window>,379 window: Arc<Window>,
370 /// Who this machine's edits name, as OneNote names the Office user: the account's380 /// The user name edits are stored under, as OneNote names the Office user.
371 /// full name.
372 author: String,381 author: String,
373 proxy: EventLoopProxy<UserEvent>,382 proxy: EventLoopProxy<UserEvent>,
374 /// Results of reads done off the frame thread, by request number.383 /// Results of reads done off the frame thread, by request number.
375 loads: (mpsc::Sender<Read>, mpsc::Receiver<Read>),384 loads: (mpsc::Sender<Read>, mpsc::Receiver<Read>),
385 /// Lays out pages the loader threads read, off the frame.
386 layouts: Arc<Mutex<TextEngine>>,
376 /// The newest read requested; older ones are dropped when they finish.387 /// The newest read requested; older ones are dropped when they finish.
377 loading: u64,388 loading: u64,
378 opening: Option<Opening>,389 opening: Option<Opening>,
...@@ -404,6 +415,10 @@ struct State {...@@ -404,6 +415,10 @@ struct State {
404 folded: HashSet<String>,415 folded: HashSet<String>,
405 /// The open context menu: what it was opened on, and where.416 /// The open context menu: what it was opened on, and where.
406 menu: Option<(menus::Target, [f32; 2])>,417 menu: Option<(menus::Target, [f32; 2])>,
418 /// The Options dialog's choices while it is open.
419 options: Option<options::Options>,
420 color_scheme: settings::ColorScheme,
421 light_pages: bool,
407 /// A section or group being renamed in the sidebar.422 /// A section or group being renamed in the sidebar.
408 renaming: Option<sidebar::Renaming>,423 renaming: Option<sidebar::Renaming>,
409 /// What the pointer is dragging: a page's tab, a section tab or a sidebar row.424 /// What the pointer is dragging: a page's tab, a section tab or a sidebar row.
...@@ -415,8 +430,7 @@ struct State {...@@ -415,8 +430,7 @@ struct State {
415 /// What the template strip shows over a blank page.430 /// What the template strip shows over a blank page.
416 templates: templates::View,431 templates: templates::View,
417 thumbnails: templates::Thumbnails,432 thumbnails: templates::Thumbnails,
418 /// Text filtering the page list.433 search: search::Search,
419 filter: String,
420 /// Whether the page list is shown beside the page.434 /// Whether the page list is shown beside the page.
421 pages_open: bool,435 pages_open: bool,
422 /// Installed font families, for the font box; listing them is slow.436 /// Installed font families, for the font box; listing them is slow.
...@@ -466,11 +480,6 @@ fn frame() -> Id {...@@ -466,11 +480,6 @@ fn frame() -> Id {
466 Id::ROOT.child("frame")480 Id::ROOT.child("frame")
467}481}
468482
469/// The field filtering the page list.
470fn filter() -> Id {
471 Id::ROOT.child("filter")
472}
473
474impl State {483impl State {
475 async fn new(484 async fn new(
476 event_loop: &ActiveEventLoop,485 event_loop: &ActiveEventLoop,
...@@ -505,6 +514,7 @@ impl State {...@@ -505,6 +514,7 @@ impl State {
505 )?,514 )?,
506 );515 );
507 platform::install_text_input(&window);516 platform::install_text_input(&window);
517 platform::install_menu();
508 let access_adapter =518 let access_adapter =
509 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());519 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
510 window.set_visible(visible);520 window.set_visible(visible);
...@@ -532,6 +542,7 @@ impl State {...@@ -532,6 +542,7 @@ impl State {
532 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;542 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;
533 eprintln!("Using {} for {target}", path.display());543 eprintln!("Using {} for {target}", path.display());
534 }544 }
545 let layouts = Arc::new(Mutex::new(engine.clone()));
535 let temporary = matches!(input, Input::Notes { .. } | Input::Page(_));546 let temporary = matches!(input, Input::Notes { .. } | Input::Page(_));
536 let mut notebooks = Vec::new();547 let mut notebooks = Vec::new();
537 let mut session = None;548 let mut session = None;
...@@ -617,8 +628,15 @@ impl State {...@@ -617,8 +628,15 @@ impl State {
617 window.set_ime_allowed(true);628 window.set_ime_allowed(true);
618 window.request_redraw();629 window.request_redraw();
619 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());630 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());
631 window.set_theme(stored.color_scheme.theme());
620 let mut ui = Ui::new(632 let mut ui = Ui::new(
621 theme(platform::appearance(&window)),633 theme(
634 stored
635 .color_scheme
636 .theme()
637 .unwrap_or_else(|| platform::appearance(&window)),
638 stored.light_pages,
639 ),
622 platform::double_click_interval(),640 platform::double_click_interval(),
623 );641 );
624 if !interface_font.is_empty() {642 if !interface_font.is_empty() {
...@@ -644,10 +662,12 @@ impl State {...@@ -644,10 +662,12 @@ impl State {
644 fonts.sort_unstable_by_key(|name| name.to_lowercase());662 fonts.sort_unstable_by_key(|name| name.to_lowercase());
645 fonts.dedup();663 fonts.dedup();
646 let clipboard = platform::Clipboard::new(&window)?;664 let clipboard = platform::Clipboard::new(&window)?;
665 let redraw: std::task::Waker = Arc::new(Redraw(proxy.clone())).into();
666 let search = search::Search::new(stored.search_scope, redraw.clone());
647 let state = Self {667 let state = Self {
648 author: platform::user_name(),668 author: stored.user_name.unwrap_or_else(platform::user_name),
649 window,669 window,
650 redraw: Arc::new(Redraw(proxy.clone())).into(),670 redraw,
651 proxy,671 proxy,
652 instance,672 instance,
653 surface,673 surface,
...@@ -672,11 +692,14 @@ impl State {...@@ -672,11 +692,14 @@ impl State {
672 dismissed: HashSet::new(),692 dismissed: HashSet::new(),
673 title_focus: None,693 title_focus: None,
674 menu: None,694 menu: None,
695 options: None,
696 color_scheme: stored.color_scheme,
697 light_pages: stored.light_pages,
675 renaming: None,698 renaming: None,
676 drag: None,699 drag: None,
677 templates: templates::View::Strip,700 templates: templates::View::Strip,
678 thumbnails: templates::Thumbnails::default(),701 thumbnails: templates::Thumbnails::default(),
679 filter: String::new(),702 search,
680 pages_open: true,703 pages_open: true,
681 fonts,704 fonts,
682 recent_fonts: stored.recent_fonts,705 recent_fonts: stored.recent_fonts,
...@@ -697,6 +720,7 @@ impl State {...@@ -697,6 +720,7 @@ impl State {
697 ime_allowed: true,720 ime_allowed: true,
698 clipboard,721 clipboard,
699 loads: mpsc::channel(),722 loads: mpsc::channel(),
723 layouts,
700 loading: 0,724 loading: 0,
701 opening: None,725 opening: None,
702 places: HashMap::new(),726 places: HashMap::new(),
...@@ -732,7 +756,7 @@ impl State {...@@ -732,7 +756,7 @@ impl State {
732 let ime = if self.ui.focused() == Some(page()) {756 let ime = if self.ui.focused() == Some(page()) {
733 self.view.accepts_text()757 self.view.accepts_text()
734 } else {758 } else {
735 self.ui.focused() == Some(filter())759 search::takes_text(self.ui.focused())
736 };760 };
737 if self.ime_allowed != ime {761 if self.ime_allowed != ime {
738 self.ime_allowed = ime;762 self.ime_allowed = ime;
...@@ -740,6 +764,7 @@ impl State {...@@ -740,6 +764,7 @@ impl State {
740 }764 }
741 self.draw()?;765 self.draw()?;
742 lap("drawn", start);766 lap("drawn", start);
767 self.sync_index(false);
743 let commands = std::mem::take(&mut self.commands);768 let commands = std::mem::take(&mut self.commands);
744 let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some();769 let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some();
745 for command in commands {770 for command in commands {
...@@ -767,6 +792,7 @@ impl State {...@@ -767,6 +792,7 @@ impl State {
767 fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box<dyn Error>> {792 fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box<dyn Error>> {
768 self.ui.begin(size, scale, Instant::now());793 self.ui.begin(size, scale, Instant::now());
769 let (section, open_tab, open_page) = self.build()?;794 let (section, open_tab, open_page) = self.build()?;
795 self.options_dialog();
770 self.ui.end();796 self.ui.end();
771 self.edges(section, open_tab, open_page);797 self.edges(section, open_tab, open_page);
772 if let Some(rect) = self.ui.rect(page()) {798 if let Some(rect) = self.ui.rect(page()) {
...@@ -1554,9 +1580,6 @@ impl State {...@@ -1554,9 +1580,6 @@ impl State {
15541580
1555 /// The search box and the page list's buttons, above the list.1581 /// The search box and the page list's buttons, above the list.
1556 fn page_tools(&mut self, theme: &Theme) {1582 fn page_tools(&mut self, theme: &Theme) {
1557 let Some(session) = &self.session else {
1558 return;
1559 };
1560 self.ui.open(1583 self.ui.open(
1561 "tools",1584 "tools",
1562 Spec {1585 Spec {
...@@ -1566,60 +1589,8 @@ impl State {...@@ -1566,60 +1589,8 @@ impl State {
1566 ..Spec::default()1589 ..Spec::default()
1567 },1590 },
1568 );1591 );
1569 let focused = self.ui.focused() == Some(filter());1592 if let Err(error) = self.search_box(theme) {
1570 self.ui.open(1593 eprintln!("{error}");
1571 "search",
1572 Spec {
1573 size: [fill(), px(ui::shell::TOOL)],
1574 fill: Some(theme.base),
1575 border: Some(if focused { theme.accent } else { theme.chip }),
1576 radius: 4.0,
1577 pad: [6.0, 0.0],
1578 ..Spec::default()
1579 },
1580 );
1581 self.ui.leaf(
1582 "icon",
1583 Spec {
1584 size: [fit(), px(ui::shell::TOOL)],
1585 icon: Some(art::SEARCH),
1586 color: Some(theme.text_dim),
1587 ..Spec::default()
1588 },
1589 );
1590 let signal = ui::text_field(
1591 &mut self.ui,
1592 filter(),
1593 &mut self.filter,
1594 "Search pages",
1595 Spec {
1596 size: [fill(), px(ui::shell::TOOL)],
1597 pad: [6.0, 0.0],
1598 ..Spec::default()
1599 },
1600 );
1601 if !self.filter.is_empty()
1602 && ui::shell::tool_button(&mut self.ui, "clear", art::CLOSE, theme.text_dim, false)
1603 .clicked
1604 {
1605 self.filter.clear();
1606 }
1607 self.ui.close();
1608 for event in &signal.events {
1609 if let ui::Event::Key {
1610 key: Key::Named(key @ (NamedKey::Escape | NamedKey::Enter)),
1611 ..
1612 } = event
1613 {
1614 let first = matching(&session.pages, &self.filter).next();
1615 if *key == NamedKey::Enter
1616 && let Some((space, ..)) = first.filter(|(space, ..)| *space != session.space)
1617 {
1618 self.commands.push(Command::OpenPage(*space));
1619 }
1620 self.filter.clear();
1621 self.ui.set_focus(Some(page()));
1622 }
1623 }1594 }
1624 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked {1595 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked {
1625 self.commands.push(Command::NewPage { under: None });1596 self.commands.push(Command::NewPage { under: None });
...@@ -1652,7 +1623,10 @@ impl State {...@@ -1652,7 +1623,10 @@ impl State {
1652 ..Spec::default()1623 ..Spec::default()
1653 },1624 },
1654 );1625 );
1655 let rows = page_rows(&mut self.ui, theme, section, session, &self.filter);1626 let found = self
1627 .search
1628 .found_in(&session.library.key(&session.tabs[session.tab].path));
1629 let rows = page_rows(&mut self.ui, theme, section, session, &found);
1656 self.commands.extend(rows.clicked.map(Command::OpenPage));1630 self.commands.extend(rows.clicked.map(Command::OpenPage));
1657 if let Some((space, point)) = rows.context {1631 if let Some((space, point)) = rows.context {
1658 self.menu = Some((menus::Target::Page(space), point));1632 self.menu = Some((menus::Target::Page(space), point));
...@@ -1718,11 +1692,19 @@ impl State {...@@ -1718,11 +1692,19 @@ impl State {
1718 ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?,1692 ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?,
1719 ui::Event::Key { key, text } => {1693 ui::Event::Key { key, text } => {
1720 let modifiers = self.view.modifiers();1694 let modifiers = self.view.modifiers();
1721 let find = matches!(&key, Key::Character(character)1695 // OneNote's Find on This Page is Ctrl+F, its search Ctrl+E.
1722 if character.eq_ignore_ascii_case("f"));1696 if modifiers.command && !modifiers.shift && self.session.is_some() {
1723 if modifiers.command && !modifiers.shift && find && self.session.is_some() {1697 match &key {
1724 self.ui.set_focus(Some(filter()));1698 Key::Character(character) if character.eq_ignore_ascii_case("f") => {
1725 continue;1699 self.start_find(false)?;
1700 continue;
1701 }
1702 Key::Character(character) if character.eq_ignore_ascii_case("e") => {
1703 self.start_search();
1704 continue;
1705 }
1706 _ => {}
1707 }
1726 }1708 }
1727 // OneNote's New Page is Ctrl+N.1709 // OneNote's New Page is Ctrl+N.
1728 let new = matches!(&key, Key::Character(character)1710 let new = matches!(&key, Key::Character(character)
...@@ -1819,10 +1801,20 @@ impl State {...@@ -1819,10 +1801,20 @@ impl State {
1819 Command::NewNotebook => self.new_notebook()?,1801 Command::NewNotebook => self.new_notebook()?,
1820 Command::CloseNotebook(library) => self.close_notebook(&library),1802 Command::CloseNotebook(library) => self.close_notebook(&library),
1821 Command::Structure(library, change) => self.restructure(library, change),1803 Command::Structure(library, change) => self.restructure(library, change),
1822 Command::NewPage { under } => self.new_page(under)?,1804 // The index follows the section's page list.
1823 Command::DeletePages(pages) => self.delete_pages(pages)?,1805 Command::NewPage { under } => {
1806 self.new_page(under)?;
1807 self.edited(Vec::new());
1808 }
1809 Command::DeletePages(pages) => {
1810 self.delete_pages(pages)?;
1811 self.edited(Vec::new());
1812 }
1824 Command::Pages(edits) => self.edit_pages(edits)?,1813 Command::Pages(edits) => self.edit_pages(edits)?,
1825 Command::MovePage { space, path } => self.move_page(space, path)?,1814 Command::MovePage { space, path } => {
1815 self.move_page(space, path)?;
1816 self.edited(Vec::new());
1817 }
1826 Command::Template(choice) => self.apply_template(choice)?,1818 Command::Template(choice) => self.apply_template(choice)?,
1827 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,1819 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
1828 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,1820 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
...@@ -1837,48 +1829,52 @@ impl State {...@@ -1837,48 +1829,52 @@ impl State {
1837 Ok(())1829 Ok(())
1838 }1830 }
18391831
1840 /// Runs `read` on a thread of its own; `open_loaded` shows what it read unless a newer1832 /// Runs `read` on a thread of its own, which lays out the page it read; `open_loaded`
1841 /// read was asked for meanwhile.1833 /// shows it unless a newer read was asked for meanwhile.
1842 fn load(1834 fn load(
1843 &mut self,1835 &mut self,
1844 read: impl FnOnce() -> Result<Loaded, Box<dyn Error>> + Send + 'static,1836 read: impl FnOnce() -> Result<Loaded, Box<dyn Error>> + Send + 'static,
1845 ) {1837 ) {
1846 self.loading += 1;1838 self.loading += 1;
1847 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());1839 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());
1840 let layouts = Arc::clone(&self.layouts);
1848 std::thread::spawn(move || {1841 std::thread::spawn(move || {
1849 let _ = sender.send((id, read().map_err(|error| error.to_string())));1842 let laid = read().and_then(|loaded| {
1843 let (shown, page) = match loaded {
1844 Loaded::Section(session, page) => (Shown::Section(session), page),
1845 Loaded::Page(space, page) => (Shown::Page(space), page),
1846 Loaded::Library(library, path) => return Ok(Laid::Library(library, path)),
1847 };
1848 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;
1849 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
1850 Ok(Laid::Page(Box::new(Opening {
1851 loaded: shown,
1852 scene: (scene, [0.0; 2]),
1853 editor,
1854 since: Instant::now(),
1855 })))
1856 });
1857 let _ = sender.send((id, laid.map_err(|error| error.to_string())));
1850 redraw.wake();1858 redraw.wake();
1851 });1859 });
1852 }1860 }
18531861
1854 /// Lays out the newest page read, and shows it once the pictures it shows first are1862 /// Shows the newest page read once the pictures it shows first are drawn, or after
1855 /// drawn, or after `HOLD`.1863 /// `HOLD`.
1856 fn open_loaded(&mut self) -> Result<(), Box<dyn Error>> {1864 fn open_loaded(&mut self) -> Result<(), Box<dyn Error>> {
1857 let reads: Vec<Read> = self.loads.1.try_iter().collect();1865 let reads: Vec<Read> = self.loads.1.try_iter().collect();
1858 for (id, loaded) in reads {1866 for (id, loaded) in reads {
1859 if id != self.loading {1867 if id != self.loading {
1860 continue;1868 continue;
1861 }1869 }
1862 let (loaded, page) = match loaded {1870 match loaded {
1863 Ok(Loaded::Section(session, page)) => (Shown::Section(session), page),1871 Ok(Laid::Page(opening)) => self.opening = Some(*opening),
1864 Ok(Loaded::Page(space, page)) => (Shown::Page(space), page),1872 Ok(Laid::Library(library, path)) => self.adopt(library, &path)?,
1865 Ok(Loaded::Library(library, path)) => {
1866 self.adopt(library, &path)?;
1867 continue;
1868 }
1869 Err(error) => {1873 Err(error) => {
1870 eprintln!("{error}");1874 eprintln!("{error}");
1871 platform::alert("Couldn't open", &error);1875 platform::alert("Couldn't open", &error);
1872 continue;
1873 }1876 }
1874 };1877 }
1875 let (scene, editor) = PageScene::from_page(page, &mut self.view.engine)?;
1876 self.opening = Some(Opening {
1877 loaded,
1878 scene: (scene, [0.0; 2]),
1879 editor,
1880 since: Instant::now(),
1881 });
1882 }1878 }
1883 let Some(mut opening) = self.opening.take() else {1879 let Some(mut opening) = self.opening.take() else {
1884 return Ok(());1880 return Ok(());
...@@ -1905,6 +1901,11 @@ impl State {...@@ -1905,6 +1901,11 @@ impl State {
1905 }1901 }
1906 match opening.loaded {1902 match opening.loaded {
1907 Shown::Section(session) => {1903 Shown::Section(session) => {
1904 // The settings keep the notebook shown, not the section.
1905 let other_notebook = self
1906 .session
1907 .as_ref()
1908 .is_none_or(|open| open.library.location != session.library.location);
1908 // A notebook read again after a change replaces the one it was.1909 // A notebook read again after a change replaces the one it was.
1909 match self1910 match self
1910 .notebooks1911 .notebooks
...@@ -1915,8 +1916,9 @@ impl State {...@@ -1915,8 +1916,9 @@ impl State {
1915 None => self.notebooks.push(Arc::clone(&session.library)),1916 None => self.notebooks.push(Arc::clone(&session.library)),
1916 }1917 }
1917 self.session = Some(*session);1918 self.session = Some(*session);
1918 self.filter.clear();1919 if other_notebook {
1919 self.save_settings();1920 self.save_settings();
1921 }
1920 }1922 }
1921 Shown::Page(space) => {1923 Shown::Page(space) => {
1922 let session = self.session.as_mut().ok_or("No section is open")?;1924 let session = self.session.as_mut().ok_or("No section is open")?;
...@@ -1932,6 +1934,7 @@ impl State {...@@ -1932,6 +1934,7 @@ impl State {
1932 });1934 });
1933 self.view.open(opening.editor, Some(opening.scene), place);1935 self.view.open(opening.editor, Some(opening.scene), place);
1934 self.opened()?;1936 self.opened()?;
1937 self.refind(true)?;
1935 if self.title_focus.take().is_some_and(|space| {1938 if self.title_focus.take().is_some_and(|space| {
1936 self.session1939 self.session
1937 .as_ref()1940 .as_ref()
...@@ -1987,7 +1990,10 @@ impl State {...@@ -1987,7 +1990,10 @@ impl State {
19871990
1988 /// Follows a page shown in place of another.1991 /// Follows a page shown in place of another.
1989 fn opened(&mut self) -> Result<(), Box<dyn Error>> {1992 fn opened(&mut self) -> Result<(), Box<dyn Error>> {
1990 self.ui.set_focus(Some(page()));1993 // A page a search result shows leaves the keys with the search.
1994 if !search::takes_text(self.ui.focused()) {
1995 self.ui.set_focus(Some(page()));
1996 }
1991 self.title();1997 self.title();
1992 self.update_accessibility()?;1998 self.update_accessibility()?;
1993 self.window.request_redraw();1999 self.window.request_redraw();
...@@ -2000,6 +2006,7 @@ impl State {...@@ -2000,6 +2006,7 @@ impl State {
2000 let start = Instant::now();2006 let start = Instant::now();
2001 let saved = self.persist();2007 let saved = self.persist();
2002 lap("save", start);2008 lap("save", start);
2009 self.refind(false)?;
2003 self.title();2010 self.title();
2004 self.after_move()?;2011 self.after_move()?;
2005 saved2012 saved
...@@ -2167,6 +2174,7 @@ impl State {...@@ -2167,6 +2174,7 @@ impl State {
2167 if self.view.editor.active_outline().title {2174 if self.view.editor.active_outline().title {
2168 session.pages = session.section.pages()?;2175 session.pages = session.section.pages()?;
2169 }2176 }
2177 self.edited(vec![space]);
2170 Ok(())2178 Ok(())
2171 }2179 }
21722180
...@@ -2225,6 +2233,11 @@ impl State {...@@ -2225,6 +2233,11 @@ impl State {
2225 Event::Changed(spaces) => {2233 Event::Changed(spaces) => {
2226 listed = true;2234 listed = true;
2227 changed |= spaces.contains(&session.space);2235 changed |= spaces.contains(&session.space);
2236 self.search.changed(
2237 session.library.key(&session.tabs[session.tab].path),
2238 session.section.replica(),
2239 spaces,
2240 );
2228 continue;2241 continue;
2229 }2242 }
2230 Event::Rejected { spaces, error } => {2243 Event::Rejected { spaces, error } => {
...@@ -2600,16 +2613,33 @@ impl State {...@@ -2600,16 +2613,33 @@ impl State {
2600 }2613 }
2601 return;2614 return;
2602 }2615 }
2603 self.ui.event(event);2616 // Settings' shortcut, which AppKit's menu takes first on macOS.
2617 if let ui::Event::Key {
2618 key: Key::Character(character),
2619 ..
2620 } = &event
2621 && character == ","
2622 && ui::edit_modifiers(self.ui.modifiers()).command
2623 {
2624 self.open_options();
2625 } else {
2626 self.ui.event(event);
2627 }
2604 self.window.request_redraw();2628 self.window.request_redraw();
2605 }2629 }
2606}2630}
26072631
2608fn theme(appearance: winit::window::Theme) -> Theme {2632/// The interface's colours in `appearance`, on white pages when `light_pages`.
2609 match appearance {2633fn theme(appearance: winit::window::Theme, light_pages: bool) -> Theme {
2634 let mut theme = match appearance {
2610 winit::window::Theme::Dark => Theme::dark(),2635 winit::window::Theme::Dark => Theme::dark(),
2611 winit::window::Theme::Light => Theme::light(),2636 winit::window::Theme::Light => Theme::light(),
2637 };
2638 if light_pages {
2639 let light = Theme::light();
2640 [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink];
2612 }2641 }
2642 theme
2613}2643}
26142644
2615/// A section's colour as linear RGBA; sections without one take OneNote's default blue.2645/// A section's colour as linear RGBA; sections without one take OneNote's default blue.
...@@ -2754,17 +2784,17 @@ struct Rows {...@@ -2754,17 +2784,17 @@ struct Rows {
2754 held: Option<ExGuid>,2784 held: Option<ExGuid>,
2755}2785}
27562786
2757/// The page list's rows for the pages matching `filter`, a page's conflict pages beneath it2787/// The page list's rows, a page's conflict pages beneath it while shown and the pages a
2758/// while shown.2788/// search `found` marked.
2759fn page_rows(2789fn page_rows(
2760 ui: &mut Ui,2790 ui: &mut Ui,
2761 theme: &Theme,2791 theme: &Theme,
2762 section: &ui::Section,2792 section: &ui::Section,
2763 session: &Session,2793 session: &Session,
2764 filter: &str,2794 found: &HashSet<ExGuid>,
2765) -> Rows {2795) -> Rows {
2766 let mut rows = Rows::default();2796 let mut rows = Rows::default();
2767 for (space, title, level) in matching(&session.pages, filter) {2797 for (space, title, level) in &session.pages {
2768 let versions = session.versions(*space);2798 let versions = session.versions(*space);
2769 let tab = PageTab {2799 let tab = PageTab {
2770 label: if title.is_empty() {2800 label: if title.is_empty() {
...@@ -2775,6 +2805,7 @@ fn page_rows(...@@ -2775,6 +2805,7 @@ fn page_rows(
2775 dim: title.is_empty(),2805 dim: title.is_empty(),
2776 indent: level.saturating_sub(1),2806 indent: level.saturating_sub(1),
2777 conflicted: !versions.is_empty(),2807 conflicted: !versions.is_empty(),
2808 found: found.contains(space),
2778 };2809 };
2779 let selected = *space == session.space;2810 let selected = *space == session.space;
2780 if selected {2811 if selected {
...@@ -2809,6 +2840,7 @@ fn page_rows(...@@ -2809,6 +2840,7 @@ fn page_rows(
2809 dim: false,2840 dim: false,
2810 indent: level.saturating_sub(1),2841 indent: level.saturating_sub(1),
2811 conflicted: false,2842 conflicted: false,
2843 found: false,
2812 };2844 };
2813 let selected = version.space == session.space;2845 let selected = version.space == session.space;
2814 if selected {2846 if selected {
...@@ -2830,6 +2862,8 @@ struct PageTab<'a> {...@@ -2830,6 +2862,8 @@ struct PageTab<'a> {
2830 indent: u32,2862 indent: u32,
2831 /// The page has conflict pages, which OneNote marks at the tab's end.2863 /// The page has conflict pages, which OneNote marks at the tab's end.
2832 conflicted: bool,2864 conflicted: bool,
2865 /// A search found the page, which OneNote marks yellow.
2866 found: bool,
2833}2867}
28342868
2835/// A page's tab down the frame's right side: the open one is the page's colour and joins2869/// A page's tab down the frame's right side: the open one is the page's colour and joins
...@@ -2857,9 +2891,14 @@ fn page_tab(...@@ -2857,9 +2891,14 @@ fn page_tab(
2857 };2891 };
2858 (spec, theme.paper_ink)2892 (spec, theme.paper_ink)
2859 } else {2893 } else {
2894 let fill = if tab.found {
2895 ui::mix(section.tab, draw::srgb(0xff, 0xd8, 0x30), 0.6)
2896 } else {
2897 section.tab
2898 };
2860 let spec = Spec {2899 let spec = Spec {
2861 fill: Some(section.tab),2900 fill: Some(fill),
2862 hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)),2901 hover_fill: Some(ui::mix(fill, section.accent, 0.3)),
2863 radius: ROUNDING,2902 radius: ROUNDING,
2864 inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP],2903 inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP],
2865 // The label keeps its place as the tab opens and closes.2904 // The label keeps its place as the tab opens and closes.
...@@ -2899,17 +2938,6 @@ fn page_tab(...@@ -2899,17 +2938,6 @@ fn page_tab(
2899 ui.signal(row)2938 ui.signal(row)
2900}2939}
29012940
2902/// The pages whose titles contain `filter`, ignoring case.
2903fn matching<'a>(
2904 pages: &'a [(ExGuid, String, u32)],
2905 filter: &str,
2906) -> impl Iterator<Item = &'a (ExGuid, String, u32)> {
2907 let filter = filter.to_lowercase();
2908 pages
2909 .iter()
2910 .filter(move |(_, title, _)| title.to_lowercase().contains(&filter))
2911}
2912
2913/// A toolbar group's rows, stacked.2941/// A toolbar group's rows, stacked.
2914fn group(ui: &mut Ui, part: &str, rows: impl FnOnce(&mut Ui)) {2942fn group(ui: &mut Ui, part: &str, rows: impl FnOnce(&mut Ui)) {
2915 ui.open(2943 ui.open(
...@@ -3083,6 +3111,13 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3083,6 +3111,13 @@ impl ApplicationHandler<UserEvent> for App {
3083 }3111 }
3084 return;3112 return;
3085 }3113 }
3114 UserEvent::Options => {
3115 if let Some(state) = &mut self.state {
3116 state.open_options();
3117 state.window.request_redraw();
3118 }
3119 return;
3120 }
3086 UserEvent::Redraw => {3121 UserEvent::Redraw => {
3087 if let Some(state) = &self.state {3122 if let Some(state) = &self.state {
3088 state.window.request_redraw();3123 state.window.request_redraw();
...@@ -3097,7 +3132,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3097,7 +3132,7 @@ impl ApplicationHandler<UserEvent> for App {
3097 Replay::Tick => {}3132 Replay::Tick => {}
3098 Replay::Appearance(appearance) => {3133 Replay::Appearance(appearance) => {
3099 state.window.set_theme(Some(appearance));3134 state.window.set_theme(Some(appearance));
3100 state.ui.theme = theme(appearance);3135 state.ui.theme = theme(appearance, state.light_pages);
3101 }3136 }
3102 Replay::Quit => {3137 Replay::Quit => {
3103 self.close(event_loop);3138 self.close(event_loop);
...@@ -3206,7 +3241,8 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3206,7 +3241,8 @@ impl ApplicationHandler<UserEvent> for App {
3206 return state.frame();3241 return state.frame();
3207 }3242 }
3208 WindowEvent::ThemeChanged(appearance) => {3243 WindowEvent::ThemeChanged(appearance) => {
3209 state.ui.theme = theme(appearance);3244 let chosen = state.color_scheme.theme().unwrap_or(appearance);
3245 state.ui.theme = theme(chosen, state.light_pages);
3210 state.window.request_redraw();3246 state.window.request_redraw();
3211 }3247 }
3212 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {3248 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
crates/snowbound/src/menus.rs-3
...@@ -194,9 +194,6 @@ impl State {...@@ -194,9 +194,6 @@ impl State {
194 let Some(session) = &self.session else {194 let Some(session) = &self.session else {
195 return;195 return;
196 };196 };
197 if held.is_some() && !self.filter.is_empty() {
198 return;
199 }
200 let dropped = match (&self.drag, held) {197 let dropped = match (&self.drag, held) {
201 (_, Some(space)) => Some(space),198 (_, Some(space)) => Some(space),
202 (Some(Drag::Page(space)), None) => Some(*space),199 (Some(Drag::Page(space)), None) => Some(*space),
crates/snowbound/src/options.rs created+303
...@@ -0,0 +1,303 @@
1//! OneNote's Options dialog: pages of settings listed on the left as OneNote 2010 lists
2//! them, grouped under the same headings, each kept when OK is chosen.
3
4use crate::{State, platform, settings::ColorScheme};
5use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, children, fill, popup::Item, px};
6use winit::keyboard::NamedKey;
7
8const WIDTH: f32 = 640.0;
9/// The pages' area, the same for every page so the dialog keeps its size.
10const HEIGHT: f32 = 320.0;
11const NAV: f32 = 150.0;
12/// The column the fields' labels share.
13const LABEL: f32 = 104.0;
14const SCHEMES: [(ColorScheme, &str); 3] = [
15 (ColorScheme::System, "System"),
16 (ColorScheme::Light, "Light"),
17 (ColorScheme::Dark, "Dark"),
18];
19
20#[derive(Clone, Copy, PartialEq)]
21enum Page {
22 General,
23 Display,
24 SaveBackup,
25}
26
27impl Page {
28 const ALL: [(Page, &str); 3] = [
29 (Page::General, "General"),
30 (Page::Display, "Display"),
31 (Page::SaveBackup, "Save & Backup"),
32 ];
33}
34
35/// The page shown and what the fields hold until OK keeps them.
36pub struct Options {
37 page: Page,
38 user_name: String,
39 color_scheme: ColorScheme,
40 light_pages: bool,
41}
42
43fn id() -> Id {
44 Id::ROOT.child("options")
45}
46
47fn user_name() -> Id {
48 id().child("user-name")
49}
50
51fn schemes() -> Id {
52 id().child("color-schemes")
53}
54
55impl State {
56 pub(crate) fn open_options(&mut self) {
57 self.options = Some(Options {
58 page: Page::General,
59 user_name: self.author.clone(),
60 color_scheme: self.color_scheme,
61 light_pages: self.light_pages,
62 });
63 self.ui.open_popup(id());
64 self.ui.set_focus(Some(user_name()));
65 }
66
67 /// Builds the Options dialog while it is open. OK, or Enter in a field, keeps its choices
68 /// with a non-empty user name; Cancel, Escape or a press outside leave them.
69 pub(crate) fn options_dialog(&mut self) {
70 let Some(options) = &mut self.options else {
71 return;
72 };
73 let ui = &mut self.ui;
74 if !ui.popup_open(id()) {
75 self.options = None;
76 return;
77 }
78 let theme = ui.theme.clone();
79 let row = theme.font_size * 2.0;
80 let window = ui.rect(Id::ROOT).unwrap_or_default();
81 let height = ui.rect(id()).map_or(HEIGHT, |rect| rect[3] - rect[1]);
82 let [left, top] = [
83 ((window[2] - WIDTH) / 2.0).max(0.0),
84 ((window[3] - height) / 2.0).max(0.0),
85 ];
86 let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter])
87 .contains(&NamedKey::Enter);
88 ui.open_as(
89 id(),
90 Spec {
91 axis: Axis::Y,
92 size: [px(WIDTH), children()],
93 fill: Some(theme.popup),
94 border: Some(theme.chip),
95 shadow: Some(theme.shadow),
96 radius: 8.0,
97 pad: [6.0, 6.0],
98 anchor: Some(Anchor::Below([left, top, left, top])),
99 ..Spec::default()
100 },
101 );
102 ui.open(
103 "body",
104 Spec {
105 size: [fill(), px(HEIGHT)],
106 ..Spec::default()
107 },
108 );
109 ui.open(
110 "pages",
111 Spec {
112 axis: Axis::Y,
113 size: [px(NAV), fill()],
114 fill: Some(theme.panel),
115 radius: 5.0,
116 pad: [6.0, 6.0],
117 gap: 2.0,
118 ..Spec::default()
119 },
120 );
121 for (page, name) in Page::ALL {
122 let shown = page == options.page;
123 let spec = Spec {
124 flags: Flags::CLICKABLE,
125 size: [fill(), px(row)],
126 text: Some(name),
127 bold: shown,
128 fill: shown.then(|| theme.hover()),
129 hover_fill: Some(theme.hover()),
130 radius: 4.0,
131 pad: [8.0, 0.0],
132 ..Spec::default()
133 };
134 if ui.leaf(name, spec).clicked {
135 options.page = page;
136 }
137 }
138 ui.close();
139 ui.open(
140 "page",
141 Spec {
142 axis: Axis::Y,
143 size: [fill(), fill()],
144 pad: [16.0, 10.0],
145 gap: 6.0,
146 ..Spec::default()
147 },
148 );
149 match options.page {
150 Page::General => {
151 heading(ui, &theme, "User Interface Options");
152 field(ui, "Color scheme:", |ui| {
153 let combo = ui.id("combo");
154 let current = SCHEMES
155 .iter()
156 .find(|(scheme, _)| *scheme == options.color_scheme)
157 .map_or("", |(_, name)| name);
158 if ui::shell::combo(ui, "combo", current, 140.0).pressed {
159 ui.open_popup(schemes());
160 }
161 let items = SCHEMES.map(|(scheme, name)| Item {
162 text: name,
163 checked: scheme == options.color_scheme,
164 ..Item::default()
165 });
166 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());
167 if let Some(index) = ui::popup::menu(ui, schemes(), anchor, &items, None) {
168 options.color_scheme = SCHEMES[index].0;
169 }
170 });
171 heading(ui, &theme, "Personalize");
172 field(ui, "User name:", |ui| {
173 ui::text_field(
174 ui,
175 user_name(),
176 &mut options.user_name,
177 "",
178 Spec {
179 size: [px(260.0), px(row)],
180 fill: Some(theme.base),
181 border: Some(theme.accent),
182 radius: 4.0,
183 pad: [6.0, 0.0],
184 ..Spec::default()
185 },
186 );
187 });
188 }
189 Page::Display => {
190 heading(ui, &theme, "Display");
191 let dark = !options.light_pages;
192 if ui::check_box(ui, "dark-pages", "Pages appear dark in the Dark color scheme", dark)
193 .clicked
194 {
195 options.light_pages = dark;
196 }
197 }
198 Page::SaveBackup => {
199 heading(ui, &theme, "Cache file location");
200 let cache = &self.cache;
201 field(ui, "Path:", |ui| {
202 ui.leaf(
203 "path",
204 Spec {
205 flags: Flags::CLIP,
206 size: [fill(), px(row)],
207 text: Some(&cache.to_string_lossy()),
208 color: Some(theme.text_dim),
209 ..Spec::default()
210 },
211 );
212 let label = if cfg!(target_os = "macos") {
213 "Show in Finder"
214 } else {
215 "Open Folder"
216 };
217 if ui::button(ui, "reveal", label).clicked {
218 platform::reveal(cache);
219 }
220 });
221 }
222 }
223 ui.close();
224 ui.close();
225 ui.open(
226 "buttons",
227 Spec {
228 size: [fill(), children()],
229 pad: [10.0, 8.0],
230 gap: 8.0,
231 ..Spec::default()
232 },
233 );
234 ui.leaf(
235 "space",
236 Spec {
237 size: [fill(), px(1.0)],
238 ..Spec::default()
239 },
240 );
241 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
242 let ok = ui::button(ui, "ok", "OK").clicked || entered;
243 ui.close();
244 ui.close();
245 let name = options.user_name.trim();
246 if ok && !name.is_empty() {
247 self.author = name.to_owned();
248 self.color_scheme = options.color_scheme;
249 self.light_pages = options.light_pages;
250 self.window.set_theme(self.color_scheme.theme());
251 let appearance = self
252 .color_scheme
253 .theme()
254 .unwrap_or_else(|| platform::appearance(&self.window));
255 self.ui.theme = crate::theme(appearance, self.light_pages);
256 self.save_settings();
257 } else if !cancel {
258 return;
259 }
260 self.ui.close_popup(id());
261 self.options = None;
262 }
263}
264
265/// A group's heading on a band, as OneNote heads the groups of its Options pages.
266fn heading(ui: &mut Ui, theme: &Theme, text: &str) {
267 ui.leaf(
268 text,
269 Spec {
270 size: [fill(), px(theme.font_size * 1.8)],
271 text: Some(text),
272 bold: true,
273 fill: Some(theme.strip),
274 radius: 4.0,
275 pad: [8.0, 0.0],
276 ..Spec::default()
277 },
278 );
279}
280
281/// A row of `label` in the labels' column and the controls `build` adds after it.
282fn field(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) {
283 let row = ui.theme.font_size * 2.0;
284 ui.open(
285 label,
286 Spec {
287 size: [fill(), px(row)],
288 pad: [8.0, 0.0],
289 gap: 8.0,
290 ..Spec::default()
291 },
292 );
293 ui.leaf(
294 "label",
295 Spec {
296 size: [px(LABEL), px(row)],
297 text: Some(label),
298 ..Spec::default()
299 },
300 );
301 build(ui);
302 ui.close();
303}
crates/snowbound/src/screenshot.rs+1-1
...@@ -37,7 +37,7 @@ impl State {...@@ -37,7 +37,7 @@ impl State {
37 }37 }
38 for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] {38 for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] {
39 self.window.set_theme(Some(appearance));39 self.window.set_theme(Some(appearance));
40 self.ui.theme = theme(appearance);40 self.ui.theme = theme(appearance, self.light_pages);
41 // The page's box sizes the view whose pictures settle.41 // The page's box sizes the view whose pictures settle.
42 self.layout(SIZE, SCALE)?;42 self.layout(SIZE, SCALE)?;
43 let paper = self.paper();43 let paper = self.paper();
crates/snowbound/src/search.rs created+1212
...@@ -0,0 +1,1212 @@
1//! OneNote 2010's search: the box beside the section tabs searching the open notebooks, its
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.
4
5use crate::{Command, Library, State, Theme, art, lap, page};
6use canvas::search::{Entry, Found, Index, Query, page_matches};
7use notebook::Replica;
8use onestore::ExGuid;
9use std::{
10 collections::{HashMap, HashSet},
11 error::Error,
12 path::{Path, PathBuf},
13 sync::{
14 Arc, Mutex, Weak,
15 atomic::{AtomicBool, AtomicU64, Ordering},
16 mpsc,
17 },
18 time::{Duration, Instant, SystemTime},
19};
20use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px};
21use winit::keyboard::NamedKey;
22
23/// Where a search looks, as OneNote's scope menu offers it.
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25pub enum Scope {
26 Section,
27 Group,
28 Notebook,
29 #[default]
30 All,
31}
32
33impl Scope {
34 const ALL: [Scope; 4] = [Scope::Section, Scope::Group, Scope::Notebook, Scope::All];
35
36 fn name(self) -> &'static str {
37 match self {
38 Scope::Section => "This Section",
39 Scope::Group => "This Section Group",
40 Scope::Notebook => "This Notebook",
41 Scope::All => "All Notebooks",
42 }
43 }
44}
45
46const SEARCH_KEY: &str = if cfg!(target_os = "macos") {
47 "⌘E"
48} else {
49 "Ctrl+E"
50};
51const FIND_KEY: &str = if cfg!(target_os = "macos") {
52 "⌘F"
53} else {
54 "Ctrl+F"
55};
56/// The results list's rows, the headings above their groups, and its width.
57const RESULT: f32 = 42.0;
58const HEADING: f32 = 24.0;
59const RESULTS: f32 = 460.0;
60/// Room the section name takes beside a result's title.
61const PLACE: f32 = 150.0;
62/// How long typing pauses before the index reads the pages it changed.
63const SETTLE: Duration = Duration::from_millis(400);
64
65fn results() -> Id {
66 Id::ROOT.child("results")
67}
68
69fn scope_menu() -> Id {
70 Id::ROOT.child("scope")
71}
72
73/// The field of the box, and of the results over it.
74fn field() -> Id {
75 Id::ROOT.child("search-field")
76}
77
78fn find_field() -> Id {
79 Id::ROOT.child("find-field")
80}
81
82/// Whether `focus` is one of the box's fields, which take typed text.
83pub fn takes_text(focus: Option<Id>) -> bool {
84 [Some(field()), Some(find_field())].contains(&focus)
85}
86
87/// Work for the index thread.
88enum Job {
89 /// Brings the index to these notebooks: sections whose files changed are read again and
90 /// those no longer listed dropped. The open section, by key, is read through its replica.
91 Notebooks {
92 libraries: Vec<Arc<Library>>,
93 open: Option<(String, Weak<Replica>)>,
94 },
95 /// Reads pages of the open section again, with its page list.
96 Pages {
97 key: String,
98 replica: Weak<Replica>,
99 spaces: Vec<ExGuid>,
100 },
101}
102
103/// The search box's state and the index behind it.
104pub struct Search {
105 pub query: String,
106 pub scope: Scope,
107 /// Scope searches start in, as "Set This Scope as Default" leaves it.
108 pub default: Scope,
109 /// Find on This Page is open, at the match selected.
110 finding: bool,
111 current: Option<usize>,
112 /// The page a result opened, whose first match is selected once it shows.
113 reveal: Option<ExGuid>,
114 index: Arc<Mutex<Index>>,
115 /// Counts the index's changes, so results are made again when it changes.
116 version: Arc<AtomicU64>,
117 busy: Arc<AtomicBool>,
118 jobs: mpsc::Sender<Job>,
119 found: Vec<Found>,
120 found_for: Option<(Query, Scope, u64, String)>,
121 selected: Option<u64>,
122 /// The notebooks and open section the index was last brought to, by identity.
123 synced: Vec<usize>,
124 /// The results drop down, until chosen from or dismissed.
125 open: bool,
126 /// Where the scope menu opens.
127 anchor: Option<Anchor>,
128 /// The find field takes the keys once built.
129 claim: bool,
130}
131
132impl Search {
133 pub fn new(default: Scope, redraw: std::task::Waker) -> Self {
134 let (jobs, receiver) = mpsc::channel();
135 let index = Arc::new(Mutex::new(Index::default()));
136 let version = Arc::new(AtomicU64::new(0));
137 let busy = Arc::new(AtomicBool::new(false));
138 let shared = (Arc::clone(&index), Arc::clone(&version), Arc::clone(&busy));
139 std::thread::Builder::new()
140 .name("search-index".into())
141 .spawn(move || run(receiver, shared, redraw))
142 .expect("the index thread starts");
143 Self {
144 query: String::new(),
145 scope: default,
146 default,
147 finding: false,
148 current: None,
149 reveal: None,
150 index,
151 version,
152 busy,
153 jobs,
154 found: Vec::new(),
155 found_for: None,
156 selected: None,
157 synced: Vec::new(),
158 open: false,
159 anchor: None,
160 claim: false,
161 }
162 }
163
164 /// Asks the index to read `spaces` of the open section again, as edits or another
165 /// writer changed them, and its page list.
166 pub fn changed(&self, key: String, replica: &Arc<Replica>, spaces: Vec<ExGuid>) {
167 let _ = self.jobs.send(Job::Pages {
168 key,
169 replica: Arc::downgrade(replica),
170 spaces,
171 });
172 }
173
174 /// Pages of section `key` among the results, which the page list marks.
175 pub fn found_in(&self, key: &str) -> HashSet<ExGuid> {
176 if self.finding || self.query.trim().is_empty() {
177 return HashSet::new();
178 }
179 self.found
180 .iter()
181 .filter(|found| found.section == key)
182 .map(|found| found.space)
183 .collect()
184 }
185
186 /// The query whose matches the page shows.
187 fn shown(&self) -> Query {
188 Query::new(&self.query)
189 }
190}
191
192/// When a file last changed, as far as its length and modification time tell.
193type Stamp = Option<(u64, SystemTime)>;
194
195fn stamp(file: &Path) -> Stamp {
196 let metadata = std::fs::metadata(file).ok()?;
197 Some((metadata.len(), metadata.modified().ok()?))
198}
199
200/// Now in Time32, seconds since 1980, as page modification times are kept.
201fn now() -> u64 {
202 SystemTime::now()
203 .duration_since(SystemTime::UNIX_EPOCH)
204 .map_or(0, |since| since.as_secs().saturating_sub(315_532_800))
205}
206
207/// The index thread: answers jobs, the newest notebooks first, then page changes, and
208/// wakes the window once the index changed.
209fn run(
210 jobs: mpsc::Receiver<Job>,
211 (index, version, busy): (Arc<Mutex<Index>>, Arc<AtomicU64>, Arc<AtomicBool>),
212 redraw: std::task::Waker,
213) {
214 let mut stamps: HashMap<String, Stamp> = HashMap::new();
215 while let Ok(first) = jobs.recv() {
216 busy.store(true, Ordering::Relaxed);
217 // Typing sends a job a keystroke; a pause gathers them into one read.
218 if matches!(first, Job::Pages { .. }) {
219 std::thread::sleep(SETTLE);
220 }
221 let mut notebooks = None;
222 let mut pages: HashMap<String, (Weak<Replica>, HashSet<ExGuid>)> = HashMap::new();
223 for job in std::iter::once(first).chain(jobs.try_iter()) {
224 match job {
225 Job::Notebooks { libraries, open } => notebooks = Some((libraries, open)),
226 Job::Pages {
227 key,
228 replica,
229 spaces,
230 } => pages
231 .entry(key)
232 .or_insert_with(|| (replica, HashSet::new()))
233 .1
234 .extend(spaces),
235 }
236 }
237 let start = Instant::now();
238 if let Some((libraries, open)) = notebooks {
239 sync(&index, &version, &mut stamps, &libraries, open.as_ref());
240 lap("index notebooks", start);
241 }
242 for (key, (replica, spaces)) in pages {
243 if let Err(error) = reread(&index, &key, &replica, &spaces) {
244 eprintln!("Cannot index the open section: {error}");
245 }
246 version.fetch_add(1, Ordering::Relaxed);
247 }
248 lap("index", start);
249 busy.store(false, Ordering::Relaxed);
250 redraw.wake_by_ref();
251 }
252}
253
254/// The readable sections of `library` outside its recycle bin, by catalog path.
255fn sections(library: &Library) -> Vec<String> {
256 match &library.notebook {
257 Ok(Some(notebook)) => {
258 let mut paths = Vec::new();
259 let mut folders = vec![notebook.catalog()];
260 while let Some(folder) = folders.pop() {
261 paths.extend(
262 folder
263 .sections
264 .iter()
265 .filter(|section| {
266 matches!(
267 section.state,
268 notebook::discover::SectionState::Readable { .. }
269 )
270 })
271 .map(|section| section.path.clone()),
272 );
273 folders.extend(
274 folder
275 .groups
276 .iter()
277 .filter(|group| !crate::library::recycle_bin(group)),
278 );
279 }
280 paths
281 }
282 Ok(None) => vec![library.location.clone()],
283 Err(_) => Vec::new(),
284 }
285}
286
287fn sync(
288 index: &Mutex<Index>,
289 version: &AtomicU64,
290 stamps: &mut HashMap<String, Stamp>,
291 libraries: &[Arc<Library>],
292 open: Option<&(String, Weak<Replica>)>,
293) {
294 let mut listed = HashSet::new();
295 for library in libraries {
296 for path in sections(library) {
297 let key = library.key(&path);
298 listed.insert(key.clone());
299 let notebook = library.notebook.as_ref().ok().and_then(Option::as_ref);
300 let file = match notebook {
301 Some(_) => Path::new(&library.location).join(&path),
302 None => PathBuf::from(&library.location),
303 };
304 let stamp = stamp(&file);
305 // A file with no stamp, as on a share not mounted, is read once.
306 if stamps
307 .get(&key)
308 .is_some_and(|known| known == &stamp || stamp.is_none())
309 {
310 continue;
311 }
312 let replica = open
313 .filter(|(open, _)| *open == key)
314 .and_then(|(_, replica)| replica.upgrade());
315 let read = || -> Result<Vec<Entry>, Box<dyn Error>> {
316 let bytes = match notebook {
317 Some(notebook) => notebook.read_section(&path)?,
318 None => onestore::read_file(&file)?,
319 };
320 let stored = notebook::session::stored_pages(&bytes)?;
321 let modified = |space: ExGuid| {
322 stored
323 .iter()
324 .find(|page| page.space == space)
325 .and_then(|page| page.modified)
326 .map_or_else(now, u64::from)
327 };
328 Ok(match &replica {
329 // The open section shows its edits before they reach the file.
330 Some(replica) => replica
331 .pages()?
332 .into_iter()
333 .map(|(space, ..)| {
334 Ok(Entry::new(
335 &key,
336 space,
337 &replica.page(space)?,
338 modified(space),
339 ))
340 })
341 .collect::<Result<_, notebook::Error>>()?,
342 None => stored
343 .iter()
344 .map(|page| Entry::new(&key, page.space, &page.page, modified(page.space)))
345 .collect(),
346 })
347 };
348 match read() {
349 Ok(entries) => {
350 let mut index = index.lock().unwrap_or_else(|error| error.into_inner());
351 index.retain(|entry| entry.section != key);
352 for entry in entries {
353 index.set(entry);
354 }
355 drop(index);
356 stamps.insert(key, stamp);
357 version.fetch_add(1, Ordering::Relaxed);
358 }
359 Err(error) => eprintln!("Cannot index {path}: {error}"),
360 }
361 }
362 }
363 stamps.retain(|key, _| listed.contains(key));
364 index
365 .lock()
366 .unwrap_or_else(|error| error.into_inner())
367 .retain(|entry| listed.contains(&entry.section));
368 version.fetch_add(1, Ordering::Relaxed);
369}
370
371/// Reads `spaces` of the open section `key` again, and pages its list gained, dropping
372/// those it lost.
373fn reread(
374 index: &Mutex<Index>,
375 key: &str,
376 replica: &Weak<Replica>,
377 spaces: &HashSet<ExGuid>,
378) -> Result<(), Box<dyn Error>> {
379 let Some(replica) = replica.upgrade() else {
380 return Ok(());
381 };
382 let listed: Vec<ExGuid> = replica
383 .pages()?
384 .into_iter()
385 .map(|(space, ..)| space)
386 .collect();
387 let unread: Vec<ExGuid> = {
388 let index = index.lock().unwrap_or_else(|error| error.into_inner());
389 listed
390 .iter()
391 .copied()
392 .filter(|space| spaces.contains(space) || index.get(key, *space).is_none())
393 .collect()
394 };
395 let entries = unread
396 .into_iter()
397 .map(|space| Ok(Entry::new(key, space, &replica.page(space)?, now())))
398 .collect::<Result<Vec<_>, notebook::Error>>()?;
399 let mut index = index.lock().unwrap_or_else(|error| error.into_inner());
400 index.retain(|entry| entry.section != key || listed.contains(&entry.space));
401 for entry in entries {
402 index.set(entry);
403 }
404 Ok(())
405}
406
407/// A text box's label with `hits`, byte ranges of `text`, marked in OneNote's yellow.
408fn marked(ui: &mut Ui, part: &str, text: &str, hits: &[std::ops::Range<usize>], spec: Spec) {
409 let height = match spec.size[1].size {
410 ui::Size::Pixels(height) => height,
411 _ => 0.0,
412 };
413 let pad = spec.pad[0];
414 ui.open(
415 part,
416 Spec {
417 text: Some(text),
418 ..spec
419 },
420 );
421 for hit in hits {
422 let (Some(before), Some(word)) = (text.get(..hit.start), text.get(hit.clone())) else {
423 continue;
424 };
425 let [x, _] = ui.measure(before);
426 let [width, tall] = ui.measure(word);
427 let top = (height - tall) / 2.0;
428 ui.mark(
429 [pad + x - 1.0, top, pad + x + width + 1.0, top + tall],
430 [1.0, 0.82, 0.0, 0.45],
431 2.0,
432 );
433 }
434 ui.close();
435}
436
437/// A section's place as a result names it: its notebook, groups and name.
438fn place(notebooks: &[Arc<Library>], key: &str) -> String {
439 let (location, path) = key.split_once('\n').unwrap_or((key, ""));
440 let notebook = notebooks
441 .iter()
442 .find(|library| library.location == location)
443 .map_or("", |library| library.name.as_str());
444 let path = match notebooks
445 .iter()
446 .find(|library| library.location == location)
447 {
448 Some(library) if matches!(library.notebook, Ok(None)) => {
449 crate::library::section_name(path, &None)
450 }
451 _ => path.strip_suffix(".one").unwrap_or(path).to_owned(),
452 };
453 format!("({notebook}/{path})")
454}
455
456impl State {
457 /// Brings the index to the open notebooks and section when they changed, and to
458 /// section files changed since, when `always`.
459 pub(crate) fn sync_index(&mut self, always: bool) {
460 let open = self.session.as_ref().map(|session| {
461 (
462 session.library.key(&session.tabs[session.tab].path),
463 Arc::downgrade(session.section.replica()),
464 )
465 });
466 let identity: Vec<usize> = self
467 .notebooks
468 .iter()
469 .map(|library| Arc::as_ptr(library) as usize)
470 .chain(open.as_ref().map(|(_, replica)| replica.as_ptr() as usize))
471 .collect();
472 if !always && identity == self.search.synced {
473 return;
474 }
475 self.search.synced = identity;
476 let _ = self.search.jobs.send(Job::Notebooks {
477 libraries: self.notebooks.clone(),
478 open,
479 });
480 }
481
482 /// Tells the index an edit changed page `space` of the open section.
483 pub(crate) fn edited(&self, spaces: Vec<ExGuid>) {
484 if let Some(session) = &self.session {
485 self.search.changed(
486 session.library.key(&session.tabs[session.tab].path),
487 session.section.replica(),
488 spaces,
489 );
490 }
491 }
492
493 /// Whether `key` names a section the search's scope takes in.
494 fn in_scope(&self, scope: Scope, key: &str) -> bool {
495 let Some(session) = &self.session else {
496 return scope == Scope::All;
497 };
498 let path = &session.tabs[session.tab].path;
499 let notebook = format!("{}\n", session.library.location);
500 match scope {
501 Scope::Section => *key == session.library.key(path),
502 Scope::Group => match path.rsplit_once('/') {
503 Some((folder, _)) => key.starts_with(&format!("{notebook}{folder}/")),
504 None => key.starts_with(&notebook),
505 },
506 Scope::Notebook => key.starts_with(&notebook),
507 Scope::All => true,
508 }
509 }
510
511 /// The results for the query in its scope, made again when either or the index changed.
512 fn refresh_results(&mut self) -> Result<(), Box<dyn Error>> {
513 let query = Query::new(&self.search.query);
514 let version = self.search.version.load(Ordering::Relaxed);
515 let open = self
516 .session
517 .as_ref()
518 .map(|session| session.library.key(&session.tabs[session.tab].path))
519 .unwrap_or_default();
520 let wanted = (query.clone(), self.search.scope, version, open);
521 if self.search.found_for.as_ref() == Some(&wanted) {
522 return Ok(());
523 }
524 let start = Instant::now();
525 let scope = self.search.scope;
526 let found = {
527 let index = self
528 .search
529 .index
530 .lock()
531 .unwrap_or_else(|error| error.into_inner());
532 index.search(&query, |key| self.in_scope(scope, key))
533 };
534 lap("search", start);
535 if self
536 .search
537 .found_for
538 .as_ref()
539 .is_none_or(|(before, ..)| *before != query)
540 {
541 // The page shown marks the matches as they are typed.
542 self.search.selected = None;
543 self.refind(false)?;
544 }
545 self.search.found = found;
546 self.search.found_for = Some(wanted);
547 Ok(())
548 }
549
550 /// Marks the query's matches on the page; with `select`, selects the match Find on
551 /// This Page is at, or the first when a result opened the page.
552 pub(crate) fn refind(&mut self, select: bool) -> Result<(), Box<dyn Error>> {
553 self.view.found = page_matches(&self.view.editor, &self.search.shown());
554 if !select {
555 return Ok(());
556 }
557 let space = self.session.as_ref().map(|session| session.space);
558 let at = if self.search.finding {
559 self.search.current = match self.search.current {
560 Some(at) if at < self.view.found.len() => Some(at),
561 _ => (!self.view.found.is_empty()).then_some(0),
562 };
563 self.search.current
564 } else if self.search.reveal.is_some() && self.search.reveal == space {
565 self.search.reveal = None;
566 (!self.view.found.is_empty()).then_some(0)
567 } else {
568 None
569 };
570 match at {
571 Some(at) => self.select_match(at),
572 None => Ok(()),
573 }
574 }
575
576 fn select_match(&mut self, at: usize) -> Result<(), Box<dyn Error>> {
577 let Some(&(outline, selection)) = self.view.found.get(at) else {
578 return Ok(());
579 };
580 self.view.editor.focus_outline(outline)?;
581 self.view.editor.select(selection)?;
582 let response = self.view.focus_text()?;
583 self.respond(response);
584 Ok(())
585 }
586
587 /// Opens the results under the box, as Ctrl+E does.
588 pub(crate) fn start_search(&mut self) {
589 self.search.finding = false;
590 if !self.ui.popup_open(results()) {
591 self.sync_index(true);
592 self.ui.open_popup(results());
593 self.search.open = true;
594 }
595 }
596
597 /// Turns the box into Find on This Page, as Ctrl+F does; from the page it starts empty.
598 pub(crate) fn start_find(&mut self, keep: bool) -> Result<(), Box<dyn Error>> {
599 if !keep {
600 self.search.query.clear();
601 }
602 self.search.open = false;
603 self.ui.close_popup(results());
604 self.search.finding = true;
605 self.search.claim = true;
606 self.search.current = None;
607 self.refind(true)
608 }
609
610 /// Ends the search or find: the box empties, the marks go and the page takes the keys,
611 /// keeping any match selected.
612 fn end_search(&mut self) {
613 self.search.open = false;
614 self.search.query.clear();
615 self.search.finding = false;
616 self.search.current = None;
617 self.search.scope = self.search.default;
618 self.view.found.clear();
619 self.ui.close_popup(results());
620 self.ui.set_focus(Some(page()));
621 }
622
623 /// Opens a result's page, whose first match is selected once it shows.
624 fn open_result(&mut self, index: usize) -> Result<(), Box<dyn Error>> {
625 let Some(found) = self.search.found.get(index) else {
626 return Ok(());
627 };
628 let (location, path) = found.section.split_once('\n').unwrap_or_default();
629 let (space, section) = (found.space, found.section.clone());
630 self.search.reveal = Some(space);
631 let open = self.session.as_ref().map(|session| {
632 (
633 session.library.key(&session.tabs[session.tab].path),
634 session.space,
635 )
636 });
637 match open {
638 Some((key, shown)) if key == section && shown == space => self.refind(true)?,
639 Some((key, _)) if key == section => self.commands.push(Command::OpenPage(space)),
640 _ => {
641 let library = self
642 .notebooks
643 .iter()
644 .find(|library| library.location == location)
645 .ok_or("The notebook is no longer open")?;
646 self.last_pages.insert(section, space);
647 self.commands
648 .push(Command::OpenSection(Arc::clone(library), path.to_owned()));
649 }
650 }
651 Ok(())
652 }
653
654 /// The search box in the tab row: OneNote's search while idle or searching, Find on
655 /// This Page while finding.
656 pub(crate) fn search_box(&mut self, theme: &Theme) -> Result<(), Box<dyn Error>> {
657 let focused = [Some(field()), Some(find_field())].contains(&self.ui.focused());
658 let searching = self.ui.popup_open(results());
659 let spec = |focused: bool| Spec {
660 size: [fill(), px(ui::shell::TOOL)],
661 fill: Some(theme.base),
662 border: Some(if focused { theme.accent } else { theme.chip }),
663 radius: 4.0,
664 pad: [4.0, 0.0],
665 gap: 2.0,
666 ..Spec::default()
667 };
668 let box_id = self.ui.open("search", spec(focused || searching));
669 if self.search.finding {
670 self.find_bar(theme)?;
671 } else if searching {
672 // The results' own field lies over the box.
673 self.ui.leaf(
674 "covered",
675 Spec {
676 size: [fill(), px(ui::shell::TOOL)],
677 ..Spec::default()
678 },
679 );
680 } else {
681 let placeholder = format!("Search {} ({SEARCH_KEY})", self.search.scope.name());
682 let signal = ui::text_field(
683 &mut self.ui,
684 field(),
685 &mut self.search.query,
686 &placeholder,
687 Spec {
688 size: [fill(), px(ui::shell::TOOL)],
689 pad: [4.0, 0.0],
690 ..Spec::default()
691 },
692 );
693 if signal.pressed && !searching {
694 self.start_search();
695 }
696 self.scope_button(theme);
697 }
698 self.ui.close();
699 // Results opened this frame build from the next, as the box's field was built.
700 if searching || !self.ui.popup_open(results()) {
701 let rect = self.ui.rect(box_id).unwrap_or_default();
702 self.results_popup(theme, rect)?;
703 }
704 self.scope_popup();
705 Ok(())
706 }
707
708 /// Whether `signal`, a field's, holds the command key with `letter`.
709 fn shortcut(&self, signal: &ui::Signal, letter: &str) -> bool {
710 self.view.modifiers().command
711 && signal.events.iter().any(|event| {
712 matches!(event, ui::Event::Key { key: winit::keyboard::Key::Character(key), .. }
713 if key.eq_ignore_ascii_case(letter))
714 })
715 }
716
717 fn scope_button(&mut self, theme: &Theme) {
718 let button = self.ui.id("scope");
719 if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, false).pressed
720 {
721 let rect = self.ui.rect(button).unwrap_or_default();
722 self.search.anchor = Some(Anchor::Below(rect));
723 self.ui.open_popup(scope_menu());
724 }
725 }
726
727 /// OneNote's "Search In:" menu.
728 fn scope_popup(&mut self) {
729 let current = self.search.scope;
730 let mut items = vec![
731 ui::popup::Item {
732 text: "Search In:",
733 heading: true,
734 ..Default::default()
735 },
736 ui::popup::Item {
737 text: "Find on This Page",
738 shortcut: FIND_KEY,
739 ..Default::default()
740 },
741 ];
742 items.extend(
743 Scope::ALL
744 .iter()
745 .enumerate()
746 .map(|(index, scope)| ui::popup::Item {
747 text: scope.name(),
748 checked: *scope == current,
749 separated: index == 0,
750 ..Default::default()
751 }),
752 );
753 items.push(ui::popup::Item {
754 text: "Set This Scope as Default",
755 separated: true,
756 disabled: current == self.search.default,
757 ..Default::default()
758 });
759 let anchor = self.search.anchor.unwrap_or(Anchor::Point([0.0, 0.0]));
760 match ui::popup::menu(&mut self.ui, scope_menu(), anchor, &items, None) {
761 Some(1) => {
762 if let Err(error) = self.start_find(true) {
763 eprintln!("{error}");
764 }
765 }
766 Some(index @ 2..=5) => {
767 self.search.scope = Scope::ALL[index - 2];
768 self.start_search();
769 }
770 Some(6) => {
771 self.search.default = current;
772 self.save_settings();
773 }
774 _ => {}
775 }
776 }
777
778 /// The dropdown of results, over the box it drops from.
779 fn results_popup(&mut self, theme: &Theme, rect: [f32; 4]) -> Result<(), Box<dyn Error>> {
780 if !self.ui.popup_open(results()) {
781 // Closed by Esc or a press elsewhere: the search ends.
782 if std::mem::take(&mut self.search.open) {
783 self.end_search();
784 }
785 return Ok(());
786 }
787 self.refresh_results()?;
788 let keys = ui::popup::navigation(
789 &mut self.ui,
790 &[field(), results()],
791 &[
792 NamedKey::ArrowUp,
793 NamedKey::ArrowDown,
794 NamedKey::PageUp,
795 NamedKey::PageDown,
796 NamedKey::Enter,
797 ],
798 );
799 if self.ui.focused() == Some(results()) {
800 self.ui.set_focus(Some(field()));
801 }
802 let [left, top, right, bottom] = rect;
803 self.ui.open_as(
804 results(),
805 Spec {
806 axis: Axis::Y,
807 size: [px(RESULTS.max(right - left)), children()],
808 fill: Some(theme.popup),
809 border: Some(theme.chip),
810 shadow: Some(theme.shadow),
811 radius: 6.0,
812 pad: [4.0, 4.0],
813 gap: 4.0,
814 anchor: Some(Anchor::Over([
815 left - 4.0,
816 top - 4.0,
817 right + 4.0,
818 bottom + 4.0,
819 ])),
820 ..Spec::default()
821 },
822 );
823 self.ui.open(
824 "box",
825 Spec {
826 size: [fill(), px(ui::shell::TOOL)],
827 fill: Some(theme.base),
828 border: Some(theme.accent),
829 radius: 4.0,
830 pad: [4.0, 0.0],
831 gap: 2.0,
832 ..Spec::default()
833 },
834 );
835 let placeholder = format!("Search {} ({SEARCH_KEY})", self.search.scope.name());
836 let signal = ui::text_field(
837 &mut self.ui,
838 field(),
839 &mut self.search.query,
840 &placeholder,
841 Spec {
842 size: [fill(), px(ui::shell::TOOL)],
843 pad: [4.0, 0.0],
844 ..Spec::default()
845 },
846 );
847 self.scope_button(theme);
848 self.ui.close();
849 if self.shortcut(&signal, "f") {
850 self.ui.close();
851 return self.start_find(true);
852 }
853 let query = self.search.query.trim().to_owned();
854 let found = &self.search.found;
855 let titled = found.iter().take_while(|found| found.in_title).count();
856 // OneNote's status line: whether the search is done, and where it looked.
857 let status = if query.is_empty() {
858 "Search In:"
859 } else if self.search.busy.load(Ordering::Relaxed) {
860 "Searching:"
861 } else if found.is_empty() {
862 "No matches:"
863 } else {
864 "Finished:"
865 };
866 let scope = if query.is_empty() || !found.is_empty() {
867 format!("{} (change)", self.search.scope.name())
868 } else {
869 self.search.scope.name().to_owned()
870 };
871 self.ui.open(
872 "status",
873 Spec {
874 size: [fill(), px(22.0)],
875 pad: [6.0, 0.0],
876 gap: 4.0,
877 ..Spec::default()
878 },
879 );
880 self.ui.leaf(
881 "state",
882 Spec {
883 size: [fit(), px(22.0)],
884 text: Some(status),
885 color: Some(theme.text_dim),
886 ..Spec::default()
887 },
888 );
889 let change = self.ui.id("scope");
890 if self
891 .ui
892 .leaf(
893 "scope",
894 Spec {
895 flags: Flags::CLICKABLE,
896 size: [fit(), px(22.0)],
897 text: Some(&scope),
898 color: Some(theme.accent),
899 ..Spec::default()
900 },
901 )
902 .clicked
903 {
904 let rect = self.ui.rect(change).unwrap_or_default();
905 self.search.anchor = Some(Anchor::Below(rect));
906 self.ui.open_popup(scope_menu());
907 }
908 self.ui.leaf(
909 "space",
910 Spec {
911 size: [fill(), px(1.0)],
912 ..Spec::default()
913 },
914 );
915 self.ui.leaf(
916 "find",
917 Spec {
918 size: [fit(), px(22.0)],
919 text: Some(&format!("Find on page: {FIND_KEY}")),
920 color: Some(theme.text_dim),
921 ..Spec::default()
922 },
923 );
924 self.ui.close();
925 let mut chosen = None;
926 if !found.is_empty() {
927 let rows = Results {
928 titled,
929 count: found.len(),
930 };
931 let content = found.len() as f32 * RESULT + ui::Rows::space_before(&rows, found.len());
932 let window = self.ui.rect(Id::ROOT).map_or(600.0, |window| window[3]);
933 let view = content.min((window - bottom - 80.0).max(RESULT));
934 let before = self.search.selected;
935 let places: Vec<String> = found
936 .iter()
937 .map(|found| place(&self.notebooks, &found.section))
938 .collect();
939 let headings = [
940 format!("Title contains: {query} ({titled})"),
941 format!("Body contains: {query} ({})", found.len() - titled),
942 ];
943 let clicked = ui::list(
944 &mut self.ui,
945 results().child("rows"),
946 Spec {
947 size: [fill(), px(view)],
948 fill: Some(theme.popup),
949 ..Spec::default()
950 },
951 ui::List {
952 rows: &rows,
953 row: RESULT,
954 keys: &keys,
955 hover_selects: true,
956 },
957 &mut self.search.selected,
958 |ui, row| {
959 let found = &found[row.index];
960 if row.index == 0 || row.index == titled {
961 let heading = if found.in_title {
962 &headings[0]
963 } else {
964 &headings[1]
965 };
966 ui.leaf(
967 "heading",
968 Spec {
969 flags: Flags::FLOAT,
970 size: [fill(), px(HEADING)],
971 position: [0.0, -HEADING],
972 text: Some(heading),
973 bold: true,
974 color: Some(theme.text),
975 pad: [6.0, 0.0],
976 ..Spec::default()
977 },
978 );
979 }
980 result_row(ui, theme, found, &places[row.index], row.selected);
981 },
982 );
983 // Arrows show the page they select, as OneNote previews it.
984 if before != self.search.selected
985 && keys.iter().any(|key| *key != NamedKey::Enter)
986 && let Some(index) = self.search.selected.map(|key| key as usize)
987 {
988 self.open_result(index)?;
989 }
990 let entered = keys.contains(&NamedKey::Enter);
991 chosen = clicked.or_else(|| {
992 self.search
993 .selected
994 .map(|key| key as usize)
995 .filter(|_| entered)
996 .or_else(|| entered.then_some(0))
997 });
998 }
999 self.ui.close();
1000 if let Some(index) = chosen {
1001 self.open_result(index)?;
1002 self.search.open = false;
1003 self.ui.close_popup(results());
1004 self.ui.set_focus(Some(page()));
1005 }
1006 Ok(())
1007 }
1008
1009 /// Find on This Page in the box: the match count with previous and next, then the field.
1010 fn find_bar(&mut self, theme: &Theme) -> Result<(), Box<dyn Error>> {
1011 let keys = ui::popup::navigation(
1012 &mut self.ui,
1013 &[find_field()],
1014 &[NamedKey::Enter, NamedKey::F3, NamedKey::Escape],
1015 );
1016 let before = self.search.query.clone();
1017 let count = self.view.found.len();
1018 let label = if self.search.query.trim().is_empty() {
1019 "Find on page".to_owned()
1020 } else if count == 0 {
1021 format!("No matches. Try {SEARCH_KEY}")
1022 } else {
1023 format!(
1024 "Match {} of {count}",
1025 self.search.current.map_or(0, |at| at + 1)
1026 )
1027 };
1028 self.ui.leaf(
1029 "count",
1030 Spec {
1031 size: [fit(), px(ui::shell::TOOL)],
1032 inset: [0.0, 3.0, 0.0, 3.0],
1033 text: Some(&label),
1034 fill: Some(draw::srgb(0xff, 0xe0, 0x5a)),
1035 color: Some(draw::srgb(0x20, 0x20, 0x20)),
1036 radius: 3.0,
1037 pad: [5.0, 0.0],
1038 ..Spec::default()
1039 },
1040 );
1041 let mut step = None;
1042 if count > 0 {
1043 if ui::shell::tool_button(&mut self.ui, "previous", art::CHEVRON_UP, theme.text, false)
1044 .clicked
1045 {
1046 step = Some(false);
1047 }
1048 if ui::shell::tool_button(&mut self.ui, "next", ui::shell::CHEVRON, theme.text, false)
1049 .clicked
1050 {
1051 step = Some(true);
1052 }
1053 }
1054 let signal = ui::text_field(
1055 &mut self.ui,
1056 find_field(),
1057 &mut self.search.query,
1058 "",
1059 Spec {
1060 size: [fill(), px(ui::shell::TOOL)],
1061 pad: [4.0, 0.0],
1062 ..Spec::default()
1063 },
1064 );
1065 if std::mem::take(&mut self.search.claim) {
1066 self.ui.set_focus(Some(find_field()));
1067 }
1068 if self.shortcut(&signal, "e") {
1069 self.start_search();
1070 return Ok(());
1071 }
1072 if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, false).clicked
1073 {
1074 self.end_search();
1075 return Ok(());
1076 }
1077 let shift = self.view.modifiers().shift;
1078 for key in keys {
1079 match key {
1080 NamedKey::Escape => {
1081 self.end_search();
1082 return Ok(());
1083 }
1084 _ => step = Some(!shift),
1085 }
1086 }
1087 if self.search.query != before {
1088 self.search.current = None;
1089 self.refind(true)?;
1090 } else if let Some(forward) = step
1091 && count > 0
1092 {
1093 let at = self.search.current.unwrap_or(0);
1094 let next = if forward {
1095 (at + 1) % count
1096 } else {
1097 (at + count - 1) % count
1098 };
1099 self.search.current = Some(next);
1100 self.select_match(next)?;
1101 }
1102 Ok(())
1103 }
1104}
1105
1106/// The results list's rows: those whose titles hold every word, then the others, each
1107/// group under its heading.
1108struct Results {
1109 titled: usize,
1110 count: usize,
1111}
1112
1113impl ui::Rows for Results {
1114 fn count(&self) -> usize {
1115 self.count
1116 }
1117
1118 fn key(&self, index: usize) -> u64 {
1119 index as u64
1120 }
1121
1122 fn find(&self, key: u64) -> Option<usize> {
1123 ((key as usize) < self.count).then_some(key as usize)
1124 }
1125
1126 fn space_before(&self, index: usize) -> f32 {
1127 let headings = usize::from(self.count > 0)
1128 + usize::from(0 < self.titled && self.titled < self.count && index >= self.titled);
1129 headings as f32 * HEADING
1130 }
1131}
1132
1133/// A result: its page's title and section, and the snippet around its first match.
1134fn result_row(ui: &mut Ui, theme: &Theme, found: &Found, place: &str, selected: bool) {
1135 // On the highlight every line takes the text's colour, as menus do.
1136 let dim = if selected { theme.text } else { theme.text_dim };
1137 ui.open(
1138 "result",
1139 Spec {
1140 size: [fill(), fill()],
1141 fill: selected.then(|| theme.hover()),
1142 radius: 4.0,
1143 pad: [6.0, 3.0],
1144 gap: 6.0,
1145 ..Spec::default()
1146 },
1147 );
1148 ui.leaf(
1149 "icon",
1150 Spec {
1151 size: [px(16.0), px(18.0)],
1152 icon: Some(art::PAGE),
1153 color: Some(dim),
1154 ..Spec::default()
1155 },
1156 );
1157 ui.open(
1158 "lines",
1159 Spec {
1160 axis: Axis::Y,
1161 size: [fill(), fill()],
1162 ..Spec::default()
1163 },
1164 );
1165 ui.open(
1166 "top",
1167 Spec {
1168 size: [fill(), px(18.0)],
1169 gap: 8.0,
1170 ..Spec::default()
1171 },
1172 );
1173 let title = if found.title.trim().is_empty() {
1174 "Untitled page"
1175 } else {
1176 found.title.as_str()
1177 };
1178 marked(
1179 ui,
1180 "title",
1181 title,
1182 &found.title_hits,
1183 Spec {
1184 size: [fill(), px(18.0)],
1185 color: Some(theme.text),
1186 ..Spec::default()
1187 },
1188 );
1189 ui.leaf(
1190 "place",
1191 Spec {
1192 size: [px(PLACE), px(18.0)],
1193 text: Some(place),
1194 color: Some(dim),
1195 ..Spec::default()
1196 },
1197 );
1198 ui.close();
1199 marked(
1200 ui,
1201 "snippet",
1202 &found.snippet,
1203 &found.snippet_hits,
1204 Spec {
1205 size: [fill(), px(18.0)],
1206 color: Some(dim),
1207 ..Spec::default()
1208 },
1209 );
1210 ui.close();
1211 ui.close();
1212}
crates/snowbound/src/settings.rs+41-4
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1//! What the app keeps between launches: the open notebooks, the sidebar and recent fonts,1//! What the app keeps between launches: the open notebooks, the sidebar, recent fonts and
2//! as JSON in the platform's settings directory.2//! the Options dialog's choices, as JSON in the platform's settings directory.
33
4use serde::{Deserialize, Serialize};4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};5use std::path::{Path, PathBuf};
...@@ -15,6 +15,35 @@ pub struct Settings {...@@ -15,6 +15,35 @@ pub struct Settings {
15 pub sidebar: bool,15 pub sidebar: bool,
16 /// Fonts picked from the font box, latest first.16 /// Fonts picked from the font box, latest first.
17 pub recent_fonts: Vec<String>,17 pub recent_fonts: Vec<String>,
18 /// The name edits are stored under, OneNote's User name; the account's full name until
19 /// first saved.
20 pub user_name: Option<String>,
21 pub color_scheme: ColorScheme,
22 /// Keeps pages white in the Dark color scheme.
23 pub light_pages: bool,
24 /// Where searches look first, as "Set This Scope as Default" chose.
25 pub search_scope: crate::search::Scope,
26}
27
28/// The interface's colours: the system's appearance, or one chosen in Options.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum ColorScheme {
32 #[default]
33 System,
34 Light,
35 Dark,
36}
37
38impl ColorScheme {
39 /// The appearance the window is held to; none follows the system.
40 pub fn theme(self) -> Option<winit::window::Theme> {
41 match self {
42 Self::System => None,
43 Self::Light => Some(winit::window::Theme::Light),
44 Self::Dark => Some(winit::window::Theme::Dark),
45 }
46 }
18}47}
1948
20impl Settings {49impl Settings {
...@@ -45,8 +74,8 @@ impl Settings {...@@ -45,8 +74,8 @@ impl Settings {
45}74}
4675
47impl crate::State {76impl crate::State {
48 /// Keeps what the next launch restores: the open notebooks, the one shown, the sidebar77 /// Keeps what the next launch restores: the open notebooks, the one shown, the sidebar,
49 /// and recent fonts. A section opened on its own is not kept.78 /// recent fonts and the Options dialog's choices. A section opened on its own is not kept.
50 pub(crate) fn save_settings(&self) {79 pub(crate) fn save_settings(&self) {
51 let Some(path) = &self.settings else {80 let Some(path) = &self.settings else {
52 return;81 return;
...@@ -68,6 +97,10 @@ impl crate::State {...@@ -68,6 +97,10 @@ impl crate::State {
68 .map(|library| library.location.clone()),97 .map(|library| library.location.clone()),
69 sidebar: self.sidebar,98 sidebar: self.sidebar,
70 recent_fonts: self.recent_fonts.clone(),99 recent_fonts: self.recent_fonts.clone(),
100 user_name: Some(self.author.clone()),
101 color_scheme: self.color_scheme,
102 light_pages: self.light_pages,
103 search_scope: self.search.default,
71 };104 };
72 if let Err(error) = settings.save(path) {105 if let Err(error) = settings.save(path) {
73 eprintln!("Cannot save the settings in {}: {error}", path.display());106 eprintln!("Cannot save the settings in {}: {error}", path.display());
...@@ -107,6 +140,10 @@ mod tests {...@@ -107,6 +140,10 @@ mod tests {
107 current: Some("/notebooks/Personal".into()),140 current: Some("/notebooks/Personal".into()),
108 sidebar: true,141 sidebar: true,
109 recent_fonts: vec!["Georgia".into()],142 recent_fonts: vec!["Georgia".into()],
143 user_name: Some("Snowbound Test".into()),
144 color_scheme: ColorScheme::Dark,
145 light_pages: true,
146 search_scope: crate::search::Scope::Notebook,
110 };147 };
111 settings.save(&path).unwrap();148 settings.save(&path).unwrap();
112 assert_eq!(Settings::load(&path), settings);149 assert_eq!(Settings::load(&path), settings);
crates/snowbound/src/sidebar.rs+8-1
...@@ -30,6 +30,8 @@ pub enum Action {...@@ -30,6 +30,8 @@ pub enum Action {
30 Fold(String),30 Fold(String),
31 NewNotebook,31 NewNotebook,
32 OpenNotebook,32 OpenNotebook,
33 /// Opens the Options dialog, which macOS reaches from the application menu instead.
34 Options,
33 /// A row's context menu, opened here.35 /// A row's context menu, opened here.
34 Menu(Target, [f32; 2]),36 Menu(Target, [f32; 2]),
35 /// Ends renaming, with the name typed or without.37 /// Ends renaming, with the name typed or without.
...@@ -179,10 +181,14 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32...@@ -179,10 +181,14 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
179 ..Spec::default()181 ..Spec::default()
180 },182 },
181 );183 );
184 let options = ("options", art::OPTIONS, "Options", Action::Options);
182 for (part, icon, label, chosen) in [185 for (part, icon, label, chosen) in [
183 ("new", art::PLUS, "New Notebook", Action::NewNotebook),186 ("new", art::PLUS, "New Notebook", Action::NewNotebook),
184 ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook),187 ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook),
185 ] {188 ]
189 .into_iter()
190 .chain((!cfg!(target_os = "macos")).then_some(options))
191 {
186 let (row, _) = tree_row(192 let (row, _) = tree_row(
187 ui,193 ui,
188 tree,194 tree,
...@@ -555,6 +561,7 @@ impl crate::State {...@@ -555,6 +561,7 @@ impl crate::State {
555 }561 }
556 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),562 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),
557 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),563 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),
564 Some(Action::Options) => self.open_options(),
558 None => {}565 None => {}
559 }566 }
560 }567 }
crates/ui/src/lib.rs+2-2
...@@ -13,7 +13,7 @@ mod widgets;...@@ -13,7 +13,7 @@ mod widgets;
1313
14pub use list::{List, Row, Rows, list};14pub use list::{List, Row, Rows, list};
15pub use theme::{Section, Shades, Theme};15pub use theme::{Section, Shades, Theme};
16pub use widgets::{button, edit_key, edit_modifiers, scrollbar, text_field};16pub use widgets::{button, check_box, edit_key, edit_modifiers, scrollbar, text_field};
1717
18use draw::{18use draw::{
19 PathStyle, Primitive, RasterImage, Stroke,19 PathStyle, Primitive, RasterImage, Stroke,
...@@ -521,7 +521,7 @@ impl Ui {...@@ -521,7 +521,7 @@ impl Ui {
521 self.scale521 self.scale
522 }522 }
523523
524 pub(crate) fn modifiers(&self) -> ModifiersState {524 pub fn modifiers(&self) -> ModifiersState {
525 self.modifiers525 self.modifiers
526 }526 }
527527
crates/ui/src/popup.rs+2-2
...@@ -13,7 +13,7 @@ use nucleo_matcher::{...@@ -13,7 +13,7 @@ use nucleo_matcher::{
13use std::cell::RefCell;13use std::cell::RefCell;
14use winit::keyboard::{Key, NamedKey};14use winit::keyboard::{Key, NamedKey};
1515
16const CHECK: &[&str] = &[include_str!("../assets/check.svg")];16pub(crate) const CHECK: &[&str] = &[include_str!("../assets/check.svg")];
17/// Inset of a popup's contents, and its distance from what it drops down from.17/// Inset of a popup's contents, and its distance from what it drops down from.
18const PAD: f32 = 4.0;18const PAD: f32 = 4.0;
19const ROW: f32 = 26.0;19const ROW: f32 = 26.0;
...@@ -498,7 +498,7 @@ fn state(ui: &mut Ui, id: Id) -> &mut Popup {...@@ -498,7 +498,7 @@ fn state(ui: &mut Ui, id: Id) -> &mut Popup {
498}498}
499499
500/// Takes the presses of `keys` routed this frame to the focus when it is one of `owners`.500/// Takes the presses of `keys` routed this frame to the focus when it is one of `owners`.
501fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec<NamedKey> {501pub fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec<NamedKey> {
502 let Some(signal) = ui502 let Some(signal) = ui
503 .focus503 .focus
504 .filter(|focus| owners.contains(focus))504 .filter(|focus| owners.contains(focus))
crates/ui/src/widgets.rs+45
...@@ -29,6 +29,51 @@ pub fn button(ui: &mut Ui, part: impl Hash, text: &str) -> Signal {...@@ -29,6 +29,51 @@ pub fn button(ui: &mut Ui, part: impl Hash, text: &str) -> Signal {
29 ui.leaf(part, spec)29 ui.leaf(part, spec)
30}30}
3131
32/// A check box before `label`, `checked` or not; a click on either reports for the caller
33/// to toggle.
34pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Signal {
35 let theme = ui.theme.clone();
36 let [height, side] = [theme.font_size * 2.0, 16.0];
37 let margin = (height - side) / 2.0;
38 let id = ui.open(
39 part,
40 Spec {
41 flags: Flags::CLICKABLE,
42 size: [crate::children(), px(height)],
43 gap: 8.0,
44 ..Spec::default()
45 },
46 );
47 let hovered = ui.signal(id).hovered;
48 ui.leaf(
49 "box",
50 Spec {
51 size: [px(side), px(height)],
52 inset: [0.0, margin, 0.0, margin],
53 fill: Some(if checked { theme.accent } else { theme.base }),
54 border: Some(if checked || hovered {
55 theme.accent
56 } else {
57 theme.chip
58 }),
59 radius: 3.0,
60 icon: checked.then_some(crate::popup::CHECK),
61 color: Some([1.0; 4]),
62 ..Spec::default()
63 },
64 );
65 ui.leaf(
66 "label",
67 Spec {
68 size: [fit(), px(height)],
69 text: Some(label),
70 ..Spec::default()
71 },
72 );
73 ui.close();
74 ui.signal(id)
75}
76
32/// An overlay scrollbar along the far edge of the current box, for content whose scroll77/// An overlay scrollbar along the far edge of the current box, for content whose scroll
33/// offset ranges over `range` while `view` of it shows, all in one unit. Returns the offset78/// offset ranges over `range` while `view` of it shows, all in one unit. Returns the offset
34/// a drag of its thumb chose.79/// a drag of its thumb chose.
fuzz/Cargo.lock+16
...@@ -82,6 +82,7 @@ name = "canvas"...@@ -82,6 +82,7 @@ name = "canvas"
82version = "0.1.0"82version = "0.1.0"
83dependencies = [83dependencies = [
84 "draw",84 "draw",
85 "icu_normalizer",
85 "onestore",86 "onestore",
86 "parley",87 "parley",
87 "serde",88 "serde",
...@@ -392,6 +393,9 @@ dependencies = [...@@ -392,6 +393,9 @@ dependencies = [
392 "icu_properties",393 "icu_properties",
393 "icu_provider",394 "icu_provider",
394 "smallvec",395 "smallvec",
396 "utf16_iter",
397 "utf8_iter",
398 "write16",
395 "zerovec",399 "zerovec",
396]400]
397401
...@@ -1030,6 +1034,12 @@ version = "1.0.24"...@@ -1030,6 +1034,12 @@ version = "1.0.24"
1030source = "registry+https://github.com/rust-lang/crates.io-index"1034source = "registry+https://github.com/rust-lang/crates.io-index"
1031checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"1035checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
10321036
1037[[package]]
1038name = "utf16_iter"
1039version = "1.0.5"
1040source = "registry+https://github.com/rust-lang/crates.io-index"
1041checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
1042
1033[[package]]1043[[package]]
1034name = "utf8_iter"1044name = "utf8_iter"
1035version = "1.0.4"1045version = "1.0.4"
...@@ -1206,6 +1216,12 @@ dependencies = [...@@ -1206,6 +1216,12 @@ dependencies = [
1206 "windows-link",1216 "windows-link",
1207]1217]
12081218
1219[[package]]
1220name = "write16"
1221version = "1.0.0"
1222source = "registry+https://github.com/rust-lang/crates.io-index"
1223checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
1224
1209[[package]]1225[[package]]
1210name = "writeable"1226name = "writeable"
1211version = "0.6.4"1227version = "0.6.4"
platform/snow-leopard/Cargo.lock created+1907
...@@ -0,0 +1,1907 @@
1# This file is automatically @generated by Cargo.
2# It is not intended for manual editing.
3version = 4
4
5[[package]]
6name = "adler2"
7version = "2.0.1"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10
11[[package]]
12name = "allocator-api2"
13version = "0.2.21"
14source = "registry+https://github.com/rust-lang/crates.io-index"
15checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
16
17[[package]]
18name = "android_system_properties"
19version = "0.1.6"
20source = "registry+https://github.com/rust-lang/crates.io-index"
21checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
22dependencies = [
23 "libc",
24]
25
26[[package]]
27name = "arrayvec"
28version = "0.7.8"
29source = "registry+https://github.com/rust-lang/crates.io-index"
30checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
31
32[[package]]
33name = "ash"
34version = "0.38.0+1.3.281"
35source = "registry+https://github.com/rust-lang/crates.io-index"
36checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
37dependencies = [
38 "libloading",
39]
40
41[[package]]
42name = "autocfg"
43version = "1.5.1"
44source = "registry+https://github.com/rust-lang/crates.io-index"
45checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
46
47[[package]]
48name = "bit-set"
49version = "0.10.0"
50source = "registry+https://github.com/rust-lang/crates.io-index"
51checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
52dependencies = [
53 "bit-vec",
54]
55
56[[package]]
57name = "bit-vec"
58version = "0.9.1"
59source = "registry+https://github.com/rust-lang/crates.io-index"
60checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
61
62[[package]]
63name = "bitflags"
64version = "2.13.1"
65source = "registry+https://github.com/rust-lang/crates.io-index"
66checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
67
68[[package]]
69name = "block2"
70version = "0.6.2"
71source = "registry+https://github.com/rust-lang/crates.io-index"
72checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
73dependencies = [
74 "objc2",
75]
76
77[[package]]
78name = "bumpalo"
79version = "3.20.3"
80source = "registry+https://github.com/rust-lang/crates.io-index"
81checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
82
83[[package]]
84name = "bytemuck"
85version = "1.25.2"
86source = "registry+https://github.com/rust-lang/crates.io-index"
87checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
88dependencies = [
89 "bytemuck_derive",
90]
91
92[[package]]
93name = "bytemuck_derive"
94version = "1.12.1"
95source = "registry+https://github.com/rust-lang/crates.io-index"
96checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
97dependencies = [
98 "proc-macro2",
99 "quote",
100 "syn 3.0.6",
101]
102
103[[package]]
104name = "byteorder-lite"
105version = "0.1.0"
106source = "registry+https://github.com/rust-lang/crates.io-index"
107checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
108
109[[package]]
110name = "cfg-if"
111version = "1.0.5"
112source = "registry+https://github.com/rust-lang/crates.io-index"
113checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
114
115[[package]]
116name = "cfg_aliases"
117version = "0.2.2"
118source = "registry+https://github.com/rust-lang/crates.io-index"
119checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
120
121[[package]]
122name = "codespan-reporting"
123version = "0.13.1"
124source = "registry+https://github.com/rust-lang/crates.io-index"
125checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
126dependencies = [
127 "serde",
128 "termcolor",
129 "unicode-width",
130]
131
132[[package]]
133name = "color_quant"
134version = "1.1.0"
135source = "registry+https://github.com/rust-lang/crates.io-index"
136checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
137
138[[package]]
139name = "crc32fast"
140version = "1.5.2"
141source = "registry+https://github.com/rust-lang/crates.io-index"
142checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
143dependencies = [
144 "cfg-if",
145]
146
147[[package]]
148name = "crunchy"
149version = "0.2.4"
150source = "registry+https://github.com/rust-lang/crates.io-index"
151checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
152
153[[package]]
154name = "dispatch2"
155version = "0.3.1"
156source = "registry+https://github.com/rust-lang/crates.io-index"
157checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
158dependencies = [
159 "bitflags",
160 "objc2",
161]
162
163[[package]]
164name = "displaydoc"
165version = "0.2.7"
166source = "registry+https://github.com/rust-lang/crates.io-index"
167checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
168dependencies = [
169 "proc-macro2",
170 "quote",
171 "syn 3.0.6",
172]
173
174[[package]]
175name = "dlib"
176version = "0.5.3"
177source = "registry+https://github.com/rust-lang/crates.io-index"
178checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
179dependencies = [
180 "libloading",
181]
182
183[[package]]
184name = "document-features"
185version = "0.2.12"
186source = "registry+https://github.com/rust-lang/crates.io-index"
187checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
188dependencies = [
189 "litrs",
190]
191
192[[package]]
193name = "draw"
194version = "0.1.0"
195dependencies = [
196 "bytemuck",
197 "image",
198 "linebender_resource_handle",
199 "parley",
200 "roxmltree",
201 "swash",
202 "wgpu",
203]
204
205[[package]]
206name = "equivalent"
207version = "1.0.2"
208source = "registry+https://github.com/rust-lang/crates.io-index"
209checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
210
211[[package]]
212name = "fax"
213version = "0.2.7"
214source = "registry+https://github.com/rust-lang/crates.io-index"
215checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
216
217[[package]]
218name = "fdeflate"
219version = "0.3.7"
220source = "registry+https://github.com/rust-lang/crates.io-index"
221checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
222dependencies = [
223 "simd-adler32",
224]
225
226[[package]]
227name = "flate2"
228version = "1.1.10"
229source = "registry+https://github.com/rust-lang/crates.io-index"
230checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
231dependencies = [
232 "crc32fast",
233 "miniz_oxide 0.9.1",
234 "zlib-rs",
235]
236
237[[package]]
238name = "foldhash"
239version = "0.2.0"
240source = "registry+https://github.com/rust-lang/crates.io-index"
241checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
242
243[[package]]
244name = "font-types"
245version = "0.12.5"
246source = "registry+https://github.com/rust-lang/crates.io-index"
247checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
248dependencies = [
249 "bytemuck",
250]
251
252[[package]]
253name = "fontique"
254version = "0.11.0"
255source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
256dependencies = [
257 "hashbrown 0.17.1",
258 "linebender_resource_handle",
259 "memmap2",
260 "objc2",
261 "objc2-core-foundation",
262 "objc2-core-text",
263 "objc2-foundation",
264 "parlance",
265 "read-fonts",
266 "roxmltree",
267 "smallvec",
268 "windows",
269 "windows-core",
270 "yeslogic-fontconfig-sys",
271]
272
273[[package]]
274name = "futures-core"
275version = "0.3.34"
276source = "registry+https://github.com/rust-lang/crates.io-index"
277checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
278
279[[package]]
280name = "futures-task"
281version = "0.3.34"
282source = "registry+https://github.com/rust-lang/crates.io-index"
283checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
284
285[[package]]
286name = "futures-util"
287version = "0.3.34"
288source = "registry+https://github.com/rust-lang/crates.io-index"
289checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
290dependencies = [
291 "futures-core",
292 "futures-task",
293 "pin-project-lite",
294 "slab",
295]
296
297[[package]]
298name = "gif"
299version = "0.14.2"
300source = "registry+https://github.com/rust-lang/crates.io-index"
301checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
302dependencies = [
303 "color_quant",
304 "weezl",
305]
306
307[[package]]
308name = "gl21"
309version = "0.0.0"
310
311[[package]]
312name = "gl_generator"
313version = "0.14.0"
314source = "registry+https://github.com/rust-lang/crates.io-index"
315checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
316dependencies = [
317 "khronos_api",
318 "log",
319 "xml-rs",
320]
321
322[[package]]
323name = "glow"
324version = "0.17.0"
325source = "registry+https://github.com/rust-lang/crates.io-index"
326checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
327dependencies = [
328 "js-sys",
329 "slotmap",
330 "wasm-bindgen",
331 "web-sys",
332]
333
334[[package]]
335name = "glutin_wgl_sys"
336version = "0.6.1"
337source = "registry+https://github.com/rust-lang/crates.io-index"
338checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
339dependencies = [
340 "gl_generator",
341]
342
343[[package]]
344name = "gpu-allocator"
345version = "0.28.0"
346source = "registry+https://github.com/rust-lang/crates.io-index"
347checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
348dependencies = [
349 "ash",
350 "hashbrown 0.16.1",
351 "log",
352 "presser",
353 "thiserror",
354 "windows",
355]
356
357[[package]]
358name = "half"
359version = "2.7.1"
360source = "registry+https://github.com/rust-lang/crates.io-index"
361checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
362dependencies = [
363 "cfg-if",
364 "crunchy",
365 "num-traits",
366 "zerocopy",
367]
368
369[[package]]
370name = "harfrust"
371version = "0.12.0"
372source = "registry+https://github.com/rust-lang/crates.io-index"
373checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
374dependencies = [
375 "bitflags",
376 "bytemuck",
377 "read-fonts",
378 "smallvec",
379]
380
381[[package]]
382name = "hashbrown"
383version = "0.16.1"
384source = "registry+https://github.com/rust-lang/crates.io-index"
385checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
386dependencies = [
387 "allocator-api2",
388 "equivalent",
389 "foldhash",
390]
391
392[[package]]
393name = "hashbrown"
394version = "0.17.1"
395source = "registry+https://github.com/rust-lang/crates.io-index"
396checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
397dependencies = [
398 "foldhash",
399]
400
401[[package]]
402name = "hello-snow-leopard"
403version = "0.0.0"
404
405[[package]]
406name = "icu_collections"
407version = "2.3.0"
408source = "registry+https://github.com/rust-lang/crates.io-index"
409checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
410dependencies = [
411 "displaydoc",
412 "potential_utf",
413 "utf8_iter",
414 "yoke",
415 "zerofrom",
416 "zerovec",
417]
418
419[[package]]
420name = "icu_locale_core"
421version = "2.3.0"
422source = "registry+https://github.com/rust-lang/crates.io-index"
423checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
424dependencies = [
425 "displaydoc",
426 "litemap",
427 "serde",
428 "tinystr",
429 "writeable",
430 "zerovec",
431]
432
433[[package]]
434name = "icu_locale_fallback"
435version = "2.3.0"
436source = "registry+https://github.com/rust-lang/crates.io-index"
437checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
438dependencies = [
439 "icu_locale_core",
440 "icu_locale_fallback_data",
441 "icu_provider",
442 "potential_utf",
443 "tinystr",
444 "zerovec",
445]
446
447[[package]]
448name = "icu_locale_fallback_data"
449version = "2.3.0"
450source = "registry+https://github.com/rust-lang/crates.io-index"
451checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
452
453[[package]]
454name = "icu_normalizer"
455version = "2.3.0"
456source = "registry+https://github.com/rust-lang/crates.io-index"
457checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
458dependencies = [
459 "icu_collections",
460 "icu_normalizer_data",
461 "icu_properties",
462 "icu_provider",
463 "smallvec",
464 "zerovec",
465]
466
467[[package]]
468name = "icu_normalizer_data"
469version = "2.3.0"
470source = "registry+https://github.com/rust-lang/crates.io-index"
471checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
472
473[[package]]
474name = "icu_properties"
475version = "2.3.0"
476source = "registry+https://github.com/rust-lang/crates.io-index"
477checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
478dependencies = [
479 "displaydoc",
480 "icu_collections",
481 "icu_locale_core",
482 "icu_properties_data",
483 "icu_provider",
484 "zerotrie",
485 "zerovec",
486]
487
488[[package]]
489name = "icu_properties_data"
490version = "2.3.0"
491source = "registry+https://github.com/rust-lang/crates.io-index"
492checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
493
494[[package]]
495name = "icu_provider"
496version = "2.3.1"
497source = "registry+https://github.com/rust-lang/crates.io-index"
498checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
499dependencies = [
500 "displaydoc",
501 "icu_locale_core",
502 "serde",
503 "stable_deref_trait",
504 "writeable",
505 "yoke",
506 "zerofrom",
507 "zerotrie",
508 "zerovec",
509]
510
511[[package]]
512name = "icu_segmenter"
513version = "2.3.0"
514source = "registry+https://github.com/rust-lang/crates.io-index"
515checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
516dependencies = [
517 "icu_collections",
518 "icu_locale_fallback",
519 "icu_provider",
520 "icu_segmenter_data",
521 "potential_utf",
522 "smallvec",
523 "utf8_iter",
524 "zerovec",
525]
526
527[[package]]
528name = "icu_segmenter_data"
529version = "2.3.0"
530source = "registry+https://github.com/rust-lang/crates.io-index"
531checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
532
533[[package]]
534name = "image"
535version = "0.25.10"
536source = "registry+https://github.com/rust-lang/crates.io-index"
537checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
538dependencies = [
539 "bytemuck",
540 "byteorder-lite",
541 "color_quant",
542 "gif",
543 "moxcms",
544 "num-traits",
545 "png",
546 "tiff",
547 "zune-core",
548 "zune-jpeg",
549]
550
551[[package]]
552name = "indexmap"
553version = "2.14.2"
554source = "registry+https://github.com/rust-lang/crates.io-index"
555checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
556dependencies = [
557 "equivalent",
558 "hashbrown 0.17.1",
559]
560
561[[package]]
562name = "jni-sys"
563version = "0.3.1"
564source = "registry+https://github.com/rust-lang/crates.io-index"
565checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
566dependencies = [
567 "jni-sys 0.4.1",
568]
569
570[[package]]
571name = "jni-sys"
572version = "0.4.1"
573source = "registry+https://github.com/rust-lang/crates.io-index"
574checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
575dependencies = [
576 "jni-sys-macros",
577]
578
579[[package]]
580name = "jni-sys-macros"
581version = "0.4.1"
582source = "registry+https://github.com/rust-lang/crates.io-index"
583checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
584dependencies = [
585 "quote",
586 "syn 2.0.119",
587]
588
589[[package]]
590name = "js-sys"
591version = "0.3.105"
592source = "registry+https://github.com/rust-lang/crates.io-index"
593checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
594dependencies = [
595 "cfg-if",
596 "futures-util",
597 "wasm-bindgen",
598]
599
600[[package]]
601name = "khronos-egl"
602version = "6.0.0"
603source = "registry+https://github.com/rust-lang/crates.io-index"
604checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
605dependencies = [
606 "libc",
607 "libloading",
608 "pkg-config",
609]
610
611[[package]]
612name = "khronos_api"
613version = "3.1.0"
614source = "registry+https://github.com/rust-lang/crates.io-index"
615checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
616
617[[package]]
618name = "libc"
619version = "0.2.189"
620source = "registry+https://github.com/rust-lang/crates.io-index"
621checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
622
623[[package]]
624name = "libloading"
625version = "0.8.9"
626source = "registry+https://github.com/rust-lang/crates.io-index"
627checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
628dependencies = [
629 "cfg-if",
630 "windows-link",
631]
632
633[[package]]
634name = "libm"
635version = "0.2.16"
636source = "registry+https://github.com/rust-lang/crates.io-index"
637checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
638
639[[package]]
640name = "linebender_resource_handle"
641version = "0.1.1"
642source = "registry+https://github.com/rust-lang/crates.io-index"
643checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
644
645[[package]]
646name = "litemap"
647version = "0.8.3"
648source = "registry+https://github.com/rust-lang/crates.io-index"
649checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
650
651[[package]]
652name = "litrs"
653version = "1.0.0"
654source = "registry+https://github.com/rust-lang/crates.io-index"
655checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
656
657[[package]]
658name = "lock_api"
659version = "0.4.14"
660source = "registry+https://github.com/rust-lang/crates.io-index"
661checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
662dependencies = [
663 "scopeguard",
664]
665
666[[package]]
667name = "log"
668version = "0.4.34"
669source = "registry+https://github.com/rust-lang/crates.io-index"
670checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
671
672[[package]]
673name = "memchr"
674version = "2.8.3"
675source = "registry+https://github.com/rust-lang/crates.io-index"
676checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
677
678[[package]]
679name = "memmap2"
680version = "0.9.11"
681source = "registry+https://github.com/rust-lang/crates.io-index"
682checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
683dependencies = [
684 "libc",
685]
686
687[[package]]
688name = "miniz_oxide"
689version = "0.8.9"
690source = "registry+https://github.com/rust-lang/crates.io-index"
691checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
692dependencies = [
693 "adler2",
694 "simd-adler32",
695]
696
697[[package]]
698name = "miniz_oxide"
699version = "0.9.1"
700source = "registry+https://github.com/rust-lang/crates.io-index"
701checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
702dependencies = [
703 "adler2",
704 "simd-adler32",
705]
706
707[[package]]
708name = "moxcms"
709version = "0.8.1"
710source = "registry+https://github.com/rust-lang/crates.io-index"
711checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
712dependencies = [
713 "num-traits",
714 "pxfm",
715]
716
717[[package]]
718name = "naga"
719version = "30.0.1"
720source = "registry+https://github.com/rust-lang/crates.io-index"
721checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
722dependencies = [
723 "arrayvec",
724 "bit-set",
725 "bitflags",
726 "cfg-if",
727 "cfg_aliases",
728 "codespan-reporting",
729 "half",
730 "hashbrown 0.17.1",
731 "indexmap",
732 "libm",
733 "log",
734 "naga-types",
735 "num-traits",
736 "once_cell",
737 "rustc-hash",
738 "spirv",
739 "thiserror",
740 "unicode-ident",
741]
742
743[[package]]
744name = "naga-types"
745version = "30.0.1"
746source = "registry+https://github.com/rust-lang/crates.io-index"
747checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
748dependencies = [
749 "hashbrown 0.17.1",
750 "indexmap",
751 "rustc-hash",
752 "thiserror",
753]
754
755[[package]]
756name = "ndk-sys"
757version = "0.6.0+11769913"
758source = "registry+https://github.com/rust-lang/crates.io-index"
759checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
760dependencies = [
761 "jni-sys 0.3.1",
762]
763
764[[package]]
765name = "num-traits"
766version = "0.2.19"
767source = "registry+https://github.com/rust-lang/crates.io-index"
768checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
769dependencies = [
770 "autocfg",
771 "libm",
772]
773
774[[package]]
775name = "objc2"
776version = "0.6.4"
777source = "registry+https://github.com/rust-lang/crates.io-index"
778checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
779dependencies = [
780 "objc2-encode",
781]
782
783[[package]]
784name = "objc2-app-kit"
785version = "0.3.2"
786source = "registry+https://github.com/rust-lang/crates.io-index"
787checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
788dependencies = [
789 "bitflags",
790 "objc2",
791 "objc2-foundation",
792]
793
794[[package]]
795name = "objc2-core-foundation"
796version = "0.3.2"
797source = "registry+https://github.com/rust-lang/crates.io-index"
798checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
799dependencies = [
800 "bitflags",
801 "dispatch2",
802 "objc2",
803]
804
805[[package]]
806name = "objc2-core-graphics"
807version = "0.3.2"
808source = "registry+https://github.com/rust-lang/crates.io-index"
809checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
810dependencies = [
811 "dispatch2",
812 "objc2",
813 "objc2-core-foundation",
814 "objc2-io-surface",
815]
816
817[[package]]
818name = "objc2-core-text"
819version = "0.3.2"
820source = "registry+https://github.com/rust-lang/crates.io-index"
821checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
822dependencies = [
823 "bitflags",
824 "objc2-core-foundation",
825]
826
827[[package]]
828name = "objc2-encode"
829version = "4.1.0"
830source = "registry+https://github.com/rust-lang/crates.io-index"
831checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
832
833[[package]]
834name = "objc2-foundation"
835version = "0.3.2"
836source = "registry+https://github.com/rust-lang/crates.io-index"
837checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
838dependencies = [
839 "bitflags",
840 "objc2",
841 "objc2-core-foundation",
842]
843
844[[package]]
845name = "objc2-io-surface"
846version = "0.3.2"
847source = "registry+https://github.com/rust-lang/crates.io-index"
848checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
849dependencies = [
850 "bitflags",
851 "objc2",
852 "objc2-core-foundation",
853]
854
855[[package]]
856name = "objc2-metal"
857version = "0.3.2"
858source = "registry+https://github.com/rust-lang/crates.io-index"
859checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
860dependencies = [
861 "bitflags",
862 "block2",
863 "objc2",
864 "objc2-foundation",
865]
866
867[[package]]
868name = "objc2-quartz-core"
869version = "0.3.2"
870source = "registry+https://github.com/rust-lang/crates.io-index"
871checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
872dependencies = [
873 "bitflags",
874 "objc2",
875 "objc2-core-foundation",
876 "objc2-core-graphics",
877 "objc2-foundation",
878 "objc2-metal",
879]
880
881[[package]]
882name = "once_cell"
883version = "1.21.4"
884source = "registry+https://github.com/rust-lang/crates.io-index"
885checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
886
887[[package]]
888name = "ordered-float"
889version = "5.5.0"
890source = "registry+https://github.com/rust-lang/crates.io-index"
891checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
892dependencies = [
893 "num-traits",
894]
895
896[[package]]
897name = "parking_lot"
898version = "0.12.5"
899source = "registry+https://github.com/rust-lang/crates.io-index"
900checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
901dependencies = [
902 "lock_api",
903 "parking_lot_core",
904]
905
906[[package]]
907name = "parking_lot_core"
908version = "0.9.12"
909source = "registry+https://github.com/rust-lang/crates.io-index"
910checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
911dependencies = [
912 "cfg-if",
913 "libc",
914 "redox_syscall",
915 "smallvec",
916 "windows-link",
917]
918
919[[package]]
920name = "parlance"
921version = "0.1.0"
922source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
923
924[[package]]
925name = "parley"
926version = "0.11.0"
927source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
928dependencies = [
929 "fontique",
930 "hashbrown 0.17.1",
931 "linebender_resource_handle",
932 "parlance",
933 "parley_engine",
934 "skrifa",
935 "smallvec",
936]
937
938[[package]]
939name = "parley_data"
940version = "0.11.0"
941source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
942dependencies = [
943 "icu_properties",
944]
945
946[[package]]
947name = "parley_engine"
948version = "0.11.0"
949source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
950dependencies = [
951 "fontique",
952 "harfrust",
953 "hashbrown 0.17.1",
954 "icu_normalizer",
955 "icu_properties",
956 "icu_segmenter",
957 "linebender_resource_handle",
958 "parlance",
959 "parley_data",
960 "skrifa",
961]
962
963[[package]]
964name = "pin-project-lite"
965version = "0.2.17"
966source = "registry+https://github.com/rust-lang/crates.io-index"
967checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
968
969[[package]]
970name = "pkg-config"
971version = "0.3.34"
972source = "registry+https://github.com/rust-lang/crates.io-index"
973checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
974
975[[package]]
976name = "png"
977version = "0.18.1"
978source = "registry+https://github.com/rust-lang/crates.io-index"
979checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
980dependencies = [
981 "bitflags",
982 "crc32fast",
983 "fdeflate",
984 "flate2",
985 "miniz_oxide 0.8.9",
986]
987
988[[package]]
989name = "pollster"
990version = "0.4.0"
991source = "registry+https://github.com/rust-lang/crates.io-index"
992checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
993
994[[package]]
995name = "portable-atomic"
996version = "1.15.0"
997source = "registry+https://github.com/rust-lang/crates.io-index"
998checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
999
1000[[package]]
1001name = "portable-atomic-util"
1002version = "0.2.8"
1003source = "registry+https://github.com/rust-lang/crates.io-index"
1004checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
1005dependencies = [
1006 "portable-atomic",
1007]
1008
1009[[package]]
1010name = "potential_utf"
1011version = "0.1.6"
1012source = "registry+https://github.com/rust-lang/crates.io-index"
1013checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
1014dependencies = [
1015 "serde_core",
1016 "writeable",
1017 "zerovec",
1018]
1019
1020[[package]]
1021name = "presser"
1022version = "0.3.1"
1023source = "registry+https://github.com/rust-lang/crates.io-index"
1024checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
1025
1026[[package]]
1027name = "probe"
1028version = "0.0.0"
1029dependencies = [
1030 "gl21",
1031 "objc2",
1032 "objc2-app-kit",
1033 "objc2-foundation",
1034 "png",
1035]
1036
1037[[package]]
1038name = "proc-macro2"
1039version = "1.0.107"
1040source = "registry+https://github.com/rust-lang/crates.io-index"
1041checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
1042dependencies = [
1043 "unicode-ident",
1044]
1045
1046[[package]]
1047name = "profiling"
1048version = "1.0.18"
1049source = "registry+https://github.com/rust-lang/crates.io-index"
1050checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
1051
1052[[package]]
1053name = "pxfm"
1054version = "0.1.30"
1055source = "registry+https://github.com/rust-lang/crates.io-index"
1056checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
1057
1058[[package]]
1059name = "quick-error"
1060version = "2.0.1"
1061source = "registry+https://github.com/rust-lang/crates.io-index"
1062checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
1063
1064[[package]]
1065name = "quote"
1066version = "1.0.47"
1067source = "registry+https://github.com/rust-lang/crates.io-index"
1068checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
1069dependencies = [
1070 "proc-macro2",
1071]
1072
1073[[package]]
1074name = "range-alloc"
1075version = "0.1.5"
1076source = "registry+https://github.com/rust-lang/crates.io-index"
1077checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
1078
1079[[package]]
1080name = "raw-window-handle"
1081version = "0.6.2"
1082source = "registry+https://github.com/rust-lang/crates.io-index"
1083checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
1084
1085[[package]]
1086name = "raw-window-metal"
1087version = "1.1.0"
1088source = "registry+https://github.com/rust-lang/crates.io-index"
1089checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
1090dependencies = [
1091 "objc2",
1092 "objc2-core-foundation",
1093 "objc2-foundation",
1094 "objc2-quartz-core",
1095]
1096
1097[[package]]
1098name = "read-fonts"
1099version = "0.41.0"
1100source = "registry+https://github.com/rust-lang/crates.io-index"
1101checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
1102dependencies = [
1103 "bytemuck",
1104 "font-types",
1105 "once_cell",
1106]
1107
1108[[package]]
1109name = "redox_syscall"
1110version = "0.5.18"
1111source = "registry+https://github.com/rust-lang/crates.io-index"
1112checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1113dependencies = [
1114 "bitflags",
1115]
1116
1117[[package]]
1118name = "reference"
1119version = "0.0.0"
1120dependencies = [
1121 "draw",
1122 "png",
1123 "pollster",
1124 "wgpu",
1125]
1126
1127[[package]]
1128name = "renderdoc-sys"
1129version = "1.1.0"
1130source = "registry+https://github.com/rust-lang/crates.io-index"
1131checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
1132
1133[[package]]
1134name = "roxmltree"
1135version = "0.21.1"
1136source = "registry+https://github.com/rust-lang/crates.io-index"
1137checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
1138dependencies = [
1139 "memchr",
1140]
1141
1142[[package]]
1143name = "rustc-hash"
1144version = "1.1.0"
1145source = "registry+https://github.com/rust-lang/crates.io-index"
1146checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
1147
1148[[package]]
1149name = "rustversion"
1150version = "1.0.23"
1151source = "registry+https://github.com/rust-lang/crates.io-index"
1152checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
1153
1154[[package]]
1155name = "scopeguard"
1156version = "1.2.0"
1157source = "registry+https://github.com/rust-lang/crates.io-index"
1158checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1159
1160[[package]]
1161name = "serde"
1162version = "1.0.229"
1163source = "registry+https://github.com/rust-lang/crates.io-index"
1164checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
1165dependencies = [
1166 "serde_core",
1167 "serde_derive",
1168]
1169
1170[[package]]
1171name = "serde_core"
1172version = "1.0.229"
1173source = "registry+https://github.com/rust-lang/crates.io-index"
1174checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
1175dependencies = [
1176 "serde_derive",
1177]
1178
1179[[package]]
1180name = "serde_derive"
1181version = "1.0.229"
1182source = "registry+https://github.com/rust-lang/crates.io-index"
1183checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
1184dependencies = [
1185 "proc-macro2",
1186 "quote",
1187 "syn 3.0.6",
1188]
1189
1190[[package]]
1191name = "simd-adler32"
1192version = "0.3.10"
1193source = "registry+https://github.com/rust-lang/crates.io-index"
1194checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
1195
1196[[package]]
1197name = "skrifa"
1198version = "0.44.0"
1199source = "registry+https://github.com/rust-lang/crates.io-index"
1200checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
1201dependencies = [
1202 "bytemuck",
1203 "read-fonts",
1204]
1205
1206[[package]]
1207name = "slab"
1208version = "0.4.12"
1209source = "registry+https://github.com/rust-lang/crates.io-index"
1210checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1211
1212[[package]]
1213name = "slotmap"
1214version = "1.1.1"
1215source = "registry+https://github.com/rust-lang/crates.io-index"
1216checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
1217dependencies = [
1218 "version_check",
1219]
1220
1221[[package]]
1222name = "smallvec"
1223version = "1.16.2"
1224source = "registry+https://github.com/rust-lang/crates.io-index"
1225checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
1226
1227[[package]]
1228name = "spirv"
1229version = "0.4.0+sdk-1.4.341.0"
1230source = "registry+https://github.com/rust-lang/crates.io-index"
1231checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
1232dependencies = [
1233 "bitflags",
1234]
1235
1236[[package]]
1237name = "stable_deref_trait"
1238version = "1.2.1"
1239source = "registry+https://github.com/rust-lang/crates.io-index"
1240checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1241
1242[[package]]
1243name = "static_assertions"
1244version = "1.1.0"
1245source = "registry+https://github.com/rust-lang/crates.io-index"
1246checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
1247
1248[[package]]
1249name = "swash"
1250version = "0.2.10"
1251source = "registry+https://github.com/rust-lang/crates.io-index"
1252checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e"
1253dependencies = [
1254 "skrifa",
1255 "yazi",
1256 "zeno",
1257]
1258
1259[[package]]
1260name = "syn"
1261version = "2.0.119"
1262source = "registry+https://github.com/rust-lang/crates.io-index"
1263checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
1264dependencies = [
1265 "proc-macro2",
1266 "quote",
1267 "unicode-ident",
1268]
1269
1270[[package]]
1271name = "syn"
1272version = "3.0.6"
1273source = "registry+https://github.com/rust-lang/crates.io-index"
1274checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
1275dependencies = [
1276 "proc-macro2",
1277 "quote",
1278 "unicode-ident",
1279]
1280
1281[[package]]
1282name = "synstructure"
1283version = "0.14.0"
1284source = "registry+https://github.com/rust-lang/crates.io-index"
1285checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
1286dependencies = [
1287 "proc-macro2",
1288 "quote",
1289 "syn 3.0.6",
1290]
1291
1292[[package]]
1293name = "termcolor"
1294version = "1.4.1"
1295source = "registry+https://github.com/rust-lang/crates.io-index"
1296checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
1297dependencies = [
1298 "winapi-util",
1299]
1300
1301[[package]]
1302name = "thiserror"
1303version = "2.0.21"
1304source = "registry+https://github.com/rust-lang/crates.io-index"
1305checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
1306dependencies = [
1307 "thiserror-impl",
1308]
1309
1310[[package]]
1311name = "thiserror-impl"
1312version = "2.0.21"
1313source = "registry+https://github.com/rust-lang/crates.io-index"
1314checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
1315dependencies = [
1316 "proc-macro2",
1317 "quote",
1318 "syn 3.0.6",
1319]
1320
1321[[package]]
1322name = "tiff"
1323version = "0.11.3"
1324source = "registry+https://github.com/rust-lang/crates.io-index"
1325checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
1326dependencies = [
1327 "fax",
1328 "flate2",
1329 "half",
1330 "quick-error",
1331 "weezl",
1332 "zune-jpeg",
1333]
1334
1335[[package]]
1336name = "tinystr"
1337version = "0.8.4"
1338source = "registry+https://github.com/rust-lang/crates.io-index"
1339checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
1340dependencies = [
1341 "displaydoc",
1342 "serde_core",
1343 "zerovec",
1344]
1345
1346[[package]]
1347name = "unicode-ident"
1348version = "1.0.26"
1349source = "registry+https://github.com/rust-lang/crates.io-index"
1350checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
1351
1352[[package]]
1353name = "unicode-width"
1354version = "0.2.2"
1355source = "registry+https://github.com/rust-lang/crates.io-index"
1356checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
1357
1358[[package]]
1359name = "utf8_iter"
1360version = "1.0.4"
1361source = "registry+https://github.com/rust-lang/crates.io-index"
1362checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1363
1364[[package]]
1365name = "version_check"
1366version = "0.9.5"
1367source = "registry+https://github.com/rust-lang/crates.io-index"
1368checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1369
1370[[package]]
1371name = "wasm-bindgen"
1372version = "0.2.128"
1373source = "registry+https://github.com/rust-lang/crates.io-index"
1374checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
1375dependencies = [
1376 "cfg-if",
1377 "once_cell",
1378 "rustversion",
1379 "wasm-bindgen-macro",
1380 "wasm-bindgen-shared",
1381]
1382
1383[[package]]
1384name = "wasm-bindgen-futures"
1385version = "0.4.78"
1386source = "registry+https://github.com/rust-lang/crates.io-index"
1387checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
1388dependencies = [
1389 "js-sys",
1390 "wasm-bindgen",
1391]
1392
1393[[package]]
1394name = "wasm-bindgen-macro"
1395version = "0.2.128"
1396source = "registry+https://github.com/rust-lang/crates.io-index"
1397checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
1398dependencies = [
1399 "quote",
1400 "wasm-bindgen-macro-support",
1401]
1402
1403[[package]]
1404name = "wasm-bindgen-macro-support"
1405version = "0.2.128"
1406source = "registry+https://github.com/rust-lang/crates.io-index"
1407checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
1408dependencies = [
1409 "bumpalo",
1410 "proc-macro2",
1411 "quote",
1412 "syn 3.0.6",
1413 "wasm-bindgen-shared",
1414]
1415
1416[[package]]
1417name = "wasm-bindgen-shared"
1418version = "0.2.128"
1419source = "registry+https://github.com/rust-lang/crates.io-index"
1420checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
1421dependencies = [
1422 "unicode-ident",
1423]
1424
1425[[package]]
1426name = "wayland-sys"
1427version = "0.31.11"
1428source = "registry+https://github.com/rust-lang/crates.io-index"
1429checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
1430dependencies = [
1431 "dlib",
1432 "log",
1433 "once_cell",
1434 "pkg-config",
1435]
1436
1437[[package]]
1438name = "web-sys"
1439version = "0.3.105"
1440source = "registry+https://github.com/rust-lang/crates.io-index"
1441checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
1442dependencies = [
1443 "js-sys",
1444 "wasm-bindgen",
1445]
1446
1447[[package]]
1448name = "weezl"
1449version = "0.1.12"
1450source = "registry+https://github.com/rust-lang/crates.io-index"
1451checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
1452
1453[[package]]
1454name = "wgpu"
1455version = "30.0.1"
1456source = "registry+https://github.com/rust-lang/crates.io-index"
1457checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
1458dependencies = [
1459 "arrayvec",
1460 "bitflags",
1461 "bytemuck",
1462 "cfg-if",
1463 "cfg_aliases",
1464 "document-features",
1465 "hashbrown 0.17.1",
1466 "js-sys",
1467 "log",
1468 "naga",
1469 "parking_lot",
1470 "portable-atomic",
1471 "profiling",
1472 "raw-window-handle",
1473 "smallvec",
1474 "static_assertions",
1475 "wasm-bindgen",
1476 "wasm-bindgen-futures",
1477 "web-sys",
1478 "wgpu-core",
1479 "wgpu-hal",
1480 "wgpu-types",
1481]
1482
1483[[package]]
1484name = "wgpu-core"
1485version = "30.0.1"
1486source = "registry+https://github.com/rust-lang/crates.io-index"
1487checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
1488dependencies = [
1489 "arrayvec",
1490 "bit-set",
1491 "bit-vec",
1492 "bitflags",
1493 "bytemuck",
1494 "cfg_aliases",
1495 "document-features",
1496 "hashbrown 0.17.1",
1497 "indexmap",
1498 "log",
1499 "naga",
1500 "naga-types",
1501 "once_cell",
1502 "parking_lot",
1503 "portable-atomic",
1504 "profiling",
1505 "raw-window-handle",
1506 "rustc-hash",
1507 "smallvec",
1508 "thiserror",
1509 "wgpu-core-deps-apple",
1510 "wgpu-core-deps-emscripten",
1511 "wgpu-core-deps-windows-linux-android",
1512 "wgpu-hal",
1513 "wgpu-naga-bridge",
1514 "wgpu-types",
1515]
1516
1517[[package]]
1518name = "wgpu-core-deps-apple"
1519version = "30.0.1"
1520source = "registry+https://github.com/rust-lang/crates.io-index"
1521checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
1522dependencies = [
1523 "wgpu-hal",
1524]
1525
1526[[package]]
1527name = "wgpu-core-deps-emscripten"
1528version = "30.0.1"
1529source = "registry+https://github.com/rust-lang/crates.io-index"
1530checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
1531dependencies = [
1532 "wgpu-hal",
1533]
1534
1535[[package]]
1536name = "wgpu-core-deps-windows-linux-android"
1537version = "30.0.1"
1538source = "registry+https://github.com/rust-lang/crates.io-index"
1539checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f"
1540dependencies = [
1541 "wgpu-hal",
1542]
1543
1544[[package]]
1545name = "wgpu-hal"
1546version = "30.0.1"
1547source = "registry+https://github.com/rust-lang/crates.io-index"
1548checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
1549dependencies = [
1550 "android_system_properties",
1551 "arrayvec",
1552 "ash",
1553 "bit-set",
1554 "bitflags",
1555 "block2",
1556 "bytemuck",
1557 "cfg-if",
1558 "cfg_aliases",
1559 "glow",
1560 "glutin_wgl_sys",
1561 "gpu-allocator",
1562 "hashbrown 0.17.1",
1563 "js-sys",
1564 "khronos-egl",
1565 "libc",
1566 "libloading",
1567 "log",
1568 "naga",
1569 "naga-types",
1570 "ndk-sys",
1571 "objc2",
1572 "objc2-core-foundation",
1573 "objc2-core-graphics",
1574 "objc2-foundation",
1575 "objc2-metal",
1576 "objc2-quartz-core",
1577 "once_cell",
1578 "ordered-float",
1579 "parking_lot",
1580 "portable-atomic",
1581 "portable-atomic-util",
1582 "profiling",
1583 "range-alloc",
1584 "raw-window-handle",
1585 "raw-window-metal",
1586 "renderdoc-sys",
1587 "smallvec",
1588 "static_assertions",
1589 "thiserror",
1590 "wasm-bindgen",
1591 "wayland-sys",
1592 "web-sys",
1593 "wgpu-naga-bridge",
1594 "wgpu-types",
1595 "windows",
1596 "windows-core",
1597 "windows-result",
1598]
1599
1600[[package]]
1601name = "wgpu-naga-bridge"
1602version = "30.0.1"
1603source = "registry+https://github.com/rust-lang/crates.io-index"
1604checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081"
1605dependencies = [
1606 "naga",
1607 "wgpu-types",
1608]
1609
1610[[package]]
1611name = "wgpu-types"
1612version = "30.0.1"
1613source = "registry+https://github.com/rust-lang/crates.io-index"
1614checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
1615dependencies = [
1616 "bitflags",
1617 "bytemuck",
1618 "js-sys",
1619 "log",
1620 "naga-types",
1621 "raw-window-handle",
1622 "static_assertions",
1623 "web-sys",
1624]
1625
1626[[package]]
1627name = "winapi-util"
1628version = "0.1.11"
1629source = "registry+https://github.com/rust-lang/crates.io-index"
1630checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
1631dependencies = [
1632 "windows-sys",
1633]
1634
1635[[package]]
1636name = "windows"
1637version = "0.62.2"
1638source = "registry+https://github.com/rust-lang/crates.io-index"
1639checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
1640dependencies = [
1641 "windows-collections",
1642 "windows-core",
1643 "windows-future",
1644 "windows-numerics",
1645]
1646
1647[[package]]
1648name = "windows-collections"
1649version = "0.3.2"
1650source = "registry+https://github.com/rust-lang/crates.io-index"
1651checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
1652dependencies = [
1653 "windows-core",
1654]
1655
1656[[package]]
1657name = "windows-core"
1658version = "0.62.2"
1659source = "registry+https://github.com/rust-lang/crates.io-index"
1660checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
1661dependencies = [
1662 "windows-implement",
1663 "windows-interface",
1664 "windows-link",
1665 "windows-result",
1666 "windows-strings",
1667]
1668
1669[[package]]
1670name = "windows-future"
1671version = "0.3.2"
1672source = "registry+https://github.com/rust-lang/crates.io-index"
1673checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
1674dependencies = [
1675 "windows-core",
1676 "windows-link",
1677 "windows-threading",
1678]
1679
1680[[package]]
1681name = "windows-implement"
1682version = "0.60.2"
1683source = "registry+https://github.com/rust-lang/crates.io-index"
1684checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
1685dependencies = [
1686 "proc-macro2",
1687 "quote",
1688 "syn 2.0.119",
1689]
1690
1691[[package]]
1692name = "windows-interface"
1693version = "0.59.3"
1694source = "registry+https://github.com/rust-lang/crates.io-index"
1695checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
1696dependencies = [
1697 "proc-macro2",
1698 "quote",
1699 "syn 2.0.119",
1700]
1701
1702[[package]]
1703name = "windows-link"
1704version = "0.2.1"
1705source = "registry+https://github.com/rust-lang/crates.io-index"
1706checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
1707
1708[[package]]
1709name = "windows-numerics"
1710version = "0.3.1"
1711source = "registry+https://github.com/rust-lang/crates.io-index"
1712checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
1713dependencies = [
1714 "windows-core",
1715 "windows-link",
1716]
1717
1718[[package]]
1719name = "windows-result"
1720version = "0.4.1"
1721source = "registry+https://github.com/rust-lang/crates.io-index"
1722checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
1723dependencies = [
1724 "windows-link",
1725]
1726
1727[[package]]
1728name = "windows-strings"
1729version = "0.5.1"
1730source = "registry+https://github.com/rust-lang/crates.io-index"
1731checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
1732dependencies = [
1733 "windows-link",
1734]
1735
1736[[package]]
1737name = "windows-sys"
1738version = "0.61.2"
1739source = "registry+https://github.com/rust-lang/crates.io-index"
1740checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1741dependencies = [
1742 "windows-link",
1743]
1744
1745[[package]]
1746name = "windows-threading"
1747version = "0.2.1"
1748source = "registry+https://github.com/rust-lang/crates.io-index"
1749checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
1750dependencies = [
1751 "windows-link",
1752]
1753
1754[[package]]
1755name = "writeable"
1756version = "0.6.4"
1757source = "registry+https://github.com/rust-lang/crates.io-index"
1758checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
1759
1760[[package]]
1761name = "xml-rs"
1762version = "0.8.29"
1763source = "registry+https://github.com/rust-lang/crates.io-index"
1764checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
1765
1766[[package]]
1767name = "yazi"
1768version = "0.2.1"
1769source = "registry+https://github.com/rust-lang/crates.io-index"
1770checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
1771
1772[[package]]
1773name = "yeslogic-fontconfig-sys"
1774version = "6.0.1"
1775source = "registry+https://github.com/rust-lang/crates.io-index"
1776checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
1777dependencies = [
1778 "dlib",
1779 "once_cell",
1780 "pkg-config",
1781]
1782
1783[[package]]
1784name = "yoke"
1785version = "0.8.3"
1786source = "registry+https://github.com/rust-lang/crates.io-index"
1787checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
1788dependencies = [
1789 "stable_deref_trait",
1790 "yoke-derive",
1791 "zerofrom",
1792]
1793
1794[[package]]
1795name = "yoke-derive"
1796version = "0.8.3"
1797source = "registry+https://github.com/rust-lang/crates.io-index"
1798checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
1799dependencies = [
1800 "proc-macro2",
1801 "quote",
1802 "syn 3.0.6",
1803 "synstructure",
1804]
1805
1806[[package]]
1807name = "zeno"
1808version = "0.3.3"
1809source = "registry+https://github.com/rust-lang/crates.io-index"
1810checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
1811
1812[[package]]
1813name = "zerocopy"
1814version = "0.8.59"
1815source = "registry+https://github.com/rust-lang/crates.io-index"
1816checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
1817dependencies = [
1818 "zerocopy-derive",
1819]
1820
1821[[package]]
1822name = "zerocopy-derive"
1823version = "0.8.59"
1824source = "registry+https://github.com/rust-lang/crates.io-index"
1825checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
1826dependencies = [
1827 "proc-macro2",
1828 "quote",
1829 "syn 2.0.119",
1830]
1831
1832[[package]]
1833name = "zerofrom"
1834version = "0.1.8"
1835source = "registry+https://github.com/rust-lang/crates.io-index"
1836checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
1837dependencies = [
1838 "zerofrom-derive",
1839]
1840
1841[[package]]
1842name = "zerofrom-derive"
1843version = "0.1.8"
1844source = "registry+https://github.com/rust-lang/crates.io-index"
1845checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
1846dependencies = [
1847 "proc-macro2",
1848 "quote",
1849 "syn 3.0.6",
1850 "synstructure",
1851]
1852
1853[[package]]
1854name = "zerotrie"
1855version = "0.2.5"
1856source = "registry+https://github.com/rust-lang/crates.io-index"
1857checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
1858dependencies = [
1859 "displaydoc",
1860 "yoke",
1861 "zerofrom",
1862 "zerovec",
1863]
1864
1865[[package]]
1866name = "zerovec"
1867version = "0.11.8"
1868source = "registry+https://github.com/rust-lang/crates.io-index"
1869checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
1870dependencies = [
1871 "serde",
1872 "yoke",
1873 "zerofrom",
1874 "zerovec-derive",
1875]
1876
1877[[package]]
1878name = "zerovec-derive"
1879version = "0.11.6"
1880source = "registry+https://github.com/rust-lang/crates.io-index"
1881checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
1882dependencies = [
1883 "proc-macro2",
1884 "quote",
1885 "syn 3.0.6",
1886]
1887
1888[[package]]
1889name = "zlib-rs"
1890version = "0.6.8"
1891source = "registry+https://github.com/rust-lang/crates.io-index"
1892checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
1893
1894[[package]]
1895name = "zune-core"
1896version = "0.5.3"
1897source = "registry+https://github.com/rust-lang/crates.io-index"
1898checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
1899
1900[[package]]
1901name = "zune-jpeg"
1902version = "0.5.15"
1903source = "registry+https://github.com/rust-lang/crates.io-index"
1904checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
1905dependencies = [
1906 "zune-core",
1907]
platform/snow-leopard/Cargo.toml created+13
...@@ -0,0 +1,13 @@
1# Mac OS X 10.6 work, built with cargo.sh. Its own workspace so the 10.6 target
2# never enters the main workspace's builds.
3[workspace]
4members = ["gl21", "hello", "probe", "reference"]
5resolver = "3"
6
7[workspace.dependencies]
8objc2 = "0.6.4"
9objc2-foundation = { version = "0.3.2", default-features = false }
10objc2-app-kit = { version = "0.3.2", default-features = false }
11
12[profile.dev.package."*"]
13opt-level = 3
platform/snow-leopard/cargo.sh created+21
...@@ -0,0 +1,21 @@
1#!/bin/sh
2# cargo for Mac OS X 10.6, from any package: `cargo.sh build --release -p notebook ...`.
3# Needs nightly with rust-src and the SDK from `remote.sh sdk`.
4set -eu
5here=$(cd "$(dirname "$0")" && pwd)
6sdk=${SNOW_LEOPARD_SDK:-$(cd "$here/../.." && pwd)/target/snow-leopard/MacOSX10.6.sdk}
7target=x86_64-apple-macosx10.6
8command=$1
9shift
10# Cargo can't see link.sh's inputs; folding their hash into the flags relinks on change.
11runtime=$(cat "$here/link.sh" "$here"/rt/* | shasum | cut -c1-12)
12# The deployment target is for cc-rs (bundled SQLite); rustc warns that it ignores it.
13# rustc would infer ld64 from the name `link.sh`; it is a C compiler driver.
14CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$here/../../target/snow-leopard} \
15SNOW_LEOPARD_SDK=$sdk \
16TARGET_CFLAGS="-isysroot $sdk -mmacosx-version-min=10.6" \
17MACOSX_DEPLOYMENT_TARGET=10.6 \
18exec cargo +nightly -Zbuild-std=std,panic_unwind -Zjson-target-spec \
19 --config "target.$target.linker='$here/link.sh'" \
20 --config "target.$target.rustflags=['-C','linker-flavor=darwin-cc','-C','metadata=rt-$runtime']" \
21 "$command" --target "$here/$target.json" "$@"
platform/snow-leopard/gl21/Cargo.toml created+6
...@@ -0,0 +1,6 @@
1[package]
2name = "gl21"
3version = "0.0.0"
4edition = "2024"
5publish = false
6description = "OpenGL 2.1 plus EXT_framebuffer_object, as Mac OS X 10.6 exports them."
platform/snow-leopard/gl21/src/draw.glsl created+102
...@@ -0,0 +1,102 @@
1// GLSL 1.20 port of crates/draw/src/draw.wgsl. GLSL 1.20 has no flat interpolation, so
2// the per-quad shape and stroke arrive interpolated between equal values: equality tests
3// on them use a tolerance.
4#ifdef VERTEX
5attribute vec2 position;
6attribute vec2 uv;
7attribute vec4 color;
8attribute vec2 local;
9attribute vec4 shape;
10attribute float stroke;
11#endif
12varying vec2 v_uv;
13varying vec4 v_color;
14varying vec2 v_local;
15varying vec4 v_shape;
16varying float v_stroke;
17
18#ifdef VERTEX
19void main() {
20 v_uv = uv;
21 v_color = color;
22 v_local = local;
23 v_shape = shape;
24 v_stroke = stroke;
25 gl_Position = vec4(position, 0.0, 1.0);
26}
27#else
28uniform sampler2D atlas;
29
30bool same(float a, float b) {
31 return abs(a - b) <= 1e-4 * max(1.0, max(abs(a), abs(b)));
32}
33
34float ellipse_arc(float angle, vec2 radius) {
35 if (same(radius.x, radius.y)) {
36 return angle * radius.x;
37 }
38 // Four-point Gauss-Legendre quadrature.
39 vec4 nodes = vec4(-0.86113631, -0.33998104, 0.33998104, 0.86113631);
40 vec4 weights = vec4(0.34785485, 0.65214515, 0.65214515, 0.34785485);
41 float half_angle = angle * 0.5;
42 float scale = max(radius.x, radius.y);
43 float sum = 0.0;
44 for (int i = 0; i < 4; i++) {
45 float t = half_angle * (nodes[i] + 1.0);
46 sum += weights[i] * length((radius / scale) * vec2(sin(t), cos(t)));
47 }
48 return half_angle * sum * scale;
49}
50
51void main() {
52 vec4 color = texture2D(atlas, v_uv) * v_color;
53 vec4 shape = v_shape;
54 if (shape.x > 0.0 && shape.y > 0.0) {
55 vec2 q = abs(v_local) - shape.xy + shape.zw;
56 float distance;
57 if (same(shape.z, shape.w)) {
58 distance = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - shape.z;
59 } else if (all(greaterThan(q, vec2(0.0)))) {
60 vec2 radius = max(shape.zw, vec2(0.0001));
61 vec2 normalized = q / radius;
62 float k0 = length(normalized);
63 distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20);
64 } else {
65 vec2 edge = abs(v_local) - shape.xy;
66 distance = max(edge.x, edge.y);
67 }
68 float coverage = clamp(0.5 - distance, 0.0, 1.0);
69 float width = abs(v_stroke);
70 if (width > 1e-6) {
71 coverage *= clamp(distance + width + 0.5, 0.0, 1.0);
72 }
73 if (v_stroke < -1e-6) {
74 vec2 radius = max(shape.zw - width * 0.5, vec2(0.0));
75 if (radius.x <= 1e-6 || radius.y <= 1e-6) {
76 radius = vec2(0.0);
77 }
78 vec2 straight = shape.xy - width * 0.5 - radius;
79 vec2 p = abs(v_local);
80 float quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius);
81 float along;
82 if (p.y <= straight.y) {
83 along = p.y;
84 } else if (p.x <= straight.x || radius.x == 0.0) {
85 along = quarter - p.x;
86 } else {
87 float angle = atan((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x);
88 along = straight.y + ellipse_arc(angle, radius);
89 }
90 if (v_local.x < 0.0) {
91 along = v_local.y < 0.0 ? 2.0 * quarter + along : 2.0 * quarter - along;
92 } else if (v_local.y < 0.0) {
93 along = 4.0 * quarter - along;
94 }
95 float phase = fract(along / (4.0 * width)) * (4.0 * width);
96 coverage *= clamp(width + 0.5 - abs(phase - 2.0 * width), 0.0, 1.0);
97 }
98 color *= coverage;
99 }
100 gl_FragColor = color;
101}
102#endif
platform/snow-leopard/gl21/src/lib.rs created+118
...@@ -0,0 +1,118 @@
1//! OpenGL 2.1 and the extensions every Mac OS X 10.6 GPU driver exposes, linked straight
2//! from OpenGL.framework, which exports them all; no loader is needed.
3#![allow(non_snake_case, clippy::missing_safety_doc)]
4
5pub mod painter;
6
7use std::ffi::{c_char, c_void};
8
9pub type GLenum = u32;
10pub type GLuint = u32;
11pub type GLint = i32;
12pub type GLsizei = i32;
13pub type GLfloat = f32;
14pub type GLboolean = u8;
15pub type GLbitfield = u32;
16pub type GLsizeiptr = isize;
17
18pub const NO_ERROR: GLenum = 0;
19pub const TRIANGLES: GLenum = 0x0004;
20pub const COLOR_BUFFER_BIT: GLbitfield = 0x4000;
21pub const BLEND: GLenum = 0x0BE2;
22pub const SCISSOR_TEST: GLenum = 0x0C11;
23pub const TEXTURE_2D: GLenum = 0x0DE1;
24pub const UNSIGNED_BYTE: GLenum = 0x1401;
25pub const FLOAT: GLenum = 0x1406;
26pub const RGBA: GLenum = 0x1908;
27pub const VENDOR: GLenum = 0x1F00;
28pub const RENDERER: GLenum = 0x1F01;
29pub const VERSION: GLenum = 0x1F02;
30pub const EXTENSIONS: GLenum = 0x1F03;
31pub const NEAREST: GLint = 0x2600;
32pub const LINEAR: GLint = 0x2601;
33pub const TEXTURE_MAG_FILTER: GLenum = 0x2800;
34pub const TEXTURE_MIN_FILTER: GLenum = 0x2801;
35pub const TEXTURE_WRAP_S: GLenum = 0x2802;
36pub const TEXTURE_WRAP_T: GLenum = 0x2803;
37pub const CLAMP_TO_EDGE: GLint = 0x812F;
38pub const ONE: GLenum = 1;
39pub const SRC_ALPHA: GLenum = 0x0302;
40pub const ONE_MINUS_SRC_ALPHA: GLenum = 0x0303;
41pub const TEXTURE0: GLenum = 0x84C0;
42pub const ARRAY_BUFFER: GLenum = 0x8892;
43pub const STREAM_DRAW: GLenum = 0x88E0;
44pub const FRAGMENT_SHADER: GLenum = 0x8B30;
45pub const VERTEX_SHADER: GLenum = 0x8B31;
46pub const COMPILE_STATUS: GLenum = 0x8B81;
47pub const LINK_STATUS: GLenum = 0x8B82;
48pub const INFO_LOG_LENGTH: GLenum = 0x8B84;
49pub const SRGB8_ALPHA8: GLint = 0x8C43;
50pub const READ_FRAMEBUFFER: GLenum = 0x8CA8;
51pub const DRAW_FRAMEBUFFER: GLenum = 0x8CA9;
52pub const FRAMEBUFFER_COMPLETE: GLenum = 0x8CD5;
53pub const COLOR_ATTACHMENT0: GLenum = 0x8CE0;
54pub const FRAMEBUFFER: GLenum = 0x8D40;
55pub const FRAMEBUFFER_SRGB: GLenum = 0x8DB9;
56
57#[link(name = "OpenGL", kind = "framework")]
58unsafe extern "C" {
59 pub fn glGetError() -> GLenum;
60 pub fn glGetString(name: GLenum) -> *const u8;
61 pub fn glEnable(cap: GLenum);
62 pub fn glDisable(cap: GLenum);
63 pub fn glViewport(x: GLint, y: GLint, width: GLsizei, height: GLsizei);
64 pub fn glScissor(x: GLint, y: GLint, width: GLsizei, height: GLsizei);
65 pub fn glClearColor(r: GLfloat, g: GLfloat, b: GLfloat, a: GLfloat);
66 pub fn glClear(mask: GLbitfield);
67 pub fn glFinish();
68 pub fn glFlush();
69 pub fn glBlendFuncSeparate(src_rgb: GLenum, dst_rgb: GLenum, src_a: GLenum, dst_a: GLenum);
70 pub fn glReadPixels(x: GLint, y: GLint, w: GLsizei, h: GLsizei, format: GLenum, ty: GLenum, data: *mut c_void);
71 pub fn glPixelStorei(name: GLenum, value: GLint);
72
73 pub fn glGenTextures(n: GLsizei, textures: *mut GLuint);
74 pub fn glDeleteTextures(n: GLsizei, textures: *const GLuint);
75 pub fn glBindTexture(target: GLenum, texture: GLuint);
76 pub fn glActiveTexture(unit: GLenum);
77 pub fn glTexParameteri(target: GLenum, name: GLenum, value: GLint);
78 pub fn glTexImage2D(target: GLenum, level: GLint, internal: GLint, w: GLsizei, h: GLsizei, border: GLint, format: GLenum, ty: GLenum, data: *const c_void);
79 pub fn glTexSubImage2D(target: GLenum, level: GLint, x: GLint, y: GLint, w: GLsizei, h: GLsizei, format: GLenum, ty: GLenum, data: *const c_void);
80
81 pub fn glGenBuffers(n: GLsizei, buffers: *mut GLuint);
82 pub fn glBindBuffer(target: GLenum, buffer: GLuint);
83 pub fn glBufferData(target: GLenum, size: GLsizeiptr, data: *const c_void, usage: GLenum);
84 pub fn glEnableVertexAttribArray(index: GLuint);
85 pub fn glVertexAttribPointer(index: GLuint, size: GLint, ty: GLenum, normalized: GLboolean, stride: GLsizei, offset: *const c_void);
86 pub fn glDrawArrays(mode: GLenum, first: GLint, count: GLsizei);
87
88 pub fn glCreateShader(kind: GLenum) -> GLuint;
89 pub fn glShaderSource(shader: GLuint, count: GLsizei, sources: *const *const c_char, lengths: *const GLint);
90 pub fn glCompileShader(shader: GLuint);
91 pub fn glGetShaderiv(shader: GLuint, name: GLenum, value: *mut GLint);
92 pub fn glGetShaderInfoLog(shader: GLuint, size: GLsizei, length: *mut GLsizei, log: *mut c_char);
93 pub fn glCreateProgram() -> GLuint;
94 pub fn glAttachShader(program: GLuint, shader: GLuint);
95 pub fn glBindAttribLocation(program: GLuint, index: GLuint, name: *const c_char);
96 pub fn glLinkProgram(program: GLuint);
97 pub fn glGetProgramiv(program: GLuint, name: GLenum, value: *mut GLint);
98 pub fn glGetProgramInfoLog(program: GLuint, size: GLsizei, length: *mut GLsizei, log: *mut c_char);
99 pub fn glUseProgram(program: GLuint);
100 pub fn glGetUniformLocation(program: GLuint, name: *const c_char) -> GLint;
101 pub fn glUniform1i(location: GLint, value: GLint);
102
103 pub fn glGenFramebuffersEXT(n: GLsizei, framebuffers: *mut GLuint);
104 pub fn glDeleteFramebuffersEXT(n: GLsizei, framebuffers: *const GLuint);
105 pub fn glBindFramebufferEXT(target: GLenum, framebuffer: GLuint);
106 pub fn glFramebufferTexture2DEXT(target: GLenum, attachment: GLenum, textarget: GLenum, texture: GLuint, level: GLint);
107 pub fn glCheckFramebufferStatusEXT(target: GLenum) -> GLenum;
108 pub fn glBlitFramebufferEXT(sx0: GLint, sy0: GLint, sx1: GLint, sy1: GLint, dx0: GLint, dy0: GLint, dx1: GLint, dy1: GLint, mask: GLbitfield, filter: GLenum);
109}
110
111/// A GL string such as `VERSION` or `EXTENSIONS`, empty without a current context.
112pub fn string(name: GLenum) -> String {
113 let text = unsafe { glGetString(name) };
114 if text.is_null() {
115 return String::new();
116 }
117 unsafe { std::ffi::CStr::from_ptr(text.cast()) }.to_string_lossy().into_owned()
118}
platform/snow-leopard/gl21/src/painter.rs created+272
...@@ -0,0 +1,272 @@
1//! The submission half of `draw::Renderer` on OpenGL 2.1: the atlas, image textures, one
2//! vertex buffer and the ported shader. `draw` keeps building vertices and batches; this
3//! draws them into an sRGB framebuffer object, as wgpu draws them into its target.
4use crate::*;
5use std::{ffi::CString, ops::Range, ptr};
6
7/// Laid out as `draw`'s vertex.
8#[repr(C)]
9#[derive(Clone, Copy, Debug, Default)]
10pub struct Vertex {
11 pub position: [f32; 2],
12 pub uv: [f32; 2],
13 pub color: [f32; 4],
14 pub local: [f32; 2],
15 pub shape: [f32; 4],
16 pub stroke: f32,
17}
18
19pub struct Batch<'a> {
20 pub vertices: Range<u32>,
21 /// None samples the atlas with straight alpha; an image is premultiplied.
22 pub image: Option<&'a Texture>,
23 /// Device pixels from the top left: `[x, y, width, height]`.
24 pub scissor: [u32; 4],
25}
26
27/// An sRGB RGBA texture.
28pub struct Texture {
29 name: GLuint,
30 size: [u32; 2],
31}
32
33impl Texture {
34 fn new(size: [u32; 2], filter: GLint, pixels: Option<&[u8]>) -> Self {
35 let mut name = 0;
36 unsafe {
37 glGenTextures(1, &mut name);
38 glBindTexture(TEXTURE_2D, name);
39 glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, filter);
40 glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, filter);
41 glTexParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE);
42 glTexParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE);
43 glTexImage2D(
44 TEXTURE_2D,
45 0,
46 SRGB8_ALPHA8,
47 size[0] as GLsizei,
48 size[1] as GLsizei,
49 0,
50 RGBA,
51 UNSIGNED_BYTE,
52 pixels.map_or(ptr::null(), |p| p.as_ptr().cast()),
53 );
54 }
55 Self { name, size }
56 }
57
58 /// Writes `rgba` rows, top first, at `origin`.
59 pub fn write(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) {
60 assert_eq!(rgba.len(), (size[0] * size[1] * 4) as usize);
61 unsafe {
62 glBindTexture(TEXTURE_2D, self.name);
63 glTexSubImage2D(
64 TEXTURE_2D,
65 0,
66 origin[0] as GLint,
67 origin[1] as GLint,
68 size[0] as GLsizei,
69 size[1] as GLsizei,
70 RGBA,
71 UNSIGNED_BYTE,
72 rgba.as_ptr().cast(),
73 );
74 }
75 }
76
77 pub fn size(&self) -> [u32; 2] {
78 self.size
79 }
80}
81
82impl Drop for Texture {
83 fn drop(&mut self) {
84 unsafe { glDeleteTextures(1, &self.name) };
85 }
86}
87
88/// An offscreen sRGB colour target; blending happens in linear light.
89pub struct Target {
90 framebuffer: GLuint,
91 texture: Texture,
92}
93
94impl Target {
95 pub fn new(size: [u32; 2]) -> Result<Self, String> {
96 let texture = Texture::new(size, NEAREST, None);
97 let mut framebuffer = 0;
98 unsafe {
99 glGenFramebuffersEXT(1, &mut framebuffer);
100 glBindFramebufferEXT(FRAMEBUFFER, framebuffer);
101 glFramebufferTexture2DEXT(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, texture.name, 0);
102 let status = glCheckFramebufferStatusEXT(FRAMEBUFFER);
103 glBindFramebufferEXT(FRAMEBUFFER, 0);
104 if status != FRAMEBUFFER_COMPLETE {
105 return Err(format!("framebuffer incomplete: {status:#x}"));
106 }
107 }
108 Ok(Self { framebuffer, texture })
109 }
110
111 pub fn size(&self) -> [u32; 2] {
112 self.texture.size
113 }
114
115 /// Copies the target to the current context's window framebuffer, same size.
116 pub fn present(&self) {
117 let [w, h] = self.texture.size.map(|v| v as GLint);
118 unsafe {
119 glBindFramebufferEXT(READ_FRAMEBUFFER, self.framebuffer);
120 glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0);
121 glBlitFramebufferEXT(0, 0, w, h, 0, 0, w, h, COLOR_BUFFER_BIT, NEAREST as GLenum);
122 glBindFramebufferEXT(FRAMEBUFFER, 0);
123 }
124 }
125
126 /// sRGB-encoded RGBA rows, top first.
127 pub fn read_pixels(&self) -> Vec<u8> {
128 let [w, h] = self.texture.size;
129 let mut pixels = vec![0u8; (w * h * 4) as usize];
130 unsafe {
131 glBindFramebufferEXT(FRAMEBUFFER, self.framebuffer);
132 glReadPixels(0, 0, w as GLsizei, h as GLsizei, RGBA, UNSIGNED_BYTE, pixels.as_mut_ptr().cast());
133 glBindFramebufferEXT(FRAMEBUFFER, 0);
134 }
135 pixels.chunks(w as usize * 4).rev().flatten().copied().collect()
136 }
137}
138
139impl Drop for Target {
140 fn drop(&mut self) {
141 unsafe { glDeleteFramebuffersEXT(1, &self.framebuffer) };
142 }
143}
144
145pub struct Painter {
146 program: GLuint,
147 buffer: GLuint,
148 pub atlas: Texture,
149}
150
151const ATTRIBUTES: [(&str, GLint, usize); 6] = [
152 ("position", 2, 0),
153 ("uv", 2, 8),
154 ("color", 4, 16),
155 ("local", 2, 32),
156 ("shape", 4, 40),
157 ("stroke", 1, 56),
158];
159
160impl Painter {
161 /// Needs a current context.
162 pub fn new(atlas_size: u32) -> Result<Self, String> {
163 let source = include_str!("draw.glsl");
164 let program = unsafe {
165 let vertex = compile(VERTEX_SHADER, &format!("#version 120\n#define VERTEX\n{source}"))?;
166 let fragment = compile(FRAGMENT_SHADER, &format!("#version 120\n{source}"))?;
167 let program = glCreateProgram();
168 glAttachShader(program, vertex);
169 glAttachShader(program, fragment);
170 for (index, (name, _, _)) in ATTRIBUTES.iter().enumerate() {
171 let name = CString::new(*name).unwrap();
172 glBindAttribLocation(program, index as GLuint, name.as_ptr());
173 }
174 glLinkProgram(program);
175 let mut linked = 0;
176 glGetProgramiv(program, LINK_STATUS, &mut linked);
177 if linked == 0 {
178 return Err(format!("link: {}", log(program, glGetProgramiv, glGetProgramInfoLog)));
179 }
180 program
181 };
182 let atlas = Texture::new([atlas_size; 2], NEAREST, None);
183 // The atlas's first texel is the white that solid fills sample.
184 atlas.write([0, 0], [1, 1], &[255; 4]);
185 let mut buffer = 0;
186 unsafe { glGenBuffers(1, &mut buffer) };
187 Ok(Self { program, buffer, atlas })
188 }
189
190 /// An image texture, filtered linearly; pixels are premultiplied rows, top first.
191 pub fn image(&self, size: [u32; 2], rgba: &[u8]) -> Texture {
192 Texture::new(size, LINEAR, Some(rgba))
193 }
194
195 /// Clears `target` to linear `clear` and draws the batches in order.
196 pub fn draw(&self, target: &Target, clear: [f32; 4], vertices: &[Vertex], batches: &[Batch<'_>]) {
197 let [width, height] = target.size().map(|v| v as GLsizei);
198 unsafe {
199 glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer);
200 glEnable(FRAMEBUFFER_SRGB);
201 glViewport(0, 0, width, height);
202 glDisable(SCISSOR_TEST);
203 glClearColor(clear[0], clear[1], clear[2], clear[3]);
204 glClear(COLOR_BUFFER_BIT);
205 glUseProgram(self.program);
206 glUniform1i(glGetUniformLocation(self.program, c"atlas".as_ptr()), 0);
207 glActiveTexture(TEXTURE0);
208 glBindBuffer(ARRAY_BUFFER, self.buffer);
209 glBufferData(ARRAY_BUFFER, size_of_val(vertices) as GLsizeiptr, vertices.as_ptr().cast(), STREAM_DRAW);
210 for (index, (_, size, offset)) in ATTRIBUTES.iter().enumerate() {
211 glEnableVertexAttribArray(index as GLuint);
212 glVertexAttribPointer(
213 index as GLuint,
214 *size,
215 FLOAT,
216 0,
217 size_of::<Vertex>() as GLsizei,
218 ptr::without_provenance(*offset),
219 );
220 }
221 glEnable(BLEND);
222 glEnable(SCISSOR_TEST);
223 for batch in batches {
224 let [x, y, w, h] = batch.scissor.map(|v| v as GLint);
225 glScissor(x, height - y - h, w, h);
226 let (texture, source) = match batch.image {
227 Some(image) => (image.name, ONE),
228 None => (self.atlas.name, SRC_ALPHA),
229 };
230 glBlendFuncSeparate(source, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA);
231 glBindTexture(TEXTURE_2D, texture);
232 glDrawArrays(
233 TRIANGLES,
234 batch.vertices.start as GLint,
235 batch.vertices.len() as GLsizei,
236 );
237 }
238 glDisable(SCISSOR_TEST);
239 glDisable(BLEND);
240 glBindFramebufferEXT(FRAMEBUFFER, 0);
241 }
242 }
243}
244
245unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> {
246 unsafe {
247 let shader = glCreateShader(kind);
248 let text = CString::new(source).unwrap();
249 glShaderSource(shader, 1, &text.as_ptr(), ptr::null());
250 glCompileShader(shader);
251 let mut compiled = 0;
252 glGetShaderiv(shader, COMPILE_STATUS, &mut compiled);
253 if compiled == 0 {
254 return Err(format!("compile: {}", log(shader, glGetShaderiv, glGetShaderInfoLog)));
255 }
256 Ok(shader)
257 }
258}
259
260unsafe fn log(
261 object: GLuint,
262 get: unsafe extern "C" fn(GLuint, GLenum, *mut GLint),
263 read: unsafe extern "C" fn(GLuint, GLsizei, *mut GLsizei, *mut std::ffi::c_char),
264) -> String {
265 unsafe {
266 let mut length = 0;
267 get(object, INFO_LOG_LENGTH, &mut length);
268 let mut text = vec![0u8; length.max(1) as usize];
269 read(object, length, ptr::null_mut(), text.as_mut_ptr().cast());
270 String::from_utf8_lossy(&text).trim_end_matches('\0').to_owned()
271 }
272}
platform/snow-leopard/hello/Cargo.toml created+6
...@@ -0,0 +1,6 @@
1[package]
2name = "hello-snow-leopard"
3version = "0.0.0"
4edition = "2024"
5publish = false
6
platform/snow-leopard/hello/src/main.rs created+42
...@@ -0,0 +1,42 @@
1use std::cell::Cell;
2use std::time::{Instant, SystemTime};
3
4thread_local! {
5 static COUNTER: Cell<u32> = const { Cell::new(0) };
6}
7
8fn main() {
9 let dir = std::env::temp_dir().join(format!("snow-leopard-{}", std::process::id()));
10 std::fs::create_dir_all(&dir).unwrap();
11 let file = dir.join("hello.txt");
12 std::fs::write(&file, "hello from rust").unwrap();
13 let text = std::fs::read_to_string(&file).unwrap();
14 let meta = std::fs::metadata(&file).unwrap();
15 let entries = std::fs::read_dir(&dir).unwrap().count();
16 std::fs::remove_dir_all(&dir).unwrap();
17
18 let start = Instant::now();
19 let threads: Vec<_> = (0..4)
20 .map(|i| {
21 std::thread::spawn(move || {
22 for _ in 0..=i {
23 COUNTER.with(|c| c.set(c.get() + 1));
24 }
25 COUNTER.with(Cell::get)
26 })
27 })
28 .collect();
29 let counts: Vec<u32> = threads.into_iter().map(|t| t.join().unwrap()).collect();
30 COUNTER.with(|c| c.set(c.get() + 100));
31
32 let mut map = std::collections::HashMap::new();
33 map.insert("random-state", 1);
34
35 let since_epoch = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap();
36 println!("fs: {text:?} ({} bytes, {entries} entries)", meta.len());
37 println!("tls: threads {counts:?}, main {}", COUNTER.with(Cell::get));
38 println!("time: {}s since epoch, {:?} elapsed", since_epoch.as_secs(), start.elapsed());
39 println!("args: {:?}, HOME={:?}", std::env::args().collect::<Vec<_>>(), std::env::var("HOME").ok());
40 std::panic::catch_unwind(|| panic!("caught")).unwrap_err();
41 println!("unwind: ok");
42}
platform/snow-leopard/link.sh created+43
...@@ -0,0 +1,43 @@
1#!/bin/sh
2# rustc's linker for the Mac OS X 10.6 targets: links against the 10.6 SDK (see
3# `remote.sh sdk`), so an import 10.6 lacks fails here, plus the shims in rt/.
4# rustc clamps the deployment target to 10.12; this pins it back to 10.6.
5set -eu
6here=$(cd "$(dirname "$0")" && pwd)
7sdk=${SNOW_LEOPARD_SDK:-$here/../../target/snow-leopard/MacOSX10.6.sdk}
8[ -d "$sdk" ] || { echo "link.sh: no 10.6 SDK at $sdk; run remote.sh sdk" >&2; exit 1; }
9arch=x86_64
10prev=
11for arg; do
12 shift
13 case "$arg" in
14 -mmacosx-version-min=*) arg=-mmacosx-version-min=10.6 ;;
15 esac
16 # 10.6 keeps these inside ApplicationServices, which re-exports them to its clients.
17 if [ "$prev" = -framework ]; then
18 case "$arg" in
19 CoreGraphics | CoreText | ImageIO | ColorSync) arg=ApplicationServices ;;
20 # Until draw's wgpu backend is optional: wgpu names Metal, which 10.6 lacks,
21 # and nothing a 10.6 binary reaches calls into it.
22 Metal) arg=Foundation ;;
23 esac
24 fi
25 [ "$prev" = -arch ] && arch=$arg
26 prev=$arg
27 set -- "$@" "$arg"
28done
29work=$(mktemp -d "${TMPDIR:-/tmp}/snow-leopard-link.XXXXXX")
30trap 'rm -rf "$work"' EXIT
31for source in "$here"/rt/*.c; do
32 clang -arch "$arch" -isysroot "$sdk" -mmacosx-version-min=10.6 -O2 -Wall \
33 -c "$source" -o "$work/$(basename "$source" .c).o"
34done
35# shims.o overrides libSystem, so it links as an object; the rest fill in functions later
36# systems added, from an archive, so only binaries calling them pull in their frameworks.
37ar rcs "$work/libsnow_leopard.a" $(ls "$work"/*.o | grep -v /shims.o)
38status=0
39clang -isysroot "$sdk" "$@" "$work/shims.o" "$work/libsnow_leopard.a" 2>"$work/log" || status=$?
40# rustc's objects claim 10.12; ld-prime's stub deprecation is noise for a frozen SDK.
41grep -v -e "was built for newer 'macOS' version (10.12)" -e "dylib stub (MH_DYLIB_STUB)" \
42 -e "no platform load command found in .*crt1" "$work/log" >&2 || true
43exit $status
platform/snow-leopard/probe/Cargo.toml created+24
...@@ -0,0 +1,24 @@
1[package]
2name = "probe"
3version = "0.0.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8gl21 = { path = "../gl21" }
9objc2.workspace = true
10objc2-foundation = { workspace = true, features = ["std", "NSString", "NSAutoreleasePool", "NSObject", "NSZone", "NSGeometry", "NSDate", "NSRunLoop"] }
11objc2-app-kit = { workspace = true, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSView", "NSOpenGL", "NSOpenGLView", "NSEvent", "NSRunningApplication", "NSGraphics"] }
12png = "0.18"
13
14[[bin]]
15name = "probe"
16path = "src/main.rs"
17
18[[bin]]
19name = "window-probe"
20path = "src/window.rs"
21
22[[bin]]
23name = "render-probe"
24path = "src/render.rs"
platform/snow-leopard/probe/src/main.rs created+12
...@@ -0,0 +1,12 @@
1use objc2::{rc::{Retained, autoreleasepool}, runtime::NSObjectProtocol};
2use objc2_foundation::NSCopying;
3use objc2_foundation::{NSObject, NSString};
4
5fn main() {
6 autoreleasepool(|_| {
7 let text = NSString::from_str("objc2 on Snow Leopard");
8 let copy: Retained<NSString> = text.copy();
9 let object = NSObject::new();
10 println!("{copy} ({} chars), {object:?}", copy.length());
11 });
12}
platform/snow-leopard/probe/src/render.rs created+104
...@@ -0,0 +1,104 @@
1//! Paints `scene` through the GL 2.1 painter into an offscreen target and writes
2//! `render-probe.png`. The vertices follow `draw::Renderer`'s quad and rounded-rectangle
3//! encoding at scale 1.
4#![allow(deprecated)] // OpenGL is the only GPU API 10.6 has.
5mod scene;
6
7use gl21::painter::{Batch, Painter, Target, Vertex};
8use objc2::MainThreadMarker;
9use objc2_app_kit::{NSOpenGLContext, NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize, NSOpenGLPixelFormat};
10use scene::Shape;
11
12const WHITE_UV: [f32; 4] = [0.5 / ATLAS as f32; 4];
13const ATLAS: u32 = 2048;
14
15fn quad(vertices: &mut Vec<Vertex>, rect: [f32; 4], uv: [f32; 4], color: [f32; 4]) {
16 let [w, h] = scene::SIZE.map(|v| v as f32);
17 let (x0, x1) = (rect[0] * 2.0 / w - 1.0, rect[2] * 2.0 / w - 1.0);
18 let (y0, y1) = (1.0 - rect[1] * 2.0 / h, 1.0 - rect[3] * 2.0 / h);
19 let [hx, hy] = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5];
20 for (position, uv, local) in [
21 ([x0, y0], [uv[0], uv[1]], [-hx, -hy]),
22 ([x0, y1], [uv[0], uv[3]], [-hx, hy]),
23 ([x1, y1], [uv[2], uv[3]], [hx, hy]),
24 ([x0, y0], [uv[0], uv[1]], [-hx, -hy]),
25 ([x1, y1], [uv[2], uv[3]], [hx, hy]),
26 ([x1, y0], [uv[2], uv[1]], [hx, -hy]),
27 ] {
28 vertices.push(Vertex { position, uv, color, local, ..Default::default() });
29 }
30}
31
32fn rounded(vertices: &mut Vec<Vertex>, rect: [f32; 4], radius: [f32; 2], stroke: Option<(f32, bool)>, colors: [[f32; 4]; 2]) {
33 let start = vertices.len();
34 quad(vertices, [rect[0] - 1.0, rect[1] - 1.0, rect[2] + 1.0, rect[3] + 1.0], WHITE_UV, colors[0]);
35 let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5];
36 let radius = if radius.contains(&0.0) { [0.0; 2] } else { [radius[0].min(half[0]), radius[1].min(half[1])] };
37 let width = stroke.map_or(0.0, |(width, dashed)| {
38 let width = width.min(half[0].min(half[1]));
39 if dashed { -width } else { width }
40 });
41 for vertex in &mut vertices[start..] {
42 vertex.shape = [half[0], half[1], radius[0], radius[1]];
43 vertex.stroke = width;
44 if vertex.local[1] > 0.0 {
45 vertex.color = colors[1];
46 }
47 }
48}
49
50fn main() {
51 objc2::rc::autoreleasepool(|_| run());
52}
53
54fn run() {
55 let mtm = MainThreadMarker::new().unwrap();
56 let attributes = [NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize, 24, 0];
57 let format = unsafe {
58 NSOpenGLPixelFormat::initWithAttributes(mtm.alloc(), std::ptr::NonNull::new(attributes.as_ptr().cast_mut()).unwrap())
59 }
60 .expect("pixel format");
61 let context = NSOpenGLContext::initWithFormat_shareContext(mtm.alloc(), &format, None).expect("context");
62 context.makeCurrentContext();
63
64 let painter = Painter::new(ATLAS).unwrap_or_else(|error| panic!("{error}"));
65 let target = Target::new(scene::SIZE).unwrap();
66 let (checker_size, checker) = scene::checker();
67 let image = painter.image(checker_size, &checker);
68 let mut vertices = Vec::new();
69 let mut spans = Vec::new();
70 for shape in scene::shapes() {
71 let start = vertices.len() as u32;
72 let is_image = match shape {
73 Shape::Rect { rect, color } => {
74 quad(&mut vertices, rect, WHITE_UV, color);
75 false
76 }
77 Shape::Rounded { rect, radius, stroke, colors } => {
78 rounded(&mut vertices, rect, radius, stroke, colors);
79 false
80 }
81 Shape::Image { rect } => {
82 quad(&mut vertices, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4]);
83 true
84 }
85 };
86 spans.push((start..vertices.len() as u32, is_image));
87 }
88 let batches: Vec<Batch> = spans
89 .into_iter()
90 .map(|(vertices, is_image)| Batch {
91 vertices,
92 image: is_image.then_some(&image),
93 scissor: [0, 0, scene::SIZE[0], scene::SIZE[1]],
94 })
95 .collect();
96 let started = std::time::Instant::now();
97 painter.draw(&target, scene::CLEAR, &vertices, &batches);
98 let pixels = target.read_pixels();
99 println!("drew {} vertices in {:?}; GL error {:#x}", vertices.len(), started.elapsed(), unsafe { gl21::glGetError() });
100 let file = std::fs::File::create("render-probe.png").unwrap();
101 let mut encoder = png::Encoder::new(file, scene::SIZE[0], scene::SIZE[1]);
102 encoder.set_color(png::ColorType::Rgba);
103 encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
104}
platform/snow-leopard/probe/src/scene.rs created+40
...@@ -0,0 +1,40 @@
1//! One test scene, painted by `draw` over wgpu (the `reference` crate) and by the GL 2.1
2//! painter on 10.6 (`render-probe`), whose PNGs `compare.py` diffs. Device pixels,
3//! linear colours.
4
5pub const SIZE: [u32; 2] = [400, 300];
6pub const CLEAR: [f32; 4] = [0.85, 0.85, 0.85, 1.0];
7
8pub enum Shape {
9 Rect { rect: [f32; 4], color: [f32; 4] },
10 /// `stroke` is a width and whether it is dashed.
11 Rounded { rect: [f32; 4], radius: [f32; 2], stroke: Option<(f32, bool)>, colors: [[f32; 4]; 2] },
12 Image { rect: [f32; 4] },
13}
14
15pub fn shapes() -> Vec<Shape> {
16 let red = [0.6, 0.05, 0.05, 1.0];
17 let blue = [0.05, 0.1, 0.6, 1.0];
18 let green = [0.05, 0.4, 0.1, 1.0];
19 let ink = [0.02, 0.02, 0.02, 1.0];
20 use Shape::*;
21 vec![
22 Rect { rect: [20.0, 20.0, 120.0, 80.0], color: red },
23 Rounded { rect: [140.0, 20.0, 260.0, 80.0], radius: [12.0, 12.0], stroke: None, colors: [blue; 2] },
24 Rounded { rect: [280.0, 20.5, 380.0, 80.25], radius: [30.0, 12.0], stroke: None, colors: [green; 2] },
25 Rounded { rect: [20.0, 100.0, 120.0, 160.0], radius: [10.0, 10.0], stroke: Some((3.0, false)), colors: [ink; 2] },
26 Rounded { rect: [140.0, 100.0, 260.0, 160.0], radius: [16.0, 16.0], stroke: Some((2.0, true)), colors: [ink; 2] },
27 Rounded { rect: [280.0, 100.0, 380.0, 160.0], radius: [8.0, 8.0], stroke: None, colors: [[0.9, 0.5, 0.1, 1.0], [0.2, 0.05, 0.4, 1.0]] },
28 Image { rect: [20.0, 180.0, 120.0, 280.0] },
29 Rect { rect: [60.0, 200.0, 200.0, 260.0], color: [0.1, 0.3, 0.8, 0.5] },
30 Rounded { rect: [220.0, 180.0, 380.0, 280.0], radius: [30.0, 10.0], stroke: Some((3.0, true)), colors: [red; 2] },
31 ]
32}
33
34/// A 4×4 opaque sRGB checker, stretched and filtered by the image path.
35pub fn checker() -> ([u32; 2], Vec<u8>) {
36 let pixels = (0..16)
37 .flat_map(|i| if (i % 4 + i / 4) % 2 == 0 { [240, 200, 40, 255] } else { [30, 120, 90, 255] })
38 .collect();
39 ([4, 4], pixels)
40}
platform/snow-leopard/probe/src/window.rs created+133
...@@ -0,0 +1,133 @@
1//! Opens a window with an OpenGL 2.1 view, paints a test pattern through an sRGB
2//! framebuffer object, presents it, and writes the framebuffer to `window-probe.png`.
3#![allow(deprecated)] // OpenGL is the only GPU API 10.6 has.
4use gl21::*;
5use objc2::{MainThreadMarker, rc::Retained};
6use objc2_app_kit::{
7 NSApplication, NSApplicationActivationPolicy, NSBackingStoreType, NSOpenGLPFAAccelerated,
8 NSOpenGLPFAAlphaSize, NSOpenGLPFAColorSize, NSOpenGLPFADoubleBuffer, NSOpenGLPixelFormat,
9 NSOpenGLView, NSWindow, NSWindowStyleMask,
10};
11use objc2_foundation::{NSDate, NSDefaultRunLoopMode, NSPoint, NSRect, NSSize, NSString};
12
13const SIZE: [i32; 2] = [480, 320];
14
15fn main() {
16 // 10.6 has no implicit pool on the main thread; AppKit autoreleases from the start.
17 objc2::rc::autoreleasepool(|_| run());
18}
19
20fn run() {
21 let mtm = MainThreadMarker::new().unwrap();
22 let app = NSApplication::sharedApplication(mtm);
23 app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
24 app.finishLaunching();
25
26 let frame = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(SIZE[0] as f64, SIZE[1] as f64));
27 let window = unsafe {
28 NSWindow::initWithContentRect_styleMask_backing_defer(
29 mtm.alloc(),
30 frame,
31 NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::Resizable,
32 NSBackingStoreType::Buffered,
33 false,
34 )
35 };
36 unsafe { window.setReleasedWhenClosed(false) };
37 window.setTitle(&NSString::from_str("Snowbound on Snow Leopard"));
38 let attributes = [
39 NSOpenGLPFADoubleBuffer,
40 NSOpenGLPFAAccelerated,
41 NSOpenGLPFAColorSize,
42 24,
43 NSOpenGLPFAAlphaSize,
44 8,
45 0,
46 ];
47 let format: Retained<NSOpenGLPixelFormat> = unsafe {
48 NSOpenGLPixelFormat::initWithAttributes(
49 mtm.alloc(),
50 std::ptr::NonNull::new(attributes.as_ptr().cast_mut()).unwrap(),
51 )
52 }
53 .expect("pixel format");
54 let view = NSOpenGLView::initWithFrame_pixelFormat(mtm.alloc(), frame, Some(&format)).unwrap();
55 window.setContentView(Some(&view));
56 window.makeKeyAndOrderFront(None);
57 app.activateIgnoringOtherApps(true);
58
59 // Let the window server map the window before painting into it.
60 let until = NSDate::dateWithTimeIntervalSinceNow(0.5);
61 while let Some(event) = unsafe {
62 app.nextEventMatchingMask_untilDate_inMode_dequeue(
63 objc2_app_kit::NSEventMask::Any,
64 Some(&until),
65 NSDefaultRunLoopMode,
66 true,
67 )
68 } {
69 app.sendEvent(&event);
70 }
71
72 let context = view.openGLContext().expect("context");
73 context.makeCurrentContext();
74 println!("GL_VENDOR {}", string(VENDOR));
75 println!("GL_RENDERER {}", string(RENDERER));
76 println!("GL_VERSION {}", string(VERSION));
77 let extensions = string(EXTENSIONS);
78 for wanted in ["GL_EXT_framebuffer_object", "GL_EXT_framebuffer_blit", "GL_EXT_framebuffer_sRGB", "GL_EXT_texture_sRGB", "GL_ARB_texture_non_power_of_two"] {
79 println!("{wanted:32} {}", extensions.split(' ').any(|e| e == wanted));
80 }
81
82 let pixels = unsafe { paint() };
83 unsafe {
84 glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0);
85 glBlitFramebufferEXT(0, 0, SIZE[0], SIZE[1], 0, 0, SIZE[0], SIZE[1], COLOR_BUFFER_BIT, NEAREST as GLenum);
86 }
87 context.flushBuffer();
88 println!("GL error after present: {:#x}", unsafe { glGetError() });
89
90 let file = std::fs::File::create("window-probe.png").unwrap();
91 let mut encoder = png::Encoder::new(file, SIZE[0] as u32, SIZE[1] as u32);
92 encoder.set_color(png::ColorType::Rgba);
93 encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
94 println!("wrote window-probe.png");
95}
96
97/// Clears an sRGB framebuffer object and fills three bands with scissored clears in
98/// linear colours; returns its pixels top row first.
99unsafe fn paint() -> Vec<u8> {
100 unsafe {
101 let mut texture = 0;
102 glGenTextures(1, &mut texture);
103 glBindTexture(TEXTURE_2D, texture);
104 glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, NEAREST);
105 glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, NEAREST);
106 glTexImage2D(TEXTURE_2D, 0, SRGB8_ALPHA8, SIZE[0], SIZE[1], 0, RGBA, UNSIGNED_BYTE, std::ptr::null());
107 let mut framebuffer = 0;
108 glGenFramebuffersEXT(1, &mut framebuffer);
109 glBindFramebufferEXT(FRAMEBUFFER, framebuffer);
110 glFramebufferTexture2DEXT(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, texture, 0);
111 assert_eq!(glCheckFramebufferStatusEXT(FRAMEBUFFER), FRAMEBUFFER_COMPLETE);
112 glEnable(FRAMEBUFFER_SRGB);
113 glViewport(0, 0, SIZE[0], SIZE[1]);
114 glClearColor(0.9, 0.9, 0.9, 1.0);
115 glClear(COLOR_BUFFER_BIT);
116 glEnable(SCISSOR_TEST);
117 // Linear 0.214 is sRGB 128: the middle band reads back as 128 if encoding works.
118 for (i, color) in [[0.8, 0.1, 0.1], [0.214, 0.214, 0.214], [0.1, 0.2, 0.8]].iter().enumerate() {
119 glScissor(40 + i as i32 * 140, 60, 120, 200);
120 glClearColor(color[0], color[1], color[2], 1.0);
121 glClear(COLOR_BUFFER_BIT);
122 }
123 glDisable(SCISSOR_TEST);
124 let mut pixels = vec![0u8; (SIZE[0] * SIZE[1] * 4) as usize];
125 glReadPixels(0, 0, SIZE[0], SIZE[1], RGBA, UNSIGNED_BYTE, pixels.as_mut_ptr().cast());
126 glBindFramebufferEXT(READ_FRAMEBUFFER, framebuffer);
127 let row = (SIZE[0] * 4) as usize;
128 let flipped: Vec<u8> = pixels.chunks(row).rev().flatten().copied().collect();
129 let middle = &flipped[(160 * SIZE[0] as usize + 250) * 4..][..4];
130 println!("middle band reads {middle:?} (128 means sRGB encode on write)");
131 flipped
132 }
133}
platform/snow-leopard/reference/Cargo.toml created+12
...@@ -0,0 +1,12 @@
1[package]
2name = "reference"
3version = "0.0.0"
4edition = "2024"
5publish = false
6description = "Paints the render-probe scene through draw over wgpu, on the dev Mac."
7
8[dependencies]
9draw = { path = "../../../crates/draw" }
10wgpu = "30.0.1"
11pollster = "0.4"
12png = "0.18"
platform/snow-leopard/reference/src/main.rs created+111
...@@ -0,0 +1,111 @@
1//! `reference OUT.png` paints the render-probe scene through `draw::Renderer` over wgpu;
2//! `reference --compare A.png B.png DIFF.png` reports how two renders differ and writes
3//! their difference, amplified.
4#[path = "../../probe/src/scene.rs"]
5mod scene;
6
7use draw::{Layer, Primitive, RasterImage, Stroke};
8use scene::Shape;
9
10fn main() {
11 let args: Vec<String> = std::env::args().skip(1).collect();
12 match args.as_slice() {
13 [flag, a, b, diff] if flag == "--compare" => compare(a, b, diff),
14 [out] => paint(out),
15 _ => panic!("usage: reference OUT.png | reference --compare A.png B.png DIFF.png"),
16 }
17}
18
19fn read(path: &str) -> ([u32; 2], Vec<u8>) {
20 let mut decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
21 decoder.set_transformations(png::Transformations::normalize_to_color8());
22 let mut reader = decoder.read_info().unwrap();
23 let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
24 let info = reader.next_frame(&mut pixels).unwrap();
25 assert_eq!(info.color_type, png::ColorType::Rgba, "{path}");
26 ([info.width, info.height], pixels)
27}
28
29fn compare(a: &str, b: &str, diff: &str) {
30 let (size, a) = read(a);
31 let (other, b) = read(b);
32 assert_eq!(size, other, "sizes differ");
33 let delta: Vec<u8> = a.iter().zip(&b).map(|(a, b)| a.abs_diff(*b)).collect();
34 let worst = delta.iter().max().unwrap();
35 let off = delta.chunks(4).filter(|p| p.iter().any(|v| *v > 2)).count();
36 println!("max channel difference {worst}; {off} of {} pixels differ by more than 2", delta.len() / 4);
37 let shown: Vec<u8> = delta
38 .chunks(4)
39 .flat_map(|p| [p[0], p[1], p[2]].map(|v| v.saturating_mul(16)).into_iter().chain([255]))
40 .collect();
41 write(diff, size, &shown);
42}
43
44fn write(path: &str, [width, height]: [u32; 2], pixels: &[u8]) {
45 let mut encoder = png::Encoder::new(std::fs::File::create(path).unwrap(), width, height);
46 encoder.set_color(png::ColorType::Rgba);
47 encoder.write_header().unwrap().write_image_data(pixels).unwrap();
48}
49
50fn paint(out: &str) {
51 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
52 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
53 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
54 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
55 let mut renderer = draw::Renderer::new(device.clone(), queue.clone(), format);
56 let (checker_size, checker) = scene::checker();
57 let image = RasterImage::new(checker_size, checker).unwrap();
58 let primitives: Vec<Primitive> = scene::shapes()
59 .into_iter()
60 .map(|shape| match shape {
61 Shape::Rect { rect, color } => Primitive::Rect { rect, color },
62 Shape::Rounded { rect, radius, stroke, colors } => match stroke {
63 None => Primitive::Gradient { rect, radius, colors },
64 Some((width, dashed)) => Primitive::RoundedRect {
65 rect,
66 radius,
67 stroke: Some(if dashed { Stroke::Dashed(width) } else { Stroke::Solid(width) }),
68 color: colors[0],
69 },
70 },
71 Shape::Image { rect } => Primitive::Image { image: &image, rect },
72 })
73 .collect();
74 let [width, height] = scene::SIZE;
75 let texture = device.create_texture(&wgpu::TextureDescriptor {
76 label: None,
77 size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
78 mip_level_count: 1,
79 sample_count: 1,
80 dimension: wgpu::TextureDimension::D2,
81 format,
82 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
83 view_formats: &[],
84 });
85 let layer = Layer { scale: 1.0, origin: [0.0; 2], clip: None, backdrop: None, primitives: &primitives };
86 renderer
87 .draw(&texture.create_view(&Default::default()), scene::SIZE, scene::CLEAR, &[layer])
88 .unwrap();
89 let row = (width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
90 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
91 label: None,
92 size: u64::from(row * height),
93 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
94 mapped_at_creation: false,
95 });
96 let mut encoder = device.create_command_encoder(&Default::default());
97 encoder.copy_texture_to_buffer(
98 texture.as_image_copy(),
99 wgpu::TexelCopyBufferInfo {
100 buffer: &buffer,
101 layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: None },
102 },
103 wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
104 );
105 queue.submit([encoder.finish()]);
106 buffer.map_async(wgpu::MapMode::Read, .., |result| result.unwrap());
107 device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }).unwrap();
108 let mapped = buffer.get_mapped_range(..).unwrap();
109 let pixels: Vec<u8> = mapped.chunks(row as usize).flat_map(|r| &r[..width as usize * 4]).copied().collect();
110 write(out, scene::SIZE, &pixels);
111}
platform/snow-leopard/remote.sh created+60
...@@ -0,0 +1,60 @@
1#!/bin/sh
2# Drives the Mac OS X 10.6 test machine over ssh (scp doesn't pass its proxy).
3#
4# remote.sh run [-C DIR] BINARY [ARGS...]
5# ship to /tmp/snow-leopard, run in DIR (default there),
6# print any new crash log
7# remote.sh mirror PATH... copy repo paths to the same absolute paths there, so
8# test binaries find fixtures by CARGO_MANIFEST_DIR
9# remote.sh pull REMOTE [LOCAL] copy a file back (e.g. a screenshot)
10# remote.sh sdk fetch the machine's MacOSX10.6.sdk for link.sh
11# remote.sh sh COMMAND... run a shell command there
12set -eu
13host=${SNOW_LEOPARD_HOST:-osx10.6@10.0.0.1}
14here=$(cd "$(dirname "$0")" && pwd)
15dir=/tmp/snow-leopard
16
17case "${1:-}" in
18run)
19 shift
20 cwd=$dir
21 if [ "$1" = -C ]; then cwd=$2; shift 2; fi
22 binary=$1
23 shift
24 name=$(basename "$binary")
25 ssh "$host" "mkdir -p $dir && cat > $dir/$name && chmod +x $dir/$name" < "$binary"
26 quoted=
27 for arg; do quoted="$quoted '$(printf %s "$arg" | sed "s/'/'\\\\''/g")'"; done
28 # A crash report lands a few seconds after the process dies.
29 ssh "$host" "touch $dir/.start && cd '$cwd' && status=0 && $dir/$name$quoted || status=\$?
30 if [ \$status -ge 128 ]; then
31 sleep 5
32 for log in \$(find ~/Library/Logs/CrashReporter -name '${name}_*' -newer $dir/.start); do
33 echo \"--- \$log\" >&2; sed -n '1,/^Binary Images/p' \"\$log\" >&2
34 done
35 fi
36 exit \$status" < /dev/null
37 ;;
38mirror)
39 shift
40 root=$(cd "$here/../.." && pwd)
41 (cd "$root" && COPYFILE_DISABLE=1 tar czf - --no-mac-metadata --no-xattrs --no-acls --exclude 'corpus/private' --exclude target "$@") |
42 ssh "$host" "mkdir -p '$root' && tar xzf - -C '$root'"
43 ;;
44pull)
45 ssh "$host" "cat '$2'" > "${3:-$(basename "$2")}"
46 ;;
47sdk)
48 mkdir -p "$here/../../target/snow-leopard"
49 ssh "$host" 'cd /Developer/SDKs && tar czf - MacOSX10.6.sdk' |
50 tar xzf - -C "$here/../../target/snow-leopard"
51 ;;
52sh)
53 shift
54 ssh "$host" "$@"
55 ;;
56*)
57 sed -n '2,12s/^# \{0,1\}//p' "$0" >&2
58 exit 2
59 ;;
60esac
platform/snow-leopard/rt/coretext.c created+9
...@@ -0,0 +1,9 @@
1// CoreText functions fontique calls that arrived after 10.6.
2#include <ApplicationServices/ApplicationServices.h>
3
4// 10.9: fallback font for a string in a language; 10.6 can only ignore the language.
5CTFontRef CTFontCreateForStringWithLanguage(CTFontRef font, CFStringRef string, CFRange range,
6 CFStringRef language) {
7 (void)language;
8 return CTFontCreateForString(font, string, range);
9}
platform/snow-leopard/rt/objc.c created+39
...@@ -0,0 +1,39 @@
1// The ARC runtime entry points objc2 calls, which libobjc gained in 10.7 (objc_alloc in
2// 10.9); on 10.6 they were compiled into each binary from libarclite. Linked from an
3// archive, so only binaries that use Objective-C pull in libobjc.
4#include <objc/message.h>
5#include <objc/runtime.h>
6#include <stddef.h>
7
8typedef id (*Send)(id, SEL);
9#define SEND(receiver, selector) ((Send)objc_msgSend)((receiver), sel_registerName(selector))
10
11extern void *_Block_copy(const void *);
12
13id objc_retain(id object) { return object ? SEND(object, "retain") : nil; }
14void objc_release(id object) { if (object) SEND(object, "release"); }
15id objc_autorelease(id object) { return object ? SEND(object, "autorelease") : nil; }
16id objc_autoreleaseReturnValue(id object) { return objc_autorelease(object); }
17id objc_retainAutorelease(id object) { return objc_autorelease(objc_retain(object)); }
18id objc_retainAutoreleaseReturnValue(id object) { return objc_retainAutorelease(object); }
19id objc_retainAutoreleasedReturnValue(id object) { return objc_retain(object); }
20id objc_retainBlock(id block) { return (id)_Block_copy(block); }
21
22void objc_storeStrong(id *location, id value) {
23 id previous = *location;
24 *location = objc_retain(value);
25 objc_release(previous);
26}
27
28id objc_alloc(Class cls) { return cls ? SEND((id)cls, "alloc") : nil; }
29
30id objc_allocWithZone(Class cls) {
31 return cls ? ((id (*)(id, SEL, void *))objc_msgSend)((id)cls, sel_registerName("allocWithZone:"), NULL) : nil;
32}
33
34// Draining a pool also drains every pool pushed after it, as objc_autoreleasePoolPop does.
35void *objc_autoreleasePoolPush(void) {
36 return SEND(SEND((id)objc_getClass("NSAutoreleasePool"), "alloc"), "init");
37}
38
39void objc_autoreleasePoolPop(void *pool) { SEND((id)pool, "drain"); }
platform/snow-leopard/rt/shims.c created+188
...@@ -0,0 +1,188 @@
1// libSystem functions Rust std and LLVM reach for that Mac OS X 10.6 lacks or treats
2// differently. Linked into the executable, so its own references bind here first under
3// the two-level namespace; libraries the system loads keep the real ones.
4#include <dirent.h>
5#include <dlfcn.h>
6#include <errno.h>
7#include <fcntl.h>
8#include <limits.h>
9#include <math.h>
10#include <mach/mach_time.h>
11#include <stdarg.h>
12#include <stdint.h>
13#include <string.h>
14#include <sys/stat.h>
15#include <sys/time.h>
16#include <time.h>
17#include <unistd.h>
18
19#define SNOW_O_CLOEXEC 0x01000000 // 10.7
20#define SNOW_F_DUPFD_CLOEXEC 67 // 10.7
21#define SNOW_AT_FDCWD -2
22#define SNOW_AT_SYMLINK_NOFOLLOW 0x0020
23#define SNOW_AT_REMOVEDIR 0x0080
24
25// The real function this file overrides, from the next image in load order.
26#define REAL(name) \
27 static __typeof__(name) *real_##name; \
28 if (!real_##name) real_##name = (__typeof__(name) *)dlsym(RTLD_NEXT, #name)
29
30// 10.6's kernel predates O_CLOEXEC; set the flag after the fact.
31int open(const char *path, int flags, ...) {
32 REAL(open);
33 va_list ap;
34 va_start(ap, flags);
35 int mode = (flags & O_CREAT) ? va_arg(ap, int) : 0;
36 va_end(ap);
37 int fd = real_open(path, flags & ~SNOW_O_CLOEXEC, mode);
38 if (fd >= 0 && (flags & SNOW_O_CLOEXEC)) fcntl(fd, F_SETFD, FD_CLOEXEC);
39 return fd;
40}
41
42int fcntl(int fd, int cmd, ...) {
43 REAL(fcntl);
44 va_list ap;
45 va_start(ap, cmd);
46 intptr_t arg = va_arg(ap, intptr_t);
47 va_end(ap);
48 if (cmd == SNOW_F_DUPFD_CLOEXEC) {
49 int copy = real_fcntl(fd, F_DUPFD, arg);
50 if (copy >= 0) real_fcntl(copy, F_SETFD, FD_CLOEXEC);
51 return copy;
52 }
53 return real_fcntl(fd, cmd, arg);
54}
55
56// The *at family arrived in 10.10. Emulated through the directory's path, so a
57// directory renamed mid-walk is not followed as the real calls would.
58static int resolve_at(int dirfd, const char *path, char out[PATH_MAX]) {
59 if (dirfd == SNOW_AT_FDCWD || path[0] == '/') {
60 if (strlcpy(out, path, PATH_MAX) >= PATH_MAX) return errno = ENAMETOOLONG, -1;
61 return 0;
62 }
63 if (fcntl(dirfd, F_GETPATH, out) == -1) return -1;
64 if (strlcat(out, "/", PATH_MAX) >= PATH_MAX || strlcat(out, path, PATH_MAX) >= PATH_MAX)
65 return errno = ENAMETOOLONG, -1;
66 return 0;
67}
68
69int openat(int dirfd, const char *path, int flags, ...) {
70 va_list ap;
71 va_start(ap, flags);
72 int mode = (flags & O_CREAT) ? va_arg(ap, int) : 0;
73 va_end(ap);
74 char full[PATH_MAX];
75 if (resolve_at(dirfd, path, full) == -1) return -1;
76 return open(full, flags, mode);
77}
78
79int unlinkat(int dirfd, const char *path, int flags) {
80 char full[PATH_MAX];
81 if (resolve_at(dirfd, path, full) == -1) return -1;
82 return (flags & SNOW_AT_REMOVEDIR) ? rmdir(full) : unlink(full);
83}
84
85int fstatat(int dirfd, const char *path, struct stat *st, int flags)
86 __asm("_fstatat" __DARWIN_SUF_64_BIT_INO_T);
87int fstatat(int dirfd, const char *path, struct stat *st, int flags) {
88 char full[PATH_MAX];
89 if (resolve_at(dirfd, path, full) == -1) return -1;
90 return (flags & SNOW_AT_SYMLINK_NOFOLLOW) ? lstat(full, st) : stat(full, st);
91}
92
93int linkat(int fromfd, const char *from, int tofd, const char *to, int flags) {
94 char source[PATH_MAX], target[PATH_MAX];
95 (void)flags; // std passes 0
96 if (resolve_at(fromfd, from, source) == -1 || resolve_at(tofd, to, target) == -1) return -1;
97 return link(source, target);
98}
99
100// Clones are APFS-only (10.12); std's fs::copy falls back to fcopyfile on ENOTSUP.
101int fclonefileat(int fd, int dirfd, const char *to, int flags) {
102 (void)fd, (void)dirfd, (void)to, (void)flags;
103 return errno = ENOTSUP, -1;
104}
105
106// Callers keep using `fd` (std's remove_dir_all passes it to openat/unlinkat), so the
107// stream takes over that number rather than closing it.
108DIR *fdopendir(int fd) __asm("_fdopendir" __DARWIN_SUF_64_BIT_INO_T __DARWIN_SUF_UNIX03);
109DIR *fdopendir(int fd) {
110 char path[PATH_MAX];
111 if (fcntl(fd, F_GETPATH, path) == -1) return NULL;
112 DIR *dir = opendir(path);
113 if (!dir) return NULL;
114 if (dup2(dir->__dd_fd, fd) == -1) {
115 closedir(dir);
116 return NULL;
117 }
118 close(dir->__dd_fd);
119 dir->__dd_fd = fd;
120 fcntl(fd, F_SETFD, FD_CLOEXEC);
121 return dir;
122}
123
124// A function only from 10.8; earlier headers define it as this macro.
125#undef dirfd
126int dirfd(DIR *dir) {
127 return dir->__dd_fd;
128}
129
130// clock_gettime arrived in 10.12. std asks for CLOCK_UPTIME_RAW (Instant) and
131// CLOCK_REALTIME (SystemTime).
132int clock_gettime(int clock, struct timespec *ts) {
133 switch (clock) {
134 case 0: { // CLOCK_REALTIME
135 struct timeval tv;
136 gettimeofday(&tv, NULL);
137 ts->tv_sec = tv.tv_sec;
138 ts->tv_nsec = tv.tv_usec * 1000;
139 return 0;
140 }
141 case 4: case 5: case 6: case 8: case 9: { // monotonic and uptime clocks
142 static mach_timebase_info_data_t base;
143 if (!base.denom) mach_timebase_info(&base);
144 uint64_t ns = mach_absolute_time() * base.numer / base.denom;
145 ts->tv_sec = ns / 1000000000;
146 ts->tv_nsec = ns % 1000000000;
147 return 0;
148 }
149 default:
150 errno = EINVAL;
151 return -1;
152 }
153}
154
155static int read_urandom(void *bytes, size_t count) {
156 static int urandom = -1;
157 if (urandom < 0) {
158 int fd = open("/dev/urandom", O_RDONLY | SNOW_O_CLOEXEC);
159 if (fd < 0) return -1;
160 if (!__sync_bool_compare_and_swap(&urandom, -1, fd)) close(fd);
161 }
162 for (uint8_t *p = bytes; count;) {
163 ssize_t n = read(urandom, p, count);
164 if (n < 0 && errno == EINTR) continue;
165 if (n <= 0) return -1;
166 p += n;
167 count -= (size_t)n;
168 }
169 return 0;
170}
171
172// CCRandomGenerateBytes (std) arrived in 10.10, getentropy (getrandom) in 10.12.
173int32_t CCRandomGenerateBytes(void *bytes, size_t count) {
174 return read_urandom(bytes, count);
175}
176
177int getentropy(void *bytes, size_t count) {
178 if (count > 256) return errno = EIO, -1;
179 return read_urandom(bytes, count);
180}
181
182// LLVM emits these for macOS 10.9+, and rustc hands LLVM at least 10.12.
183struct snow_double2 { double sinval, cosval; };
184struct snow_float2 { float sinval, cosval; };
185struct snow_double2 __sincos_stret(double x) { return (struct snow_double2){sin(x), cos(x)}; }
186struct snow_float2 __sincosf_stret(float x) { return (struct snow_float2){sinf(x), cosf(x)}; }
187double __exp10(double x) { return pow(10.0, x); }
188float __exp10f(float x) { return powf(10.0f, x); }
platform/snow-leopard/x86_64-apple-macosx10.6.json created+54
...@@ -0,0 +1,54 @@
1{
2 "abi-return-struct-as-int": true,
3 "arch": "x86_64",
4 "archive-format": "darwin",
5 "binary-format": "mach-o",
6 "cpu": "core2",
7 "crt-objects-fallback": "false",
8 "data-layout": "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
9 "debuginfo-kind": "dwarf-dsym",
10 "dll-suffix": ".dylib",
11 "dynamic-linking": true,
12 "eh-frame-header": false,
13 "emit-debug-gdb-scripts": false,
14 "frame-pointer": "always",
15 "function-sections": false,
16 "has-rpath": true,
17 "has-thread-local": false,
18 "is-like-darwin": true,
19 "link-env": [
20 "ZERO_AR_DATE=1"
21 ],
22 "link-env-remove": [
23 "IPHONEOS_DEPLOYMENT_TARGET",
24 "TVOS_DEPLOYMENT_TARGET",
25 "XROS_DEPLOYMENT_TARGET"
26 ],
27 "linker-flavor": "darwin-cc",
28 "linker-is-gnu": false,
29 "lld-flavor": "darwin",
30 "llvm-target": "x86_64-apple-macosx",
31 "max-atomic-width": 128,
32 "metadata": {
33 "description": "x86_64 Mac OS X 10.6 Snow Leopard",
34 "host_tools": false,
35 "std": true,
36 "tier": 3
37 },
38 "os": "macos",
39 "split-debuginfo": "packed",
40 "stack-probes": {
41 "kind": "inline"
42 },
43 "supported-split-debuginfo": [
44 "packed",
45 "unpacked",
46 "off"
47 ],
48 "target-family": [
49 "unix"
50 ],
51 "target-mcount": "\u0001mcount",
52 "target-pointer-width": 64,
53 "vendor": "apple"
54}
\ No newline at end of file
tools/TESTING.md+19-3
...@@ -12,9 +12,9 @@ The command runs formatting, all workspace features/targets, doctests, Clippy,...@@ -12,9 +12,9 @@ The command runs formatting, all workspace features/targets, doctests, Clippy,
12diagnostic/example builds, and Python regression tests. Each stage has its own12diagnostic/example builds, and Python regression tests. Each stage has its own
13log; `results.json` records executed commands, elapsed times and exit statuses.13log; `results.json` records executed commands, elapsed times and exit statuses.
14Only a completed run receives `status: passed`. Existing result directories are14Only a completed run receives `status: passed`. Existing result directories are
15refused. This lane clears inherited `ONESTORE_*` overrides and uses this15refused. This lane clears inherited `ONESTORE_*` and `SNOWBOUND_*` overrides and
16checkout's binaries so personal captures or external exporters cannot silently16uses this checkout's binaries so personal captures or external exporters cannot
17replace public inputs.17silently replace public inputs.
1818
19Repeat the same command in a fresh checkout containing only versioned files to19Repeat the same command in a fresh checkout containing only versioned files to
20verify clean-checkout compatibility. Fixture symlinks must remain symlinks; their20verify clean-checkout compatibility. Fixture symlinks must remain symlinks; their
...@@ -27,6 +27,22 @@ are **not** part of a successful public run. The Python suite tests native/lab...@@ -27,6 +27,22 @@ are **not** part of a successful public run. The Python suite tests native/lab
27harness logic using retained synthetic captures and mocks; it does not claim a27harness logic using retained synthetic captures and mocks; it does not claim a
28new execution of OneNote or a real server interruption.28new execution of OneNote or a real server interruption.
2929
30## Sweeps and soak
31
32Seeded sweeps (random edit walks, multi-client schedules, differentials across
33corpus sections) run a smoke slice by default. `SNOWBOUND_SWEEP=0` runs them in
34full with the seeds as written; any other number shifts every sweep's seeds, and
35a failure replays under the same value.
36
37`tools/soak.sh [seconds per fuzz target]` soaks a dedicated machine until
38stopped: each round runs the full sweeps under a random shift, in release with
39debug assertions, then every `fuzz/` target for the time limit. A failing round
40leaves `soak/*.log` ending in the command that replays it; crash inputs stay in
41`fuzz/artifacts/<target>/`. On a Linux VM, install rustup's stable and nightly
42toolchains, `cargo install cargo-fuzz`, and `build-essential pkg-config
43libfontconfig-dev`; copy the checkout together with the untracked `evidence/`,
44which tests read, and run the script under `tmux`.
45
30## Private and native verification46## Private and native verification
3147
32Private notebooks stay outside versioned fixtures. Materialize a copy before48Private notebooks stay outside versioned fixtures. Materialize a copy before
tools/canvas/README.md+3-3
...@@ -50,11 +50,11 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a...@@ -50,11 +50,11 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a
5050
51`--notebook FOLDER` opens a notebook laid out as OneNote lays one out. The title bar shows the application's icon and the notebook's name; below it two rows of toolbar buttons follow the Home ribbon's Basic Text group, the first nine tags and zoom (only zoom acts yet). Readable top-level sections are tabs in the notebook's order, each leaning over the next at 45° with a faint highlight along its top, and the open one on top, casting a soft shadow and framing the page and the page tabs on the right in its colour, which shades slightly down the window; a tab that opens rises to meet the frame as its outline fades in. The frame's top corners and the page's corners are rounded concentric with the window's. The open page's tab is the page's colour and joins it with rounded inside corners; subpages are indented. The interface follows the system's light or dark appearance, and in the dark one the page's paper darkens and text left at OneNote's automatic colour turns light (except on a highlight), resolved when painting so nothing re-lays out; explicit colours, highlights, pictures and ink keep theirs, except that the background art of OneNote 2010's page templates, recognised by the picture's hash, paints from bundled vector recreations drawn for the paper. Sections take their stored colour's hue at a saturation and lightness chosen for each appearance. `--ui-font FONT_FILE_OR_FOLDER` sets the interface's typeface in place of the system's; fonts are not bundled. The section's colour is also the interface accent, easing to the next section's when another opens. The search box, New Page and the page-tab toggle sit above the page tabs. Command-F searches the page titles; Enter opens the first match and Escape clears it. A page that also changed on another computer shows Keep mine / Keep theirs above it. `--section FILE TITLE` opens one section the same way at that page. Section files must be regular files; discovery rejects symbolic links.51`--notebook FOLDER` opens a notebook laid out as OneNote lays one out. The title bar shows the application's icon and the notebook's name; below it two rows of toolbar buttons follow the Home ribbon's Basic Text group, the first nine tags and zoom (only zoom acts yet). Readable top-level sections are tabs in the notebook's order, each leaning over the next at 45° with a faint highlight along its top, and the open one on top, casting a soft shadow and framing the page and the page tabs on the right in its colour, which shades slightly down the window; a tab that opens rises to meet the frame as its outline fades in. The frame's top corners and the page's corners are rounded concentric with the window's. The open page's tab is the page's colour and joins it with rounded inside corners; subpages are indented. The interface follows the system's light or dark appearance, and in the dark one the page's paper darkens and text left at OneNote's automatic colour turns light (except on a highlight), resolved when painting so nothing re-lays out; explicit colours, highlights, pictures and ink keep theirs, except that the background art of OneNote 2010's page templates, recognised by the picture's hash, paints from bundled vector recreations drawn for the paper. Sections take their stored colour's hue at a saturation and lightness chosen for each appearance. `--ui-font FONT_FILE_OR_FOLDER` sets the interface's typeface in place of the system's; fonts are not bundled. The section's colour is also the interface accent, easing to the next section's when another opens. The search box, New Page and the page-tab toggle sit above the page tabs. Command-F searches the page titles; Enter opens the first match and Escape clears it. A page that also changed on another computer shows Keep mine / Keep theirs above it. `--section FILE TITLE` opens one section the same way at that page. Section files must be regular files; discovery rejects symbolic links.
5252
53Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar and recent fonts) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`).53Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar, recent fonts and the user name edits are stored under, which Settings… (⌘,; Options in the sidebar and Ctrl+, on Linux) changes and which starts as the account's full name) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`).
5454
55The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads it on a thread of its own while the current page stays live; the newest read is laid out and replaces the page once the pictures it shows first are drawn, or after 200 ms.55The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms.
5656
57A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `appearance light|dark` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook.57A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `appearance light|dark` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`.
5858
59```sh59```sh
60cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one60cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one
tools/check_public.py+1-1
...@@ -18,7 +18,7 @@ def main():...@@ -18,7 +18,7 @@ def main():
18 output = args.output.resolve()18 output = args.output.resolve()
19 output.mkdir(parents=True, exist_ok=False)19 output.mkdir(parents=True, exist_ok=False)
20 environment = {key: value for key, value in os.environ.items()20 environment = {key: value for key, value in os.environ.items()
21 if not key.startswith('ONESTORE_')}21 if not key.startswith(('ONESTORE_', 'SNOWBOUND_'))}
22 environment['PYTHONPATH'] = str(root / 'tools')22 environment['PYTHONPATH'] = str(root / 'tools')
23 environment['CARGO_TARGET_DIR'] = str(root / 'target')23 environment['CARGO_TARGET_DIR'] = str(root / 'target')
24 commands = [24 commands = [
tools/soak.sh created+35
...@@ -0,0 +1,35 @@
1#!/bin/sh
2# Soaks a dedicated machine until stopped: each round runs every seeded sweep in full with
3# a fresh seed shift, then each fuzz target for a time limit. A failing round leaves its
4# log in soak/ with the command that replays it; libFuzzer keeps crash inputs in
5# fuzz/artifacts/<target>/.
6#
7# tools/soak.sh [seconds per fuzz target, default 600]
8set -u
9cd "$(dirname "$0")/.."
10seconds=${1:-600}
11mkdir -p soak
12# Release speed with the checks debug builds make.
13sweeps="cargo test --release --no-fail-fast --config profile.release.debug-assertions=true --config profile.release.overflow-checks=true -p onestore -p notebook -p canvas"
14$sweeps --no-run || exit 1
15cargo +nightly fuzz build || exit 1
16while :; do
17 shift=$(od -An -N4 -tu4 /dev/urandom | tr -d ' ')
18 log=soak/sweep-$shift.log
19 if SNOWBOUND_SWEEP=$shift $sweeps >"$log" 2>&1; then
20 rm "$log"
21 else
22 echo "replay: SNOWBOUND_SWEEP=$shift $sweeps" | tee -a "$log"
23 sed -n 's/^---- \(.*\) stdout ----$/\1/p' "$log" | while read -r name; do
24 echo "replay: SNOWBOUND_SWEEP=$shift $sweeps -- --exact $name" | tee -a "$log"
25 done
26 fi
27 for target in $(cargo +nightly fuzz list); do
28 log=soak/fuzz-$target-$(date +%Y%m%d-%H%M%S).log
29 if cargo +nightly fuzz run "$target" -- -max_total_time="$seconds" >"$log" 2>&1; then
30 rm "$log"
31 else
32 grep 'cargo fuzz run' "$log" | sed 's/^[[:space:]]*/replay: /' | tee -a "$log"
33 fi
34 done
35done
tools/test_concurrent_rust.py+3-3
...@@ -14,14 +14,14 @@ class OracleTests(unittest.TestCase):...@@ -14,14 +14,14 @@ class OracleTests(unittest.TestCase):
14 executable = output / 'client'14 executable = output / 'client'
15 executable.write_text(f'#!{sys.executable}\nimport json, os, sys, time\nprint(json.dumps(dict(os.environ)), flush=True)\nwhile not os.path.exists(sys.argv[5]): time.sleep(.001)\n')15 executable.write_text(f'#!{sys.executable}\nimport json, os, sys, time\nprint(json.dumps(dict(os.environ)), flush=True)\nwhile not os.path.exists(sys.argv[5]): time.sleep(.001)\n')
16 executable.chmod(0o700)16 executable.chmod(0o700)
17 with running_clients(output, 'unused.one', 2, 1, 1, 7, timeout=1.25,17 with running_clients(output, 'unused.one', 2, 1, 1, 7, timeout=600.25,
18 executable=executable, environment={'KEPT': 'value'}):18 executable=executable, environment={'KEPT': 'value'}):
19 (output / 'start').touch()19 (output / 'start').touch()
20 for actor in ['w0', 'w1', 'r0']:20 for actor in ['w0', 'w1', 'r0']:
21 environment = json.loads((output / f'{actor}.jsonl').read_text())21 environment = json.loads((output / f'{actor}.jsonl').read_text())
22 self.assertEqual(environment['ONESTORE_CLIENT_TIMEOUT_MS'], '1250')22 self.assertEqual(environment['ONESTORE_CLIENT_TIMEOUT_MS'], '600250')
23 self.assertEqual(environment['KEPT'], 'value')23 self.assertEqual(environment['KEPT'], 'value')
24 self.assertEqual(json.loads((output / 'clients.json').read_text())['timeout_ms'], 1250)24 self.assertEqual(json.loads((output / 'clients.json').read_text())['timeout_ms'], 600250)
2525
26 def test_distinct_reader_binary_is_launched_and_recorded(self):26 def test_distinct_reader_binary_is_launched_and_recorded(self):
27 with tempfile.TemporaryDirectory() as directory:27 with tempfile.TemporaryDirectory() as directory: