| author | |
| committer | |
| log | 04cf1f351630be2d74efa40ded0af99c0f440f8f |
| tree | 3e2d98809c7937d41ac1a26825b9010db5d49b1e |
| parent | bb5a7aff22be6cf47f808f3d65e597869ba30486 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-593 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 | /.env | 13 | /.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."*"] |
| 16 | opt-level = 3 | 16 | opt-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] | ||
| 21 | opt-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] _ in | 32 | 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] _ in | 35 | 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 | } |
| 26 | 26 | ||
| 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. |
| 28 | enum Author { | 28 | enum Author { |
| 29 | private static let key = "author" | 29 | private static let key = "author" |
| 30 | 30 | ||
| ... | @@ -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 | } |
| 35 | 35 | ||
| 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] _ in | 42 | 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 in | 46 | alert.addTextField { field in |
| 46 | field.text = name | 47 | field.text = name |
| 47 | field.placeholder = "Name" | 48 | field.placeholder = "User name" |
| 48 | field.textContentType = .name | 49 | field.textContentType = .name |
| 49 | field.autocapitalizationType = .words | 50 | field.autocapitalizationType = .words |
| 50 | save.isEnabled = !(field.text ?? "").isEmpty | 51 | save.isEnabled = !(field.text ?? "").isEmpty |
corpus/search/README.md created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | # Search | ||
| 2 | |||
| 3 | OneNote 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 | ||
| 5 | against 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 | } |
| 417 | 417 | ||
| 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 the | 476 | /// 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 | } |
| 2446 | 2504 | ||
| 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 | } |
| 2450 | 2509 | ||
| 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 | } |
| 2460 | 2519 | ||
| 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 per | 104 | /// 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)] |
| 107 | struct Paint { | 107 | struct 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 | } |
| 118 | 120 | ||
| 119 | enum Drag { | 121 | enum 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 the | 204 | /// 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 | } |
| 277 | 283 | ||
| 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 | outline | 1937 | 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 | }; |
| 14 | 14 | ||
| 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`. |
| 16 | pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint { | 16 | pub(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 | } |
| 26 | 27 | ||
| ... | @@ -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 | }; |
| 9 | use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId}; | 9 | use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId}; |
| 10 | use std::{ | 10 | use std::{ |
| ... | @@ -26,6 +26,7 @@ pub struct TextEngine { | ... | @@ -26,6 +26,7 @@ pub struct TextEngine { |
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | /// A font laid out in place of a family. | 28 | /// A font laid out in place of a family. |
| 29 | #[derive(Clone)] | ||
| 29 | pub struct Substitute { | 30 | pub 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 | } |
| 152 | 157 | ||
| 158 | /// Another engine with the same fonts and substitutes, to lay out on another thread. | ||
| 159 | impl 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 | |||
| 153 | impl TextEngine { | 170 | impl TextEngine { |
| 154 | /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as | 171 | /// 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 | } |
| 217 | 217 | ||
| 218 | impl 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)] |
| 220 | pub struct Found { | 236 | pub 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 | } |
| 247 | 263 | ||
| 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 self | 266 | match self |
| 261 | .entries | 267 | .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 { |
| 323 | fn snippet(entry: &Entry, query: &Query) -> (String, Vec<Range<usize>>) { | 329 | fn 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 > 0 | 346 | 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's | 2 | //! 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 sections | 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 sections |
| 4 | //! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural | 4 | //! 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. | ||
| 6 | 7 | ||
| 7 | use canvas::{ | 8 | use canvas::{ |
| 8 | document::TextPosition, | 9 | document::TextPosition, |
| ... | @@ -18,6 +19,9 @@ use onestore::{ | ... | @@ -18,6 +19,9 @@ use onestore::{ |
| 18 | }; | 19 | }; |
| 19 | use std::{collections::BTreeMap, path::Path}; | 20 | use std::{collections::BTreeMap, path::Path}; |
| 20 | 21 | ||
| 22 | #[path = "../../onestore/tests/support/sweep.rs"] | ||
| 23 | mod sweep; | ||
| 24 | |||
| 21 | const SECTIONS: [&str; 15] = [ | 25 | const 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 | |||
| 4 | use canvas::{ | ||
| 5 | document::TextDocument, | ||
| 6 | editor::CanvasEditor, | ||
| 7 | layout::TextEngine, | ||
| 8 | search::{Entry, Index, Query, fold, page_matches}, | ||
| 9 | }; | ||
| 10 | use onestore::{ | ||
| 11 | ExGuid, RevisionIndex, Store, | ||
| 12 | document::{Document, Format}, | ||
| 13 | page::{Page, text::Paragraph}, | ||
| 14 | }; | ||
| 15 | use std::path::Path; | ||
| 16 | |||
| 17 | /// Each page of the corpus notebook's section `name`. | ||
| 18 | fn 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 | |||
| 37 | fn 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 | |||
| 47 | fn 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] | ||
| 58 | fn 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] | ||
| 89 | fn 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] | ||
| 105 | fn 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] | ||
| 139 | fn 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`. | ||
| 146 | fn 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] | ||
| 158 | fn 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 rereads | 1 | //! 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 probe | 3 | //! 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. | ||
| 5 | 6 | ||
| 6 | use canvas::{ | 7 | use canvas::{ |
| 7 | document::TextPosition, | 8 | document::TextPosition, |
| ... | @@ -15,6 +16,9 @@ use onestore::{ | ... | @@ -15,6 +16,9 @@ use onestore::{ |
| 15 | }; | 16 | }; |
| 16 | use std::{collections::BTreeMap, path::Path}; | 17 | use std::{collections::BTreeMap, path::Path}; |
| 17 | 18 | ||
| 19 | #[path = "../../onestore/tests/support/sweep.rs"] | ||
| 20 | mod sweep; | ||
| 21 | |||
| 18 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] | 22 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] |
| 19 | enum Kind { | 23 | enum 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 | } |
| 291 | 295 | ||
| 292 | fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut Tally) { | 296 | fn 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 | } |
| 415 | 415 | ||
| 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 | } |
| 438 | 427 | ||
| ... | @@ -447,7 +436,7 @@ impl Canvas { | ... | @@ -447,7 +436,7 @@ impl Canvas { |
| 447 | outline | 436 | 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 { |
| 511 | 500 | ||
| 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. |
| 4 | 4 | ||
| 5 | use crate::{Result, owned, report, string}; | 5 | use crate::{Result, owned, report, string}; |
| 6 | use canvas::search::{Entry, Index, Query}; | ||
| 6 | use notebook::{ | 7 | use 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 | }; |
| 12 | use onestore::{ | 13 | use 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 | }; |
| 19 | use std::{ | 18 | use 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 | } |
| 174 | 173 | ||
| 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. |
| 176 | struct Indexed { | 175 | struct Checked { |
| 177 | checked: Instant, | 176 | at: Instant, |
| 178 | stamp: Vec<u8>, | 177 | stamp: Vec<u8>, |
| 179 | pages: Vec<(ExGuid, String, String)>, | ||
| 180 | } | 178 | } |
| 181 | 179 | ||
| 182 | pub struct Library { | 180 | pub 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 | } |
| 189 | 187 | ||
| 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. |
| 666 | const REJECTED: u32 = 4; | 664 | const REJECTED: u32 = 4; |
| 667 | 665 | ||
| 668 | /// The text a paragraph shows, without hidden field codes. | ||
| 669 | pub(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. | ||
| 683 | pub(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. | ||
| 713 | fn 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)] |
| 728 | pub(crate) struct Found { | 667 | pub(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 | } |
| 736 | 675 | ||
| 737 | /// Up to 60 characters either side of the first place `lower` occurs in `text`, on its line. | ||
| 738 | fn 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 | |||
| 766 | impl Library { | 676 | impl Library { |
| 767 | /// Pages of the notebook whose title or text holds `query`, ignoring case: the open | 677 | /// 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 | section | 693 | 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 | } |
| 803 | 719 | ||
| 804 | /// The pages of the section at catalog `path` with their texts, read again only when | 720 | /// 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 | } |
| 847 | 771 |
crates/mobile/src/tests.rs+7-7| ... | @@ -214,7 +214,7 @@ fn undo_restores_typing() { | ... | @@ -214,7 +214,7 @@ fn undo_restores_typing() { |
| 214 | fn stored_text(file: &Path, space: ExGuid) -> String { | 214 | fn 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 | } |
| 219 | 219 | ||
| 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 | } |
| 250 | 250 | ||
| 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 | } |
| 522 | 522 | ||
| 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 |
| 267 | when the page itself was moved to another section. Other URLs and unknown | 267 | when the page itself was moved to another section. Other URLs and unknown |
| 268 | targets are `None`. | 268 | targets are `None`. |
| 269 | 269 | ||
| 270 | `Notebook::read_section(path)` reads a section file as stored, without opening a | ||
| 271 | replica, 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 | |||
| 270 | With the optional `protected` feature, `Notebook::unlock(path, password)` | 274 | With the optional `protected` feature, `Notebook::unlock(path, password)` |
| 271 | reads a `Locked` section as `Unlocked { pages, .. }`, and | 275 | reads 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 | } |
| 748 | 748 | ||
| 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 decoded | 755 | /// 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 | } |
| 830 | 836 | ||
| 837 | /// A page as a section file stores it. | ||
| 838 | pub 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. | ||
| 847 | pub 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 the | 865 | /// 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 sections | 866 | /// 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::{ |
| 19 | mod model_ops; | 19 | mod model_ops; |
| 20 | #[path = "support/server.rs"] | 20 | #[path = "support/server.rs"] |
| 21 | mod server; | 21 | mod server; |
| 22 | #[path = "../../onestore/tests/support/sweep.rs"] | ||
| 23 | mod sweep; | ||
| 22 | use server::snapshot; | 24 | use server::snapshot; |
| 23 | 25 | ||
| 24 | fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { | 26 | fn 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 | random | 388 | 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<_> = text | 392 | 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; |
| 9 | use server::*; | 9 | use server::*; |
| 10 | #[path = "support/model_ops.rs"] | 10 | #[path = "support/model_ops.rs"] |
| 11 | mod model_ops; | 11 | mod model_ops; |
| 12 | #[path = "../../onestore/tests/support/sweep.rs"] | ||
| 13 | mod sweep; | ||
| 12 | 14 | ||
| 13 | /// Saves a replacement of `range` in the page holding `text`. | 15 | /// Saves a replacement of `range` in the page holding `text`. |
| 14 | fn save(cache: &Replica, text: ExGuid, range: Range<u32>, replacement: &str) -> Option<u64> { | 16 | fn 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.resume | 448 | 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.server | 504 | paused.server |
| 503 | }); | 505 | }); |
| 504 | entered_rx | 506 | 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() { |
| 671 | fn seeded_conflicts_keep_the_remote_text_and_later_edits_publish_once() { | 674 | fn 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 |
| 12 | 12 | ||
| 13 | #[test] | 13 | #[test] |
| 14 | fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() { | 14 | fn 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; |
| 16 | use server::{Fault, Server, conflicted, pages, snapshot}; | 16 | use server::{Fault, Server, conflicted, pages, snapshot}; |
| 17 | #[path = "support/model_ops.rs"] | 17 | #[path = "support/model_ops.rs"] |
| 18 | mod model_ops; | 18 | mod model_ops; |
| 19 | #[path = "../../onestore/tests/support/sweep.rs"] | ||
| 20 | mod sweep; | ||
| 19 | use model_ops::{ | 21 | use 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 = state | 272 | 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"] |
| 25 | mod model_schedule; | 25 | mod model_schedule; |
| 26 | #[path = "../../onestore/tests/support/sweep.rs"] | ||
| 27 | mod sweep; | ||
| 26 | #[path = "sync/tree.rs"] | 28 | #[path = "sync/tree.rs"] |
| 27 | mod tree; | 29 | mod tree; |
| 28 | 30 |
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"] |
| 23 | mod page_schedule; | 23 | mod page_schedule; |
| 24 | use page_schedule::body_outline; | 24 | use page_schedule::body_outline; |
| 25 | #[path = "../../onestore/tests/support/sweep.rs"] | ||
| 26 | mod sweep; | ||
| 25 | 27 | ||
| 26 | #[test] | 28 | #[test] |
| 27 | fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() { | 29 | fn 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 | }; |
| 21 | 21 | ||
| 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. | ||
| 24 | const PATIENCE: Duration = Duration::from_secs(120); | ||
| 25 | |||
| 22 | #[derive(Clone)] | 26 | #[derive(Clone)] |
| 23 | struct Shared(Arc<Mutex<Server>>); | 27 | struct Shared(Arc<Mutex<Server>>); |
| 24 | 28 | ||
| ... | @@ -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) = rx | 411 | 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 = cache | 522 | 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 { |
| 499 | 499 | ||
| 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)] | ||
| 666 | mod 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; |
| 24 | mod section; | 24 | mod section; |
| 25 | mod snapshot; | 25 | mod snapshot; |
| 26 | mod store; | 26 | mod store; |
| 27 | #[cfg(test)] | ||
| 28 | #[path = "../tests/support/sweep.rs"] | ||
| 29 | mod sweep; | ||
| 27 | mod toc; | 30 | mod toc; |
| 28 | mod tree; | 31 | mod tree; |
| 29 | mod write; | 32 | mod 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 { |
| 1225 | fn lowered_edits_store_the_edited_page() { | 1225 | fn 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] |
| 1623 | fn random_ops_read_back_as_the_model_predicts() { | 1622 | fn 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 | } |
| 934 | 937 | ||
| ... | @@ -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. | ||
| 215 | fn 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] | ||
| 223 | fn 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; |
| 4 | mod current; | 4 | mod current; |
| 5 | #[path = "support/disk.rs"] | 5 | #[path = "support/disk.rs"] |
| 6 | mod disk; | 6 | mod disk; |
| 7 | #[path = "support/sweep.rs"] | ||
| 8 | mod sweep; | ||
| 7 | use onestore::{ | 9 | use 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; |
| 6 | mod disk; | 6 | mod disk; |
| 7 | #[path = "support/ops.rs"] | 7 | #[path = "support/ops.rs"] |
| 8 | mod ops; | 8 | mod ops; |
| 9 | #[path = "support/sweep.rs"] | ||
| 10 | mod sweep; | ||
| 9 | 11 | ||
| 10 | use onestore::{ | 12 | use 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; |
| 11 | mod disk; | 11 | mod disk; |
| 12 | #[path = "support/ops.rs"] | 12 | #[path = "support/ops.rs"] |
| 13 | mod ops; | 13 | mod ops; |
| 14 | #[path = "support/sweep.rs"] | ||
| 15 | mod sweep; | ||
| 14 | 16 | ||
| 15 | const SOURCE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); | 17 | const SOURCE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); |
| 16 | 18 | ||
| ... | @@ -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() { |
| 360 | 366 | ||
| 361 | #[path = "support/page_schedule.rs"] | 367 | #[path = "support/page_schedule.rs"] |
| 362 | mod page_schedule; | 368 | mod page_schedule; |
| 369 | #[path = "support/sweep.rs"] | ||
| 370 | mod sweep; | ||
| 363 | 371 | ||
| 364 | #[test] | 372 | #[test] |
| 365 | fn twelve_client_page_schedules_include_removal_empty_sections_and_stale_edits() { | 373 | 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,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::{ |
| 11 | mod current; | 11 | mod current; |
| 12 | #[path = "support/disk.rs"] | 12 | #[path = "support/disk.rs"] |
| 13 | mod disk; | 13 | mod disk; |
| 14 | #[path = "support/sweep.rs"] | ||
| 15 | mod sweep; | ||
| 14 | 16 | ||
| 15 | const SOURCE: &[u8] = | 17 | const 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 | |||
| 6 | use std::ops::Range; | ||
| 7 | |||
| 8 | /// The shift `SNOWBOUND_SWEEP` asks full sweeps to run with. | ||
| 9 | pub 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. | ||
| 15 | pub 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; |
| 11 | mod disk; | 11 | mod disk; |
| 12 | #[path = "support/ops.rs"] | 12 | #[path = "support/ops.rs"] |
| 13 | mod ops; | 13 | mod ops; |
| 14 | #[path = "support/sweep.rs"] | ||
| 15 | mod sweep; | ||
| 14 | #[path = "support/tree_model.rs"] | 16 | #[path = "support/tree_model.rs"] |
| 15 | mod tree_model; | 17 | mod tree_model; |
| 16 | 18 | ||
| ... | @@ -27,8 +29,9 @@ const FIXTURES: [(&[u8], &[u8]); 2] = [ | ... | @@ -27,8 +29,9 @@ const FIXTURES: [(&[u8], &[u8]); 2] = [ |
| 27 | 29 | ||
| 28 | #[test] | 30 | #[test] |
| 29 | fn twelve_client_tree_schedules_preserve_content_and_indentation() { | 31 | fn 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] |
| 25 | libc = "0.2" | 25 | libc = "0.2" |
| 26 | objc2 = "0.5.2" | 26 | objc2 = "0.5.2" |
| 27 | objc2-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"] } | 27 | objc2-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"] } |
| 28 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] } | 28 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] } |
| 29 | 29 | ||
| 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 | |||
| 5 | use canvas::{ | ||
| 6 | editor::CanvasEditor, | ||
| 7 | layout::TextEngine, | ||
| 8 | search::{Entry, Index, Query, page_matches}, | ||
| 9 | }; | ||
| 10 | use std::time::Instant; | ||
| 11 | |||
| 12 | fn 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"); |
| 26 | pub const LINK: &[&str] = art!("icons/link"); | 26 | pub const LINK: &[&str] = art!("icons/link"); |
| 27 | pub const NOTEBOOK: &[&str] = art!("icons/notebook"); | 27 | pub const NOTEBOOK: &[&str] = art!("icons/notebook"); |
| 28 | pub const NUMBERING: &[&str] = art!("icons/numbering"); | 28 | pub const NUMBERING: &[&str] = art!("icons/numbering"); |
| 29 | pub const OPTIONS: &[&str] = art!("icons/options"); | ||
| 29 | pub const OUTDENT: &[&str] = art!("icons/outdent"); | 30 | pub const OUTDENT: &[&str] = art!("icons/outdent"); |
| 31 | pub const PAGE: &[&str] = art!("icons/page"); | ||
| 30 | pub const PICTURE: &[&str] = art!("icons/picture"); | 32 | pub const PICTURE: &[&str] = art!("icons/picture"); |
| 31 | pub const PLUS: &[&str] = art!("icons/plus"); | 33 | pub const PLUS: &[&str] = art!("icons/plus"); |
| 32 | pub const REDO: &[&str] = art!("icons/redo"); | 34 | pub 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. |
| 164 | pub fn install_text_input(_: &Window) {} | 164 | pub fn install_text_input(_: &Window) {} |
| 165 | 165 | ||
| 166 | /// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu. | ||
| 167 | pub fn install_menu() {} | ||
| 168 | |||
| 166 | pub fn clear_marked_text(_: &Window) {} | 169 | pub fn clear_marked_text(_: &Window) {} |
| 167 | 170 | ||
| 168 | pub fn configure_presentation(_: &wgpu::Surface<'_>) {} | 171 | pub 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 | } |
| 542 | 545 | ||
| 546 | /// Opens `folder` in the file manager. | ||
| 547 | pub 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. |
| 544 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { | 554 | pub 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::{ |
| 10 | use objc2_app_kit::{ | 10 | use 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 | }; |
| 14 | use objc2_foundation::{ | 15 | use 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 | } |
| 636 | 637 | ||
| 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 | } |
| 672 | 678 | ||
| 679 | /// Adds Settings… to the application menu Winit made, below About as macOS places it. | ||
| 680 | pub 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. | ||
| 704 | pub 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. |
| 674 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { | 711 | pub 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; |
| 5 | mod manage; | 5 | mod manage; |
| 6 | mod meeting; | 6 | mod meeting; |
| 7 | mod menus; | 7 | mod menus; |
| 8 | mod 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")] |
| 10 | mod platform; | 11 | mod platform; |
| 11 | mod screenshot; | 12 | mod screenshot; |
| 13 | mod search; | ||
| 12 | mod settings; | 14 | mod settings; |
| 13 | mod sidebar; | 15 | mod sidebar; |
| 14 | mod templates; | 16 | mod 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 | }; |
| 41 | use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px}; | 43 | use 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 | } |
| 106 | 111 | ||
| 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 | } |
| 318 | 323 | ||
| 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. |
| 320 | type Read = (u64, Result<Loaded, String>); | 325 | type Read = (u64, Result<Laid, String>); |
| 326 | |||
| 327 | enum Laid { | ||
| 328 | Page(Box<Opening>), | ||
| 329 | Library(Arc<Library>, String), | ||
| 330 | } | ||
| 321 | 331 | ||
| 322 | /// The newest page read, laid out and waiting for the pictures it shows first, so it | 332 | /// 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 { |
| 367 | 377 | ||
| 368 | struct State { | 378 | struct State { |
| 369 | window: Arc<Window>, | 379 | window: Arc<Window>, |
| 370 | /// Who this machine's edits name, as OneNote names the Office user: the account's | 380 | /// 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 | } |
| 468 | 482 | ||
| 469 | /// The field filtering the page list. | ||
| 470 | fn filter() -> Id { | ||
| 471 | Id::ROOT.child("filter") | ||
| 472 | } | ||
| 473 | |||
| 474 | impl State { | 483 | impl 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 { |
| 1554 | 1580 | ||
| 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 | } |
| 1839 | 1831 | ||
| 1840 | /// Runs `read` on a thread of its own; `open_loaded` shows what it read unless a newer | 1832 | /// 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 | } |
| 1853 | 1861 | ||
| 1854 | /// Lays out the newest page read, and shows it once the pictures it shows first are | 1862 | /// 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 self | 1910 | match self |
| 1910 | .notebooks | 1911 | .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.session | 1939 | self.session |
| 1937 | .as_ref() | 1940 | .as_ref() |
| ... | @@ -1987,7 +1990,10 @@ impl State { | ... | @@ -1987,7 +1990,10 @@ impl State { |
| 1987 | 1990 | ||
| 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 | saved | 2012 | 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 | } |
| 2172 | 2180 | ||
| ... | @@ -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 | } |
| 2607 | 2631 | ||
| 2608 | fn theme(appearance: winit::window::Theme) -> Theme { | 2632 | /// The interface's colours in `appearance`, on white pages when `light_pages`. |
| 2609 | match appearance { | 2633 | fn 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 | } |
| 2614 | 2644 | ||
| 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 | } |
| 2756 | 2786 | ||
| 2757 | /// The page list's rows for the pages matching `filter`, a page's conflict pages beneath it | 2787 | /// 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. |
| 2759 | fn page_rows( | 2789 | fn 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 | } |
| 2834 | 2868 | ||
| 2835 | /// A page's tab down the frame's right side: the open one is the page's colour and joins | 2869 | /// 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 | } |
| 2901 | 2940 | ||
| 2902 | /// The pages whose titles contain `filter`, ignoring case. | ||
| 2903 | fn 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. |
| 2914 | fn group(ui: &mut Ui, part: &str, rows: impl FnOnce(&mut Ui)) { | 2942 | fn 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 | |||
| 4 | use crate::{State, platform, settings::ColorScheme}; | ||
| 5 | use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, children, fill, popup::Item, px}; | ||
| 6 | use winit::keyboard::NamedKey; | ||
| 7 | |||
| 8 | const WIDTH: f32 = 640.0; | ||
| 9 | /// The pages' area, the same for every page so the dialog keeps its size. | ||
| 10 | const HEIGHT: f32 = 320.0; | ||
| 11 | const NAV: f32 = 150.0; | ||
| 12 | /// The column the fields' labels share. | ||
| 13 | const LABEL: f32 = 104.0; | ||
| 14 | const SCHEMES: [(ColorScheme, &str); 3] = [ | ||
| 15 | (ColorScheme::System, "System"), | ||
| 16 | (ColorScheme::Light, "Light"), | ||
| 17 | (ColorScheme::Dark, "Dark"), | ||
| 18 | ]; | ||
| 19 | |||
| 20 | #[derive(Clone, Copy, PartialEq)] | ||
| 21 | enum Page { | ||
| 22 | General, | ||
| 23 | Display, | ||
| 24 | SaveBackup, | ||
| 25 | } | ||
| 26 | |||
| 27 | impl 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. | ||
| 36 | pub struct Options { | ||
| 37 | page: Page, | ||
| 38 | user_name: String, | ||
| 39 | color_scheme: ColorScheme, | ||
| 40 | light_pages: bool, | ||
| 41 | } | ||
| 42 | |||
| 43 | fn id() -> Id { | ||
| 44 | Id::ROOT.child("options") | ||
| 45 | } | ||
| 46 | |||
| 47 | fn user_name() -> Id { | ||
| 48 | id().child("user-name") | ||
| 49 | } | ||
| 50 | |||
| 51 | fn schemes() -> Id { | ||
| 52 | id().child("color-schemes") | ||
| 53 | } | ||
| 54 | |||
| 55 | impl 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. | ||
| 266 | fn 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. | ||
| 282 | fn 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 | |||
| 5 | use crate::{Command, Library, State, Theme, art, lap, page}; | ||
| 6 | use canvas::search::{Entry, Found, Index, Query, page_matches}; | ||
| 7 | use notebook::Replica; | ||
| 8 | use onestore::ExGuid; | ||
| 9 | use 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 | }; | ||
| 20 | use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px}; | ||
| 21 | use 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)] | ||
| 25 | pub enum Scope { | ||
| 26 | Section, | ||
| 27 | Group, | ||
| 28 | Notebook, | ||
| 29 | #[default] | ||
| 30 | All, | ||
| 31 | } | ||
| 32 | |||
| 33 | impl 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 | |||
| 46 | const SEARCH_KEY: &str = if cfg!(target_os = "macos") { | ||
| 47 | "⌘E" | ||
| 48 | } else { | ||
| 49 | "Ctrl+E" | ||
| 50 | }; | ||
| 51 | const 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. | ||
| 57 | const RESULT: f32 = 42.0; | ||
| 58 | const HEADING: f32 = 24.0; | ||
| 59 | const RESULTS: f32 = 460.0; | ||
| 60 | /// Room the section name takes beside a result's title. | ||
| 61 | const PLACE: f32 = 150.0; | ||
| 62 | /// How long typing pauses before the index reads the pages it changed. | ||
| 63 | const SETTLE: Duration = Duration::from_millis(400); | ||
| 64 | |||
| 65 | fn results() -> Id { | ||
| 66 | Id::ROOT.child("results") | ||
| 67 | } | ||
| 68 | |||
| 69 | fn scope_menu() -> Id { | ||
| 70 | Id::ROOT.child("scope") | ||
| 71 | } | ||
| 72 | |||
| 73 | /// The field of the box, and of the results over it. | ||
| 74 | fn field() -> Id { | ||
| 75 | Id::ROOT.child("search-field") | ||
| 76 | } | ||
| 77 | |||
| 78 | fn 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. | ||
| 83 | pub fn takes_text(focus: Option<Id>) -> bool { | ||
| 84 | [Some(field()), Some(find_field())].contains(&focus) | ||
| 85 | } | ||
| 86 | |||
| 87 | /// Work for the index thread. | ||
| 88 | enum 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. | ||
| 104 | pub 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 | |||
| 132 | impl 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. | ||
| 193 | type Stamp = Option<(u64, SystemTime)>; | ||
| 194 | |||
| 195 | fn 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. | ||
| 201 | fn 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. | ||
| 209 | fn 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. | ||
| 255 | fn 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 | |||
| 287 | fn 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. | ||
| 373 | fn 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. | ||
| 408 | fn 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. | ||
| 438 | fn 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 | |||
| 456 | impl 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. | ||
| 1108 | struct Results { | ||
| 1109 | titled: usize, | ||
| 1110 | count: usize, | ||
| 1111 | } | ||
| 1112 | |||
| 1113 | impl 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. | ||
| 1134 | fn 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. |
| 3 | 3 | ||
| 4 | use serde::{Deserialize, Serialize}; | 4 | use serde::{Deserialize, Serialize}; |
| 5 | use std::path::{Path, PathBuf}; | 5 | use 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")] | ||
| 31 | pub enum ColorScheme { | ||
| 32 | #[default] | ||
| 33 | System, | ||
| 34 | Light, | ||
| 35 | Dark, | ||
| 36 | } | ||
| 37 | |||
| 38 | impl 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 | } |
| 19 | 48 | ||
| 20 | impl Settings { | 49 | impl Settings { |
| ... | @@ -45,8 +74,8 @@ impl Settings { | ... | @@ -45,8 +74,8 @@ impl Settings { |
| 45 | } | 74 | } |
| 46 | 75 | ||
| 47 | impl crate::State { | 76 | impl crate::State { |
| 48 | /// Keeps what the next launch restores: the open notebooks, the one shown, the sidebar | 77 | /// 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; |
| 13 | 13 | ||
| 14 | pub use list::{List, Row, Rows, list}; | 14 | pub use list::{List, Row, Rows, list}; |
| 15 | pub use theme::{Section, Shades, Theme}; | 15 | pub use theme::{Section, Shades, Theme}; |
| 16 | pub use widgets::{button, edit_key, edit_modifiers, scrollbar, text_field}; | 16 | pub use widgets::{button, check_box, edit_key, edit_modifiers, scrollbar, text_field}; |
| 17 | 17 | ||
| 18 | use draw::{ | 18 | use 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.scale | 521 | self.scale |
| 522 | } | 522 | } |
| 523 | 523 | ||
| 524 | pub(crate) fn modifiers(&self) -> ModifiersState { | 524 | pub fn modifiers(&self) -> ModifiersState { |
| 525 | self.modifiers | 525 | self.modifiers |
| 526 | } | 526 | } |
| 527 | 527 |
crates/ui/src/popup.rs+2-2| ... | @@ -13,7 +13,7 @@ use nucleo_matcher::{ | ... | @@ -13,7 +13,7 @@ use nucleo_matcher::{ |
| 13 | use std::cell::RefCell; | 13 | use std::cell::RefCell; |
| 14 | use winit::keyboard::{Key, NamedKey}; | 14 | use winit::keyboard::{Key, NamedKey}; |
| 15 | 15 | ||
| 16 | const CHECK: &[&str] = &[include_str!("../assets/check.svg")]; | 16 | pub(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. |
| 18 | const PAD: f32 = 4.0; | 18 | const PAD: f32 = 4.0; |
| 19 | const ROW: f32 = 26.0; | 19 | const 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 | } |
| 499 | 499 | ||
| 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`. |
| 501 | fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec<NamedKey> { | 501 | pub fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec<NamedKey> { |
| 502 | let Some(signal) = ui | 502 | let Some(signal) = ui |
| 503 | .focus | 503 | .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 | } |
| 31 | 31 | ||
| 32 | /// A check box before `label`, `checked` or not; a click on either reports for the caller | ||
| 33 | /// to toggle. | ||
| 34 | pub 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 scroll | 77 | /// 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 offset | 78 | /// 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" |
| 82 | version = "0.1.0" | 82 | version = "0.1.0" |
| 83 | dependencies = [ | 83 | dependencies = [ |
| 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 | ] |
| 397 | 401 | ||
| ... | @@ -1030,6 +1034,12 @@ version = "1.0.24" | ... | @@ -1030,6 +1034,12 @@ version = "1.0.24" |
| 1030 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1034 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1031 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | 1035 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" |
| 1032 | 1036 | ||
| 1037 | [[package]] | ||
| 1038 | name = "utf16_iter" | ||
| 1039 | version = "1.0.5" | ||
| 1040 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1041 | checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" | ||
| 1042 | |||
| 1033 | [[package]] | 1043 | [[package]] |
| 1034 | name = "utf8_iter" | 1044 | name = "utf8_iter" |
| 1035 | version = "1.0.4" | 1045 | version = "1.0.4" |
| ... | @@ -1206,6 +1216,12 @@ dependencies = [ | ... | @@ -1206,6 +1216,12 @@ dependencies = [ |
| 1206 | "windows-link", | 1216 | "windows-link", |
| 1207 | ] | 1217 | ] |
| 1208 | 1218 | ||
| 1219 | [[package]] | ||
| 1220 | name = "write16" | ||
| 1221 | version = "1.0.0" | ||
| 1222 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1223 | checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" | ||
| 1224 | |||
| 1209 | [[package]] | 1225 | [[package]] |
| 1210 | name = "writeable" | 1226 | name = "writeable" |
| 1211 | version = "0.6.4" | 1227 | version = "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. | ||
| 3 | version = 4 | ||
| 4 | |||
| 5 | [[package]] | ||
| 6 | name = "adler2" | ||
| 7 | version = "2.0.1" | ||
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 9 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | ||
| 10 | |||
| 11 | [[package]] | ||
| 12 | name = "allocator-api2" | ||
| 13 | version = "0.2.21" | ||
| 14 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 15 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | ||
| 16 | |||
| 17 | [[package]] | ||
| 18 | name = "android_system_properties" | ||
| 19 | version = "0.1.6" | ||
| 20 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 21 | checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" | ||
| 22 | dependencies = [ | ||
| 23 | "libc", | ||
| 24 | ] | ||
| 25 | |||
| 26 | [[package]] | ||
| 27 | name = "arrayvec" | ||
| 28 | version = "0.7.8" | ||
| 29 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 30 | checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" | ||
| 31 | |||
| 32 | [[package]] | ||
| 33 | name = "ash" | ||
| 34 | version = "0.38.0+1.3.281" | ||
| 35 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 36 | checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" | ||
| 37 | dependencies = [ | ||
| 38 | "libloading", | ||
| 39 | ] | ||
| 40 | |||
| 41 | [[package]] | ||
| 42 | name = "autocfg" | ||
| 43 | version = "1.5.1" | ||
| 44 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 45 | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | ||
| 46 | |||
| 47 | [[package]] | ||
| 48 | name = "bit-set" | ||
| 49 | version = "0.10.0" | ||
| 50 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 51 | checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" | ||
| 52 | dependencies = [ | ||
| 53 | "bit-vec", | ||
| 54 | ] | ||
| 55 | |||
| 56 | [[package]] | ||
| 57 | name = "bit-vec" | ||
| 58 | version = "0.9.1" | ||
| 59 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 60 | checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" | ||
| 61 | |||
| 62 | [[package]] | ||
| 63 | name = "bitflags" | ||
| 64 | version = "2.13.1" | ||
| 65 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 66 | checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" | ||
| 67 | |||
| 68 | [[package]] | ||
| 69 | name = "block2" | ||
| 70 | version = "0.6.2" | ||
| 71 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 72 | checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" | ||
| 73 | dependencies = [ | ||
| 74 | "objc2", | ||
| 75 | ] | ||
| 76 | |||
| 77 | [[package]] | ||
| 78 | name = "bumpalo" | ||
| 79 | version = "3.20.3" | ||
| 80 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 81 | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | ||
| 82 | |||
| 83 | [[package]] | ||
| 84 | name = "bytemuck" | ||
| 85 | version = "1.25.2" | ||
| 86 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 87 | checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" | ||
| 88 | dependencies = [ | ||
| 89 | "bytemuck_derive", | ||
| 90 | ] | ||
| 91 | |||
| 92 | [[package]] | ||
| 93 | name = "bytemuck_derive" | ||
| 94 | version = "1.12.1" | ||
| 95 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 96 | checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" | ||
| 97 | dependencies = [ | ||
| 98 | "proc-macro2", | ||
| 99 | "quote", | ||
| 100 | "syn 3.0.6", | ||
| 101 | ] | ||
| 102 | |||
| 103 | [[package]] | ||
| 104 | name = "byteorder-lite" | ||
| 105 | version = "0.1.0" | ||
| 106 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 107 | checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" | ||
| 108 | |||
| 109 | [[package]] | ||
| 110 | name = "cfg-if" | ||
| 111 | version = "1.0.5" | ||
| 112 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 113 | checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" | ||
| 114 | |||
| 115 | [[package]] | ||
| 116 | name = "cfg_aliases" | ||
| 117 | version = "0.2.2" | ||
| 118 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 119 | checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" | ||
| 120 | |||
| 121 | [[package]] | ||
| 122 | name = "codespan-reporting" | ||
| 123 | version = "0.13.1" | ||
| 124 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 125 | checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" | ||
| 126 | dependencies = [ | ||
| 127 | "serde", | ||
| 128 | "termcolor", | ||
| 129 | "unicode-width", | ||
| 130 | ] | ||
| 131 | |||
| 132 | [[package]] | ||
| 133 | name = "color_quant" | ||
| 134 | version = "1.1.0" | ||
| 135 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 136 | checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" | ||
| 137 | |||
| 138 | [[package]] | ||
| 139 | name = "crc32fast" | ||
| 140 | version = "1.5.2" | ||
| 141 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 142 | checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" | ||
| 143 | dependencies = [ | ||
| 144 | "cfg-if", | ||
| 145 | ] | ||
| 146 | |||
| 147 | [[package]] | ||
| 148 | name = "crunchy" | ||
| 149 | version = "0.2.4" | ||
| 150 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 151 | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | ||
| 152 | |||
| 153 | [[package]] | ||
| 154 | name = "dispatch2" | ||
| 155 | version = "0.3.1" | ||
| 156 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 157 | checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" | ||
| 158 | dependencies = [ | ||
| 159 | "bitflags", | ||
| 160 | "objc2", | ||
| 161 | ] | ||
| 162 | |||
| 163 | [[package]] | ||
| 164 | name = "displaydoc" | ||
| 165 | version = "0.2.7" | ||
| 166 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 167 | checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" | ||
| 168 | dependencies = [ | ||
| 169 | "proc-macro2", | ||
| 170 | "quote", | ||
| 171 | "syn 3.0.6", | ||
| 172 | ] | ||
| 173 | |||
| 174 | [[package]] | ||
| 175 | name = "dlib" | ||
| 176 | version = "0.5.3" | ||
| 177 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 178 | checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" | ||
| 179 | dependencies = [ | ||
| 180 | "libloading", | ||
| 181 | ] | ||
| 182 | |||
| 183 | [[package]] | ||
| 184 | name = "document-features" | ||
| 185 | version = "0.2.12" | ||
| 186 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 187 | checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" | ||
| 188 | dependencies = [ | ||
| 189 | "litrs", | ||
| 190 | ] | ||
| 191 | |||
| 192 | [[package]] | ||
| 193 | name = "draw" | ||
| 194 | version = "0.1.0" | ||
| 195 | dependencies = [ | ||
| 196 | "bytemuck", | ||
| 197 | "image", | ||
| 198 | "linebender_resource_handle", | ||
| 199 | "parley", | ||
| 200 | "roxmltree", | ||
| 201 | "swash", | ||
| 202 | "wgpu", | ||
| 203 | ] | ||
| 204 | |||
| 205 | [[package]] | ||
| 206 | name = "equivalent" | ||
| 207 | version = "1.0.2" | ||
| 208 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 209 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | ||
| 210 | |||
| 211 | [[package]] | ||
| 212 | name = "fax" | ||
| 213 | version = "0.2.7" | ||
| 214 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 215 | checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" | ||
| 216 | |||
| 217 | [[package]] | ||
| 218 | name = "fdeflate" | ||
| 219 | version = "0.3.7" | ||
| 220 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 221 | checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" | ||
| 222 | dependencies = [ | ||
| 223 | "simd-adler32", | ||
| 224 | ] | ||
| 225 | |||
| 226 | [[package]] | ||
| 227 | name = "flate2" | ||
| 228 | version = "1.1.10" | ||
| 229 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 230 | checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" | ||
| 231 | dependencies = [ | ||
| 232 | "crc32fast", | ||
| 233 | "miniz_oxide 0.9.1", | ||
| 234 | "zlib-rs", | ||
| 235 | ] | ||
| 236 | |||
| 237 | [[package]] | ||
| 238 | name = "foldhash" | ||
| 239 | version = "0.2.0" | ||
| 240 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 241 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | ||
| 242 | |||
| 243 | [[package]] | ||
| 244 | name = "font-types" | ||
| 245 | version = "0.12.5" | ||
| 246 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 247 | checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" | ||
| 248 | dependencies = [ | ||
| 249 | "bytemuck", | ||
| 250 | ] | ||
| 251 | |||
| 252 | [[package]] | ||
| 253 | name = "fontique" | ||
| 254 | version = "0.11.0" | ||
| 255 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | ||
| 256 | dependencies = [ | ||
| 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]] | ||
| 274 | name = "futures-core" | ||
| 275 | version = "0.3.34" | ||
| 276 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 277 | checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" | ||
| 278 | |||
| 279 | [[package]] | ||
| 280 | name = "futures-task" | ||
| 281 | version = "0.3.34" | ||
| 282 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 283 | checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" | ||
| 284 | |||
| 285 | [[package]] | ||
| 286 | name = "futures-util" | ||
| 287 | version = "0.3.34" | ||
| 288 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 289 | checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" | ||
| 290 | dependencies = [ | ||
| 291 | "futures-core", | ||
| 292 | "futures-task", | ||
| 293 | "pin-project-lite", | ||
| 294 | "slab", | ||
| 295 | ] | ||
| 296 | |||
| 297 | [[package]] | ||
| 298 | name = "gif" | ||
| 299 | version = "0.14.2" | ||
| 300 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 301 | checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" | ||
| 302 | dependencies = [ | ||
| 303 | "color_quant", | ||
| 304 | "weezl", | ||
| 305 | ] | ||
| 306 | |||
| 307 | [[package]] | ||
| 308 | name = "gl21" | ||
| 309 | version = "0.0.0" | ||
| 310 | |||
| 311 | [[package]] | ||
| 312 | name = "gl_generator" | ||
| 313 | version = "0.14.0" | ||
| 314 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 315 | checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" | ||
| 316 | dependencies = [ | ||
| 317 | "khronos_api", | ||
| 318 | "log", | ||
| 319 | "xml-rs", | ||
| 320 | ] | ||
| 321 | |||
| 322 | [[package]] | ||
| 323 | name = "glow" | ||
| 324 | version = "0.17.0" | ||
| 325 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 326 | checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" | ||
| 327 | dependencies = [ | ||
| 328 | "js-sys", | ||
| 329 | "slotmap", | ||
| 330 | "wasm-bindgen", | ||
| 331 | "web-sys", | ||
| 332 | ] | ||
| 333 | |||
| 334 | [[package]] | ||
| 335 | name = "glutin_wgl_sys" | ||
| 336 | version = "0.6.1" | ||
| 337 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 338 | checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" | ||
| 339 | dependencies = [ | ||
| 340 | "gl_generator", | ||
| 341 | ] | ||
| 342 | |||
| 343 | [[package]] | ||
| 344 | name = "gpu-allocator" | ||
| 345 | version = "0.28.0" | ||
| 346 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 347 | checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" | ||
| 348 | dependencies = [ | ||
| 349 | "ash", | ||
| 350 | "hashbrown 0.16.1", | ||
| 351 | "log", | ||
| 352 | "presser", | ||
| 353 | "thiserror", | ||
| 354 | "windows", | ||
| 355 | ] | ||
| 356 | |||
| 357 | [[package]] | ||
| 358 | name = "half" | ||
| 359 | version = "2.7.1" | ||
| 360 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 361 | checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" | ||
| 362 | dependencies = [ | ||
| 363 | "cfg-if", | ||
| 364 | "crunchy", | ||
| 365 | "num-traits", | ||
| 366 | "zerocopy", | ||
| 367 | ] | ||
| 368 | |||
| 369 | [[package]] | ||
| 370 | name = "harfrust" | ||
| 371 | version = "0.12.0" | ||
| 372 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 373 | checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" | ||
| 374 | dependencies = [ | ||
| 375 | "bitflags", | ||
| 376 | "bytemuck", | ||
| 377 | "read-fonts", | ||
| 378 | "smallvec", | ||
| 379 | ] | ||
| 380 | |||
| 381 | [[package]] | ||
| 382 | name = "hashbrown" | ||
| 383 | version = "0.16.1" | ||
| 384 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 385 | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | ||
| 386 | dependencies = [ | ||
| 387 | "allocator-api2", | ||
| 388 | "equivalent", | ||
| 389 | "foldhash", | ||
| 390 | ] | ||
| 391 | |||
| 392 | [[package]] | ||
| 393 | name = "hashbrown" | ||
| 394 | version = "0.17.1" | ||
| 395 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 396 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | ||
| 397 | dependencies = [ | ||
| 398 | "foldhash", | ||
| 399 | ] | ||
| 400 | |||
| 401 | [[package]] | ||
| 402 | name = "hello-snow-leopard" | ||
| 403 | version = "0.0.0" | ||
| 404 | |||
| 405 | [[package]] | ||
| 406 | name = "icu_collections" | ||
| 407 | version = "2.3.0" | ||
| 408 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 409 | checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" | ||
| 410 | dependencies = [ | ||
| 411 | "displaydoc", | ||
| 412 | "potential_utf", | ||
| 413 | "utf8_iter", | ||
| 414 | "yoke", | ||
| 415 | "zerofrom", | ||
| 416 | "zerovec", | ||
| 417 | ] | ||
| 418 | |||
| 419 | [[package]] | ||
| 420 | name = "icu_locale_core" | ||
| 421 | version = "2.3.0" | ||
| 422 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 423 | checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" | ||
| 424 | dependencies = [ | ||
| 425 | "displaydoc", | ||
| 426 | "litemap", | ||
| 427 | "serde", | ||
| 428 | "tinystr", | ||
| 429 | "writeable", | ||
| 430 | "zerovec", | ||
| 431 | ] | ||
| 432 | |||
| 433 | [[package]] | ||
| 434 | name = "icu_locale_fallback" | ||
| 435 | version = "2.3.0" | ||
| 436 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 437 | checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" | ||
| 438 | dependencies = [ | ||
| 439 | "icu_locale_core", | ||
| 440 | "icu_locale_fallback_data", | ||
| 441 | "icu_provider", | ||
| 442 | "potential_utf", | ||
| 443 | "tinystr", | ||
| 444 | "zerovec", | ||
| 445 | ] | ||
| 446 | |||
| 447 | [[package]] | ||
| 448 | name = "icu_locale_fallback_data" | ||
| 449 | version = "2.3.0" | ||
| 450 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 451 | checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" | ||
| 452 | |||
| 453 | [[package]] | ||
| 454 | name = "icu_normalizer" | ||
| 455 | version = "2.3.0" | ||
| 456 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 457 | checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" | ||
| 458 | dependencies = [ | ||
| 459 | "icu_collections", | ||
| 460 | "icu_normalizer_data", | ||
| 461 | "icu_properties", | ||
| 462 | "icu_provider", | ||
| 463 | "smallvec", | ||
| 464 | "zerovec", | ||
| 465 | ] | ||
| 466 | |||
| 467 | [[package]] | ||
| 468 | name = "icu_normalizer_data" | ||
| 469 | version = "2.3.0" | ||
| 470 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 471 | checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" | ||
| 472 | |||
| 473 | [[package]] | ||
| 474 | name = "icu_properties" | ||
| 475 | version = "2.3.0" | ||
| 476 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 477 | checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" | ||
| 478 | dependencies = [ | ||
| 479 | "displaydoc", | ||
| 480 | "icu_collections", | ||
| 481 | "icu_locale_core", | ||
| 482 | "icu_properties_data", | ||
| 483 | "icu_provider", | ||
| 484 | "zerotrie", | ||
| 485 | "zerovec", | ||
| 486 | ] | ||
| 487 | |||
| 488 | [[package]] | ||
| 489 | name = "icu_properties_data" | ||
| 490 | version = "2.3.0" | ||
| 491 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 492 | checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" | ||
| 493 | |||
| 494 | [[package]] | ||
| 495 | name = "icu_provider" | ||
| 496 | version = "2.3.1" | ||
| 497 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 498 | checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" | ||
| 499 | dependencies = [ | ||
| 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]] | ||
| 512 | name = "icu_segmenter" | ||
| 513 | version = "2.3.0" | ||
| 514 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 515 | checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" | ||
| 516 | dependencies = [ | ||
| 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]] | ||
| 528 | name = "icu_segmenter_data" | ||
| 529 | version = "2.3.0" | ||
| 530 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 531 | checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" | ||
| 532 | |||
| 533 | [[package]] | ||
| 534 | name = "image" | ||
| 535 | version = "0.25.10" | ||
| 536 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 537 | checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" | ||
| 538 | dependencies = [ | ||
| 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]] | ||
| 552 | name = "indexmap" | ||
| 553 | version = "2.14.2" | ||
| 554 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 555 | checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" | ||
| 556 | dependencies = [ | ||
| 557 | "equivalent", | ||
| 558 | "hashbrown 0.17.1", | ||
| 559 | ] | ||
| 560 | |||
| 561 | [[package]] | ||
| 562 | name = "jni-sys" | ||
| 563 | version = "0.3.1" | ||
| 564 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 565 | checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" | ||
| 566 | dependencies = [ | ||
| 567 | "jni-sys 0.4.1", | ||
| 568 | ] | ||
| 569 | |||
| 570 | [[package]] | ||
| 571 | name = "jni-sys" | ||
| 572 | version = "0.4.1" | ||
| 573 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 574 | checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" | ||
| 575 | dependencies = [ | ||
| 576 | "jni-sys-macros", | ||
| 577 | ] | ||
| 578 | |||
| 579 | [[package]] | ||
| 580 | name = "jni-sys-macros" | ||
| 581 | version = "0.4.1" | ||
| 582 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 583 | checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" | ||
| 584 | dependencies = [ | ||
| 585 | "quote", | ||
| 586 | "syn 2.0.119", | ||
| 587 | ] | ||
| 588 | |||
| 589 | [[package]] | ||
| 590 | name = "js-sys" | ||
| 591 | version = "0.3.105" | ||
| 592 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 593 | checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" | ||
| 594 | dependencies = [ | ||
| 595 | "cfg-if", | ||
| 596 | "futures-util", | ||
| 597 | "wasm-bindgen", | ||
| 598 | ] | ||
| 599 | |||
| 600 | [[package]] | ||
| 601 | name = "khronos-egl" | ||
| 602 | version = "6.0.0" | ||
| 603 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 604 | checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" | ||
| 605 | dependencies = [ | ||
| 606 | "libc", | ||
| 607 | "libloading", | ||
| 608 | "pkg-config", | ||
| 609 | ] | ||
| 610 | |||
| 611 | [[package]] | ||
| 612 | name = "khronos_api" | ||
| 613 | version = "3.1.0" | ||
| 614 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 615 | checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" | ||
| 616 | |||
| 617 | [[package]] | ||
| 618 | name = "libc" | ||
| 619 | version = "0.2.189" | ||
| 620 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 621 | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" | ||
| 622 | |||
| 623 | [[package]] | ||
| 624 | name = "libloading" | ||
| 625 | version = "0.8.9" | ||
| 626 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 627 | checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" | ||
| 628 | dependencies = [ | ||
| 629 | "cfg-if", | ||
| 630 | "windows-link", | ||
| 631 | ] | ||
| 632 | |||
| 633 | [[package]] | ||
| 634 | name = "libm" | ||
| 635 | version = "0.2.16" | ||
| 636 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 637 | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" | ||
| 638 | |||
| 639 | [[package]] | ||
| 640 | name = "linebender_resource_handle" | ||
| 641 | version = "0.1.1" | ||
| 642 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 643 | checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" | ||
| 644 | |||
| 645 | [[package]] | ||
| 646 | name = "litemap" | ||
| 647 | version = "0.8.3" | ||
| 648 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 649 | checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" | ||
| 650 | |||
| 651 | [[package]] | ||
| 652 | name = "litrs" | ||
| 653 | version = "1.0.0" | ||
| 654 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 655 | checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" | ||
| 656 | |||
| 657 | [[package]] | ||
| 658 | name = "lock_api" | ||
| 659 | version = "0.4.14" | ||
| 660 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 661 | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" | ||
| 662 | dependencies = [ | ||
| 663 | "scopeguard", | ||
| 664 | ] | ||
| 665 | |||
| 666 | [[package]] | ||
| 667 | name = "log" | ||
| 668 | version = "0.4.34" | ||
| 669 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 670 | checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" | ||
| 671 | |||
| 672 | [[package]] | ||
| 673 | name = "memchr" | ||
| 674 | version = "2.8.3" | ||
| 675 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 676 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | ||
| 677 | |||
| 678 | [[package]] | ||
| 679 | name = "memmap2" | ||
| 680 | version = "0.9.11" | ||
| 681 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 682 | checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" | ||
| 683 | dependencies = [ | ||
| 684 | "libc", | ||
| 685 | ] | ||
| 686 | |||
| 687 | [[package]] | ||
| 688 | name = "miniz_oxide" | ||
| 689 | version = "0.8.9" | ||
| 690 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 691 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" | ||
| 692 | dependencies = [ | ||
| 693 | "adler2", | ||
| 694 | "simd-adler32", | ||
| 695 | ] | ||
| 696 | |||
| 697 | [[package]] | ||
| 698 | name = "miniz_oxide" | ||
| 699 | version = "0.9.1" | ||
| 700 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 701 | checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" | ||
| 702 | dependencies = [ | ||
| 703 | "adler2", | ||
| 704 | "simd-adler32", | ||
| 705 | ] | ||
| 706 | |||
| 707 | [[package]] | ||
| 708 | name = "moxcms" | ||
| 709 | version = "0.8.1" | ||
| 710 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 711 | checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" | ||
| 712 | dependencies = [ | ||
| 713 | "num-traits", | ||
| 714 | "pxfm", | ||
| 715 | ] | ||
| 716 | |||
| 717 | [[package]] | ||
| 718 | name = "naga" | ||
| 719 | version = "30.0.1" | ||
| 720 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 721 | checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" | ||
| 722 | dependencies = [ | ||
| 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]] | ||
| 744 | name = "naga-types" | ||
| 745 | version = "30.0.1" | ||
| 746 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 747 | checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" | ||
| 748 | dependencies = [ | ||
| 749 | "hashbrown 0.17.1", | ||
| 750 | "indexmap", | ||
| 751 | "rustc-hash", | ||
| 752 | "thiserror", | ||
| 753 | ] | ||
| 754 | |||
| 755 | [[package]] | ||
| 756 | name = "ndk-sys" | ||
| 757 | version = "0.6.0+11769913" | ||
| 758 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 759 | checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" | ||
| 760 | dependencies = [ | ||
| 761 | "jni-sys 0.3.1", | ||
| 762 | ] | ||
| 763 | |||
| 764 | [[package]] | ||
| 765 | name = "num-traits" | ||
| 766 | version = "0.2.19" | ||
| 767 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 768 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | ||
| 769 | dependencies = [ | ||
| 770 | "autocfg", | ||
| 771 | "libm", | ||
| 772 | ] | ||
| 773 | |||
| 774 | [[package]] | ||
| 775 | name = "objc2" | ||
| 776 | version = "0.6.4" | ||
| 777 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 778 | checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" | ||
| 779 | dependencies = [ | ||
| 780 | "objc2-encode", | ||
| 781 | ] | ||
| 782 | |||
| 783 | [[package]] | ||
| 784 | name = "objc2-app-kit" | ||
| 785 | version = "0.3.2" | ||
| 786 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 787 | checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" | ||
| 788 | dependencies = [ | ||
| 789 | "bitflags", | ||
| 790 | "objc2", | ||
| 791 | "objc2-foundation", | ||
| 792 | ] | ||
| 793 | |||
| 794 | [[package]] | ||
| 795 | name = "objc2-core-foundation" | ||
| 796 | version = "0.3.2" | ||
| 797 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 798 | checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" | ||
| 799 | dependencies = [ | ||
| 800 | "bitflags", | ||
| 801 | "dispatch2", | ||
| 802 | "objc2", | ||
| 803 | ] | ||
| 804 | |||
| 805 | [[package]] | ||
| 806 | name = "objc2-core-graphics" | ||
| 807 | version = "0.3.2" | ||
| 808 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 809 | checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" | ||
| 810 | dependencies = [ | ||
| 811 | "dispatch2", | ||
| 812 | "objc2", | ||
| 813 | "objc2-core-foundation", | ||
| 814 | "objc2-io-surface", | ||
| 815 | ] | ||
| 816 | |||
| 817 | [[package]] | ||
| 818 | name = "objc2-core-text" | ||
| 819 | version = "0.3.2" | ||
| 820 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 821 | checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" | ||
| 822 | dependencies = [ | ||
| 823 | "bitflags", | ||
| 824 | "objc2-core-foundation", | ||
| 825 | ] | ||
| 826 | |||
| 827 | [[package]] | ||
| 828 | name = "objc2-encode" | ||
| 829 | version = "4.1.0" | ||
| 830 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 831 | checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" | ||
| 832 | |||
| 833 | [[package]] | ||
| 834 | name = "objc2-foundation" | ||
| 835 | version = "0.3.2" | ||
| 836 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 837 | checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" | ||
| 838 | dependencies = [ | ||
| 839 | "bitflags", | ||
| 840 | "objc2", | ||
| 841 | "objc2-core-foundation", | ||
| 842 | ] | ||
| 843 | |||
| 844 | [[package]] | ||
| 845 | name = "objc2-io-surface" | ||
| 846 | version = "0.3.2" | ||
| 847 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 848 | checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" | ||
| 849 | dependencies = [ | ||
| 850 | "bitflags", | ||
| 851 | "objc2", | ||
| 852 | "objc2-core-foundation", | ||
| 853 | ] | ||
| 854 | |||
| 855 | [[package]] | ||
| 856 | name = "objc2-metal" | ||
| 857 | version = "0.3.2" | ||
| 858 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 859 | checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" | ||
| 860 | dependencies = [ | ||
| 861 | "bitflags", | ||
| 862 | "block2", | ||
| 863 | "objc2", | ||
| 864 | "objc2-foundation", | ||
| 865 | ] | ||
| 866 | |||
| 867 | [[package]] | ||
| 868 | name = "objc2-quartz-core" | ||
| 869 | version = "0.3.2" | ||
| 870 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 871 | checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" | ||
| 872 | dependencies = [ | ||
| 873 | "bitflags", | ||
| 874 | "objc2", | ||
| 875 | "objc2-core-foundation", | ||
| 876 | "objc2-core-graphics", | ||
| 877 | "objc2-foundation", | ||
| 878 | "objc2-metal", | ||
| 879 | ] | ||
| 880 | |||
| 881 | [[package]] | ||
| 882 | name = "once_cell" | ||
| 883 | version = "1.21.4" | ||
| 884 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 885 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | ||
| 886 | |||
| 887 | [[package]] | ||
| 888 | name = "ordered-float" | ||
| 889 | version = "5.5.0" | ||
| 890 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 891 | checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" | ||
| 892 | dependencies = [ | ||
| 893 | "num-traits", | ||
| 894 | ] | ||
| 895 | |||
| 896 | [[package]] | ||
| 897 | name = "parking_lot" | ||
| 898 | version = "0.12.5" | ||
| 899 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 900 | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" | ||
| 901 | dependencies = [ | ||
| 902 | "lock_api", | ||
| 903 | "parking_lot_core", | ||
| 904 | ] | ||
| 905 | |||
| 906 | [[package]] | ||
| 907 | name = "parking_lot_core" | ||
| 908 | version = "0.9.12" | ||
| 909 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 910 | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" | ||
| 911 | dependencies = [ | ||
| 912 | "cfg-if", | ||
| 913 | "libc", | ||
| 914 | "redox_syscall", | ||
| 915 | "smallvec", | ||
| 916 | "windows-link", | ||
| 917 | ] | ||
| 918 | |||
| 919 | [[package]] | ||
| 920 | name = "parlance" | ||
| 921 | version = "0.1.0" | ||
| 922 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | ||
| 923 | |||
| 924 | [[package]] | ||
| 925 | name = "parley" | ||
| 926 | version = "0.11.0" | ||
| 927 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | ||
| 928 | dependencies = [ | ||
| 929 | "fontique", | ||
| 930 | "hashbrown 0.17.1", | ||
| 931 | "linebender_resource_handle", | ||
| 932 | "parlance", | ||
| 933 | "parley_engine", | ||
| 934 | "skrifa", | ||
| 935 | "smallvec", | ||
| 936 | ] | ||
| 937 | |||
| 938 | [[package]] | ||
| 939 | name = "parley_data" | ||
| 940 | version = "0.11.0" | ||
| 941 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | ||
| 942 | dependencies = [ | ||
| 943 | "icu_properties", | ||
| 944 | ] | ||
| 945 | |||
| 946 | [[package]] | ||
| 947 | name = "parley_engine" | ||
| 948 | version = "0.11.0" | ||
| 949 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | ||
| 950 | dependencies = [ | ||
| 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]] | ||
| 964 | name = "pin-project-lite" | ||
| 965 | version = "0.2.17" | ||
| 966 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 967 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | ||
| 968 | |||
| 969 | [[package]] | ||
| 970 | name = "pkg-config" | ||
| 971 | version = "0.3.34" | ||
| 972 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 973 | checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" | ||
| 974 | |||
| 975 | [[package]] | ||
| 976 | name = "png" | ||
| 977 | version = "0.18.1" | ||
| 978 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 979 | checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" | ||
| 980 | dependencies = [ | ||
| 981 | "bitflags", | ||
| 982 | "crc32fast", | ||
| 983 | "fdeflate", | ||
| 984 | "flate2", | ||
| 985 | "miniz_oxide 0.8.9", | ||
| 986 | ] | ||
| 987 | |||
| 988 | [[package]] | ||
| 989 | name = "pollster" | ||
| 990 | version = "0.4.0" | ||
| 991 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 992 | checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" | ||
| 993 | |||
| 994 | [[package]] | ||
| 995 | name = "portable-atomic" | ||
| 996 | version = "1.15.0" | ||
| 997 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 998 | checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" | ||
| 999 | |||
| 1000 | [[package]] | ||
| 1001 | name = "portable-atomic-util" | ||
| 1002 | version = "0.2.8" | ||
| 1003 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1004 | checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" | ||
| 1005 | dependencies = [ | ||
| 1006 | "portable-atomic", | ||
| 1007 | ] | ||
| 1008 | |||
| 1009 | [[package]] | ||
| 1010 | name = "potential_utf" | ||
| 1011 | version = "0.1.6" | ||
| 1012 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1013 | checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" | ||
| 1014 | dependencies = [ | ||
| 1015 | "serde_core", | ||
| 1016 | "writeable", | ||
| 1017 | "zerovec", | ||
| 1018 | ] | ||
| 1019 | |||
| 1020 | [[package]] | ||
| 1021 | name = "presser" | ||
| 1022 | version = "0.3.1" | ||
| 1023 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1024 | checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" | ||
| 1025 | |||
| 1026 | [[package]] | ||
| 1027 | name = "probe" | ||
| 1028 | version = "0.0.0" | ||
| 1029 | dependencies = [ | ||
| 1030 | "gl21", | ||
| 1031 | "objc2", | ||
| 1032 | "objc2-app-kit", | ||
| 1033 | "objc2-foundation", | ||
| 1034 | "png", | ||
| 1035 | ] | ||
| 1036 | |||
| 1037 | [[package]] | ||
| 1038 | name = "proc-macro2" | ||
| 1039 | version = "1.0.107" | ||
| 1040 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1041 | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | ||
| 1042 | dependencies = [ | ||
| 1043 | "unicode-ident", | ||
| 1044 | ] | ||
| 1045 | |||
| 1046 | [[package]] | ||
| 1047 | name = "profiling" | ||
| 1048 | version = "1.0.18" | ||
| 1049 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1050 | checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" | ||
| 1051 | |||
| 1052 | [[package]] | ||
| 1053 | name = "pxfm" | ||
| 1054 | version = "0.1.30" | ||
| 1055 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1056 | checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" | ||
| 1057 | |||
| 1058 | [[package]] | ||
| 1059 | name = "quick-error" | ||
| 1060 | version = "2.0.1" | ||
| 1061 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1062 | checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" | ||
| 1063 | |||
| 1064 | [[package]] | ||
| 1065 | name = "quote" | ||
| 1066 | version = "1.0.47" | ||
| 1067 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1068 | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | ||
| 1069 | dependencies = [ | ||
| 1070 | "proc-macro2", | ||
| 1071 | ] | ||
| 1072 | |||
| 1073 | [[package]] | ||
| 1074 | name = "range-alloc" | ||
| 1075 | version = "0.1.5" | ||
| 1076 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1077 | checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" | ||
| 1078 | |||
| 1079 | [[package]] | ||
| 1080 | name = "raw-window-handle" | ||
| 1081 | version = "0.6.2" | ||
| 1082 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1083 | checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" | ||
| 1084 | |||
| 1085 | [[package]] | ||
| 1086 | name = "raw-window-metal" | ||
| 1087 | version = "1.1.0" | ||
| 1088 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1089 | checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" | ||
| 1090 | dependencies = [ | ||
| 1091 | "objc2", | ||
| 1092 | "objc2-core-foundation", | ||
| 1093 | "objc2-foundation", | ||
| 1094 | "objc2-quartz-core", | ||
| 1095 | ] | ||
| 1096 | |||
| 1097 | [[package]] | ||
| 1098 | name = "read-fonts" | ||
| 1099 | version = "0.41.0" | ||
| 1100 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1101 | checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" | ||
| 1102 | dependencies = [ | ||
| 1103 | "bytemuck", | ||
| 1104 | "font-types", | ||
| 1105 | "once_cell", | ||
| 1106 | ] | ||
| 1107 | |||
| 1108 | [[package]] | ||
| 1109 | name = "redox_syscall" | ||
| 1110 | version = "0.5.18" | ||
| 1111 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1112 | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" | ||
| 1113 | dependencies = [ | ||
| 1114 | "bitflags", | ||
| 1115 | ] | ||
| 1116 | |||
| 1117 | [[package]] | ||
| 1118 | name = "reference" | ||
| 1119 | version = "0.0.0" | ||
| 1120 | dependencies = [ | ||
| 1121 | "draw", | ||
| 1122 | "png", | ||
| 1123 | "pollster", | ||
| 1124 | "wgpu", | ||
| 1125 | ] | ||
| 1126 | |||
| 1127 | [[package]] | ||
| 1128 | name = "renderdoc-sys" | ||
| 1129 | version = "1.1.0" | ||
| 1130 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1131 | checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" | ||
| 1132 | |||
| 1133 | [[package]] | ||
| 1134 | name = "roxmltree" | ||
| 1135 | version = "0.21.1" | ||
| 1136 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1137 | checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" | ||
| 1138 | dependencies = [ | ||
| 1139 | "memchr", | ||
| 1140 | ] | ||
| 1141 | |||
| 1142 | [[package]] | ||
| 1143 | name = "rustc-hash" | ||
| 1144 | version = "1.1.0" | ||
| 1145 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1146 | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" | ||
| 1147 | |||
| 1148 | [[package]] | ||
| 1149 | name = "rustversion" | ||
| 1150 | version = "1.0.23" | ||
| 1151 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1152 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | ||
| 1153 | |||
| 1154 | [[package]] | ||
| 1155 | name = "scopeguard" | ||
| 1156 | version = "1.2.0" | ||
| 1157 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1158 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | ||
| 1159 | |||
| 1160 | [[package]] | ||
| 1161 | name = "serde" | ||
| 1162 | version = "1.0.229" | ||
| 1163 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1164 | checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" | ||
| 1165 | dependencies = [ | ||
| 1166 | "serde_core", | ||
| 1167 | "serde_derive", | ||
| 1168 | ] | ||
| 1169 | |||
| 1170 | [[package]] | ||
| 1171 | name = "serde_core" | ||
| 1172 | version = "1.0.229" | ||
| 1173 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1174 | checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" | ||
| 1175 | dependencies = [ | ||
| 1176 | "serde_derive", | ||
| 1177 | ] | ||
| 1178 | |||
| 1179 | [[package]] | ||
| 1180 | name = "serde_derive" | ||
| 1181 | version = "1.0.229" | ||
| 1182 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1183 | checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" | ||
| 1184 | dependencies = [ | ||
| 1185 | "proc-macro2", | ||
| 1186 | "quote", | ||
| 1187 | "syn 3.0.6", | ||
| 1188 | ] | ||
| 1189 | |||
| 1190 | [[package]] | ||
| 1191 | name = "simd-adler32" | ||
| 1192 | version = "0.3.10" | ||
| 1193 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1194 | checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" | ||
| 1195 | |||
| 1196 | [[package]] | ||
| 1197 | name = "skrifa" | ||
| 1198 | version = "0.44.0" | ||
| 1199 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1200 | checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" | ||
| 1201 | dependencies = [ | ||
| 1202 | "bytemuck", | ||
| 1203 | "read-fonts", | ||
| 1204 | ] | ||
| 1205 | |||
| 1206 | [[package]] | ||
| 1207 | name = "slab" | ||
| 1208 | version = "0.4.12" | ||
| 1209 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1210 | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | ||
| 1211 | |||
| 1212 | [[package]] | ||
| 1213 | name = "slotmap" | ||
| 1214 | version = "1.1.1" | ||
| 1215 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1216 | checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" | ||
| 1217 | dependencies = [ | ||
| 1218 | "version_check", | ||
| 1219 | ] | ||
| 1220 | |||
| 1221 | [[package]] | ||
| 1222 | name = "smallvec" | ||
| 1223 | version = "1.16.2" | ||
| 1224 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1225 | checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" | ||
| 1226 | |||
| 1227 | [[package]] | ||
| 1228 | name = "spirv" | ||
| 1229 | version = "0.4.0+sdk-1.4.341.0" | ||
| 1230 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1231 | checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" | ||
| 1232 | dependencies = [ | ||
| 1233 | "bitflags", | ||
| 1234 | ] | ||
| 1235 | |||
| 1236 | [[package]] | ||
| 1237 | name = "stable_deref_trait" | ||
| 1238 | version = "1.2.1" | ||
| 1239 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1240 | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" | ||
| 1241 | |||
| 1242 | [[package]] | ||
| 1243 | name = "static_assertions" | ||
| 1244 | version = "1.1.0" | ||
| 1245 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1246 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" | ||
| 1247 | |||
| 1248 | [[package]] | ||
| 1249 | name = "swash" | ||
| 1250 | version = "0.2.10" | ||
| 1251 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1252 | checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" | ||
| 1253 | dependencies = [ | ||
| 1254 | "skrifa", | ||
| 1255 | "yazi", | ||
| 1256 | "zeno", | ||
| 1257 | ] | ||
| 1258 | |||
| 1259 | [[package]] | ||
| 1260 | name = "syn" | ||
| 1261 | version = "2.0.119" | ||
| 1262 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1263 | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | ||
| 1264 | dependencies = [ | ||
| 1265 | "proc-macro2", | ||
| 1266 | "quote", | ||
| 1267 | "unicode-ident", | ||
| 1268 | ] | ||
| 1269 | |||
| 1270 | [[package]] | ||
| 1271 | name = "syn" | ||
| 1272 | version = "3.0.6" | ||
| 1273 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1274 | checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" | ||
| 1275 | dependencies = [ | ||
| 1276 | "proc-macro2", | ||
| 1277 | "quote", | ||
| 1278 | "unicode-ident", | ||
| 1279 | ] | ||
| 1280 | |||
| 1281 | [[package]] | ||
| 1282 | name = "synstructure" | ||
| 1283 | version = "0.14.0" | ||
| 1284 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1285 | checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" | ||
| 1286 | dependencies = [ | ||
| 1287 | "proc-macro2", | ||
| 1288 | "quote", | ||
| 1289 | "syn 3.0.6", | ||
| 1290 | ] | ||
| 1291 | |||
| 1292 | [[package]] | ||
| 1293 | name = "termcolor" | ||
| 1294 | version = "1.4.1" | ||
| 1295 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1296 | checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" | ||
| 1297 | dependencies = [ | ||
| 1298 | "winapi-util", | ||
| 1299 | ] | ||
| 1300 | |||
| 1301 | [[package]] | ||
| 1302 | name = "thiserror" | ||
| 1303 | version = "2.0.21" | ||
| 1304 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1305 | checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" | ||
| 1306 | dependencies = [ | ||
| 1307 | "thiserror-impl", | ||
| 1308 | ] | ||
| 1309 | |||
| 1310 | [[package]] | ||
| 1311 | name = "thiserror-impl" | ||
| 1312 | version = "2.0.21" | ||
| 1313 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1314 | checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" | ||
| 1315 | dependencies = [ | ||
| 1316 | "proc-macro2", | ||
| 1317 | "quote", | ||
| 1318 | "syn 3.0.6", | ||
| 1319 | ] | ||
| 1320 | |||
| 1321 | [[package]] | ||
| 1322 | name = "tiff" | ||
| 1323 | version = "0.11.3" | ||
| 1324 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1325 | checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" | ||
| 1326 | dependencies = [ | ||
| 1327 | "fax", | ||
| 1328 | "flate2", | ||
| 1329 | "half", | ||
| 1330 | "quick-error", | ||
| 1331 | "weezl", | ||
| 1332 | "zune-jpeg", | ||
| 1333 | ] | ||
| 1334 | |||
| 1335 | [[package]] | ||
| 1336 | name = "tinystr" | ||
| 1337 | version = "0.8.4" | ||
| 1338 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1339 | checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" | ||
| 1340 | dependencies = [ | ||
| 1341 | "displaydoc", | ||
| 1342 | "serde_core", | ||
| 1343 | "zerovec", | ||
| 1344 | ] | ||
| 1345 | |||
| 1346 | [[package]] | ||
| 1347 | name = "unicode-ident" | ||
| 1348 | version = "1.0.26" | ||
| 1349 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1350 | checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" | ||
| 1351 | |||
| 1352 | [[package]] | ||
| 1353 | name = "unicode-width" | ||
| 1354 | version = "0.2.2" | ||
| 1355 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1356 | checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" | ||
| 1357 | |||
| 1358 | [[package]] | ||
| 1359 | name = "utf8_iter" | ||
| 1360 | version = "1.0.4" | ||
| 1361 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1362 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | ||
| 1363 | |||
| 1364 | [[package]] | ||
| 1365 | name = "version_check" | ||
| 1366 | version = "0.9.5" | ||
| 1367 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1368 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | ||
| 1369 | |||
| 1370 | [[package]] | ||
| 1371 | name = "wasm-bindgen" | ||
| 1372 | version = "0.2.128" | ||
| 1373 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1374 | checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" | ||
| 1375 | dependencies = [ | ||
| 1376 | "cfg-if", | ||
| 1377 | "once_cell", | ||
| 1378 | "rustversion", | ||
| 1379 | "wasm-bindgen-macro", | ||
| 1380 | "wasm-bindgen-shared", | ||
| 1381 | ] | ||
| 1382 | |||
| 1383 | [[package]] | ||
| 1384 | name = "wasm-bindgen-futures" | ||
| 1385 | version = "0.4.78" | ||
| 1386 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1387 | checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" | ||
| 1388 | dependencies = [ | ||
| 1389 | "js-sys", | ||
| 1390 | "wasm-bindgen", | ||
| 1391 | ] | ||
| 1392 | |||
| 1393 | [[package]] | ||
| 1394 | name = "wasm-bindgen-macro" | ||
| 1395 | version = "0.2.128" | ||
| 1396 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1397 | checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" | ||
| 1398 | dependencies = [ | ||
| 1399 | "quote", | ||
| 1400 | "wasm-bindgen-macro-support", | ||
| 1401 | ] | ||
| 1402 | |||
| 1403 | [[package]] | ||
| 1404 | name = "wasm-bindgen-macro-support" | ||
| 1405 | version = "0.2.128" | ||
| 1406 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1407 | checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" | ||
| 1408 | dependencies = [ | ||
| 1409 | "bumpalo", | ||
| 1410 | "proc-macro2", | ||
| 1411 | "quote", | ||
| 1412 | "syn 3.0.6", | ||
| 1413 | "wasm-bindgen-shared", | ||
| 1414 | ] | ||
| 1415 | |||
| 1416 | [[package]] | ||
| 1417 | name = "wasm-bindgen-shared" | ||
| 1418 | version = "0.2.128" | ||
| 1419 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1420 | checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" | ||
| 1421 | dependencies = [ | ||
| 1422 | "unicode-ident", | ||
| 1423 | ] | ||
| 1424 | |||
| 1425 | [[package]] | ||
| 1426 | name = "wayland-sys" | ||
| 1427 | version = "0.31.11" | ||
| 1428 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1429 | checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" | ||
| 1430 | dependencies = [ | ||
| 1431 | "dlib", | ||
| 1432 | "log", | ||
| 1433 | "once_cell", | ||
| 1434 | "pkg-config", | ||
| 1435 | ] | ||
| 1436 | |||
| 1437 | [[package]] | ||
| 1438 | name = "web-sys" | ||
| 1439 | version = "0.3.105" | ||
| 1440 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1441 | checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" | ||
| 1442 | dependencies = [ | ||
| 1443 | "js-sys", | ||
| 1444 | "wasm-bindgen", | ||
| 1445 | ] | ||
| 1446 | |||
| 1447 | [[package]] | ||
| 1448 | name = "weezl" | ||
| 1449 | version = "0.1.12" | ||
| 1450 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1451 | checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" | ||
| 1452 | |||
| 1453 | [[package]] | ||
| 1454 | name = "wgpu" | ||
| 1455 | version = "30.0.1" | ||
| 1456 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1457 | checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" | ||
| 1458 | dependencies = [ | ||
| 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]] | ||
| 1484 | name = "wgpu-core" | ||
| 1485 | version = "30.0.1" | ||
| 1486 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1487 | checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" | ||
| 1488 | dependencies = [ | ||
| 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]] | ||
| 1518 | name = "wgpu-core-deps-apple" | ||
| 1519 | version = "30.0.1" | ||
| 1520 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1521 | checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" | ||
| 1522 | dependencies = [ | ||
| 1523 | "wgpu-hal", | ||
| 1524 | ] | ||
| 1525 | |||
| 1526 | [[package]] | ||
| 1527 | name = "wgpu-core-deps-emscripten" | ||
| 1528 | version = "30.0.1" | ||
| 1529 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1530 | checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" | ||
| 1531 | dependencies = [ | ||
| 1532 | "wgpu-hal", | ||
| 1533 | ] | ||
| 1534 | |||
| 1535 | [[package]] | ||
| 1536 | name = "wgpu-core-deps-windows-linux-android" | ||
| 1537 | version = "30.0.1" | ||
| 1538 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1539 | checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" | ||
| 1540 | dependencies = [ | ||
| 1541 | "wgpu-hal", | ||
| 1542 | ] | ||
| 1543 | |||
| 1544 | [[package]] | ||
| 1545 | name = "wgpu-hal" | ||
| 1546 | version = "30.0.1" | ||
| 1547 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1548 | checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" | ||
| 1549 | dependencies = [ | ||
| 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]] | ||
| 1601 | name = "wgpu-naga-bridge" | ||
| 1602 | version = "30.0.1" | ||
| 1603 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1604 | checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" | ||
| 1605 | dependencies = [ | ||
| 1606 | "naga", | ||
| 1607 | "wgpu-types", | ||
| 1608 | ] | ||
| 1609 | |||
| 1610 | [[package]] | ||
| 1611 | name = "wgpu-types" | ||
| 1612 | version = "30.0.1" | ||
| 1613 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1614 | checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" | ||
| 1615 | dependencies = [ | ||
| 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]] | ||
| 1627 | name = "winapi-util" | ||
| 1628 | version = "0.1.11" | ||
| 1629 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1630 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | ||
| 1631 | dependencies = [ | ||
| 1632 | "windows-sys", | ||
| 1633 | ] | ||
| 1634 | |||
| 1635 | [[package]] | ||
| 1636 | name = "windows" | ||
| 1637 | version = "0.62.2" | ||
| 1638 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1639 | checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" | ||
| 1640 | dependencies = [ | ||
| 1641 | "windows-collections", | ||
| 1642 | "windows-core", | ||
| 1643 | "windows-future", | ||
| 1644 | "windows-numerics", | ||
| 1645 | ] | ||
| 1646 | |||
| 1647 | [[package]] | ||
| 1648 | name = "windows-collections" | ||
| 1649 | version = "0.3.2" | ||
| 1650 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1651 | checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" | ||
| 1652 | dependencies = [ | ||
| 1653 | "windows-core", | ||
| 1654 | ] | ||
| 1655 | |||
| 1656 | [[package]] | ||
| 1657 | name = "windows-core" | ||
| 1658 | version = "0.62.2" | ||
| 1659 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1660 | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | ||
| 1661 | dependencies = [ | ||
| 1662 | "windows-implement", | ||
| 1663 | "windows-interface", | ||
| 1664 | "windows-link", | ||
| 1665 | "windows-result", | ||
| 1666 | "windows-strings", | ||
| 1667 | ] | ||
| 1668 | |||
| 1669 | [[package]] | ||
| 1670 | name = "windows-future" | ||
| 1671 | version = "0.3.2" | ||
| 1672 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1673 | checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" | ||
| 1674 | dependencies = [ | ||
| 1675 | "windows-core", | ||
| 1676 | "windows-link", | ||
| 1677 | "windows-threading", | ||
| 1678 | ] | ||
| 1679 | |||
| 1680 | [[package]] | ||
| 1681 | name = "windows-implement" | ||
| 1682 | version = "0.60.2" | ||
| 1683 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1684 | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | ||
| 1685 | dependencies = [ | ||
| 1686 | "proc-macro2", | ||
| 1687 | "quote", | ||
| 1688 | "syn 2.0.119", | ||
| 1689 | ] | ||
| 1690 | |||
| 1691 | [[package]] | ||
| 1692 | name = "windows-interface" | ||
| 1693 | version = "0.59.3" | ||
| 1694 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1695 | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | ||
| 1696 | dependencies = [ | ||
| 1697 | "proc-macro2", | ||
| 1698 | "quote", | ||
| 1699 | "syn 2.0.119", | ||
| 1700 | ] | ||
| 1701 | |||
| 1702 | [[package]] | ||
| 1703 | name = "windows-link" | ||
| 1704 | version = "0.2.1" | ||
| 1705 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1706 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | ||
| 1707 | |||
| 1708 | [[package]] | ||
| 1709 | name = "windows-numerics" | ||
| 1710 | version = "0.3.1" | ||
| 1711 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1712 | checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" | ||
| 1713 | dependencies = [ | ||
| 1714 | "windows-core", | ||
| 1715 | "windows-link", | ||
| 1716 | ] | ||
| 1717 | |||
| 1718 | [[package]] | ||
| 1719 | name = "windows-result" | ||
| 1720 | version = "0.4.1" | ||
| 1721 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1722 | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | ||
| 1723 | dependencies = [ | ||
| 1724 | "windows-link", | ||
| 1725 | ] | ||
| 1726 | |||
| 1727 | [[package]] | ||
| 1728 | name = "windows-strings" | ||
| 1729 | version = "0.5.1" | ||
| 1730 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1731 | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | ||
| 1732 | dependencies = [ | ||
| 1733 | "windows-link", | ||
| 1734 | ] | ||
| 1735 | |||
| 1736 | [[package]] | ||
| 1737 | name = "windows-sys" | ||
| 1738 | version = "0.61.2" | ||
| 1739 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1740 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | ||
| 1741 | dependencies = [ | ||
| 1742 | "windows-link", | ||
| 1743 | ] | ||
| 1744 | |||
| 1745 | [[package]] | ||
| 1746 | name = "windows-threading" | ||
| 1747 | version = "0.2.1" | ||
| 1748 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1749 | checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" | ||
| 1750 | dependencies = [ | ||
| 1751 | "windows-link", | ||
| 1752 | ] | ||
| 1753 | |||
| 1754 | [[package]] | ||
| 1755 | name = "writeable" | ||
| 1756 | version = "0.6.4" | ||
| 1757 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1758 | checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" | ||
| 1759 | |||
| 1760 | [[package]] | ||
| 1761 | name = "xml-rs" | ||
| 1762 | version = "0.8.29" | ||
| 1763 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1764 | checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" | ||
| 1765 | |||
| 1766 | [[package]] | ||
| 1767 | name = "yazi" | ||
| 1768 | version = "0.2.1" | ||
| 1769 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1770 | checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" | ||
| 1771 | |||
| 1772 | [[package]] | ||
| 1773 | name = "yeslogic-fontconfig-sys" | ||
| 1774 | version = "6.0.1" | ||
| 1775 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1776 | checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" | ||
| 1777 | dependencies = [ | ||
| 1778 | "dlib", | ||
| 1779 | "once_cell", | ||
| 1780 | "pkg-config", | ||
| 1781 | ] | ||
| 1782 | |||
| 1783 | [[package]] | ||
| 1784 | name = "yoke" | ||
| 1785 | version = "0.8.3" | ||
| 1786 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1787 | checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" | ||
| 1788 | dependencies = [ | ||
| 1789 | "stable_deref_trait", | ||
| 1790 | "yoke-derive", | ||
| 1791 | "zerofrom", | ||
| 1792 | ] | ||
| 1793 | |||
| 1794 | [[package]] | ||
| 1795 | name = "yoke-derive" | ||
| 1796 | version = "0.8.3" | ||
| 1797 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1798 | checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" | ||
| 1799 | dependencies = [ | ||
| 1800 | "proc-macro2", | ||
| 1801 | "quote", | ||
| 1802 | "syn 3.0.6", | ||
| 1803 | "synstructure", | ||
| 1804 | ] | ||
| 1805 | |||
| 1806 | [[package]] | ||
| 1807 | name = "zeno" | ||
| 1808 | version = "0.3.3" | ||
| 1809 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1810 | checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" | ||
| 1811 | |||
| 1812 | [[package]] | ||
| 1813 | name = "zerocopy" | ||
| 1814 | version = "0.8.59" | ||
| 1815 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1816 | checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" | ||
| 1817 | dependencies = [ | ||
| 1818 | "zerocopy-derive", | ||
| 1819 | ] | ||
| 1820 | |||
| 1821 | [[package]] | ||
| 1822 | name = "zerocopy-derive" | ||
| 1823 | version = "0.8.59" | ||
| 1824 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1825 | checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" | ||
| 1826 | dependencies = [ | ||
| 1827 | "proc-macro2", | ||
| 1828 | "quote", | ||
| 1829 | "syn 2.0.119", | ||
| 1830 | ] | ||
| 1831 | |||
| 1832 | [[package]] | ||
| 1833 | name = "zerofrom" | ||
| 1834 | version = "0.1.8" | ||
| 1835 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1836 | checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" | ||
| 1837 | dependencies = [ | ||
| 1838 | "zerofrom-derive", | ||
| 1839 | ] | ||
| 1840 | |||
| 1841 | [[package]] | ||
| 1842 | name = "zerofrom-derive" | ||
| 1843 | version = "0.1.8" | ||
| 1844 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1845 | checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" | ||
| 1846 | dependencies = [ | ||
| 1847 | "proc-macro2", | ||
| 1848 | "quote", | ||
| 1849 | "syn 3.0.6", | ||
| 1850 | "synstructure", | ||
| 1851 | ] | ||
| 1852 | |||
| 1853 | [[package]] | ||
| 1854 | name = "zerotrie" | ||
| 1855 | version = "0.2.5" | ||
| 1856 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1857 | checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" | ||
| 1858 | dependencies = [ | ||
| 1859 | "displaydoc", | ||
| 1860 | "yoke", | ||
| 1861 | "zerofrom", | ||
| 1862 | "zerovec", | ||
| 1863 | ] | ||
| 1864 | |||
| 1865 | [[package]] | ||
| 1866 | name = "zerovec" | ||
| 1867 | version = "0.11.8" | ||
| 1868 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1869 | checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" | ||
| 1870 | dependencies = [ | ||
| 1871 | "serde", | ||
| 1872 | "yoke", | ||
| 1873 | "zerofrom", | ||
| 1874 | "zerovec-derive", | ||
| 1875 | ] | ||
| 1876 | |||
| 1877 | [[package]] | ||
| 1878 | name = "zerovec-derive" | ||
| 1879 | version = "0.11.6" | ||
| 1880 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1881 | checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" | ||
| 1882 | dependencies = [ | ||
| 1883 | "proc-macro2", | ||
| 1884 | "quote", | ||
| 1885 | "syn 3.0.6", | ||
| 1886 | ] | ||
| 1887 | |||
| 1888 | [[package]] | ||
| 1889 | name = "zlib-rs" | ||
| 1890 | version = "0.6.8" | ||
| 1891 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1892 | checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" | ||
| 1893 | |||
| 1894 | [[package]] | ||
| 1895 | name = "zune-core" | ||
| 1896 | version = "0.5.3" | ||
| 1897 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1898 | checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" | ||
| 1899 | |||
| 1900 | [[package]] | ||
| 1901 | name = "zune-jpeg" | ||
| 1902 | version = "0.5.15" | ||
| 1903 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1904 | checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" | ||
| 1905 | dependencies = [ | ||
| 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] | ||
| 4 | members = ["gl21", "hello", "probe", "reference"] | ||
| 5 | resolver = "3" | ||
| 6 | |||
| 7 | [workspace.dependencies] | ||
| 8 | objc2 = "0.6.4" | ||
| 9 | objc2-foundation = { version = "0.3.2", default-features = false } | ||
| 10 | objc2-app-kit = { version = "0.3.2", default-features = false } | ||
| 11 | |||
| 12 | [profile.dev.package."*"] | ||
| 13 | opt-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`. | ||
| 4 | set -eu | ||
| 5 | here=$(cd "$(dirname "$0")" && pwd) | ||
| 6 | sdk=${SNOW_LEOPARD_SDK:-$(cd "$here/../.." && pwd)/target/snow-leopard/MacOSX10.6.sdk} | ||
| 7 | target=x86_64-apple-macosx10.6 | ||
| 8 | command=$1 | ||
| 9 | shift | ||
| 10 | # Cargo can't see link.sh's inputs; folding their hash into the flags relinks on change. | ||
| 11 | runtime=$(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. | ||
| 14 | CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$here/../../target/snow-leopard} \ | ||
| 15 | SNOW_LEOPARD_SDK=$sdk \ | ||
| 16 | TARGET_CFLAGS="-isysroot $sdk -mmacosx-version-min=10.6" \ | ||
| 17 | MACOSX_DEPLOYMENT_TARGET=10.6 \ | ||
| 18 | exec 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] | ||
| 2 | name = "gl21" | ||
| 3 | version = "0.0.0" | ||
| 4 | edition = "2024" | ||
| 5 | publish = false | ||
| 6 | description = "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 | ||
| 5 | attribute vec2 position; | ||
| 6 | attribute vec2 uv; | ||
| 7 | attribute vec4 color; | ||
| 8 | attribute vec2 local; | ||
| 9 | attribute vec4 shape; | ||
| 10 | attribute float stroke; | ||
| 11 | #endif | ||
| 12 | varying vec2 v_uv; | ||
| 13 | varying vec4 v_color; | ||
| 14 | varying vec2 v_local; | ||
| 15 | varying vec4 v_shape; | ||
| 16 | varying float v_stroke; | ||
| 17 | |||
| 18 | #ifdef VERTEX | ||
| 19 | void 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 | ||
| 28 | uniform sampler2D atlas; | ||
| 29 | |||
| 30 | bool same(float a, float b) { | ||
| 31 | return abs(a - b) <= 1e-4 * max(1.0, max(abs(a), abs(b))); | ||
| 32 | } | ||
| 33 | |||
| 34 | float 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 | |||
| 51 | void 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 | |||
| 5 | pub mod painter; | ||
| 6 | |||
| 7 | use std::ffi::{c_char, c_void}; | ||
| 8 | |||
| 9 | pub type GLenum = u32; | ||
| 10 | pub type GLuint = u32; | ||
| 11 | pub type GLint = i32; | ||
| 12 | pub type GLsizei = i32; | ||
| 13 | pub type GLfloat = f32; | ||
| 14 | pub type GLboolean = u8; | ||
| 15 | pub type GLbitfield = u32; | ||
| 16 | pub type GLsizeiptr = isize; | ||
| 17 | |||
| 18 | pub const NO_ERROR: GLenum = 0; | ||
| 19 | pub const TRIANGLES: GLenum = 0x0004; | ||
| 20 | pub const COLOR_BUFFER_BIT: GLbitfield = 0x4000; | ||
| 21 | pub const BLEND: GLenum = 0x0BE2; | ||
| 22 | pub const SCISSOR_TEST: GLenum = 0x0C11; | ||
| 23 | pub const TEXTURE_2D: GLenum = 0x0DE1; | ||
| 24 | pub const UNSIGNED_BYTE: GLenum = 0x1401; | ||
| 25 | pub const FLOAT: GLenum = 0x1406; | ||
| 26 | pub const RGBA: GLenum = 0x1908; | ||
| 27 | pub const VENDOR: GLenum = 0x1F00; | ||
| 28 | pub const RENDERER: GLenum = 0x1F01; | ||
| 29 | pub const VERSION: GLenum = 0x1F02; | ||
| 30 | pub const EXTENSIONS: GLenum = 0x1F03; | ||
| 31 | pub const NEAREST: GLint = 0x2600; | ||
| 32 | pub const LINEAR: GLint = 0x2601; | ||
| 33 | pub const TEXTURE_MAG_FILTER: GLenum = 0x2800; | ||
| 34 | pub const TEXTURE_MIN_FILTER: GLenum = 0x2801; | ||
| 35 | pub const TEXTURE_WRAP_S: GLenum = 0x2802; | ||
| 36 | pub const TEXTURE_WRAP_T: GLenum = 0x2803; | ||
| 37 | pub const CLAMP_TO_EDGE: GLint = 0x812F; | ||
| 38 | pub const ONE: GLenum = 1; | ||
| 39 | pub const SRC_ALPHA: GLenum = 0x0302; | ||
| 40 | pub const ONE_MINUS_SRC_ALPHA: GLenum = 0x0303; | ||
| 41 | pub const TEXTURE0: GLenum = 0x84C0; | ||
| 42 | pub const ARRAY_BUFFER: GLenum = 0x8892; | ||
| 43 | pub const STREAM_DRAW: GLenum = 0x88E0; | ||
| 44 | pub const FRAGMENT_SHADER: GLenum = 0x8B30; | ||
| 45 | pub const VERTEX_SHADER: GLenum = 0x8B31; | ||
| 46 | pub const COMPILE_STATUS: GLenum = 0x8B81; | ||
| 47 | pub const LINK_STATUS: GLenum = 0x8B82; | ||
| 48 | pub const INFO_LOG_LENGTH: GLenum = 0x8B84; | ||
| 49 | pub const SRGB8_ALPHA8: GLint = 0x8C43; | ||
| 50 | pub const READ_FRAMEBUFFER: GLenum = 0x8CA8; | ||
| 51 | pub const DRAW_FRAMEBUFFER: GLenum = 0x8CA9; | ||
| 52 | pub const FRAMEBUFFER_COMPLETE: GLenum = 0x8CD5; | ||
| 53 | pub const COLOR_ATTACHMENT0: GLenum = 0x8CE0; | ||
| 54 | pub const FRAMEBUFFER: GLenum = 0x8D40; | ||
| 55 | pub const FRAMEBUFFER_SRGB: GLenum = 0x8DB9; | ||
| 56 | |||
| 57 | #[link(name = "OpenGL", kind = "framework")] | ||
| 58 | unsafe 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. | ||
| 112 | pub 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. | ||
| 4 | use crate::*; | ||
| 5 | use std::{ffi::CString, ops::Range, ptr}; | ||
| 6 | |||
| 7 | /// Laid out as `draw`'s vertex. | ||
| 8 | #[repr(C)] | ||
| 9 | #[derive(Clone, Copy, Debug, Default)] | ||
| 10 | pub 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 | |||
| 19 | pub 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. | ||
| 28 | pub struct Texture { | ||
| 29 | name: GLuint, | ||
| 30 | size: [u32; 2], | ||
| 31 | } | ||
| 32 | |||
| 33 | impl 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 | |||
| 82 | impl 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. | ||
| 89 | pub struct Target { | ||
| 90 | framebuffer: GLuint, | ||
| 91 | texture: Texture, | ||
| 92 | } | ||
| 93 | |||
| 94 | impl 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 | |||
| 139 | impl Drop for Target { | ||
| 140 | fn drop(&mut self) { | ||
| 141 | unsafe { glDeleteFramebuffersEXT(1, &self.framebuffer) }; | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | pub struct Painter { | ||
| 146 | program: GLuint, | ||
| 147 | buffer: GLuint, | ||
| 148 | pub atlas: Texture, | ||
| 149 | } | ||
| 150 | |||
| 151 | const 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 | |||
| 160 | impl 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 | |||
| 245 | unsafe 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 | |||
| 260 | unsafe 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] | ||
| 2 | name = "hello-snow-leopard" | ||
| 3 | version = "0.0.0" | ||
| 4 | edition = "2024" | ||
| 5 | publish = false | ||
| 6 | |||
platform/snow-leopard/hello/src/main.rs created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | use std::cell::Cell; | ||
| 2 | use std::time::{Instant, SystemTime}; | ||
| 3 | |||
| 4 | thread_local! { | ||
| 5 | static COUNTER: Cell<u32> = const { Cell::new(0) }; | ||
| 6 | } | ||
| 7 | |||
| 8 | fn 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. | ||
| 5 | set -eu | ||
| 6 | here=$(cd "$(dirname "$0")" && pwd) | ||
| 7 | sdk=${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; } | ||
| 9 | arch=x86_64 | ||
| 10 | prev= | ||
| 11 | for 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" | ||
| 28 | done | ||
| 29 | work=$(mktemp -d "${TMPDIR:-/tmp}/snow-leopard-link.XXXXXX") | ||
| 30 | trap 'rm -rf "$work"' EXIT | ||
| 31 | for 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" | ||
| 34 | done | ||
| 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. | ||
| 37 | ar rcs "$work/libsnow_leopard.a" $(ls "$work"/*.o | grep -v /shims.o) | ||
| 38 | status=0 | ||
| 39 | clang -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. | ||
| 41 | grep -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 | ||
| 43 | exit $status | ||
platform/snow-leopard/probe/Cargo.toml created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | [package] | ||
| 2 | name = "probe" | ||
| 3 | version = "0.0.0" | ||
| 4 | edition = "2024" | ||
| 5 | publish = false | ||
| 6 | |||
| 7 | [dependencies] | ||
| 8 | gl21 = { path = "../gl21" } | ||
| 9 | objc2.workspace = true | ||
| 10 | objc2-foundation = { workspace = true, features = ["std", "NSString", "NSAutoreleasePool", "NSObject", "NSZone", "NSGeometry", "NSDate", "NSRunLoop"] } | ||
| 11 | objc2-app-kit = { workspace = true, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSView", "NSOpenGL", "NSOpenGLView", "NSEvent", "NSRunningApplication", "NSGraphics"] } | ||
| 12 | png = "0.18" | ||
| 13 | |||
| 14 | [[bin]] | ||
| 15 | name = "probe" | ||
| 16 | path = "src/main.rs" | ||
| 17 | |||
| 18 | [[bin]] | ||
| 19 | name = "window-probe" | ||
| 20 | path = "src/window.rs" | ||
| 21 | |||
| 22 | [[bin]] | ||
| 23 | name = "render-probe" | ||
| 24 | path = "src/render.rs" | ||
platform/snow-leopard/probe/src/main.rs created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | use objc2::{rc::{Retained, autoreleasepool}, runtime::NSObjectProtocol}; | ||
| 2 | use objc2_foundation::NSCopying; | ||
| 3 | use objc2_foundation::{NSObject, NSString}; | ||
| 4 | |||
| 5 | fn 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. | ||
| 5 | mod scene; | ||
| 6 | |||
| 7 | use gl21::painter::{Batch, Painter, Target, Vertex}; | ||
| 8 | use objc2::MainThreadMarker; | ||
| 9 | use objc2_app_kit::{NSOpenGLContext, NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize, NSOpenGLPixelFormat}; | ||
| 10 | use scene::Shape; | ||
| 11 | |||
| 12 | const WHITE_UV: [f32; 4] = [0.5 / ATLAS as f32; 4]; | ||
| 13 | const ATLAS: u32 = 2048; | ||
| 14 | |||
| 15 | fn 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 | |||
| 32 | fn 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 | |||
| 50 | fn main() { | ||
| 51 | objc2::rc::autoreleasepool(|_| run()); | ||
| 52 | } | ||
| 53 | |||
| 54 | fn 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 | |||
| 5 | pub const SIZE: [u32; 2] = [400, 300]; | ||
| 6 | pub const CLEAR: [f32; 4] = [0.85, 0.85, 0.85, 1.0]; | ||
| 7 | |||
| 8 | pub 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 | |||
| 15 | pub 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. | ||
| 35 | pub 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. | ||
| 4 | use gl21::*; | ||
| 5 | use objc2::{MainThreadMarker, rc::Retained}; | ||
| 6 | use objc2_app_kit::{ | ||
| 7 | NSApplication, NSApplicationActivationPolicy, NSBackingStoreType, NSOpenGLPFAAccelerated, | ||
| 8 | NSOpenGLPFAAlphaSize, NSOpenGLPFAColorSize, NSOpenGLPFADoubleBuffer, NSOpenGLPixelFormat, | ||
| 9 | NSOpenGLView, NSWindow, NSWindowStyleMask, | ||
| 10 | }; | ||
| 11 | use objc2_foundation::{NSDate, NSDefaultRunLoopMode, NSPoint, NSRect, NSSize, NSString}; | ||
| 12 | |||
| 13 | const SIZE: [i32; 2] = [480, 320]; | ||
| 14 | |||
| 15 | fn main() { | ||
| 16 | // 10.6 has no implicit pool on the main thread; AppKit autoreleases from the start. | ||
| 17 | objc2::rc::autoreleasepool(|_| run()); | ||
| 18 | } | ||
| 19 | |||
| 20 | fn 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. | ||
| 99 | unsafe 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] | ||
| 2 | name = "reference" | ||
| 3 | version = "0.0.0" | ||
| 4 | edition = "2024" | ||
| 5 | publish = false | ||
| 6 | description = "Paints the render-probe scene through draw over wgpu, on the dev Mac." | ||
| 7 | |||
| 8 | [dependencies] | ||
| 9 | draw = { path = "../../../crates/draw" } | ||
| 10 | wgpu = "30.0.1" | ||
| 11 | pollster = "0.4" | ||
| 12 | png = "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"] | ||
| 5 | mod scene; | ||
| 6 | |||
| 7 | use draw::{Layer, Primitive, RasterImage, Stroke}; | ||
| 8 | use scene::Shape; | ||
| 9 | |||
| 10 | fn 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 | |||
| 19 | fn 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 | |||
| 29 | fn 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 | |||
| 44 | fn 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 | |||
| 50 | fn 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 | ||
| 12 | set -eu | ||
| 13 | host=${SNOW_LEOPARD_HOST:-osx10.6@10.0.0.1} | ||
| 14 | here=$(cd "$(dirname "$0")" && pwd) | ||
| 15 | dir=/tmp/snow-leopard | ||
| 16 | |||
| 17 | case "${1:-}" in | ||
| 18 | run) | ||
| 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 | ;; | ||
| 38 | mirror) | ||
| 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 | ;; | ||
| 44 | pull) | ||
| 45 | ssh "$host" "cat '$2'" > "${3:-$(basename "$2")}" | ||
| 46 | ;; | ||
| 47 | sdk) | ||
| 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 | ;; | ||
| 52 | sh) | ||
| 53 | shift | ||
| 54 | ssh "$host" "$@" | ||
| 55 | ;; | ||
| 56 | *) | ||
| 57 | sed -n '2,12s/^# \{0,1\}//p' "$0" >&2 | ||
| 58 | exit 2 | ||
| 59 | ;; | ||
| 60 | esac | ||
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. | ||
| 5 | CTFontRef 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 | |||
| 8 | typedef id (*Send)(id, SEL); | ||
| 9 | #define SEND(receiver, selector) ((Send)objc_msgSend)((receiver), sel_registerName(selector)) | ||
| 10 | |||
| 11 | extern void *_Block_copy(const void *); | ||
| 12 | |||
| 13 | id objc_retain(id object) { return object ? SEND(object, "retain") : nil; } | ||
| 14 | void objc_release(id object) { if (object) SEND(object, "release"); } | ||
| 15 | id objc_autorelease(id object) { return object ? SEND(object, "autorelease") : nil; } | ||
| 16 | id objc_autoreleaseReturnValue(id object) { return objc_autorelease(object); } | ||
| 17 | id objc_retainAutorelease(id object) { return objc_autorelease(objc_retain(object)); } | ||
| 18 | id objc_retainAutoreleaseReturnValue(id object) { return objc_retainAutorelease(object); } | ||
| 19 | id objc_retainAutoreleasedReturnValue(id object) { return objc_retain(object); } | ||
| 20 | id objc_retainBlock(id block) { return (id)_Block_copy(block); } | ||
| 21 | |||
| 22 | void objc_storeStrong(id *location, id value) { | ||
| 23 | id previous = *location; | ||
| 24 | *location = objc_retain(value); | ||
| 25 | objc_release(previous); | ||
| 26 | } | ||
| 27 | |||
| 28 | id objc_alloc(Class cls) { return cls ? SEND((id)cls, "alloc") : nil; } | ||
| 29 | |||
| 30 | id 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. | ||
| 35 | void *objc_autoreleasePoolPush(void) { | ||
| 36 | return SEND(SEND((id)objc_getClass("NSAutoreleasePool"), "alloc"), "init"); | ||
| 37 | } | ||
| 38 | |||
| 39 | void 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. | ||
| 31 | int 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 | |||
| 42 | int 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. | ||
| 58 | static 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 | |||
| 69 | int 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 | |||
| 79 | int 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 | |||
| 85 | int fstatat(int dirfd, const char *path, struct stat *st, int flags) | ||
| 86 | __asm("_fstatat" __DARWIN_SUF_64_BIT_INO_T); | ||
| 87 | int 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 | |||
| 93 | int 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. | ||
| 101 | int 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. | ||
| 108 | DIR *fdopendir(int fd) __asm("_fdopendir" __DARWIN_SUF_64_BIT_INO_T __DARWIN_SUF_UNIX03); | ||
| 109 | DIR *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 | ||
| 126 | int 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). | ||
| 132 | int 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 | |||
| 155 | static 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. | ||
| 173 | int32_t CCRandomGenerateBytes(void *bytes, size_t count) { | ||
| 174 | return read_urandom(bytes, count); | ||
| 175 | } | ||
| 176 | |||
| 177 | int 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. | ||
| 183 | struct snow_double2 { double sinval, cosval; }; | ||
| 184 | struct snow_float2 { float sinval, cosval; }; | ||
| 185 | struct snow_double2 __sincos_stret(double x) { return (struct snow_double2){sin(x), cos(x)}; } | ||
| 186 | struct snow_float2 __sincosf_stret(float x) { return (struct snow_float2){sinf(x), cosf(x)}; } | ||
| 187 | double __exp10(double x) { return pow(10.0, x); } | ||
| 188 | float __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, |
| 12 | diagnostic/example builds, and Python regression tests. Each stage has its own | 12 | diagnostic/example builds, and Python regression tests. Each stage has its own |
| 13 | log; `results.json` records executed commands, elapsed times and exit statuses. | 13 | log; `results.json` records executed commands, elapsed times and exit statuses. |
| 14 | Only a completed run receives `status: passed`. Existing result directories are | 14 | Only a completed run receives `status: passed`. Existing result directories are |
| 15 | refused. This lane clears inherited `ONESTORE_*` overrides and uses this | 15 | refused. This lane clears inherited `ONESTORE_*` and `SNOWBOUND_*` overrides and |
| 16 | checkout's binaries so personal captures or external exporters cannot silently | 16 | uses this checkout's binaries so personal captures or external exporters cannot |
| 17 | replace public inputs. | 17 | silently replace public inputs. |
| 18 | 18 | ||
| 19 | Repeat the same command in a fresh checkout containing only versioned files to | 19 | Repeat the same command in a fresh checkout containing only versioned files to |
| 20 | verify clean-checkout compatibility. Fixture symlinks must remain symlinks; their | 20 | verify 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 |
| 27 | harness logic using retained synthetic captures and mocks; it does not claim a | 27 | harness logic using retained synthetic captures and mocks; it does not claim a |
| 28 | new execution of OneNote or a real server interruption. | 28 | new execution of OneNote or a real server interruption. |
| 29 | 29 | ||
| 30 | ## Sweeps and soak | ||
| 31 | |||
| 32 | Seeded sweeps (random edit walks, multi-client schedules, differentials across | ||
| 33 | corpus sections) run a smoke slice by default. `SNOWBOUND_SWEEP=0` runs them in | ||
| 34 | full with the seeds as written; any other number shifts every sweep's seeds, and | ||
| 35 | a failure replays under the same value. | ||
| 36 | |||
| 37 | `tools/soak.sh [seconds per fuzz target]` soaks a dedicated machine until | ||
| 38 | stopped: each round runs the full sweeps under a random shift, in release with | ||
| 39 | debug assertions, then every `fuzz/` target for the time limit. A failing round | ||
| 40 | leaves `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 | ||
| 42 | toolchains, `cargo install cargo-fuzz`, and `build-essential pkg-config | ||
| 43 | libfontconfig-dev`; copy the checkout together with the untracked `evidence/`, | ||
| 44 | which tests read, and run the script under `tmux`. | ||
| 45 | |||
| 30 | ## Private and native verification | 46 | ## Private and native verification |
| 31 | 47 | ||
| 32 | Private notebooks stay outside versioned fixtures. Materialize a copy before | 48 | Private 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 |
| 50 | 50 | ||
| 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. |
| 52 | 52 | ||
| 53 | Launched 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`). | 53 | Launched 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`). |
| 54 | 54 | ||
| 55 | The `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. | 55 | The `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. |
| 56 | 56 | ||
| 57 | A 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. | 57 | A 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"}`. |
| 58 | 58 | ||
| 59 | ```sh | 59 | ```sh |
| 60 | cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one | 60 | cp -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] | ||
| 8 | set -u | ||
| 9 | cd "$(dirname "$0")/.." | ||
| 10 | seconds=${1:-600} | ||
| 11 | mkdir -p soak | ||
| 12 | # Release speed with the checks debug builds make. | ||
| 13 | sweeps="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 | ||
| 15 | cargo +nightly fuzz build || exit 1 | ||
| 16 | while :; 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 | ||
| 35 | done | ||
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) |
| 25 | 25 | ||
| 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: |