authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-28 23:29:34-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 00:34:45-07:00
log074834705a828432d90e244347ec8f0913adb5e9
tree6630a16557dd76cd7b9547291437085b0539f051
parent9205754444f9fd5a6408f0268058de0dc5d9a3ac
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: full toolbar, sync status, page select-all, tooltips, desktop menus, and Snowbound on Mac OS X 10.6

- Toolbar (#17): every spec action wired through the command table, all 17 new icons, table picker, 29 OneNote tags verified against a gallery OneNote wrote, Format Painter, Picture, Screen Clipping, Symbol, Page Color, Full Page View. - Sync status (#27): the top-right shows real sync state with OneNote 2010's wording; popup with details, Work offline, and Sync Now. - Select all (#38): a further select-all selects every outline, with OneNote 2010's delete, type, move, format, copy, and one-step undo; the title box padding scales with zoom. - UI kit: tooltips with shortcuts; combo and search popups widen with their field; perspective on context menus; GNOME (libadwaita) and KDE (Breeze, KWin fade) menus. - Mac OS X 10.6 (#13): draw gains an OpenGL 2.1 backend beside wgpu, winit runs over an AppKit shim, a 10.6 app bundle, and text weight matched to native. Assisted-by: claude-opus-5.5

75 files changed, 6613 insertions(+), 1792 deletions(-)

arc/platforms.md+4
...@@ -59,6 +59,10 @@ keyboard, the toolbar and the macOS menu bar all run commands from it....@@ -59,6 +59,10 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
59 focused and not: on KDE the colour scheme's header colours from59 focused and not: on KDE the colour scheme's header colours from
60 `kdeglobals`, as KWin paints its title bars, and on GNOME winit's Adwaita60 `kdeglobals`, as KWin paints its title bars, and on GNOME winit's Adwaita
61 frame's. A settings portal signal re-reads them when the scheme changes.61 frame's. A settings portal signal re-reads them when the scheme changes.
62- Menus and other popups take the desktop's look and motion: libadwaita's
63 popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's
64 on KDE, faded as KWin fades popups and scaled by Plasma's animation speed.
65 The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own.
62- zenity or kdialog provide the pickers and alerts. The XDG settings portal66- zenity or kdialog provide the pickers and alerts. The XDG settings portal
63 provides the colour scheme. Text conventions come from the C library's67 provides the colour scheme. Text conventions come from the C library's
64 locale. Fontconfig is loaded at run time, so builds need no headers for it.68 locale. Fontconfig is loaded at run time, so builds need no headers for it.
arc/sync.md+3
...@@ -163,6 +163,9 @@ last listing lets it open while the server is unreachable, and a section opens...@@ -163,6 +163,9 @@ last listing lets it open while the server is unreachable, and a section opens
163from its replica. On reconnect the queue publishes, or rebases and publishes,163from its replica. On reconnect the queue publishes, or rebases and publishes,
164through the same path as always. A failed directory read keeps the previous164through the same path as always. A failed directory read keeps the previous
165catalog, so an unreachable share never looks like an emptied notebook.165catalog, so an unreachable share never looks like an emptied notebook.
166Working offline on purpose, as OneNote's Work Offline does, is the same state
167chosen: the sync thread stops stepping until Sync Now or until the user works
168online again.
166169
167## Notebook structure170## Notebook structure
168171
arc/ui.md+2-1
...@@ -25,7 +25,8 @@ frame-rate independent, a retargeted animation continues smoothly from where...@@ -25,7 +25,8 @@ frame-rate independent, a retargeted animation continues smoothly from where
25it is, and it settles fast. Popups open and close on short timed curves,25it is, and it settles fast. Popups open and close on short timed curves,
26slow enough to follow: a menu grows out of the pointer or its button as it26slow enough to follow: a menu grows out of the pointer or its button as it
27fades in, a combo's field widens into its list, and a dialog swings up into27fades in, a combo's field widens into its list, and a dialog swings up into
28place over a dimmed window, as Windows opens a window.28place over a dimmed window, as Windows opens a window. On GNOME and KDE, menus instead
29look and move as the desktop's own (see [platforms](platforms.md)).
2930
30## Why not native widgets31## Why not native widgets
3132
corpus/structural-probe/README.md+3
...@@ -8,6 +8,9 @@ synthetic pages. Every page starts with a plain paragraph `Above`....@@ -8,6 +8,9 @@ synthetic pages. Every page starts with a plain paragraph `Above`.
8 page per case (`c1-bullet`, `c8-split`, ...).8 page per case (`c1-bullet`, `c8-split`, ...).
9- `tags.one`: a section holding one page with the nine Ctrl+1..9 default tags,9- `tags.one`: a section holding one page with the nine Ctrl+1..9 default tags,
10 closed and flushed by OneNote.10 closed and flushed by OneNote.
11- `tag-gallery.one`: one page with each of the 29 default tags of the Home tab's
12 Tags gallery applied from the gallery in order, one per paragraph (VM `tbar`,
13 2026-09-28); two paragraphs also carry Call back. Copied while OneNote was open.
11- `summaries/<case>-<step>.txt`: the page after each step, from COM14- `summaries/<case>-<step>.txt`: the page after each step, from COM
12 `GetPageContent`, one line per paragraph:15 `GetPageContent`, one line per paragraph:
13 `L<level>[+indentN] [tags; [x]=completed] [list] qsN text`.16 `L<level>[+indentN] [tags; [x]=completed] [list] qsN text`.
corpus/structural-probe/tag-gallery.one created
Binary files /dev/null and b/corpus/structural-probe/tag-gallery.one differ
crates/canvas/Cargo.toml+1
...@@ -34,6 +34,7 @@ name = "page-probe"...@@ -34,6 +34,7 @@ name = "page-probe"
34path = "src/bin/page_probe.rs"34path = "src/bin/page_probe.rs"
3535
36[dev-dependencies]36[dev-dependencies]
37draw = { path = "../draw", features = ["wgpu"] }
37accesskit_consumer = "0.39.0"38accesskit_consumer = "0.39.0"
38pollster = "0.4"39pollster = "0.4"
39wgpu.workspace = true40wgpu.workspace = true
crates/canvas/src/editor.rs+221-27
...@@ -77,6 +77,14 @@ pub struct Selection {...@@ -77,6 +77,14 @@ pub struct Selection {
77 pub affinities: [Affinity; 2],77 pub affinities: [Affinity; 2],
78}78}
7979
80/// What OneNote 2010's Ctrl+A selects beyond text: the focused outline as an object, then every
81/// body outline on the page.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Whole {
84 Outline,
85 Page,
86}
87
80impl From<[TextPosition; 2]> for Selection {88impl From<[TextPosition; 2]> for Selection {
81 fn from(positions: [TextPosition; 2]) -> Self {89 fn from(positions: [TextPosition; 2]) -> Self {
82 Self {90 Self {
...@@ -105,6 +113,9 @@ pub struct CanvasEditor {...@@ -105,6 +113,9 @@ pub struct CanvasEditor {
105 /// since, which `refresh` compares a changed stored page with; none once the editor113 /// since, which `refresh` compares a changed stored page with; none once the editor
106 /// holds an edit that could not be stored.114 /// holds an edit that could not be stored.
107 stored: Option<(Page, Vec<PageOp>)>,115 stored: Option<(Page, Vec<PageOp>)>,
116 /// What Select All widened to, holding until the selection is moved or set, and while the
117 /// focused outline and its selection stay as they were then.
118 whole: Option<(Whole, ExGuid, Selection)>,
108}119}
109120
110/// Imported page state the editable content does not carry.121/// Imported page state the editable content does not carry.
...@@ -433,6 +444,25 @@ impl TextOutline {...@@ -433,6 +444,25 @@ impl TextOutline {
433 .join("\n")444 .join("\n")
434 }445 }
435446
447 /// All the outline's shown text.
448 pub fn whole(&self) -> Selection {
449 let paragraph = self.source_index(self.shaped.paragraphs.len() - 1);
450 let last = self.document.paragraph(paragraph).unwrap();
451 [
452 TextPosition {
453 paragraph: 0,
454 offset: 0,
455 },
456 TextPosition {
457 paragraph,
458 offset: last
459 .utf16_offset(last.text().len())
460 .expect("paragraph text fits UTF-16 offsets"),
461 },
462 ]
463 .into()
464 }
465
436 /// Highlight rectangles of `selection` in the outline, in its coordinates.466 /// Highlight rectangles of `selection` in the outline, in its coordinates.
437 pub fn range_rects(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {467 pub fn range_rects(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
438 let [anchor, focus] = selection.positions;468 let [anchor, focus] = selection.positions;
...@@ -712,6 +742,7 @@ struct Placement {...@@ -712,6 +742,7 @@ struct Placement {
712 position: [Option<f32>; 2],742 position: [Option<f32>; 2],
713}743}
714744
745#[derive(Clone)]
715enum RestoreFocus {746enum RestoreFocus {
716 Outline(ExGuid),747 Outline(ExGuid),
717 Caret {748 Caret {
...@@ -766,6 +797,8 @@ enum History {...@@ -766,6 +797,8 @@ enum History {
766 selection: Selection,797 selection: Selection,
767 focus: RestoreFocus,798 focus: RestoreFocus,
768 },799 },
800 /// One undo step of edits to OneNote's page selection, undone last first.
801 Group(Vec<History>),
769}802}
770803
771impl CanvasEditor {804impl CanvasEditor {
...@@ -800,6 +833,7 @@ impl CanvasEditor {...@@ -800,6 +833,7 @@ impl CanvasEditor {
800 pending: None,833 pending: None,
801 ops: Ok(Vec::new()),834 ops: Ok(Vec::new()),
802 stored: None,835 stored: None,
836 whole: None,
803 })837 })
804 }838 }
805839
...@@ -983,7 +1017,7 @@ impl CanvasEditor {...@@ -983,7 +1017,7 @@ impl CanvasEditor {
983 }1017 }
984 // Undoing an outline's creation focuses the outline focused before, if it is there.1018 // Undoing an outline's creation focuses the outline focused before, if it is there.
985 let gone = |focus: &RestoreFocus| matches!(focus, RestoreFocus::Outline(id) if !fresh.outlines.iter().any(|o| o.id == *id));1019 let gone = |focus: &RestoreFocus| matches!(focus, RestoreFocus::Outline(id) if !fresh.outlines.iter().any(|o| o.id == *id));
986 let reaches = |history: &History| match history {1020 let reaches_one = |history: &History| match history {
987 History::Date(_) => date_changed,1021 History::Date(_) => date_changed,
988 History::Image { .. } | History::Picture { .. } => objects_changed,1022 History::Image { .. } | History::Picture { .. } => objects_changed,
989 History::Draft { outlines, .. } => changed.contains(&outlines[0].id),1023 History::Draft { outlines, .. } => changed.contains(&outlines[0].id),
...@@ -1000,6 +1034,11 @@ impl CanvasEditor {...@@ -1000,6 +1034,11 @@ impl CanvasEditor {
1000 History::Remove { outline, focus } => changed.contains(outline) || gone(focus),1034 History::Remove { outline, focus } => changed.contains(outline) || gone(focus),
1001 History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus),1035 History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus),
1002 History::Restore { source, focus, .. } => changed.contains(&source.id) || gone(focus),1036 History::Restore { source, focus, .. } => changed.contains(&source.id) || gone(focus),
1037 History::Group(_) => unreachable!("groups do not nest"),
1038 };
1039 let reaches = |history: &History| match history {
1040 History::Group(entries) => entries.iter().any(reaches_one),
1041 history => reaches_one(history),
1003 };1042 };
1004 fresh.undo = std::mem::take(&mut self.undo)1043 fresh.undo = std::mem::take(&mut self.undo)
1005 .into_iter()1044 .into_iter()
...@@ -1347,6 +1386,7 @@ impl CanvasEditor {...@@ -1347,6 +1386,7 @@ impl CanvasEditor {
1347 pending: None,1386 pending: None,
1348 ops: Ok(Vec::new()),1387 ops: Ok(Vec::new()),
1349 stored: None,1388 stored: None,
1389 whole: None,
1350 })1390 })
1351 }1391 }
13521392
...@@ -2000,33 +2040,24 @@ impl CanvasEditor {...@@ -2000,33 +2040,24 @@ impl CanvasEditor {
2000 }2040 }
2001 self.finish_composition();2041 self.finish_composition();
2002 self.preferred_x = None;2042 self.preferred_x = None;
2043 self.whole = None;
2003 self.active_outline_mut().selection = selection;2044 self.active_outline_mut().selection = selection;
2004 Ok(())2045 Ok(())
2005 }2046 }
20062047
2007 pub fn select_all(&mut self) -> Result<(), EditError> {2048 pub fn select_all(&mut self) -> Result<(), EditError> {
2008 let paragraph = self2049 self.select(self.active_outline().whole())
2009 .active_outline()
2010 .source_index(self.active_outline().shaped.paragraphs.len() - 1);
2011 let last = self.active_outline().document.paragraph(paragraph).unwrap();
2012 self.select(
2013 [
2014 TextPosition {
2015 paragraph: 0,
2016 offset: 0,
2017 },
2018 TextPosition {
2019 paragraph,
2020 offset: last.utf16_offset(last.text().len())?,
2021 },
2022 ]
2023 .into(),
2024 )
2025 }2050 }
20262051
2027 /// Selects the smallest unit strictly holding the selection, as OneNote 2010's Ctrl+A widens2052 /// Selects the smallest unit strictly holding the selection, as OneNote 2010's Ctrl+A widens
2028 /// it: the paragraph, its subtree, each enclosing table cell, row and table, then the outline.2053 /// it: the paragraph, its subtree, each enclosing table cell, row and table, then a body
2054 /// outline as an object (the title's text instead), then every body outline on the page.
2029 pub fn widen_selection(&mut self) -> Result<(), EditError> {2055 pub fn widen_selection(&mut self) -> Result<(), EditError> {
2056 match self.whole() {
2057 Some(Whole::Page) => return Ok(()),
2058 Some(Whole::Outline) => return self.select_page(),
2059 None => {}
2060 }
2030 let outline = self.active_outline();2061 let outline = self.active_outline();
2031 let [anchor, focus] = outline.selection.positions;2062 let [anchor, focus] = outline.selection.positions;
2032 let (start, end) = (anchor.min(focus), anchor.max(focus));2063 let (start, end) = (anchor.min(focus), anchor.max(focus));
...@@ -2055,17 +2086,138 @@ impl CanvasEditor {...@@ -2055,17 +2086,138 @@ impl CanvasEditor {
2055 .into_iter()2086 .into_iter()
2056 .map(|range| (first(range.start), last_shown(range))),2087 .map(|range| (first(range.start), last_shown(range))),
2057 )2088 )
2058 .chain([(2089 .chain(outline.title.then(|| {
2059 first(0),2090 (
2060 last_shown(0..outline.document.paragraphs().count()),2091 first(0),
2061 )]);2092 last_shown(0..outline.document.paragraphs().count()),
2062 match units2093 )
2094 }))
2063 .filter_map(|(unit_start, unit_end)| Some([unit_start, unit_end?]))2095 .filter_map(|(unit_start, unit_end)| Some([unit_start, unit_end?]))
2064 .find(|&unit| unit[0] <= start && end <= unit[1] && unit != [start, end])2096 .find(|&unit| unit[0] <= start && end <= unit[1] && unit != [start, end]);
2065 {2097 let (id, title) = (outline.id, outline.title);
2098 match units {
2066 Some(unit) => self.select(unit.into()),2099 Some(unit) => self.select(unit.into()),
2067 None => Ok(()),2100 None if title => self.select_page(),
2101 None => self.select_outline(id),
2102 }
2103 }
2104
2105 /// Selects outline `id` as an object, as a click on its handle does.
2106 pub fn select_outline(&mut self, id: ExGuid) -> Result<(), EditError> {
2107 self.focus_outline(id)?;
2108 let whole = self.active_outline().whole();
2109 self.select(whole)?;
2110 self.whole = Some((Whole::Outline, id, whole));
2111 Ok(())
2112 }
2113
2114 /// What Select All has selected beyond text, if the selection is still what it chose.
2115 pub fn whole(&self) -> Option<Whole> {
2116 let (whole, id, selection) = self.whole?;
2117 let outline = self.active_outline();
2118 (outline.id == id && outline.selection == selection && self.composition.is_none())
2119 .then_some(whole)
2120 }
2121
2122 fn page_selected(&self) -> bool {
2123 self.whole() == Some(Whole::Page)
2124 }
2125
2126 /// Selects every body outline, focusing the last in page order as OneNote does: arrows
2127 /// leave the selection at its ends, and typing replaces its text.
2128 fn select_page(&mut self) -> Result<(), EditError> {
2129 let Some(last) = self.outlines.iter().rev().find(|outline| !outline.title) else {
2130 return Ok(());
2131 };
2132 let (id, whole) = (last.id, last.whole());
2133 self.focus_outline(id)?;
2134 self.select(whole)?;
2135 self.whole = Some((Whole::Page, id, whole));
2136 Ok(())
2137 }
2138
2139 /// Runs `edit` as one undo step.
2140 fn grouped<T>(
2141 &mut self,
2142 edit: impl FnOnce(&mut Self) -> Result<T, EditorError>,
2143 ) -> Result<T, EditorError> {
2144 let depth = self.undo.len();
2145 let result = edit(self);
2146 let entries = self.undo.split_off(depth);
2147 if !entries.is_empty() {
2148 self.undo.push(History::Group(entries));
2068 }2149 }
2150 result
2151 }
2152
2153 /// Removes the page selection's outlines, last first, but its last when `keep`, whose
2154 /// text stays selected to be replaced. Deleting the page selection leaves a caret where
2155 /// its first outline began, as OneNote 2010's does.
2156 fn remove_page(&mut self, engine: &mut TextEngine, keep: bool) -> Result<(), EditorError> {
2157 self.finish_composition();
2158 self.whole = None;
2159 let mut body = (0..self.outlines.len())
2160 .filter(|&index| !self.outlines[index].title)
2161 .collect::<Vec<_>>();
2162 let Some(&first) = body.first() else {
2163 return Ok(());
2164 };
2165 let kept = if keep { body.pop() } else { None };
2166 let caret = TextOutline::new(
2167 engine,
2168 TextDocument::new(vec![Paragraph::new(String::new(), Default::default())])?,
2169 DEFAULT_OUTLINE_WIDTH,
2170 self.outlines[first].origin(),
2171 )?;
2172 let index = self.outlines.len() - body.len();
2173 let focus = RestoreFocus::Caret {
2174 source: Box::new(caret.snapshot()),
2175 selection: caret.selection,
2176 index,
2177 };
2178 for &removed in body.iter().rev() {
2179 let outline = self.outlines.remove(removed);
2180 self.record(Ok(vec![PageOp::Delete { object: outline.id }]));
2181 self.undo.push(History::Insert {
2182 index: removed,
2183 outline: Box::new(outline),
2184 focus: focus.clone(),
2185 });
2186 }
2187 self.redo.clear();
2188 self.preferred_x = None;
2189 match kept {
2190 Some(kept) => {
2191 self.active = Focus::Outline(kept - body.len());
2192 self.select_all()?;
2193 }
2194 None => {
2195 self.active = Focus::Caret {
2196 outline: Box::new(caret),
2197 index,
2198 }
2199 }
2200 }
2201 Ok(())
2202 }
2203
2204 /// Moves every outline of the page selection by `delta`, as one undo step keeping it.
2205 pub fn move_page(&mut self, delta: [f32; 2]) -> Result<(), EditorError> {
2206 let moves = self
2207 .outlines
2208 .iter()
2209 .filter(|outline| !outline.title)
2210 .map(|outline| {
2211 let [x, y] = outline.origin();
2212 (outline.id, [x + delta[0], y + delta[1]])
2213 })
2214 .collect::<Vec<_>>();
2215 self.grouped(|editor| {
2216 for (id, position) in moves {
2217 editor.move_outline(id, position)?;
2218 }
2219 Ok(editor.select_page()?)
2220 })
2069 }2221 }
20702222
2071 pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> {2223 pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> {
...@@ -2145,6 +2297,7 @@ impl CanvasEditor {...@@ -2145,6 +2297,7 @@ impl CanvasEditor {
2145 extend: bool,2297 extend: bool,
2146 ) -> Result<(), EditorError> {2298 ) -> Result<(), EditorError> {
2147 self.finish_composition();2299 self.finish_composition();
2300 self.whole = None;
2148 if !extend && movement == Movement::Right && self.leave_equation()? {2301 if !extend && movement == Movement::Right && self.leave_equation()? {
2149 return Ok(());2302 return Ok(());
2150 }2303 }
...@@ -2614,6 +2767,12 @@ impl CanvasEditor {...@@ -2614,6 +2767,12 @@ impl CanvasEditor {
26142767
2615 /// Types `text` at the selection; a space ends a typed URL, which becomes a link.2768 /// Types `text` at the selection; a space ends a typed URL, which becomes a link.
2616 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {2769 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
2770 if self.page_selected() {
2771 return self.grouped(|editor| {
2772 editor.remove_page(engine, true)?;
2773 editor.insert(engine, text)
2774 });
2775 }
2617 self.leave_link_code()?;2776 self.leave_link_code()?;
2618 let start = self.active_outline().selection.positions[0]2777 let start = self.active_outline().selection.positions[0]
2619 .min(self.active_outline().selection.positions[1]);2778 .min(self.active_outline().selection.positions[1]);
...@@ -2640,6 +2799,12 @@ impl CanvasEditor {...@@ -2640,6 +2799,12 @@ impl CanvasEditor {
2640 text: &str,2799 text: &str,
2641 language: u32,2800 language: u32,
2642 ) -> Result<(), EditorError> {2801 ) -> Result<(), EditorError> {
2802 if self.page_selected() {
2803 return self.grouped(|editor| {
2804 editor.remove_page(engine, true)?;
2805 editor.paste(engine, text, language)
2806 });
2807 }
2643 let lines = text2808 let lines = text
2644 .split('\n')2809 .split('\n')
2645 .map(|line| line.strip_suffix('\r').unwrap_or(line))2810 .map(|line| line.strip_suffix('\r').unwrap_or(line))
...@@ -2888,6 +3053,10 @@ impl CanvasEditor {...@@ -2888,6 +3053,10 @@ impl CanvasEditor {
2888 /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/3053 /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/
2889 /// xml/c4-*`, `c5b-*`). Joins pass over a collapsed paragraph's hidden children.3054 /// xml/c4-*`, `c5b-*`). Joins pass over a collapsed paragraph's hidden children.
2890 pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result<bool, EditorError> {3055 pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result<bool, EditorError> {
3056 if self.page_selected() {
3057 self.grouped(|editor| editor.remove_page(engine, false))?;
3058 return Ok(true);
3059 }
2891 let selection = self.active_outline().selection;3060 let selection = self.active_outline().selection;
2892 let [anchor, focus] = selection.positions;3061 let [anchor, focus] = selection.positions;
2893 let mut range = anchor.min(focus)..anchor.max(focus);3062 let mut range = anchor.min(focus)..anchor.max(focus);
...@@ -2975,6 +3144,9 @@ impl CanvasEditor {...@@ -2975,6 +3144,9 @@ impl CanvasEditor {
2975 engine: &mut TextEngine,3144 engine: &mut TextEngine,
2976 movement: Movement,3145 movement: Movement,
2977 ) -> Result<bool, EditorError> {3146 ) -> Result<bool, EditorError> {
3147 if self.page_selected() {
3148 return self.delete(engine, false);
3149 }
2978 self.finish_composition();3150 self.finish_composition();
2979 let original = self.selection();3151 let original = self.selection();
2980 let preferred_x = self.preferred_x;3152 let preferred_x = self.preferred_x;
...@@ -3040,7 +3212,12 @@ impl CanvasEditor {...@@ -3040,7 +3212,12 @@ impl CanvasEditor {
3040 };3212 };
3041 match self.apply_history(engine, history) {3213 match self.apply_history(engine, history) {
3042 Ok(inverse) => {3214 Ok(inverse) => {
3215 let group = matches!(inverse, History::Group(_));
3043 self.redo.push(inverse);3216 self.redo.push(inverse);
3217 // Undoing an edit to the page selection selects the page again.
3218 if group {
3219 self.select_page()?;
3220 }
3044 Ok(true)3221 Ok(true)
3045 }3222 }
3046 Err((history, error)) => {3223 Err((history, error)) => {
...@@ -3113,6 +3290,7 @@ impl CanvasEditor {...@@ -3113,6 +3290,7 @@ impl CanvasEditor {
3113 History::Insert { index, .. } | History::Restore { index, .. } => {3290 History::Insert { index, .. } | History::Restore { index, .. } => {
3114 *index <= self.outlines.len()3291 *index <= self.outlines.len()
3115 }3292 }
3293 History::Group(_) => true,
3116 };3294 };
3117 if !valid {3295 if !valid {
3118 return Err((history, EditError::InvalidRange.into()));3296 return Err((history, EditError::InvalidRange.into()));
...@@ -3376,6 +3554,19 @@ impl CanvasEditor {...@@ -3376,6 +3554,19 @@ impl CanvasEditor {
3376 self.active = Focus::Outline(index);3554 self.active = Focus::Outline(index);
3377 History::Remove { outline: id, focus }3555 History::Remove { outline: id, focus }
3378 }3556 }
3557 History::Group(mut entries) => {
3558 let mut inverses = Vec::with_capacity(entries.len());
3559 while let Some(entry) = entries.pop() {
3560 match self.apply_history(engine, entry) {
3561 Ok(inverse) => inverses.push(inverse),
3562 Err((entry, error)) => {
3563 entries.push(entry);
3564 return Err((History::Group(entries), error));
3565 }
3566 }
3567 }
3568 History::Group(inverses)
3569 }
3379 };3570 };
3380 self.preferred_x = None;3571 self.preferred_x = None;
3381 Ok(inverse)3572 Ok(inverse)
...@@ -3396,6 +3587,9 @@ impl CanvasEditor {...@@ -3396,6 +3587,9 @@ impl CanvasEditor {
3396 if selected.start > selected.end {3587 if selected.start > selected.end {
3397 return Err(EditError::InvalidRange.into());3588 return Err(EditError::InvalidRange.into());
3398 }3589 }
3590 if self.page_selected() {
3591 self.grouped(|editor| editor.remove_page(engine, true))?;
3592 }
3399 let range = self.marked_range().unwrap_or_else(|| {3593 let range = self.marked_range().unwrap_or_else(|| {
3400 self.active_outline().selection.positions[0]3594 self.active_outline().selection.positions[0]
3401 .min(self.active_outline().selection.positions[1])3595 .min(self.active_outline().selection.positions[1])
crates/canvas/src/editor/format.rs+247-35
...@@ -1,13 +1,14 @@...@@ -1,13 +1,14 @@
1//! OneNote's Home-ribbon commands: character formatting, lists, alignment and note tags.1//! OneNote's Home-ribbon commands: character formatting, lists, alignment and note tags.
22
3use super::*;3use super::*;
4use crate::layout::{DEFAULT_FONT, DEFAULT_FONT_SIZE};
4use onestore::document::{Format, Kind, Tag};5use onestore::document::{Format, Kind, Tag};
5use onestore::page::ParagraphContent;6use onestore::page::ParagraphContent;
6use onestore::page::text::new_id;7use onestore::page::text::new_id;
78
8/// The COLORREF OneNote stores for automatic colour, which overrides a style's colour.9/// The COLORREF OneNote stores for automatic colour, which overrides a style's colour.
9const AUTOMATIC: u32 = 0xff00_0000;10const AUTOMATIC: u32 = 0xff00_0000;
10/// The one checkable note tag shape the page draws.11/// To Do's check box.
11const CHECKBOX: u16 = 3;12const CHECKBOX: u16 = 3;
1213
13/// A toolbar command on the selection, or with only a caret, on the text typed there next.14/// A toolbar command on the selection, or with only a caret, on the text typed there next.
...@@ -36,6 +37,9 @@ pub enum Formatting {...@@ -36,6 +37,9 @@ pub enum Formatting {
36 RemoveTags,37 RemoveTags,
37 /// Checks the selected paragraphs' check boxes, or clears them when all are checked.38 /// Checks the selected paragraphs' check boxes, or clears them when all are checked.
38 Check,39 Check,
40 /// Format Painter: gives the selection the character formatting and alignment of text
41 /// picked up with [`CanvasEditor::painted_format`], as OneNote's does.
42 Paint(Format),
39}43}
4044
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
...@@ -157,8 +161,9 @@ impl ListStyle {...@@ -157,8 +161,9 @@ impl ListStyle {
157 }161 }
158}162}
159163
160/// OneNote 2010's default tags, Ctrl+1 to Ctrl+9, as it stores their definitions164/// OneNote 2010's default tags, the first nine on Ctrl+1 to Ctrl+9, as it stores their
161/// (`corpus/structural-probe/tags.one`); declared in that order, their action types.165/// definitions (`corpus/structural-probe/tag-gallery.one`); declared in gallery order, their
166/// action types.
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub enum NoteTag {168pub enum NoteTag {
164 ToDo,169 ToDo,
...@@ -170,6 +175,26 @@ pub enum NoteTag {...@@ -170,6 +175,26 @@ pub enum NoteTag {
170 Contact,175 Contact,
171 Address,176 Address,
172 PhoneNumber,177 PhoneNumber,
178 WebSiteToVisit,
179 Idea,
180 Password,
181 Critical,
182 ProjectA,
183 ProjectB,
184 MovieToSee,
185 BookToRead,
186 MusicToListenTo,
187 SourceForArticle,
188 RememberForBlog,
189 DiscussWithPersonA,
190 DiscussWithPersonB,
191 DiscussWithManager,
192 SendInEmail,
193 ScheduleMeeting,
194 CallBack,
195 ToDoPriority1,
196 ToDoPriority2,
197 ClientRequest,
173}198}
174199
175/// What the selection shows on the toolbar.200/// What the selection shows on the toolbar.
...@@ -177,7 +202,7 @@ pub enum NoteTag {...@@ -177,7 +202,7 @@ pub enum NoteTag {
177pub struct FormatState {202pub struct FormatState {
178 /// Attributes every selected character has.203 /// Attributes every selected character has.
179 pub toggles: Vec<Toggle>,204 pub toggles: Vec<Toggle>,
180 /// The font and size every selected character shares.205 /// The font and size every selected character is laid out in, where they share one.
181 pub font: Option<String>,206 pub font: Option<String>,
182 pub font_size: Option<f32>,207 pub font_size: Option<f32>,
183 /// The alignment every selected paragraph shares.208 /// The alignment every selected paragraph shares.
...@@ -225,7 +250,7 @@ impl Toggle {...@@ -225,7 +250,7 @@ impl Toggle {
225}250}
226251
227impl NoteTag {252impl NoteTag {
228 pub(crate) const ALL: [Self; 9] = [253 pub const ALL: [Self; 29] = [
229 Self::ToDo,254 Self::ToDo,
230 Self::Important,255 Self::Important,
231 Self::Question,256 Self::Question,
...@@ -235,6 +260,26 @@ impl NoteTag {...@@ -235,6 +260,26 @@ impl NoteTag {
235 Self::Contact,260 Self::Contact,
236 Self::Address,261 Self::Address,
237 Self::PhoneNumber,262 Self::PhoneNumber,
263 Self::WebSiteToVisit,
264 Self::Idea,
265 Self::Password,
266 Self::Critical,
267 Self::ProjectA,
268 Self::ProjectB,
269 Self::MovieToSee,
270 Self::BookToRead,
271 Self::MusicToListenTo,
272 Self::SourceForArticle,
273 Self::RememberForBlog,
274 Self::DiscussWithPersonA,
275 Self::DiscussWithPersonB,
276 Self::DiscussWithManager,
277 Self::SendInEmail,
278 Self::ScheduleMeeting,
279 Self::CallBack,
280 Self::ToDoPriority1,
281 Self::ToDoPriority2,
282 Self::ClientRequest,
238 ];283 ];
239284
240 /// The tag's name, as OneNote stores and lists it.285 /// The tag's name, as OneNote stores and lists it.
...@@ -249,26 +294,71 @@ impl NoteTag {...@@ -249,26 +294,71 @@ impl NoteTag {
249 Self::Contact => "Contact",294 Self::Contact => "Contact",
250 Self::Address => "Address",295 Self::Address => "Address",
251 Self::PhoneNumber => "Phone number",296 Self::PhoneNumber => "Phone number",
297 Self::WebSiteToVisit => "Web site to visit",
298 Self::Idea => "Idea",
299 Self::Password => "Password",
300 Self::Critical => "Critical",
301 Self::ProjectA => "Project A",
302 Self::ProjectB => "Project B",
303 Self::MovieToSee => "Movie to see",
304 Self::BookToRead => "Book to read",
305 Self::MusicToListenTo => "Music to listen to",
306 Self::SourceForArticle => "Source for article",
307 Self::RememberForBlog => "Remember for blog",
308 Self::DiscussWithPersonA => "Discuss with <Person A>",
309 Self::DiscussWithPersonB => "Discuss with <Person B>",
310 Self::DiscussWithManager => "Discuss with manager",
311 Self::SendInEmail => "Send in e-mail",
312 Self::ScheduleMeeting => "Schedule meeting",
313 Self::CallBack => "Call back",
314 Self::ToDoPriority1 => "To Do priority 1",
315 Self::ToDoPriority2 => "To Do priority 2",
316 Self::ClientRequest => "Client request",
317 }
318 }
319
320 /// The symbol OneNote draws for the tag; 0 draws none.
321 pub const fn shape(self) -> u16 {
322 match self {
323 Self::ToDo => CHECKBOX,
324 Self::Important => 13,
325 Self::Question => 15,
326 Self::RememberForLater | Self::Definition => 0,
327 Self::Highlight => 136,
328 Self::Contact => 118,
329 Self::Address => 23,
330 Self::PhoneNumber => 18,
331 Self::WebSiteToVisit | Self::SourceForArticle => 125,
332 Self::Idea => 21,
333 Self::Password => 131,
334 Self::Critical => 17,
335 Self::ProjectA => 100,
336 Self::ProjectB => 101,
337 Self::MovieToSee => 122,
338 Self::BookToRead => 132,
339 Self::MusicToListenTo => 121,
340 Self::RememberForBlog => 24,
341 Self::DiscussWithPersonA | Self::DiscussWithPersonB => 94,
342 Self::DiscussWithManager => 95,
343 Self::SendInEmail => 106,
344 Self::ScheduleMeeting | Self::CallBack => 12,
345 Self::ToDoPriority1 => 28,
346 Self::ToDoPriority2 => 71,
347 Self::ClientRequest => 8,
252 }348 }
253 }349 }
254350
255 pub(super) fn definition(self) -> Definition {351 pub(super) fn definition(self) -> Definition {
256 let (shape, highlight) = match self {352 let highlight = match self {
257 Self::ToDo => (CHECKBOX, None),353 Self::RememberForLater => Some(0x0000_ffff),
258 Self::Important => (13, None),354 Self::Definition => Some(0x0000_ff00),
259 Self::Question => (15, None),355 _ => None,
260 Self::RememberForLater => (0, Some(0x0000_ffff)),
261 Self::Definition => (0, Some(0x0000_ff00)),
262 Self::Highlight => (136, None),
263 Self::Contact => (118, None),
264 Self::Address => (23, None),
265 Self::PhoneNumber => (18, None),
266 };356 };
267 Definition {357 Definition {
268 kind: Kind::TagDefinition {358 kind: Kind::TagDefinition {
269 label: Some(self.label().into()),359 label: Some(self.label().into()),
270 action_type: Some(self as u16),360 action_type: Some(self as u16),
271 shape: Some(shape),361 shape: Some(self.shape()),
272 // The highlighting tags also set black text.362 // The highlighting tags also set black text.
273 color: highlight.map(|_| 0),363 color: highlight.map(|_| 0),
274 highlight,364 highlight,
...@@ -278,6 +368,14 @@ impl NoteTag {...@@ -278,6 +368,14 @@ impl NoteTag {
278 }368 }
279}369}
280370
371/// Whether tag symbol `shape` is a check box, which OneNote stores unchecked when applied.
372fn checkable(shape: Option<u16>) -> bool {
373 matches!(
374 shape.and_then(|shape| crate::outline::TagIcon::of(shape, false)),
375 Some(crate::outline::TagIcon::CheckBox { .. })
376 )
377}
378
281/// Where a selection starts and ends, as text leaf identity and byte offset.379/// Where a selection starts and ends, as text leaf identity and byte offset.
282type Ends = [(ExGuid, usize); 2];380type Ends = [(ExGuid, usize); 2];
283381
...@@ -494,16 +592,37 @@ impl CanvasEditor {...@@ -494,16 +592,37 @@ impl CanvasEditor {
494 Some(&self.definitions.get(tag.definition.as_ref()?)?.kind)592 Some(&self.definitions.get(tag.definition.as_ref()?)?.kind)
495 }593 }
496594
497 pub fn format_state(&self) -> Result<FormatState, EditorError> {595 /// What Format Painter picks up: the first selected character's formatting, or with only
596 /// a caret, what would be typed there.
597 pub fn painted_format(&self) -> Result<Format, EditorError> {
498 let outline = self.active_outline();598 let outline = self.active_outline();
499 let (container, range, ends) = selected(&outline.document, outline.selection)?;599 let (container, range, ends) = selected(&outline.document, outline.selection)?;
600 let [anchor, focus] = outline.selection.positions;
601 let nodes = &outline.document.container(container)?[range];
602 match character_formats(covered(nodes, ends)).first() {
603 Some(format) if ends[0] != ends[1] => Ok((*format).clone()),
604 _ => Ok(self.typing_format(anchor.min(focus))?),
605 }
606 }
607
608 pub fn format_state(&self) -> Result<FormatState, EditorError> {
609 let outline = self.active_outline();
610 self.format_state_of(outline, outline.selection)
611 }
612
613 fn format_state_of(
614 &self,
615 outline: &TextOutline,
616 selection: Selection,
617 ) -> Result<FormatState, EditorError> {
618 let (container, range, ends) = selected(&outline.document, selection)?;
500 let nodes = &outline.document.container(container)?[range];619 let nodes = &outline.document.container(container)?[range];
501 let paragraphs = covered(nodes, ends)620 let paragraphs = covered(nodes, ends)
502 .map(|(node, _)| node)621 .map(|(node, _)| node)
503 .collect::<Vec<_>>();622 .collect::<Vec<_>>();
504 let caret;623 let caret;
505 let formats = if ends[0] == ends[1] {624 let formats = if ends[0] == ends[1] {
506 let [anchor, focus] = outline.selection.positions;625 let [anchor, focus] = selection.positions;
507 caret = self.typing_format(anchor.min(focus))?;626 caret = self.typing_format(anchor.min(focus))?;
508 vec![&caret]627 vec![&caret]
509 } else {628 } else {
...@@ -523,8 +642,16 @@ impl CanvasEditor {...@@ -523,8 +642,16 @@ impl CanvasEditor {
523 .all(|format| toggle.get(format) == Some(true))642 .all(|format| toggle.get(format) == Some(true))
524 })643 })
525 .collect(),644 .collect(),
526 font: common(formats.iter().map(|format| format.font.clone())),645 font: common(
527 font_size: common(formats.iter().map(|format| format.font_size)),646 formats
647 .iter()
648 .map(|format| Some(format.font.as_deref().unwrap_or(DEFAULT_FONT).to_owned())),
649 ),
650 font_size: common(
651 formats
652 .iter()
653 .map(|format| Some(format.font_size.unwrap_or(DEFAULT_FONT_SIZE))),
654 ),
528 alignment: common(paragraphs.iter().map(|node| {655 alignment: common(paragraphs.iter().map(|node| {
529 Some(656 Some(
530 match node.text().unwrap().text.spans()[0].format.alignment {657 match node.text().unwrap().text.spans()[0].format.alignment {
...@@ -555,6 +682,9 @@ impl CanvasEditor {...@@ -555,6 +682,9 @@ impl CanvasEditor {
555 engine: &mut TextEngine,682 engine: &mut TextEngine,
556 command: Formatting,683 command: Formatting,
557 ) -> Result<(), EditorError> {684 ) -> Result<(), EditorError> {
685 if self.page_selected() {
686 return self.format_page(engine, command);
687 }
558 let outline = self.active_outline();688 let outline = self.active_outline();
559 let (id, title, selection) = (outline.id, outline.title, outline.selection);689 let (id, title, selection) = (outline.id, outline.title, outline.selection);
560 let (container, mut range, ends) = selected(&outline.document, selection)?;690 let (container, mut range, ends) = selected(&outline.document, selection)?;
...@@ -696,7 +826,7 @@ impl CanvasEditor {...@@ -696,7 +826,7 @@ impl CanvasEditor {
696 });826 });
697 }827 }
698 if !remove {828 if !remove {
699 let checkable = shape == Some(CHECKBOX);829 let checkable = checkable(shape);
700 // Stored newest first.830 // Stored newest first.
701 text.tags.insert(831 text.tags.insert(
702 0,832 0,
...@@ -727,6 +857,7 @@ impl CanvasEditor {...@@ -727,6 +857,7 @@ impl CanvasEditor {
727 | Formatting::FontSize(_)857 | Formatting::FontSize(_)
728 | Formatting::Color(_)858 | Formatting::Color(_)
729 | Formatting::Highlight(_)859 | Formatting::Highlight(_)
860 | Formatting::Paint(_)
730 | Formatting::Clear => {861 | Formatting::Clear => {
731 let caret = (ends[0] == ends[1])862 let caret = (ends[0] == ends[1])
732 .then(|| {863 .then(|| {
...@@ -766,6 +897,15 @@ impl CanvasEditor {...@@ -766,6 +897,15 @@ impl CanvasEditor {
766 Formatting::Highlight(color) => {897 Formatting::Highlight(color) => {
767 format.highlight = color.or(base.highlight.map(|_| AUTOMATIC));898 format.highlight = color.or(base.highlight.map(|_| AUTOMATIC));
768 }899 }
900 Formatting::Paint(painted) => {
901 for toggle in Toggle::ALL {
902 *toggle.slot(format) = toggle.get(painted).or(toggle.get(base));
903 }
904 format.font = painted.font.clone().or_else(|| base.font.clone());
905 format.font_size = painted.font_size.or(base.font_size);
906 format.color = painted.color.or(base.color);
907 format.highlight = painted.highlight.or(base.highlight);
908 }
769 _ => {909 _ => {
770 for toggle in Toggle::ALL {910 for toggle in Toggle::ALL {
771 *toggle.slot(format) = toggle.get(base);911 *toggle.slot(format) = toggle.get(base);
...@@ -787,6 +927,13 @@ impl CanvasEditor {...@@ -787,6 +927,13 @@ impl CanvasEditor {
787 let base = &bases[&node.id];927 let base = &bases[&node.id];
788 let text = &mut node.text_mut().unwrap().text;928 let text = &mut node.text_mut().unwrap().text;
789 *text = restyle(text, range, |format| change(base, format));929 *text = restyle(text, range, |format| change(base, format));
930 if let Formatting::Paint(painted) = &command
931 && !title
932 {
933 *text = restyle(text, 0..text.text().len(), |format| {
934 format.alignment = painted.alignment;
935 });
936 }
790 }937 }
791 });938 });
792 }939 }
...@@ -808,6 +955,40 @@ impl CanvasEditor {...@@ -808,6 +955,40 @@ impl CanvasEditor {
808}955}
809956
810impl CanvasEditor {957impl CanvasEditor {
958 /// A toolbar command on OneNote 2010's page selection: each outline takes it as if selected
959 /// alone, but a toggle turns on everywhere unless every outline already has it.
960 fn format_page(
961 &mut self,
962 engine: &mut TextEngine,
963 command: Formatting,
964 ) -> Result<(), EditorError> {
965 self.whole = None;
966 let has = |state: FormatState| match &command {
967 Formatting::Toggle(toggle) => state.toggles.contains(toggle),
968 Formatting::Bullets => state.bullets,
969 Formatting::Numbering => state.numbering,
970 Formatting::Tag(tag) => state.tags.contains(tag),
971 _ => false,
972 };
973 let mut outlines = Vec::new();
974 for outline in self.outlines.iter().filter(|outline| !outline.title) {
975 let state = self.format_state_of(outline, outline.whole())?;
976 outlines.push((outline.id, has(state)));
977 }
978 let everywhere = outlines.iter().all(|(_, has)| *has);
979 self.grouped(|editor| {
980 for (id, has) in outlines {
981 if has && !everywhere {
982 continue;
983 }
984 editor.focus_outline(id)?;
985 editor.select_all()?;
986 editor.format(engine, command.clone())?;
987 }
988 Ok(editor.select_page()?)
989 })
990 }
991
811 /// A click on a check box tag: checks the check boxes of paragraph `id` in outline992 /// A click on a check box tag: checks the check boxes of paragraph `id` in outline
812 /// `outline`, or clears them once all are checked, as one undo step that keeps the993 /// `outline`, or clears them once all are checked, as one undo step that keeps the
813 /// selection.994 /// selection.
...@@ -857,10 +1038,7 @@ impl CanvasEditor {...@@ -857,10 +1038,7 @@ impl CanvasEditor {
857 let checkable = |tag: &Tag| {1038 let checkable = |tag: &Tag| {
858 matches!(1039 matches!(
859 self.tag_kind(tag),1040 self.tag_kind(tag),
860 Some(Kind::TagDefinition {1041 Some(Kind::TagDefinition { shape, .. }) if checkable(*shape)
861 shape: Some(CHECKBOX),
862 ..
863 })
864 )1042 )
865 };1043 };
866 let checked = self::covered(replacement, ends)1044 let checked = self::covered(replacement, ends)
...@@ -920,7 +1098,7 @@ pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option<Defin...@@ -920,7 +1098,7 @@ pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option<Defin
920/// and its Ctrl+. bullet store it (`corpus/paragraph-edit/reconciliation/keyboard`); a number1098/// and its Ctrl+. bullet store it (`corpus/paragraph-edit/reconciliation/keyboard`); a number
921/// also takes the text's font and language (`corpus/outline-edit/tree`).1099/// also takes the text's font and language (`corpus/outline-edit/tree`).
922pub(super) fn list_definition(style: ListStyle, format: &Format) -> Definition {1100pub(super) fn list_definition(style: ListStyle, format: &Format) -> Definition {
923 let font_size = Some(format.font_size.unwrap_or(11.0));1101 let font_size = Some(format.font_size.unwrap_or(DEFAULT_FONT_SIZE));
924 match style {1102 match style {
925 ListStyle::Number(index) => Definition {1103 ListStyle::Number(index) => Definition {
926 kind: Kind::List {1104 kind: Kind::List {
...@@ -1765,9 +1943,9 @@ mod tests {...@@ -1765,9 +1943,9 @@ mod tests {
1765 }1943 }
17661944
1767 #[test]1945 #[test]
1768 fn the_nine_default_tags_are_onenotes_stored_definitions_and_all_draw() {1946 fn the_default_tags_are_onenotes_stored_definitions_and_all_draw() {
1769 use onestore::{RevisionIndex, Store, document::Document};1947 use onestore::{RevisionIndex, Store, document::Document};
1770 let bytes = include_bytes!("../../../../corpus/structural-probe/tags.one");1948 let bytes = include_bytes!("../../../../corpus/structural-probe/tag-gallery.one");
1771 let store = Store::parse(bytes).unwrap();1949 let store = Store::parse(bytes).unwrap();
1772 let index = RevisionIndex::parse(&store).unwrap();1950 let index = RevisionIndex::parse(&store).unwrap();
1773 let document = Document::parse(&index).unwrap();1951 let document = Document::parse(&index).unwrap();
...@@ -1789,12 +1967,14 @@ mod tests {...@@ -1789,12 +1967,14 @@ mod tests {
1789 assert_eq!(editor.format_state().unwrap().tags, NoteTag::ALL);1967 assert_eq!(editor.format_state().unwrap().tags, NoteTag::ALL);
1790 let paragraph = &editor.active_outline().shaped.paragraphs[0];1968 let paragraph = &editor.active_outline().shaped.paragraphs[0];
1791 use crate::outline::TagIcon;1969 use crate::outline::TagIcon;
1970 let icons = paragraph
1971 .tags
1972 .iter()
1973 .map(|tag| tag.icon)
1974 .collect::<Vec<_>>();
1975 assert_eq!(icons.len(), NoteTag::ALL.len() - 2);
1792 assert_eq!(1976 assert_eq!(
1793 paragraph1977 icons[..7],
1794 .tags
1795 .iter()
1796 .map(|tag| tag.icon)
1797 .collect::<Vec<_>>(),
1798 [1978 [
1799 TagIcon::CheckBox { checked: false },1979 TagIcon::CheckBox { checked: false },
1800 TagIcon::Star,1980 TagIcon::Star,
...@@ -1805,6 +1985,13 @@ mod tests {...@@ -1805,6 +1985,13 @@ mod tests {
1805 TagIcon::Phone,1985 TagIcon::Phone,
1806 ]1986 ]
1807 );1987 );
1988 // As OneNote stores them unchecked: To Do, Discuss with, Schedule meeting, Call back,
1989 // the priorities and Client request.
1990 let checkable = NoteTag::ALL
1991 .into_iter()
1992 .filter(|tag| checkable(Some(tag.shape())))
1993 .count();
1994 assert_eq!(checkable, 9);
1808 // Remember for later and Definition draw no symbol; the newer one's green marks the text.1995 // Remember for later and Definition draw no symbol; the newer one's green marks the text.
1809 assert!(1996 assert!(
1810 paragraph1997 paragraph
...@@ -1885,8 +2072,8 @@ mod tests {...@@ -1885,8 +2072,8 @@ mod tests {
1885 let settled = |mut page: Page| {2072 let settled = |mut page: Page| {
1886 for object in &mut page.objects {2073 for object in &mut page.objects {
1887 if let PageObject::Outline(outline) = object {2074 if let PageObject::Outline(outline) = object {
1888 for paragraph in &mut outline.paragraphs {2075 for text in outline.paragraphs.iter_mut().filter_map(|p| p.text_mut()) {
1889 for tag in &mut paragraph.text_mut().unwrap().tags {2076 for tag in &mut text.tags {
1890 tag.extra_set = 0;2077 tag.extra_set = 0;
1891 }2078 }
1892 }2079 }
...@@ -1943,5 +2130,30 @@ mod tests {...@@ -1943,5 +2130,30 @@ mod tests {
1943 tags: vec![NoteTag::ToDo, NoteTag::Question],2130 tags: vec![NoteTag::ToDo, NoteTag::Question],
1944 }2131 }
1945 );2132 );
2133 // Format Painter, a gallery tag and an inserted table write as well.
2134 let painted = editor.painted_format().unwrap();
2135 editor.select([at(0, 0), at(0, 2)].into()).unwrap();
2136 editor
2137 .format(&mut engine, Formatting::Paint(painted))
2138 .unwrap();
2139 let state = editor.format_state().unwrap();
2140 assert_eq!(
2141 (state.toggles, state.font_size, state.alignment),
2142 (
2143 vec![Toggle::Bold, Toggle::Superscript],
2144 Some(14.0),
2145 Some(Alignment::Center)
2146 )
2147 );
2148 editor
2149 .format(&mut engine, Formatting::Tag(NoteTag::CallBack))
2150 .unwrap();
2151 editor.select([at(0, 1); 2].into()).unwrap();
2152 editor.insert_table(&mut engine, 2, 3).unwrap();
2153 assert_eq!(editor.selection(), Selection::from([at(1, 0); 2]));
2154 let edited = settled(editor.page().unwrap());
2155 let written = super::ops::saved(&written, space, &mut editor);
2156 let (_, reread) = page(&written);
2157 assert_eq!(settled(reread).objects, edited.objects);
1946 }2158 }
1947}2159}
crates/canvas/src/editor/table.rs+152
...@@ -78,6 +78,116 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos...@@ -78,6 +78,116 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos
78}78}
7979
80impl CanvasEditor {80impl CanvasEditor {
81 /// Insert, Table: an empty table of `rows` by `columns` at the caret with the caret in
82 /// its first cell, as OneNote 2010 inserts one: in place of an empty paragraph, before
83 /// or after the caret's paragraph at its start or end, and between its halves otherwise.
84 pub fn insert_table(
85 &mut self,
86 engine: &mut TextEngine,
87 rows: usize,
88 columns: usize,
89 ) -> Result<(), EditorError> {
90 let outline = self.active_outline();
91 if outline.title || rows == 0 || columns == 0 {
92 return Ok(());
93 }
94 let [anchor, focus] = outline.selection.positions;
95 let caret = anchor.max(focus);
96 let (container, index, node) = outline
97 .document
98 .leaf(caret.paragraph)
99 .ok_or(EditError::InvalidRange)?;
100 let text = &node.text().unwrap().text;
101 let end = text.utf16_offset(text.text().len())?;
102 let format = text.format_at(caret.offset)?;
103 let cell = || -> Result<TableCell, EditError> {
104 let mut paragraph = crate::document::node(
105 Paragraph::new(String::new(), format.clone()),
106 node.format.clone(),
107 )?;
108 paragraph.style = node.style;
109 Ok(TableCell {
110 id: new_id()?,
111 layout: Default::default(),
112 indents: outline.indents.clone(),
113 shading: None,
114 paragraphs: vec![paragraph],
115 unsupported: Vec::new(),
116 })
117 };
118 let table = PageParagraph {
119 id: new_id()?,
120 parent: node.parent,
121 level: node.level,
122 style: None,
123 format: node.format.clone(),
124 lists: Vec::new(),
125 tags: Vec::new(),
126 media: Default::default(),
127 collapsed: false,
128 content: ParagraphContent::Table(onestore::page::Table {
129 id: new_id()?,
130 columns: vec![
131 TableColumn {
132 width: 37.11,
133 locked: false
134 };
135 columns
136 ],
137 rows: (0..rows)
138 .map(|_| {
139 Ok(TableRow {
140 id: new_id()?,
141 cells: (0..columns).map(|_| cell()).collect::<Result<_, _>>()?,
142 })
143 })
144 .collect::<Result<_, EditError>>()?,
145 borders: Some(true),
146 layout: Default::default(),
147 tags: Vec::new(),
148 }),
149 };
150 let parent = |candidate: &PageParagraph| candidate.parent == Some(node.id);
151 let childless = !outline.document.container(container)?.iter().any(parent);
152 let (edit, first) = if end == 0 && childless {
153 let edit = DocumentEdit {
154 columns: BTreeMap::new(),
155 container,
156 range: index..index + 1,
157 replacement: vec![table],
158 };
159 (edit, caret.paragraph)
160 } else if caret.offset == 0 && end > 0 {
161 let edit = DocumentEdit {
162 columns: BTreeMap::new(),
163 container,
164 range: index..index,
165 replacement: vec![table],
166 };
167 (edit, caret.paragraph)
168 } else if caret.offset == end {
169 let edit = DocumentEdit {
170 columns: BTreeMap::new(),
171 container,
172 range: index + 1..index + 1,
173 replacement: vec![table],
174 };
175 (edit, caret.paragraph + 1)
176 } else {
177 let empty = Paragraph::new(String::new(), format.clone());
178 let mut edit = outline
179 .document
180 .replace(caret..caret, vec![empty.clone(), empty])?;
181 edit.replacement.insert(1, table);
182 (edit, caret.paragraph + 1)
183 };
184 let first = TextPosition {
185 paragraph: first,
186 offset: 0,
187 };
188 self.commit(engine, edit, [first; 2].into())
189 }
190
81 pub fn tab(&mut self, engine: &mut TextEngine, backward: bool) -> Result<(), EditorError> {191 pub fn tab(&mut self, engine: &mut TextEngine, backward: bool) -> Result<(), EditorError> {
82 if self.active_outline().title && !backward {192 if self.active_outline().title && !backward {
83 return self.leave_title(engine);193 return self.leave_title(engine);
...@@ -399,6 +509,48 @@ mod tests {...@@ -399,6 +509,48 @@ mod tests {
399 .collect()509 .collect()
400 }510 }
401511
512 /// As OneNote 2010 inserts from the Table gallery (lab, 2026-09-28).
513 #[test]
514 fn an_inserted_table_takes_an_empty_paragraph_or_splits_at_the_caret() {
515 let mut engine = TextEngine::default();
516 let texts = |editor: &CanvasEditor| {
517 editor
518 .active_outline()
519 .document
520 .nodes()
521 .iter()
522 .map(|node| match &node.content {
523 ParagraphContent::Table(table) => {
524 format!("{}x{}", table.columns.len(), table.rows.len())
525 }
526 _ => node.text().unwrap().text.text().to_owned(),
527 })
528 .collect::<Vec<_>>()
529 };
530 for (text, offset, expected) in [
531 ("", 0, vec!["3x2"]),
532 ("abc", 0, vec!["3x2", "abc"]),
533 ("abc", 3, vec!["abc", "3x2"]),
534 ("abc", 1, vec!["a", "3x2", "bc"]),
535 ] {
536 let mut editor = editor(&mut engine, text);
537 let caret = TextPosition {
538 paragraph: 0,
539 offset,
540 };
541 editor.select([caret; 2].into()).unwrap();
542 editor.insert_table(&mut engine, 2, 3).unwrap();
543 assert_eq!(texts(&editor), expected, "{text:?} at {offset}");
544 let first = usize::from(expected[0] != "3x2");
545 let (cell, _, _) = editor
546 .active_outline()
547 .document
548 .leaf(editor.selection().positions[0].paragraph)
549 .unwrap();
550 assert!(cell.is_some() && editor.selection().positions[0].paragraph == first);
551 }
552 }
553
402 #[test]554 #[test]
403 fn tab_creates_splits_and_selects_native_cells() {555 fn tab_creates_splits_and_selects_native_cells() {
404 let mut engine = TextEngine::default();556 let mut engine = TextEngine::default();
crates/canvas/src/interaction/mod.rs+199-45
...@@ -14,7 +14,7 @@ mod tests;...@@ -14,7 +14,7 @@ mod tests;
14use crate::gpu::{Paper, Viewport, page::PageScene};14use crate::gpu::{Paper, Viewport, page::PageScene};
15use crate::{15use crate::{
16 date::DateField,16 date::DateField,
17 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Selection, TextOutline},17 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Selection, TextOutline, Whole},
18 layout::TextEngine,18 layout::TextEngine,
19};19};
20use draw::{20use draw::{
...@@ -156,6 +156,7 @@ enum Drag {...@@ -156,6 +156,7 @@ enum Drag {
156#[derive(Clone, Copy)]156#[derive(Clone, Copy)]
157enum PointerFeedback<'a> {157enum PointerFeedback<'a> {
158 Hover(onestore::ExGuid),158 Hover(onestore::ExGuid),
159 /// An outline dragged this far, with the rest of the page selection it belongs to.
159 Move(onestore::ExGuid, [f32; 2]),160 Move(onestore::ExGuid, [f32; 2]),
160 Resize(&'a TextOutline),161 Resize(&'a TextOutline),
161 /// A picture being moved or resized, drawn at this origin and size.162 /// A picture being moved or resized, drawn at this origin and size.
...@@ -384,6 +385,18 @@ impl PageView {...@@ -384,6 +385,18 @@ impl PageView {
384 ))385 ))
385 }386 }
386387
388 /// The dragged outline and how far it would move.
389 fn drag_delta(&self) -> Option<(onestore::ExGuid, [f32; 2])> {
390 let (id, [x, y]) = self.outline_preview()?;
391 let outline = self
392 .editor
393 .outlines()
394 .iter()
395 .find(|outline| outline.id == id)?;
396 let [from_x, from_y] = outline.origin();
397 Some((id, [x - from_x, y - from_y]))
398 }
399
387 fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> {400 fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> {
388 let Drag::Image {401 let Drag::Image {
389 id,402 id,
...@@ -701,8 +714,8 @@ impl PageView {...@@ -701,8 +714,8 @@ impl PageView {
701 ..714 ..
702 }) => Some(PointerFeedback::Resize(outline)),715 }) => Some(PointerFeedback::Resize(outline)),
703 _ => self716 _ => self
704 .outline_preview()717 .drag_delta()
705 .map(|(id, origin)| PointerFeedback::Move(id, origin))718 .map(|(id, delta)| PointerFeedback::Move(id, delta))
706 .or_else(|| {719 .or_else(|| {
707 self.image_preview()720 self.image_preview()
708 .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size))721 .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size))
...@@ -901,7 +914,10 @@ impl PageView {...@@ -901,7 +914,10 @@ impl PageView {
901 }914 }
902 Some(Hit::Handle { id, grab }) => {915 Some(Hit::Handle { id, grab }) => {
903 self.set_object_focus(None);916 self.set_object_focus(None);
904 self.editor.focus_outline(id)?;917 // Any outline's handle drags the whole page selection.
918 if self.editor.whole() != Some(Whole::Page) {
919 self.editor.focus_outline(id)?;
920 }
905 self.drag = Some(Drag::Outline {921 self.drag = Some(Drag::Outline {
906 id,922 id,
907 grab,923 grab,
...@@ -975,7 +991,16 @@ impl PageView {...@@ -975,7 +991,16 @@ impl PageView {
975 let inside =991 let inside =
976 (0..2).all(|axis| (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis]));992 (0..2).all(|axis| (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis]));
977 let moving_image = matches!(self.drag, Some(Drag::Image { handle: [0, 0], .. }));993 let moving_image = matches!(self.drag, Some(Drag::Image { handle: [0, 0], .. }));
994 let clicked = match self.drag {
995 Some(Drag::Outline {
996 id,
997 pending_press: Some(_),
998 ..
999 }) => Some(id),
1000 _ => None,
1001 };
978 let preview = self.outline_preview().filter(|_| inside);1002 let preview = self.outline_preview().filter(|_| inside);
1003 let delta = self.drag_delta().filter(|_| inside);
979 if let Some((id, origin, size)) = self.image_preview().filter(|_| inside || !moving_image) {1004 if let Some((id, origin, size)) = self.image_preview().filter(|_| inside || !moving_image) {
980 self.editor1005 self.editor
981 .place_image(&mut self.engine, id, origin, size)?;1006 .place_image(&mut self.engine, id, origin, size)?;
...@@ -988,7 +1013,14 @@ impl PageView {...@@ -988,7 +1013,14 @@ impl PageView {
988 self.editor1013 self.editor
989 .resize(&mut self.engine, outline.bounds().width() as f32)?;1014 .resize(&mut self.engine, outline.bounds().width() as f32)?;
990 }1015 }
991 if let Some((id, origin)) = preview {1016 if let Some(id) = clicked {
1017 // A click on an outline's handle selects it as OneNote does.
1018 self.editor.select_outline(id)?;
1019 } else if let Some((_, delta)) = delta
1020 && self.editor.whole() == Some(Whole::Page)
1021 {
1022 self.editor.move_page(delta)?;
1023 } else if let Some((id, origin)) = preview {
992 self.editor.move_outline(id, origin)?;1024 self.editor.move_outline(id, origin)?;
993 }1025 }
994 self.changed()1026 self.changed()
...@@ -1075,27 +1107,49 @@ impl PageView {...@@ -1075,27 +1107,49 @@ impl PageView {
1075 self.edited()1107 self.edited()
1076 }1108 }
10771109
1078 /// Copy, or Cut with `cut`: the selected text goes to the clipboard.1110 /// Copy, or Cut with `cut`: the selected text goes to the clipboard, the page selection's
1111 /// outlines top to bottom with a blank line between, as OneNote 2010 copies them.
1079 pub fn copy(&mut self, cut: bool) -> Result<Response> {1112 pub fn copy(&mut self, cut: bool) -> Result<Response> {
1080 let [anchor, focus] = self.editor.selection().positions;1113 let text = |outline: &TextOutline, selection: Selection| -> Result<String> {
1081 let selected = self1114 let [anchor, focus] = selection.positions;
1082 .editor1115 Ok(outline
1083 .active_outline()1116 .document()
1084 .document()1117 .slice(anchor.min(focus)..anchor.max(focus))?
1085 .slice(anchor.min(focus)..anchor.max(focus))?;1118 .iter()
1086 let text = selected1119 .map(|paragraph| {
1087 .iter()1120 paragraph
1088 .map(|paragraph| {1121 .project()
1089 paragraph1122 .map(|projection| projection.text().text().to_owned())
1090 .project()1123 })
1091 .map(|projection| projection.text().text().to_owned())1124 .collect::<std::result::Result<Vec<_>, _>>()?
1092 })1125 .join("\n"))
1093 .collect::<std::result::Result<Vec<_>, _>>()?1126 };
1094 .join("\n");1127 let page = self.editor.whole() == Some(Whole::Page);
1128 let text = if page {
1129 let mut outlines = self
1130 .editor
1131 .outlines()
1132 .iter()
1133 .filter(|outline| !outline.title)
1134 .collect::<Vec<_>>();
1135 outlines.sort_by(|a, b| {
1136 let ([ax, ay], [bx, by]) = (a.origin(), b.origin());
1137 ay.total_cmp(&by).then(ax.total_cmp(&bx))
1138 });
1139 outlines
1140 .into_iter()
1141 .map(|outline| text(outline, outline.whole()))
1142 .collect::<Result<Vec<_>>>()?
1143 .join("\n\n")
1144 } else {
1145 text(self.editor.active_outline(), self.editor.selection())?
1146 };
1095 if text.is_empty() {1147 if text.is_empty() {
1096 return Ok(Response::default());1148 return Ok(Response::default());
1097 }1149 }
1098 if cut {1150 if cut && page {
1151 self.editor.delete(&mut self.engine, false)?;
1152 } else if cut {
1099 self.editor.insert(&mut self.engine, "")?;1153 self.editor.insert(&mut self.engine, "")?;
1100 }1154 }
1101 Ok(Response {1155 Ok(Response {
...@@ -1177,6 +1231,36 @@ impl PageView {...@@ -1177,6 +1231,36 @@ impl PageView {
1177 self.edited()1231 self.edited()
1178 }1232 }
11791233
1234 /// Insert, Table: see [`CanvasEditor::insert_table`].
1235 pub fn insert_table(&mut self, rows: usize, columns: usize) -> Result<Response> {
1236 if !self.accepts_text() || self.editor.marked_range().is_some() {
1237 return Ok(Response::default());
1238 }
1239 self.editor.insert_table(&mut self.engine, rows, columns)?;
1240 self.edited()
1241 }
1242
1243 /// Insert, Picture: a PNG, JPEG or GIF `size` points large after the caret's paragraph.
1244 pub fn insert_picture(&mut self, bytes: Vec<u8>, size: [f32; 2]) -> Result<Response> {
1245 if !self.accepts_text() || self.editor.marked_range().is_some() {
1246 return Ok(Response::default());
1247 }
1248 let image = onestore::page::Image {
1249 id: onestore::page::text::new_id()?,
1250 layout: onestore::document::Layout {
1251 max_width: Some(size[0]),
1252 max_height: Some(size[1]),
1253 ..Default::default()
1254 },
1255 size: Some(size),
1256 bytes: Some(bytes.into()),
1257 alt: None,
1258 background: false,
1259 };
1260 self.editor.insert_picture(&mut self.engine, image)?;
1261 self.edited()
1262 }
1263
1180 /// Professional, or Linear with `linear`, on the equation at the caret.1264 /// Professional, or Linear with `linear`, on the equation at the caret.
1181 pub fn switch_equation(&mut self, linear: bool) -> Result<Response> {1265 pub fn switch_equation(&mut self, linear: bool) -> Result<Response> {
1182 if linear {1266 if linear {
...@@ -1205,6 +1289,10 @@ impl PageView {...@@ -1205,6 +1289,10 @@ impl PageView {
1205 /// Select All, widening the selection a unit at a time as OneNote 2010's Ctrl+A does.1289 /// Select All, widening the selection a unit at a time as OneNote 2010's Ctrl+A does.
1206 pub fn widen_selection(&mut self) -> Result<Response> {1290 pub fn widen_selection(&mut self) -> Result<Response> {
1207 self.editor.widen_selection()?;1291 self.editor.widen_selection()?;
1292 // Revealing the last outline would scroll the page selection's start away.
1293 if self.editor.whole() == Some(Whole::Page) {
1294 return self.changed();
1295 }
1208 self.edited()1296 self.edited()
1209 }1297 }
12101298
...@@ -1274,6 +1362,27 @@ impl PageView {...@@ -1274,6 +1362,27 @@ impl PageView {
1274 return self.changed();1362 return self.changed();
1275 }1363 }
1276 }1364 }
1365 if self.editor.whole() == Some(Whole::Page) {
1366 match (Command::from_key(key, self.modifiers), key) {
1367 // OneNote 2010 keeps its page selection on these.
1368 (
1369 Some(
1370 Command::Move(Movement::Up | Movement::Down)
1371 | Command::MovePage { .. }
1372 | Command::MoveParagraphs { .. },
1373 ),
1374 _,
1375 )
1376 | (_, Key::Named(NamedKey::Tab)) => return Ok(Response::default()),
1377 // Enter leaves the caret at the selection's end, as Right does.
1378 (_, Key::Named(NamedKey::Enter)) => {
1379 self.editor
1380 .move_selection(&mut self.engine, Movement::Right, false)?;
1381 return self.edited();
1382 }
1383 _ => {}
1384 }
1385 }
1277 if let Some(Command::MoveParagraphs { up }) = Command::from_key(key, self.modifiers)1386 if let Some(Command::MoveParagraphs { up }) = Command::from_key(key, self.modifiers)
1278 && self.editor.marked_range().is_none()1387 && self.editor.marked_range().is_none()
1279 {1388 {
...@@ -1288,6 +1397,13 @@ impl PageView {...@@ -1288,6 +1397,13 @@ impl PageView {
1288 Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]),1397 Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]),
1289 _ => None,1398 _ => None,
1290 };1399 };
1400 if let Some(delta) = delta
1401 && self.editor.whole() == Some(Whole::Page)
1402 {
1403 let step = if shift { 10.0 } else { 1.0 };
1404 self.editor.move_page(delta.map(|axis| axis * step))?;
1405 return self.edited();
1406 }
1291 if let Some(delta) = delta {1407 if let Some(delta) = delta {
1292 let outline = self.editor.active_outline();1408 let outline = self.editor.active_outline();
1293 let origin = outline.origin();1409 let origin = outline.origin();
...@@ -1604,6 +1720,15 @@ fn page_primitives<'a>(...@@ -1604,6 +1720,15 @@ fn page_primitives<'a>(
1604 paint: Paint,1720 paint: Paint,
1605) -> Result<Vec<Primitive<'a>>> {1721) -> Result<Vec<Primitive<'a>>> {
1606 let mut primitives = Vec::new();1722 let mut primitives = Vec::new();
1723 let active = editor.active_outline().id;
1724 let whole = editor
1725 .whole()
1726 .filter(|_| paint.chrome && object_focus.is_none());
1727 let selected = |outline: &TextOutline| match whole {
1728 Some(Whole::Page) => !outline.title,
1729 Some(Whole::Outline) => outline.id == active,
1730 None => false,
1731 };
1607 let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| {1732 let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| {
1608 // An emptied page outline leaves the editor but keeps its paint slot for undo.1733 // An emptied page outline leaves the editor but keeps its paint slot for undo.
1609 let Some(outline) = editor.visible_outlines().find(|outline| outline.id == id) else {1734 let Some(outline) = editor.visible_outlines().find(|outline| outline.id == id) else {
...@@ -1613,13 +1738,19 @@ fn page_primitives<'a>(...@@ -1613,13 +1738,19 @@ fn page_primitives<'a>(
1613 Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized,1738 Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized,
1614 _ => outline,1739 _ => outline,
1615 };1740 };
1741 let [x, y] = outline.origin();
1616 let origin = match preview {1742 let origin = match preview {
1617 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,1743 Some(PointerFeedback::Move(id, [dx, dy]))
1618 _ => outline.origin(),1744 if id == outline.id || whole == Some(Whole::Page) && !outline.title =>
1745 {
1746 [x + dx, y + dy]
1747 }
1748 _ => [x, y],
1619 };1749 };
1620 // OneNote frames the title whether or not it is focused or hovered.1750 // OneNote frames the title whether or not it is focused or hovered.
1621 if outline.title1751 if outline.title
1622 || (paint.chrome && object_focus.is_none() && outline.id == editor.active_outline().id)1752 || selected(outline)
1753 || (paint.chrome && object_focus.is_none() && outline.id == active)
1623 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)1754 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
1624 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)1755 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
1625 {1756 {
...@@ -1628,14 +1759,16 @@ fn page_primitives<'a>(...@@ -1628,14 +1759,16 @@ fn page_primitives<'a>(
1628 [origin[0] + offset[0], origin[1] + offset[1]],1759 [origin[0] + offset[0], origin[1] + offset[1]],
1629 paint.pixel,1760 paint.pixel,
1630 paint.colors.paper,1761 paint.colors.paper,
1762 selected(outline),
1631 primitives,1763 primitives,
1632 );1764 );
1633 }1765 }
1634 append_outline(1766 append_outline(
1635 (object_focus.is_none()1767 (object_focus.is_none()
1636 && outline.id == editor.active_outline().id1768 && outline.id == active
1637 && !matches!(preview, Some(PointerFeedback::Resize(_))))1769 && !matches!(preview, Some(PointerFeedback::Resize(_))))
1638 .then_some(editor),1770 .then_some(editor),
1771 selected(outline),
1639 outline,1772 outline,
1640 [origin[0] + offset[0], origin[1] + offset[1]],1773 [origin[0] + offset[0], origin[1] + offset[1]],
1641 paint,1774 paint,
...@@ -1692,6 +1825,7 @@ fn page_primitives<'a>(...@@ -1692,6 +1825,7 @@ fn page_primitives<'a>(
1692 {1825 {
1693 append_outline(1826 append_outline(
1694 Some(editor),1827 Some(editor),
1828 false,
1695 outline,1829 outline,
1696 outline.origin(),1830 outline.origin(),
1697 paint,1831 paint,
...@@ -1892,22 +2026,26 @@ fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] {...@@ -1892,22 +2026,26 @@ fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] {
18922026
1893fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {2027fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {
1894 let bounds = outline.bounds();2028 let bounds = outline.bounds();
1895 // Native chrome combines page-scaled gutters with a fixed screen inset.
1896 let inset = 5.0 * pixel;
1897 let body_top = bounds.y0 as f32 - inset;
1898 if outline.title {2029 if outline.title {
2030 // The title's box scales with the page, 5 px from its text at 100% (6 px more to the
2031 // left, 2 px less to the right).
2032 let inset = 3.75;
1899 let (_, paragraph) = outline.layouts().last().unwrap();2033 let (_, paragraph) = outline.layouts().last().unwrap();
1900 let bottom = outline.origin()[1] + paragraph.origin[1] + paragraph.text.height();2034 let bottom = outline.origin()[1] + paragraph.origin[1] + paragraph.text.height();
2035 let top = bounds.y0 as f32 - inset;
1901 return (2036 return (
1902 [2037 [
1903 bounds.x0 as f32 - inset - 6.0 * pixel,2038 bounds.x0 as f32 - inset - 4.5,
1904 body_top,2039 top,
1905 bounds.x1 as f32 + inset - 2.0 * pixel,2040 bounds.x1 as f32 + inset - 1.5,
1906 bottom + inset,2041 bottom + inset,
1907 ],2042 ],
1908 body_top,2043 top,
1909 );2044 );
1910 }2045 }
2046 // Native chrome combines page-scaled gutters with a fixed screen inset.
2047 let inset = 5.0 * pixel;
2048 let body_top = bounds.y0 as f32 - inset;
1911 // OneNote widens the box leftward to hold the tag column and list markers; text and right2049 // OneNote widens the box leftward to hold the tag column and list markers; text and right
1912 // edge stay.2050 // edge stay.
1913 let shaped = outline.shaped();2051 let shaped = outline.shaped();
...@@ -1933,11 +2071,13 @@ fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {...@@ -1933,11 +2071,13 @@ fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {
1933 )2071 )
1934}2072}
19352073
2074/// `selected` greys the body, as OneNote marks an outline Select All has selected whole.
1936fn append_outline_chrome(2075fn append_outline_chrome(
1937 outline: &TextOutline,2076 outline: &TextOutline,
1938 origin: [f32; 2],2077 origin: [f32; 2],
1939 pixel: f32,2078 pixel: f32,
1940 paper: crate::gpu::Paper,2079 paper: crate::gpu::Paper,
2080 selected: bool,
1941 primitives: &mut Vec<Primitive<'_>>,2081 primitives: &mut Vec<Primitive<'_>>,
1942) {2082) {
1943 let [x, y] = origin;2083 let [x, y] = origin;
...@@ -1973,6 +2113,14 @@ fn append_outline_chrome(...@@ -1973,6 +2113,14 @@ fn append_outline_chrome(
1973 stroke: None,2113 stroke: None,
1974 color: paper.shade(crate::gpu::colorref(0x00e5dee7)),2114 color: paper.shade(crate::gpu::colorref(0x00e5dee7)),
1975 });2115 });
2116 if selected {
2117 primitives.push(Primitive::RoundedRect {
2118 rect: [left, body_top, right, bottom],
2119 radius: [3.0 * pixel; 2],
2120 stroke: None,
2121 color: paper.shade(crate::gpu::colorref(0x00f0f0f0)),
2122 });
2123 }
1976 primitives.push(Primitive::RoundedRect {2124 primitives.push(Primitive::RoundedRect {
1977 rect: [left, top, right, bottom],2125 rect: [left, top, right, bottom],
1978 radius: [3.0 * pixel; 2],2126 radius: [3.0 * pixel; 2],
...@@ -2008,8 +2156,11 @@ fn append_outline_chrome(...@@ -2008,8 +2156,11 @@ fn append_outline_chrome(
2008 }2156 }
2009}2157}
20102158
2159/// `editor` paints its selection and caret in the focused outline; `selected` highlights all
2160/// the text of another.
2011fn append_outline<'a>(2161fn append_outline<'a>(
2012 editor: Option<&'a CanvasEditor>,2162 editor: Option<&'a CanvasEditor>,
2163 selected: bool,
2013 outline: &'a TextOutline,2164 outline: &'a TextOutline,
2014 origin: [f32; 2],2165 origin: [f32; 2],
2015 paint: Paint,2166 paint: Paint,
...@@ -2052,18 +2203,21 @@ fn append_outline<'a>(...@@ -2052,18 +2203,21 @@ fn append_outline<'a>(
2052 });2203 });
2053 }2204 }
2054 }2205 }
2055 if let Some(editor) = editor {2206 let highlight = match editor {
2056 for rect in editor.selection_rects()? {2207 Some(editor) => editor.selection_rects()?,
2057 primitives.push(Primitive::Rect {2208 None if selected => outline.range_rects(outline.whole())?,
2058 rect: [2209 None => Vec::new(),
2059 rect.x0 as f32 + x,2210 };
2060 rect.y0 as f32 + y,2211 for rect in highlight {
2061 rect.x1 as f32 + x,2212 primitives.push(Primitive::Rect {
2062 rect.y1 as f32 + y,2213 rect: [
2063 ],2214 rect.x0 as f32 + x,
2064 color: colors.selection,2215 rect.y0 as f32 + y,
2065 });2216 rect.x1 as f32 + x,
2066 }2217 rect.y1 as f32 + y,
2218 ],
2219 color: colors.selection,
2220 });
2067 }2221 }
2068 for (index, paragraph) in outline.shaped().visible(rows) {2222 for (index, paragraph) in outline.shaped().visible(rows) {
2069 outline.shaped().append_paragraph_primitives(2223 outline.shaped().append_paragraph_primitives(
crates/canvas/src/interaction/tests.rs+362-2
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1use super::*;1use super::*;
2use crate::document::TextDocument;2use crate::document::{TextDocument, TextPosition};
3use crate::editor::Toggle;
3use crate::gpu::painted_layout;4use crate::gpu::painted_layout;
4use onestore::document::Format;5use onestore::document::Format;
5use onestore::page::Page;6use onestore::page::Page;
6use onestore::page::text::Paragraph;7use onestore::page::text::Paragraph;
8use std::collections::BTreeSet;
7use std::sync::Arc;9use std::sync::Arc;
810
9const COLORS: TextColors = TextColors {11const COLORS: TextColors = TextColors {
...@@ -143,6 +145,11 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {...@@ -143,6 +145,11 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
143 assert_eq!(editor.active_outline().bounds().width(), 162.0);145 assert_eq!(editor.active_outline().bounds().width(), 162.0);
144 assert_eq!(editor.active_outline().snapshot().min_width, Some(162.0));146 assert_eq!(editor.active_outline().snapshot().min_width, Some(162.0));
145 let (native_rect, _) = outline_chrome(editor.active_outline(), 0.75);147 let (native_rect, _) = outline_chrome(editor.active_outline(), 0.75);
148 // Unlike a body outline's, the title's box scales with zoom (OneNote 2010 at 200%).
149 assert_eq!(
150 outline_chrome(editor.active_outline(), 0.375).0,
151 native_rect
152 );
146 for (axis, (actual, expected)) in native_rect153 for (axis, (actual, expected)) in native_rect
147 .iter()154 .iter()
148 .zip([85.0, 98.0, 315.0, 134.0])155 .zip([85.0, 98.0, 315.0, 134.0])
...@@ -621,7 +628,7 @@ fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() {...@@ -621,7 +628,7 @@ fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() {
621 assert_eq!(borders(None).len(), 1);628 assert_eq!(borders(None).len(), 1);
622 let hovered = borders(Some(PointerFeedback::Hover(second)));629 let hovered = borders(Some(PointerFeedback::Hover(second)));
623 assert_eq!(hovered.len(), 2);630 assert_eq!(hovered.len(), 2);
624 let moved = borders(Some(PointerFeedback::Move(second, [240.0, 50.0])));631 let moved = borders(Some(PointerFeedback::Move(second, [20.0, 50.0])));
625 assert_eq!(moved[1][0] - hovered[1][0], 20.0);632 assert_eq!(moved[1][0] - hovered[1][0], 20.0);
626 assert_eq!(moved[1][1] - hovered[1][1], 50.0);633 assert_eq!(moved[1][1] - hovered[1][1], 50.0);
627 assert!(634 assert!(
...@@ -868,6 +875,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {...@@ -868,6 +875,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
868 let mut primitives = Vec::new();875 let mut primitives = Vec::new();
869 append_outline(876 append_outline(
870 Some(&editor),877 Some(&editor),
878 false,
871 editor.active_outline(),879 editor.active_outline(),
872 [24.0, 48.0],880 [24.0, 48.0],
873 Paint {881 Paint {
...@@ -941,6 +949,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {...@@ -941,6 +949,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {
941 let mut primitives = Vec::new();949 let mut primitives = Vec::new();
942 append_outline(950 append_outline(
943 Some(&editor),951 Some(&editor),
952 false,
944 editor.active_outline(),953 editor.active_outline(),
945 [24.0, 48.0],954 [24.0, 48.0],
946 Paint {955 Paint {
...@@ -2065,3 +2074,354 @@ fn an_empty_preedit_without_a_composition_changes_nothing() {...@@ -2065,3 +2074,354 @@ fn an_empty_preedit_without_a_composition_changes_nothing() {
2065 assert!(view.compose(String::new(), None).unwrap().changed);2074 assert!(view.compose(String::new(), None).unwrap().changed);
2066 assert!(view.editor.marked_range().is_none());2075 assert!(view.editor.marked_range().is_none());
2067}2076}
2077
2078/// The lab's page: a title, then outlines placed in the order Alpha, Beta, Gamma, Delta, with
2079/// Delta highest on the page, drawn at 1 point per pixel.
2080fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) {
2081 let mut engine = TextEngine::default();
2082 let outline = |engine: &mut TextEngine, text: &str, origin| {
2083 let paragraphs = text
2084 .split('\n')
2085 .map(|line| Paragraph::new(line.into(), Default::default()))
2086 .collect();
2087 TextOutline::new(
2088 engine,
2089 TextDocument::new(paragraphs).unwrap(),
2090 240.0,
2091 origin,
2092 )
2093 .unwrap()
2094 .snapshot()
2095 };
2096 let mut title = outline(&mut engine, "Multi", [0.0; 2]);
2097 title.title = true;
2098 let body = [
2099 ("Alpha one\nAlpha two", [36.0, 90.0]),
2100 ("Beta", [300.0, 180.0]),
2101 ("Gamma long line of text", [90.0, 300.0]),
2102 ("Delta", [380.0, 72.0]),
2103 ]
2104 .map(|(text, origin)| outline(&mut engine, text, origin));
2105 let ids = body.each_ref().map(|outline| outline.id);
2106 let page = Page {
2107 title: "Multi".into(),
2108 identity: None,
2109 created: None,
2110 margin_origin: [36.0, 14.4],
2111 color: None,
2112 definitions: Default::default(),
2113 objects: std::iter::once(onestore::page::PageObject::Title(onestore::page::Title {
2114 id: onestore::page::text::new_id().unwrap(),
2115 date: None,
2116 layout: Default::default(),
2117 outlines: vec![title],
2118 }))
2119 .chain(body.map(onestore::page::PageObject::Outline))
2120 .collect(),
2121 };
2122 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
2123 let mut view = PageView::new(
2124 editor,
2125 engine,
2126 Some((scene, [0.0; 2])),
2127 [800, 600],
2128 1.0,
2129 Duration::from_millis(500),
2130 );
2131 view.viewport.scale = 1.0;
2132 view.viewport.origin = [0.0; 2];
2133 (view, ids)
2134}
2135
2136const START: TextPosition = TextPosition {
2137 paragraph: 0,
2138 offset: 0,
2139};
2140
2141fn select_page(view: &mut PageView, from: onestore::ExGuid) {
2142 view.editor.focus_outline(from).unwrap();
2143 view.editor.select([START; 2].into()).unwrap();
2144 while view.editor.whole() != Some(Whole::Page) {
2145 let _ = view.widen_selection().unwrap();
2146 }
2147}
2148
2149fn origins(view: &PageView) -> Vec<[f32; 2]> {
2150 let body = view
2151 .editor
2152 .outlines()
2153 .iter()
2154 .filter(|outline| !outline.title);
2155 body.map(TextOutline::origin).collect()
2156}
2157
2158/// As OneNote 2010 widens Ctrl+A past an outline (lab, 2026-09-28): a body outline's
2159/// paragraph, the outline as an object, then every body outline, focusing the last.
2160#[test]
2161fn select_all_widens_from_an_outline_to_the_page() {
2162 let (mut view, [alpha, beta, _, delta]) = page_selection_view();
2163 let levels = |view: &mut PageView, from| {
2164 view.editor.focus_outline(from).unwrap();
2165 view.editor.select([START; 2].into()).unwrap();
2166 (0..4)
2167 .map(|_| {
2168 let _ = view.widen_selection().unwrap();
2169 (view.editor.whole(), view.editor.active_outline().id)
2170 })
2171 .collect::<Vec<_>>()
2172 };
2173 let page = (Some(Whole::Page), delta);
2174 for from in [alpha, beta] {
2175 assert_eq!(
2176 levels(&mut view, from),
2177 [(None, from), (Some(Whole::Outline), from), page, page]
2178 );
2179 }
2180 // The title has no object level: its text, then the page.
2181 let title = view.editor.outlines()[0].id;
2182 assert_eq!(levels(&mut view, title), [(None, title), page, page, page]);
2183 // The selection it chose ends with any other; an arrow leaves the caret at its start.
2184 let _ = view.key(&Key::Named(NamedKey::ArrowLeft), None).unwrap();
2185 assert_eq!(view.editor.whole(), None);
2186 assert_eq!(view.editor.active_outline().id, delta);
2187 assert_eq!(view.editor.selection().positions, [START; 2]);
2188}
2189
2190/// Each outline Select All holds shows its frame and a grey body, its text highlighted.
2191#[test]
2192fn a_page_selection_frames_and_greys_every_body_outline() {
2193 let (mut view, [alpha, ..]) = page_selection_view();
2194 let grey = crate::gpu::colorref(0x00f0f0f0);
2195 let paint = |view: &PageView| {
2196 let primitives = view.primitives(COLORS).unwrap();
2197 let count =
2198 |wanted: &dyn Fn(&Primitive) -> bool| primitives.iter().filter(|p| wanted(p)).count();
2199 (
2200 count(
2201 &|p| matches!(p, Primitive::RoundedRect { color, stroke: None, .. } if *color == grey),
2202 ),
2203 count(&|p| matches!(p, Primitive::Rect { color, .. } if *color == COLORS.selection)),
2204 )
2205 };
2206 view.editor.focus_outline(alpha).unwrap();
2207 assert_eq!(paint(&view).0, 0);
2208 let _ = view.widen_selection().unwrap();
2209 let _ = view.widen_selection().unwrap();
2210 let (greyed, alpha) = paint(&view);
2211 assert_eq!(greyed, 1);
2212 let _ = view.widen_selection().unwrap();
2213 // One line each of the others besides Alpha's.
2214 assert_eq!(paint(&view), (4, alpha + 3));
2215}
2216
2217/// Keys on the page selection (lab): arrows up and down and Tab leave it, Enter and Right
2218/// put the caret at the last outline's end, Delete and Backspace remove every outline and
2219/// leave the caret where the first began, and typing replaces them all with the last outline
2220/// holding the text. Each edit stores as one edit and undoes in one step to the selection.
2221#[test]
2222fn keys_act_on_the_page_selection_as_onenote_does() {
2223 let (mut view, [alpha, beta, gamma, delta]) = page_selection_view();
2224 let end = TextPosition {
2225 paragraph: 0,
2226 offset: 5,
2227 };
2228 for key in [
2229 NamedKey::ArrowUp,
2230 NamedKey::ArrowDown,
2231 NamedKey::Tab,
2232 NamedKey::Enter,
2233 ] {
2234 select_page(&mut view, beta);
2235 let _ = view.key(&Key::Named(key), None).unwrap();
2236 let kept = key != NamedKey::Enter;
2237 assert_eq!(view.editor.whole() == Some(Whole::Page), kept, "{key:?}");
2238 if !kept {
2239 assert_eq!(view.editor.selection().positions, [end; 2]);
2240 }
2241 }
2242 assert_eq!(view.editor.take_ops().unwrap(), []);
2243
2244 let stored = origins(&view);
2245 for key in [NamedKey::Delete, NamedKey::Backspace] {
2246 select_page(&mut view, gamma);
2247 let _ = view.key(&Key::Named(key), None).unwrap();
2248 assert!(view.editor.outlines().iter().all(|outline| outline.title));
2249 assert_eq!(
2250 view.editor.caret_outline().map(TextOutline::origin),
2251 Some(stored[0])
2252 );
2253 let ops = view.editor.take_ops().unwrap();
2254 let deleted = ops.iter().filter_map(|op| match op {
2255 onestore::op::PageOp::Delete { object } => Some(*object),
2256 _ => None,
2257 });
2258 assert_eq!(
2259 deleted.collect::<BTreeSet<_>>(),
2260 BTreeSet::from([alpha, beta, gamma, delta])
2261 );
2262 assert_eq!(ops.len(), 4);
2263 let _ = view.undo(false).unwrap();
2264 assert_eq!(origins(&view), stored);
2265 assert_eq!(view.editor.whole(), Some(Whole::Page));
2266 view.editor.take_ops().unwrap();
2267 }
2268
2269 select_page(&mut view, alpha);
2270 let _ = view.key(&Key::Character("Z".into()), Some("Z")).unwrap();
2271 let left = view
2272 .editor
2273 .outlines()
2274 .iter()
2275 .filter(|outline| !outline.title);
2276 assert_eq!(
2277 left.map(|outline| (outline.id, outline.shown_text()))
2278 .collect::<Vec<_>>(),
2279 [(delta, "Z".to_owned())]
2280 );
2281 let _ = view.undo(false).unwrap();
2282 assert_eq!(origins(&view), stored);
2283 assert_eq!(view.editor.whole(), Some(Whole::Page));
2284 assert_eq!(view.editor.active_outline().shown_text(), "Delta");
2285}
2286
2287/// Copy takes the outlines top to bottom with a blank line between, as OneNote's plain text
2288/// does; Cut removes them as Delete does.
2289#[test]
2290fn copy_and_cut_take_the_page_selection() {
2291 let (mut view, [alpha, ..]) = page_selection_view();
2292 select_page(&mut view, alpha);
2293 let copied = Some(Request::Copy(
2294 "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text".into(),
2295 ));
2296 assert_eq!(view.copy(false).unwrap().request, copied);
2297 assert_eq!(view.editor.whole(), Some(Whole::Page));
2298 assert_eq!(view.copy(true).unwrap().request, copied);
2299 assert!(view.editor.outlines().iter().all(|outline| outline.title));
2300 assert!(view.editor.caret_outline().is_some());
2301}
2302
2303/// Dragging any selected outline's handle moves them all by one snapped offset, stored as one
2304/// edit and undone in one step; a click on a handle selects that outline alone.
2305#[test]
2306fn a_dragged_page_selection_moves_every_outline() {
2307 let (mut view, [alpha, beta, ..]) = page_selection_view();
2308 let stored = origins(&view);
2309 select_page(&mut view, alpha);
2310 let now = Instant::now();
2311 let bounds = view
2312 .editor
2313 .outlines()
2314 .iter()
2315 .find(|outline| outline.id == beta)
2316 .unwrap()
2317 .bounds();
2318 let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0];
2319 let _ = view.pointer_moved(header).unwrap();
2320 let _ = view.pointer_pressed(now).unwrap();
2321 assert_eq!(view.editor.whole(), Some(Whole::Page));
2322 let _ = view
2323 .pointer_moved([header[0] + 30.0, header[1] + 40.0])
2324 .unwrap();
2325 // Every selected outline follows the drag while it lasts.
2326 let dragged = view.primitives(COLORS).unwrap().len();
2327 assert!(dragged > 0);
2328 let _ = view.pointer_released().unwrap();
2329 // Beta lands on the grid, 18 pt cells from the margin origin, and the rest follow.
2330 let moved = origins(&view);
2331 assert_eq!(moved[1], [324.0, 212.4]);
2332 for (before, after) in stored.iter().zip(&moved) {
2333 for axis in 0..2 {
2334 let delta = moved[1][axis] - stored[1][axis];
2335 assert!((after[axis] - before[axis] - delta).abs() < 1e-3);
2336 }
2337 }
2338 assert_eq!(view.editor.whole(), Some(Whole::Page));
2339 assert_eq!(view.editor.take_ops().unwrap().len(), 4);
2340 let _ = view.undo(false).unwrap();
2341 assert_eq!(origins(&view), stored);
2342
2343 // Option-Command-arrows nudge the whole selection.
2344 let _ = view
2345 .modifiers_changed(Modifiers {
2346 command: true,
2347 option: true,
2348 ..Modifiers::default()
2349 })
2350 .unwrap();
2351 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
2352 let _ = view.modifiers_changed(Modifiers::default()).unwrap();
2353 assert!(
2354 origins(&view)
2355 .iter()
2356 .zip(&stored)
2357 .all(|(after, before)| *after == [before[0] + 1.0, before[1]])
2358 );
2359
2360 let _ = view.pointer_moved(header).unwrap();
2361 let _ = view.pointer_pressed(now + Duration::from_secs(1)).unwrap();
2362 let _ = view.pointer_released().unwrap();
2363 assert_eq!(view.editor.whole(), Some(Whole::Outline));
2364 assert_eq!(view.editor.active_outline().id, beta);
2365}
2366
2367/// A toggle on the page selection turns on everywhere unless every outline has it, as one
2368/// undo step that keeps the selection.
2369#[test]
2370fn formatting_the_page_selection_settles_on_one_state() {
2371 let (mut view, [alpha, beta, ..]) = page_selection_view();
2372 let bold = |view: &mut PageView| {
2373 let ids = view
2374 .editor
2375 .outlines()
2376 .iter()
2377 .filter(|outline| !outline.title)
2378 .map(|outline| outline.id)
2379 .collect::<Vec<_>>();
2380 let focus = view.editor.active_outline().id;
2381 let selection = view.editor.selection();
2382 let whole = view.editor.whole();
2383 let states = ids
2384 .into_iter()
2385 .map(|id| {
2386 view.editor.focus_outline(id).unwrap();
2387 view.editor.select_all().unwrap();
2388 view.editor
2389 .format_state()
2390 .unwrap()
2391 .toggles
2392 .contains(&Toggle::Bold)
2393 })
2394 .collect::<Vec<_>>();
2395 if whole == Some(Whole::Page) {
2396 select_page(view, focus);
2397 } else {
2398 view.editor.focus_outline(focus).unwrap();
2399 view.editor.select(selection).unwrap();
2400 }
2401 states
2402 };
2403 view.editor.focus_outline(beta).unwrap();
2404 view.editor.select_all().unwrap();
2405 let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap();
2406 assert_eq!(bold(&mut view), [false, true, false, false]);
2407 select_page(&mut view, alpha);
2408 let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap();
2409 assert_eq!(view.editor.whole(), Some(Whole::Page));
2410 assert_eq!(bold(&mut view), [true; 4]);
2411 let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap();
2412 assert_eq!(bold(&mut view), [false; 4]);
2413 let _ = view.undo(false).unwrap();
2414 assert_eq!(bold(&mut view), [true; 4]);
2415 assert_eq!(view.editor.whole(), Some(Whole::Page));
2416}
2417
2418/// Selecting the last outline's text again by other means selects only that text.
2419#[test]
2420fn the_page_selection_does_not_return_with_its_text_selection() {
2421 let (mut view, [alpha, ..]) = page_selection_view();
2422 select_page(&mut view, alpha);
2423 let whole = view.editor.selection();
2424 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
2425 view.editor.select(whole).unwrap();
2426 assert_eq!(view.editor.whole(), None);
2427}
crates/canvas/src/layout.rs+6-2
...@@ -102,6 +102,10 @@ impl fmt::Display for LayoutError {...@@ -102,6 +102,10 @@ impl fmt::Display for LayoutError {
102102
103impl std::error::Error for LayoutError {}103impl std::error::Error for LayoutError {}
104104
105/// What a run without a font or size of its own is laid out in.
106pub const DEFAULT_FONT: &str = "Arial";
107pub const DEFAULT_FONT_SIZE: f32 = 11.0;
108
105/// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font109/// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font
106/// Licence files beside them, by the family each stands in for.110/// Licence files beside them, by the family each stands in for.
107const BUNDLED: [(&str, &[&[u8]]); 4] = [111const BUNDLED: [(&str, &[&[u8]]); 4] = [
...@@ -283,7 +287,7 @@ impl TextEngine {...@@ -283,7 +287,7 @@ impl TextEngine {
283 for (index, span) in paragraph.spans().iter().enumerate() {287 for (index, span) in paragraph.spans().iter().enumerate() {
284 let format = &span.format;288 let format = &span.format;
285 let link = format.hyperlink == Some(true);289 let link = format.hyperlink == Some(true);
286 let size = format.font_size.unwrap_or(11.0);290 let size = format.font_size.unwrap_or(DEFAULT_FONT_SIZE);
287 if !size.is_finite() || size <= 0.0 {291 if !size.is_finite() || size <= 0.0 {
288 return Err(LayoutError::InvalidFontSize);292 return Err(LayoutError::InvalidFontSize);
289 }293 }
...@@ -303,7 +307,7 @@ impl TextEngine {...@@ -303,7 +307,7 @@ impl TextEngine {
303 StyleProperty::FontFamily(FontFamily::List(307 StyleProperty::FontFamily(FontFamily::List(
304 [308 [
305 Some(FontFamilyName::named(309 Some(FontFamilyName::named(
306 format.font.as_deref().unwrap_or("Arial"),310 format.font.as_deref().unwrap_or(DEFAULT_FONT),
307 )),311 )),
308 // macOS ships STIX Two Math where Windows has Cambria Math.312 // macOS ships STIX Two Math where Windows has Cambria Math.
309 (format.font.as_deref() == Some("Cambria Math"))313 (format.font.as_deref() == Some("Cambria Math"))
crates/canvas/src/math.rs+1-1
...@@ -612,7 +612,7 @@ pub(crate) fn built(paragraph: &Paragraph) -> Vec<std::ops::Range<usize>> {...@@ -612,7 +612,7 @@ pub(crate) fn built(paragraph: &Paragraph) -> Vec<std::ops::Range<usize>> {
612pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayout, LayoutError> {612pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayout, LayoutError> {
613 let nodes = Math::parse(paragraph).map_err(|_| LayoutError::UnsupportedContent)?;613 let nodes = Math::parse(paragraph).map_err(|_| LayoutError::UnsupportedContent)?;
614 let format = &paragraph.spans()[0].format;614 let format = &paragraph.spans()[0].format;
615 let size = format.font_size.unwrap_or(11.0);615 let size = format.font_size.unwrap_or(crate::layout::DEFAULT_FONT_SIZE);
616 let color = format.color.filter(|color| *color != 0xff00_0000);616 let color = format.color.filter(|color| *color != 0xff00_0000);
617 // An equation line is at least as tall as a line of text at its size.617 // An equation line is at least as tall as a line of text at its size.
618 let strut = engine.layout(618 let strut = engine.layout(
crates/canvas/src/outline.rs+28-17
...@@ -231,6 +231,30 @@ pub enum TagIcon {...@@ -231,6 +231,30 @@ pub enum TagIcon {
231 Other,231 Other,
232}232}
233233
234impl TagIcon {
235 /// How the page draws tag symbol `shape`, `checked` where it is a check box; none for
236 /// shape 0, which marks the text instead.
237 pub fn of(shape: u16, checked: bool) -> Option<Self> {
238 Some(match shape {
239 0 => return None,
240 // OneNote's check boxes, plain and with a person, arrow, number or bulb.
241 3 | 8 | 12 | 28 | 71 | 94 | 95 => Self::CheckBox { checked },
242 13 => Self::Star,
243 15 => Self::Question,
244 17 => Self::Exclamation,
245 18 => Self::Phone,
246 23 => Self::Address,
247 118 => Self::Contact,
248 136 => Self::Highlight,
249 100 => Self::RedSquare,
250 101 => Self::YellowSquare,
251 102 => Self::BlueSquare,
252 121 => Self::Music,
253 _ => Self::Other,
254 })
255 }
256}
257
234#[derive(Clone, Debug, PartialEq)]258#[derive(Clone, Debug, PartialEq)]
235pub struct ParagraphTag {259pub struct ParagraphTag {
236 pub icon: TagIcon,260 pub icon: TagIcon,
...@@ -823,25 +847,12 @@ impl ParagraphLayout {...@@ -823,25 +847,12 @@ impl ParagraphLayout {
823 let (tag, shape, label) = (tag.0, tag.1, tag.2);847 let (tag, shape, label) = (tag.0, tag.1, tag.2);
824 let icon = match shape {848 let icon = match shape {
825 None => TagIcon::Flag,849 None => TagIcon::Flag,
826 Some(Some(0) | None) => continue,850 Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) {
827 Some(Some(3)) => TagIcon::CheckBox {851 Some(icon) => icon,
828 checked: tag.status & 1 != 0,852 None => continue,
829 },853 },
830 Some(Some(13)) => TagIcon::Star,
831 Some(Some(15)) => TagIcon::Question,
832 Some(Some(17)) => TagIcon::Exclamation,
833 Some(Some(18)) => TagIcon::Phone,
834 Some(Some(23)) => TagIcon::Address,
835 Some(Some(118)) => TagIcon::Contact,
836 Some(Some(136)) => TagIcon::Highlight,
837 Some(Some(100)) => TagIcon::RedSquare,
838 Some(Some(101)) => TagIcon::YellowSquare,
839 Some(Some(102)) => TagIcon::BlueSquare,
840 Some(Some(121)) => TagIcon::Music,
841 // A symbol without artwork here shows as a plain tag.
842 Some(_) => TagIcon::Other,
843 };854 };
844 let size = format.font_size.unwrap_or(11.0);855 let size = format.font_size.unwrap_or(crate::layout::DEFAULT_FONT_SIZE);
845 let side = ParagraphTag::side(size);856 let side = ParagraphTag::side(size);
846 tags.push(ParagraphTag {857 tags.push(ParagraphTag {
847 icon,858 icon,
crates/canvas/tests/ops_differential.rs+31-2
...@@ -7,7 +7,7 @@...@@ -7,7 +7,7 @@
77
8use canvas::{8use canvas::{
9 document::TextPosition,9 document::TextPosition,
10 editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle},10 editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle, Whole},
11 layout::TextEngine,11 layout::TextEngine,
12};12};
13use draw::edit::Movement;13use draw::edit::Movement;
...@@ -91,11 +91,15 @@ enum Kind {...@@ -91,11 +91,15 @@ enum Kind {
91 Equation,91 Equation,
92 Linear,92 Linear,
93 Professional,93 Professional,
94 PageDelete,
95 PageType,
96 PageMove,
97 PageBold,
94 Undo,98 Undo,
95 Redo,99 Redo,
96}100}
97101
98const KINDS: [Kind; 48] = [102const KINDS: [Kind; 52] = [
99 Kind::Type,103 Kind::Type,
100 Kind::TypeEnd,104 Kind::TypeEnd,
101 Kind::Compose,105 Kind::Compose,
...@@ -142,6 +146,10 @@ const KINDS: [Kind; 48] = [...@@ -142,6 +146,10 @@ const KINDS: [Kind; 48] = [
142 Kind::Equation,146 Kind::Equation,
143 Kind::Linear,147 Kind::Linear,
144 Kind::Professional,148 Kind::Professional,
149 Kind::PageDelete,
150 Kind::PageType,
151 Kind::PageMove,
152 Kind::PageBold,
145 Kind::Undo,153 Kind::Undo,
146 Kind::Redo,154 Kind::Redo,
147];155];
...@@ -253,6 +261,18 @@ fn perform(...@@ -253,6 +261,18 @@ fn perform(
253 |editor: &mut CanvasEditor, engine: &mut TextEngine, random: &mut Random, command| {261 |editor: &mut CanvasEditor, engine: &mut TextEngine, random: &mut Random, command| {
254 range(editor, random) && editor.format(engine, command).is_ok()262 range(editor, random) && editor.format(engine, command).is_ok()
255 };263 };
264 // Select All until it holds every body outline.
265 let page = |editor: &mut CanvasEditor| {
266 for _ in 0..16 {
267 if editor.whole() == Some(Whole::Page) {
268 return true;
269 }
270 if editor.widen_selection().is_err() {
271 return false;
272 }
273 }
274 false
275 };
256 match kind {276 match kind {
257 Kind::Type => place(editor, middle) && editor.insert(engine, "ab").is_ok(),277 Kind::Type => place(editor, middle) && editor.insert(engine, "ab").is_ok(),
258 Kind::TypeEnd => place(editor, length) && editor.commit_text(engine, "z".into()).is_ok(),278 Kind::TypeEnd => place(editor, length) && editor.commit_text(engine, "z".into()).is_ok(),
...@@ -409,6 +429,15 @@ fn perform(...@@ -409,6 +429,15 @@ fn perform(
409 Kind::Professional => {429 Kind::Professional => {
410 place(editor, middle) && editor.build_equation(engine).unwrap_or(false)430 place(editor, middle) && editor.build_equation(engine).unwrap_or(false)
411 }431 }
432 Kind::PageDelete => page(editor) && editor.delete(engine, false).is_ok(),
433 Kind::PageType => page(editor) && editor.insert(engine, "Z").is_ok(),
434 Kind::PageMove => page(editor) && editor.move_page([36.0, 18.0]).is_ok(),
435 Kind::PageBold => {
436 page(editor)
437 && editor
438 .format(engine, Formatting::Toggle(Toggle::Bold))
439 .is_ok()
440 }
412 Kind::Undo => editor.undo(engine).unwrap_or(false),441 Kind::Undo => editor.undo(engine).unwrap_or(false),
413 Kind::Redo => editor.redo(engine).unwrap_or(false),442 Kind::Redo => editor.redo(engine).unwrap_or(false),
414 }443 }
crates/draw/Cargo.toml+5-3
...@@ -5,9 +5,11 @@ edition = "2024"...@@ -5,9 +5,11 @@ edition = "2024"
5publish = false5publish = false
66
7[features]7[features]
8default = ["render"]8default = ["wgpu"]
9# The wgpu renderer; `edit` needs only parley.9# The renderer; `edit` needs only parley. It submits through wgpu, or without it through
10render = ["dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash", "dep:wgpu"]10# OpenGL 2.1, for Mac OS X 10.6.
11render = ["dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]
12wgpu = ["render", "dep:wgpu"]
1113
12[dependencies]14[dependencies]
13parley.workspace = true15parley.workspace = true
crates/draw/src/draw.glsl created+102
...@@ -0,0 +1,102 @@
1// GLSL 1.20 port of crates/draw/src/draw.wgsl. GLSL 1.20 has no flat interpolation, so
2// the per-quad shape and stroke arrive interpolated between equal values: equality tests
3// on them use a tolerance.
4#ifdef VERTEX
5attribute vec2 position;
6attribute vec2 uv;
7attribute vec4 color;
8attribute vec2 local;
9attribute vec4 shape;
10attribute float stroke;
11#endif
12varying vec2 v_uv;
13varying vec4 v_color;
14varying vec2 v_local;
15varying vec4 v_shape;
16varying float v_stroke;
17
18#ifdef VERTEX
19void main() {
20 v_uv = uv;
21 v_color = color;
22 v_local = local;
23 v_shape = shape;
24 v_stroke = stroke;
25 gl_Position = vec4(position, 0.0, 1.0);
26}
27#else
28uniform sampler2D atlas;
29
30bool same(float a, float b) {
31 return abs(a - b) <= 1e-4 * max(1.0, max(abs(a), abs(b)));
32}
33
34float ellipse_arc(float angle, vec2 radius) {
35 if (same(radius.x, radius.y)) {
36 return angle * radius.x;
37 }
38 // Four-point Gauss-Legendre quadrature.
39 vec4 nodes = vec4(-0.86113631, -0.33998104, 0.33998104, 0.86113631);
40 vec4 weights = vec4(0.34785485, 0.65214515, 0.65214515, 0.34785485);
41 float half_angle = angle * 0.5;
42 float scale = max(radius.x, radius.y);
43 float sum = 0.0;
44 for (int i = 0; i < 4; i++) {
45 float t = half_angle * (nodes[i] + 1.0);
46 sum += weights[i] * length((radius / scale) * vec2(sin(t), cos(t)));
47 }
48 return half_angle * sum * scale;
49}
50
51void main() {
52 vec4 color = texture2D(atlas, v_uv) * v_color;
53 vec4 shape = v_shape;
54 if (shape.x > 0.0 && shape.y > 0.0) {
55 vec2 q = abs(v_local) - shape.xy + shape.zw;
56 float distance;
57 if (same(shape.z, shape.w)) {
58 distance = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - shape.z;
59 } else if (all(greaterThan(q, vec2(0.0)))) {
60 vec2 radius = max(shape.zw, vec2(0.0001));
61 vec2 normalized = q / radius;
62 float k0 = length(normalized);
63 distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20);
64 } else {
65 vec2 edge = abs(v_local) - shape.xy;
66 distance = max(edge.x, edge.y);
67 }
68 float coverage = clamp(0.5 - distance, 0.0, 1.0);
69 float width = abs(v_stroke);
70 if (width > 1e-6) {
71 coverage *= clamp(distance + width + 0.5, 0.0, 1.0);
72 }
73 if (v_stroke < -1e-6) {
74 vec2 radius = max(shape.zw - width * 0.5, vec2(0.0));
75 if (radius.x <= 1e-6 || radius.y <= 1e-6) {
76 radius = vec2(0.0);
77 }
78 vec2 straight = shape.xy - width * 0.5 - radius;
79 vec2 p = abs(v_local);
80 float quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius);
81 float along;
82 if (p.y <= straight.y) {
83 along = p.y;
84 } else if (p.x <= straight.x || radius.x == 0.0) {
85 along = quarter - p.x;
86 } else {
87 float angle = atan((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x);
88 along = straight.y + ellipse_arc(angle, radius);
89 }
90 if (v_local.x < 0.0) {
91 along = v_local.y < 0.0 ? 2.0 * quarter + along : 2.0 * quarter - along;
92 } else if (v_local.y < 0.0) {
93 along = 4.0 * quarter - along;
94 }
95 float phase = fract(along / (4.0 * width)) * (4.0 * width);
96 coverage *= clamp(width + 0.5 - abs(phase - 2.0 * width), 0.0, 1.0);
97 }
98 color *= coverage;
99 }
100 gl_FragColor = color;
101}
102#endif
crates/draw/src/edit.rs+1
...@@ -32,6 +32,7 @@ pub enum NamedKey {...@@ -32,6 +32,7 @@ pub enum NamedKey {
32 End,32 End,
33 PageUp,33 PageUp,
34 PageDown,34 PageDown,
35 F11,
35 /// Shift, Control, Option or Command pressed alone.36 /// Shift, Control, Option or Command pressed alone.
36 Modifier,37 Modifier,
37 Other,38 Other,
crates/draw/src/lib.rs+2-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1//! What the page and the interface share: painting layers of glyphs, icons, paths,1//! What the page and the interface share: painting layers of glyphs, icons, paths,
2//! images, rounded rectangles and pen strokes with wgpu, each layer with its own transform2//! images, rounded rectangles and pen strokes with wgpu or OpenGL, each layer with its
3//! and clip, and editing text within a laid-out line.3//! own transform and clip, and editing text within a laid-out line.
44
5pub mod edit;5pub mod edit;
6#[cfg(feature = "render")]6#[cfg(feature = "render")]
crates/draw/src/render.rs+88-276
...@@ -1,10 +1,21 @@...@@ -1,10 +1,21 @@
1#[cfg(not(feature = "wgpu"))]
2mod gl;
1mod icon;3mod icon;
2
3pub use icon::Palette;
4mod text;4mod text;
5#[cfg(feature = "wgpu")]
5mod translucent;6mod translucent;
7#[cfg(feature = "wgpu")]
8mod webgpu;
69
10#[cfg(not(feature = "wgpu"))]
11use gl as backend;
12#[cfg(feature = "wgpu")]
13use webgpu as backend;
14
15pub use backend::Target;
16pub use icon::Palette;
7pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};17pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};
18#[cfg(feature = "wgpu")]
8pub use translucent::Translucent;19pub use translucent::Translucent;
920
10use bytemuck::{Pod, Zeroable};21use bytemuck::{Pod, Zeroable};
...@@ -24,6 +35,7 @@ use swash::{...@@ -24,6 +35,7 @@ use swash::{
24const ATLAS_SIZE: u32 = 2048;35const ATLAS_SIZE: u32 = 2048;
25const MAX_GLYPHS: usize = 8192;36const MAX_GLYPHS: usize = 8192;
26const MAX_VERTICES: usize = 65_536;37const MAX_VERTICES: usize = 65_536;
38const VERTEX_BUFFER_BYTES: u64 = (MAX_VERTICES * size_of::<Vertex>()) as u64;
27/// Decoded bytes of all images one frame may paint.39/// Decoded bytes of all images one frame may paint.
28pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;40pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
29const MAX_IMAGES: usize = 256;41const MAX_IMAGES: usize = 256;
...@@ -146,7 +158,7 @@ impl RasterImage {...@@ -146,7 +158,7 @@ impl RasterImage {
146}158}
147159
148struct CachedImage {160struct CachedImage {
149 binding: wgpu::BindGroup,161 texture: backend::Image,
150 bytes: u64,162 bytes: u64,
151 /// The texture goes when every copy of its image has.163 /// The texture goes when every copy of its image has.
152 pixels: WeakBlob<u8>,164 pixels: WeakBlob<u8>,
...@@ -190,6 +202,8 @@ enum AtlasKey {...@@ -190,6 +202,8 @@ enum AtlasKey {
190 phase: [u8; 2],202 phase: [u8; 2],
191 embolden: bool,203 embolden: bool,
192 skew: u32,204 skew: u32,
205 /// The ink's sRGB luminance in sixteenths, which `text_coverage` weights by.
206 tone: u8,
193 },207 },
194 Icon {208 Icon {
195 sources: &'static [&'static str],209 sources: &'static [&'static str],
...@@ -435,15 +449,11 @@ pub struct Occupancy {...@@ -435,15 +449,11 @@ pub struct Occupancy {
435}449}
436450
437pub struct Renderer {451pub struct Renderer {
452 #[cfg(feature = "wgpu")]
438 pub device: wgpu::Device,453 pub device: wgpu::Device,
454 #[cfg(feature = "wgpu")]
439 pub queue: wgpu::Queue,455 pub queue: wgpu::Queue,
440 pipeline: wgpu::RenderPipeline,456 gpu: backend::Gpu,
441 image_pipeline: wgpu::RenderPipeline,
442 erase_pipeline: wgpu::RenderPipeline,
443 atlas: wgpu::Texture,
444 bind_group: wgpu::BindGroup,
445 image_sampler: wgpu::Sampler,
446 vertex_buffer: wgpu::Buffer,
447 vertices: Vec<Vertex>,457 vertices: Vec<Vertex>,
448 glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>,458 glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>,
449 images: HashMap<u64, CachedImage>,459 images: HashMap<u64, CachedImage>,
...@@ -454,150 +464,17 @@ pub struct Renderer {...@@ -454,150 +464,17 @@ pub struct Renderer {
454}464}
455465
456impl Renderer {466impl Renderer {
457 pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self {467 fn with_gpu(
458 let shader = device.create_shader_module(wgpu::include_wgsl!("draw.wgsl"));468 #[cfg(feature = "wgpu")] device: wgpu::Device,
459 let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {469 #[cfg(feature = "wgpu")] queue: wgpu::Queue,
460 label: Some("Draw texture"),470 gpu: backend::Gpu,
461 entries: &[471 ) -> Self {
462 wgpu::BindGroupLayoutEntry {472 let renderer = Self {
463 binding: 0,473 #[cfg(feature = "wgpu")]
464 visibility: wgpu::ShaderStages::FRAGMENT,
465 ty: wgpu::BindingType::Texture {
466 sample_type: wgpu::TextureSampleType::Float { filterable: true },
467 view_dimension: wgpu::TextureViewDimension::D2,
468 multisampled: false,
469 },
470 count: None,
471 },
472 wgpu::BindGroupLayoutEntry {
473 binding: 1,
474 visibility: wgpu::ShaderStages::FRAGMENT,
475 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
476 count: None,
477 },
478 ],
479 });
480 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
481 label: Some("Draw"),
482 bind_group_layouts: &[Some(&binding_layout)],
483 ..Default::default()
484 });
485 let make_pipeline = |blend| {
486 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
487 label: Some("Draw"),
488 layout: Some(&pipeline_layout),
489 vertex: wgpu::VertexState {
490 module: &shader,
491 entry_point: Some("vertex"),
492 compilation_options: Default::default(),
493 buffers: &[Some(wgpu::VertexBufferLayout {
494 array_stride: size_of::<Vertex>() as u64,
495 step_mode: wgpu::VertexStepMode::Vertex,
496 attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32],
497 })],
498 },
499 fragment: Some(wgpu::FragmentState {
500 module: &shader,
501 entry_point: Some("fragment"),
502 compilation_options: Default::default(),
503 targets: &[Some(wgpu::ColorTargetState {
504 format,
505 blend: Some(blend),
506 write_mask: wgpu::ColorWrites::ALL,
507 })],
508 }),
509 primitive: Default::default(),
510 depth_stencil: None,
511 multisample: Default::default(),
512 multiview_mask: None,
513 cache: None,
514 })
515 };
516 // Coverage accumulates in alpha, leaving premultiplied colour over a transparent clear.
517 let pipeline = make_pipeline(wgpu::BlendState {
518 color: wgpu::BlendState::ALPHA_BLENDING.color,
519 alpha: wgpu::BlendComponent::OVER,
520 });
521 let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
522 let erase = wgpu::BlendComponent {
523 src_factor: wgpu::BlendFactor::Zero,
524 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
525 operation: wgpu::BlendOperation::Add,
526 };
527 let erase_pipeline = make_pipeline(wgpu::BlendState {
528 color: erase,
529 alpha: erase,
530 });
531 let atlas = device.create_texture(&wgpu::TextureDescriptor {
532 label: Some("Glyph atlas"),
533 size: wgpu::Extent3d {
534 width: ATLAS_SIZE,
535 height: ATLAS_SIZE,
536 depth_or_array_layers: 1,
537 },
538 mip_level_count: 1,
539 sample_count: 1,
540 dimension: wgpu::TextureDimension::D2,
541 format: wgpu::TextureFormat::Rgba8UnormSrgb,
542 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
543 view_formats: &[],
544 });
545 let view = atlas.create_view(&Default::default());
546 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
547 mag_filter: wgpu::FilterMode::Nearest,
548 min_filter: wgpu::FilterMode::Nearest,
549 ..Default::default()
550 });
551 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
552 label: Some("Glyph atlas"),
553 layout: &pipeline.get_bind_group_layout(0),
554 entries: &[
555 wgpu::BindGroupEntry {
556 binding: 0,
557 resource: wgpu::BindingResource::TextureView(&view),
558 },
559 wgpu::BindGroupEntry {
560 binding: 1,
561 resource: wgpu::BindingResource::Sampler(&sampler),
562 },
563 ],
564 });
565 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
566 label: Some("Draw vertices"),
567 size: (MAX_VERTICES * size_of::<Vertex>()) as u64,
568 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
569 mapped_at_creation: false,
570 });
571 let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
572 label: Some("Draw image filtering"),
573 mag_filter: wgpu::FilterMode::Linear,
574 min_filter: wgpu::FilterMode::Linear,
575 ..Default::default()
576 });
577 queue.write_texture(
578 atlas.as_image_copy(),
579 &[255; 4],
580 wgpu::TexelCopyBufferLayout {
581 offset: 0,
582 bytes_per_row: Some(4),
583 rows_per_image: Some(1),
584 },
585 wgpu::Extent3d {
586 width: 1,
587 height: 1,
588 depth_or_array_layers: 1,
589 },
590 );
591 Self {
592 device,474 device,
475 #[cfg(feature = "wgpu")]
593 queue,476 queue,
594 pipeline,477 gpu,
595 image_pipeline,
596 erase_pipeline,
597 atlas,
598 bind_group,
599 image_sampler,
600 vertex_buffer,
601 vertices: Vec::new(),478 vertices: Vec::new(),
602 glyphs: HashMap::new(),479 glyphs: HashMap::new(),
603 images: HashMap::new(),480 images: HashMap::new(),
...@@ -605,7 +482,10 @@ impl Renderer {...@@ -605,7 +482,10 @@ impl Renderer {
605 scaler: ScaleContext::with_max_entries(32),482 scaler: ScaleContext::with_max_entries(32),
606 pen: [1, 0],483 pen: [1, 0],
607 row_height: 1,484 row_height: 1,
608 }485 };
486 // The atlas's first texel is the white that solid fills sample.
487 renderer.write_atlas([0, 0], [1, 1], &[255; 4]);
488 renderer
609 }489 }
610490
611 pub fn clear_glyph_cache(&mut self) {491 pub fn clear_glyph_cache(&mut self) {
...@@ -637,8 +517,8 @@ impl Renderer {...@@ -637,8 +517,8 @@ impl Renderer {
637 batch_capacity_bytes: self.batches.capacity() * size_of::<Batch>(),517 batch_capacity_bytes: self.batches.capacity() * size_of::<Batch>(),
638 glyph_capacity: self.glyphs.capacity(),518 glyph_capacity: self.glyphs.capacity(),
639 image_capacity: self.images.capacity(),519 image_capacity: self.images.capacity(),
640 atlas_bytes: u64::from(self.atlas.width()) * u64::from(self.atlas.height()) * 4,520 atlas_bytes: u64::from(ATLAS_SIZE) * u64::from(ATLAS_SIZE) * 4,
641 vertex_buffer_bytes: self.vertex_buffer.size(),521 vertex_buffer_bytes: VERTEX_BUFFER_BYTES,
642 within_budget: self.glyphs.len() <= MAX_GLYPHS522 within_budget: self.glyphs.len() <= MAX_GLYPHS
643 && self.images.len() <= MAX_IMAGES523 && self.images.len() <= MAX_IMAGES
644 && image_bytes <= MAX_IMAGE_BYTES524 && image_bytes <= MAX_IMAGE_BYTES
...@@ -649,7 +529,7 @@ impl Renderer {...@@ -649,7 +529,7 @@ impl Renderer {
649 /// Clears `target`, `size` device pixels, to linear `clear` and paints the layers in order.529 /// Clears `target`, `size` device pixels, to linear `clear` and paints the layers in order.
650 pub fn draw(530 pub fn draw(
651 &mut self,531 &mut self,
652 target: &wgpu::TextureView,532 target: &Target,
653 size: [u32; 2],533 size: [u32; 2],
654 clear: [f32; 4],534 clear: [f32; 4],
655 layers: &[Layer<'_>],535 layers: &[Layer<'_>],
...@@ -702,39 +582,7 @@ impl Renderer {...@@ -702,39 +582,7 @@ impl Renderer {
702 }582 }
703 }583 }
704 }584 }
705 self.queue585 self.submit(target, clear);
706 .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices));
707 let mut encoder = self.device.create_command_encoder(&Default::default());
708 {
709 let [r, g, b, a] = clear.map(f64::from);
710 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
711 label: Some("Draw"),
712 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
713 view: target,
714 depth_slice: None,
715 resolve_target: None,
716 ops: wgpu::Operations {
717 load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }),
718 store: wgpu::StoreOp::Store,
719 },
720 })],
721 ..Default::default()
722 });
723 pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
724 for batch in &self.batches {
725 let [x, y, width, height] = batch.scissor;
726 pass.set_scissor_rect(x, y, width, height);
727 let (pipeline, binding) = match batch.blend {
728 Blend::Over => (&self.pipeline, &self.bind_group),
729 Blend::Image(id) => (&self.image_pipeline, &self.images[&id].binding),
730 Blend::Erase => (&self.erase_pipeline, &self.bind_group),
731 };
732 pass.set_pipeline(pipeline);
733 pass.set_bind_group(0, binding, &[]);
734 pass.draw(batch.vertices.clone(), 0..1);
735 }
736 }
737 self.queue.submit([encoder.finish()]);
738 Ok(())586 Ok(())
739 }587 }
740588
...@@ -892,11 +740,7 @@ impl Renderer {...@@ -892,11 +740,7 @@ impl Renderer {
892 if self.images.contains_key(&image.id()) {740 if self.images.contains_key(&image.id()) {
893 return Ok(());741 return Ok(());
894 }742 }
895 if image743 if image.size.iter().any(|v| *v > self.max_texture_dimension()) {
896 .size
897 .iter()
898 .any(|v| *v > self.device.limits().max_texture_dimension_2d)
899 {
900 return Err(RenderError::ImageTooLarge);744 return Err(RenderError::ImageTooLarge);
901 }745 }
902 let bytes = image.pixels().len() as u64;746 let bytes = image.pixels().len() as u64;
...@@ -911,51 +755,10 @@ impl Renderer {...@@ -911,51 +755,10 @@ impl Renderer {
911 .ok_or(RenderError::ImageBudget)?;755 .ok_or(RenderError::ImageBudget)?;
912 self.images.remove(&id);756 self.images.remove(&id);
913 }757 }
914 let size = wgpu::Extent3d {
915 width: image.size[0],
916 height: image.size[1],
917 depth_or_array_layers: 1,
918 };
919 let texture = self.device.create_texture(&wgpu::TextureDescriptor {
920 label: Some("Draw image"),
921 size,
922 mip_level_count: 1,
923 sample_count: 1,
924 dimension: wgpu::TextureDimension::D2,
925 format: wgpu::TextureFormat::Rgba8UnormSrgb,
926 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
927 view_formats: &[],
928 });
929 self.queue.write_texture(
930 texture.as_image_copy(),
931 image.pixels(),
932 wgpu::TexelCopyBufferLayout {
933 offset: 0,
934 bytes_per_row: Some(image.size[0] * 4),
935 rows_per_image: Some(image.size[1]),
936 },
937 size,
938 );
939 let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
940 label: Some("Draw image"),
941 layout: &self.pipeline.get_bind_group_layout(0),
942 entries: &[
943 wgpu::BindGroupEntry {
944 binding: 0,
945 resource: wgpu::BindingResource::TextureView(
946 &texture.create_view(&Default::default()),
947 ),
948 },
949 wgpu::BindGroupEntry {
950 binding: 1,
951 resource: wgpu::BindingResource::Sampler(&self.image_sampler),
952 },
953 ],
954 });
955 self.images.insert(758 self.images.insert(
956 image.id(),759 image.id(),
957 CachedImage {760 CachedImage {
958 binding,761 texture: self.upload_image(image),
959 bytes,762 bytes,
960 pixels: image.pixels.downgrade(),763 pixels: image.pixels.downgrade(),
961 },764 },
...@@ -980,6 +783,12 @@ impl Renderer {...@@ -980,6 +783,12 @@ impl Renderer {
980 if !size.is_finite() || size > ATLAS_SIZE as f32 {783 if !size.is_finite() || size > ATLAS_SIZE as f32 {
981 return Err(RenderError::AtlasFull);784 return Err(RenderError::AtlasFull);
982 }785 }
786 let color = run
787 .color
788 .map_or(ink, |color| legible(color, space.backdrop, run.backdrop));
789 let [red, green, blue, _] = color;
790 let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
791 let tone = (f32::from(srgb_byte(luminance)) / 255.0 * 16.0).round() as u8;
983 for glyph in run.glyphs {792 for glyph in run.glyphs {
984 let x = glyph.x * scale + origin[0];793 let x = glyph.x * scale + origin[0];
985 let y = glyph.y * scale + origin[1];794 let y = glyph.y * scale + origin[1];
...@@ -1000,6 +809,7 @@ impl Renderer {...@@ -1000,6 +809,7 @@ impl Renderer {
1000 phase,809 phase,
1001 embolden: run.embolden,810 embolden: run.embolden,
1002 skew: run.skew.unwrap_or(0.0).to_bits(),811 skew: run.skew.unwrap_or(0.0).to_bits(),
812 tone,
1003 };813 };
1004 let cached = if let Some(cached) = self.glyphs.get(&key) {814 let cached = if let Some(cached) = self.glyphs.get(&key) {
1005 *cached815 *cached
...@@ -1035,7 +845,15 @@ impl Renderer {...@@ -1035,7 +845,15 @@ impl Renderer {
1035 Angle::from_degrees(0.0),845 Angle::from_degrees(0.0),
1036 )));846 )));
1037 }847 }
1038 let image = render.render(&mut scaler, glyph_id);848 let image = render.render(&mut scaler, glyph_id).map(|mut image| {
849 if image.content == Content::Mask {
850 let coverage = text_coverage(tone);
851 for alpha in &mut image.data {
852 *alpha = coverage[usize::from(*alpha)];
853 }
854 }
855 image
856 });
1039 let cached = if let Some(image) =857 let cached = if let Some(image) =
1040 image.filter(|i| i.placement.width > 0 && i.placement.height > 0)858 image.filter(|i| i.placement.width > 0 && i.placement.height > 0)
1041 {859 {
...@@ -1068,12 +886,7 @@ impl Renderer {...@@ -1068,12 +886,7 @@ impl Renderer {
1068 (glyph.x + glyph.width) as f32 / atlas_size,886 (glyph.x + glyph.width) as f32 / atlas_size,
1069 (glyph.y + glyph.height) as f32 / atlas_size,887 (glyph.y + glyph.height) as f32 / atlas_size,
1070 ],888 ],
1071 if glyph.color {889 if glyph.color { [1.0; 4] } else { color },
1072 [1.0; 4]
1073 } else {
1074 run.color
1075 .map_or(ink, |color| legible(color, space.backdrop, run.backdrop))
1076 },
1077 )?;890 )?;
1078 }891 }
1079 }892 }
...@@ -1127,27 +940,7 @@ impl Renderer {...@@ -1127,27 +940,7 @@ impl Renderer {
1127 .collect(),940 .collect(),
1128 _ => image.data,941 _ => image.data,
1129 };942 };
1130 self.queue.write_texture(943 self.write_atlas([cached.x, cached.y], [p.width, p.height], &rgba);
1131 wgpu::TexelCopyTextureInfo {
1132 origin: wgpu::Origin3d {
1133 x: cached.x,
1134 y: cached.y,
1135 z: 0,
1136 },
1137 ..self.atlas.as_image_copy()
1138 },
1139 &rgba,
1140 wgpu::TexelCopyBufferLayout {
1141 offset: 0,
1142 bytes_per_row: Some(p.width * 4),
1143 rows_per_image: Some(p.height),
1144 },
1145 wgpu::Extent3d {
1146 width: p.width,
1147 height: p.height,
1148 depth_or_array_layers: 1,
1149 },
1150 );
1151 self.pen[0] += p.width + 1;944 self.pen[0] += p.width + 1;
1152 self.row_height = self.row_height.max(p.height);945 self.row_height = self.row_height.max(p.height);
1153 Ok(cached)946 Ok(cached)
...@@ -1558,6 +1351,32 @@ fn srgb_byte(value: f32) -> u8 {...@@ -1558,6 +1351,32 @@ fn srgb_byte(value: f32) -> u8 {
1558 (srgb * 255.0).round() as u81351 (srgb * 255.0).round() as u8
1559}1352}
15601353
1354/// Coverage for text of sRGB luminance `tone` sixteenths that, blended in linear light,
1355/// darkens a white backdrop as the rasterized coverage would blended sRGB-encoded, as
1356/// systems blend text: dark text keeps its weight instead of thinning, light text is
1357/// nearly untouched.
1358fn text_coverage(tone: u8) -> [u8; 256] {
1359 let encoded = f32::from(tone) / 16.0;
1360 let ink = linear(encoded);
1361 std::array::from_fn(|coverage| {
1362 let coverage = coverage as f32 / 255.0;
1363 let weighted = if ink < 0.999 {
1364 (1.0 - linear(1.0 - coverage * (1.0 - encoded))) / (1.0 - ink)
1365 } else {
1366 coverage
1367 };
1368 (weighted * 255.0).round() as u8
1369 })
1370}
1371
1372fn linear(value: f32) -> f32 {
1373 if value <= 0.04045 {
1374 value / 12.92
1375 } else {
1376 ((value + 0.055) / 1.055).powf(2.4)
1377 }
1378}
1379
1561/// Least OKLab lightness difference between text and the backdrop it stays legible on.1380/// Least OKLab lightness difference between text and the backdrop it stays legible on.
1562const LEGIBLE: f32 = 0.4;1381const LEGIBLE: f32 = 0.4;
15631382
...@@ -1609,18 +1428,11 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] {...@@ -1609,18 +1428,11 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] {
16091428
1610/// Linear RGBA of an opaque sRGB colour.1429/// Linear RGBA of an opaque sRGB colour.
1611pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {1430pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {
1612 let linear = |byte: u8| {1431 let [red, green, blue] = [red, green, blue].map(|byte| linear(f32::from(byte) / 255.0));
1613 let value = f32::from(byte) / 255.0;1432 [red, green, blue, 1.0]
1614 if value <= 0.04045 {
1615 value / 12.92
1616 } else {
1617 ((value + 0.055) / 1.055).powf(2.4)
1618 }
1619 };
1620 [linear(red), linear(green), linear(blue), 1.0]
1621}1433}
16221434
1623#[cfg(test)]1435#[cfg(all(test, feature = "wgpu"))]
1624mod tests {1436mod tests {
1625 use super::*;1437 use super::*;
1626 use parley::{1438 use parley::{
...@@ -2032,8 +1844,8 @@ mod tests {...@@ -2032,8 +1844,8 @@ mod tests {
2032 let mut moment = 0.0_f64;1844 let mut moment = 0.0_f64;
2033 for y in 212..252 {1845 for y in 212..252 {
2034 for x in (40 + 48 * step)..(72 + 48 * step) {1846 for x in (40 + 48 * step)..(72 + 48 * step) {
2035 let value = captures[0][(y * 512 + x) * 4];1847 // Black text over white leaves the rasterized coverage sRGB-encoded.
2036 let coverage = 1.0 - f64::from(srgb(value, 0, 0)[0]);1848 let coverage = 1.0 - f64::from(captures[0][(y * 512 + x) * 4]) / 255.0;
2037 weight += coverage;1849 weight += coverage;
2038 moment += coverage * y as f64;1850 moment += coverage * y as f64;
2039 }1851 }
crates/draw/src/render/gl.rs created+469
...@@ -0,0 +1,469 @@
1//! Submission through OpenGL 2.1 with EXT_framebuffer_object, EXT_framebuffer_blit and
2//! the sRGB extensions, which every Mac OS X 10.6 driver has and OpenGL.framework
3//! exports. Frames draw into an sRGB framebuffer object, so blending happens in linear
4//! light as it does in wgpu's sRGB targets.
5#![allow(non_snake_case)]
6use super::*;
7use std::{
8 ffi::{CStr, CString, c_char, c_void},
9 mem::offset_of,
10 ptr,
11};
12
13type GLenum = u32;
14type GLuint = u32;
15type GLint = i32;
16type GLsizei = i32;
17
18const TRIANGLES: GLenum = 0x0004;
19const COLOR_BUFFER_BIT: GLenum = 0x4000;
20const ZERO: GLenum = 0;
21const ONE: GLenum = 1;
22const SRC_ALPHA: GLenum = 0x0302;
23const ONE_MINUS_SRC_ALPHA: GLenum = 0x0303;
24const BLEND: GLenum = 0x0BE2;
25const SCISSOR_TEST: GLenum = 0x0C11;
26const MAX_TEXTURE_SIZE: GLenum = 0x0D33;
27const TEXTURE_2D: GLenum = 0x0DE1;
28const UNSIGNED_BYTE: GLenum = 0x1401;
29const FLOAT: GLenum = 0x1406;
30const RGBA: GLenum = 0x1908;
31const NEAREST: GLint = 0x2600;
32const LINEAR: GLint = 0x2601;
33const TEXTURE_MAG_FILTER: GLenum = 0x2800;
34const TEXTURE_MIN_FILTER: GLenum = 0x2801;
35const TEXTURE_WRAP_S: GLenum = 0x2802;
36const TEXTURE_WRAP_T: GLenum = 0x2803;
37const CLAMP_TO_EDGE: GLint = 0x812F;
38const TEXTURE0: GLenum = 0x84C0;
39const ARRAY_BUFFER: GLenum = 0x8892;
40const STREAM_DRAW: GLenum = 0x88E0;
41const FRAGMENT_SHADER: GLenum = 0x8B30;
42const VERTEX_SHADER: GLenum = 0x8B31;
43const COMPILE_STATUS: GLenum = 0x8B81;
44const LINK_STATUS: GLenum = 0x8B82;
45const INFO_LOG_LENGTH: GLenum = 0x8B84;
46const SRGB8_ALPHA8: GLint = 0x8C43;
47const READ_FRAMEBUFFER: GLenum = 0x8CA8;
48const DRAW_FRAMEBUFFER: GLenum = 0x8CA9;
49const FRAMEBUFFER_COMPLETE: GLenum = 0x8CD5;
50const COLOR_ATTACHMENT0: GLenum = 0x8CE0;
51const FRAMEBUFFER: GLenum = 0x8D40;
52const FRAMEBUFFER_SRGB: GLenum = 0x8DB9;
53
54#[cfg_attr(target_os = "macos", link(name = "OpenGL", kind = "framework"))]
55unsafe extern "C" {
56 fn glEnable(cap: GLenum);
57 fn glDisable(cap: GLenum);
58 fn glGetIntegerv(name: GLenum, value: *mut GLint);
59 fn glViewport(x: GLint, y: GLint, width: GLsizei, height: GLsizei);
60 fn glScissor(x: GLint, y: GLint, width: GLsizei, height: GLsizei);
61 fn glClearColor(r: f32, g: f32, b: f32, a: f32);
62 fn glClear(mask: GLenum);
63 fn glBlendFuncSeparate(src_rgb: GLenum, dst_rgb: GLenum, src_a: GLenum, dst_a: GLenum);
64 fn glReadPixels(
65 x: GLint,
66 y: GLint,
67 w: GLsizei,
68 h: GLsizei,
69 format: GLenum,
70 ty: GLenum,
71 data: *mut c_void,
72 );
73
74 fn glGenTextures(n: GLsizei, textures: *mut GLuint);
75 fn glDeleteTextures(n: GLsizei, textures: *const GLuint);
76 fn glBindTexture(target: GLenum, texture: GLuint);
77 fn glActiveTexture(unit: GLenum);
78 fn glTexParameteri(target: GLenum, name: GLenum, value: GLint);
79 fn glTexImage2D(
80 target: GLenum,
81 level: GLint,
82 internal: GLint,
83 w: GLsizei,
84 h: GLsizei,
85 border: GLint,
86 format: GLenum,
87 ty: GLenum,
88 data: *const c_void,
89 );
90 fn glTexSubImage2D(
91 target: GLenum,
92 level: GLint,
93 x: GLint,
94 y: GLint,
95 w: GLsizei,
96 h: GLsizei,
97 format: GLenum,
98 ty: GLenum,
99 data: *const c_void,
100 );
101
102 fn glGenBuffers(n: GLsizei, buffers: *mut GLuint);
103 fn glBindBuffer(target: GLenum, buffer: GLuint);
104 fn glBufferData(target: GLenum, size: isize, data: *const c_void, usage: GLenum);
105 fn glEnableVertexAttribArray(index: GLuint);
106 fn glVertexAttribPointer(
107 index: GLuint,
108 size: GLint,
109 ty: GLenum,
110 normalized: u8,
111 stride: GLsizei,
112 offset: *const c_void,
113 );
114 fn glDrawArrays(mode: GLenum, first: GLint, count: GLsizei);
115
116 fn glCreateShader(kind: GLenum) -> GLuint;
117 fn glShaderSource(
118 shader: GLuint,
119 count: GLsizei,
120 sources: *const *const c_char,
121 lengths: *const GLint,
122 );
123 fn glCompileShader(shader: GLuint);
124 fn glGetShaderiv(shader: GLuint, name: GLenum, value: *mut GLint);
125 fn glGetShaderInfoLog(shader: GLuint, size: GLsizei, length: *mut GLsizei, log: *mut c_char);
126 fn glCreateProgram() -> GLuint;
127 fn glAttachShader(program: GLuint, shader: GLuint);
128 fn glBindAttribLocation(program: GLuint, index: GLuint, name: *const c_char);
129 fn glLinkProgram(program: GLuint);
130 fn glGetProgramiv(program: GLuint, name: GLenum, value: *mut GLint);
131 fn glGetProgramInfoLog(program: GLuint, size: GLsizei, length: *mut GLsizei, log: *mut c_char);
132 fn glUseProgram(program: GLuint);
133 fn glGetUniformLocation(program: GLuint, name: *const c_char) -> GLint;
134 fn glUniform1i(location: GLint, value: GLint);
135
136 fn glGenFramebuffersEXT(n: GLsizei, framebuffers: *mut GLuint);
137 fn glDeleteFramebuffersEXT(n: GLsizei, framebuffers: *const GLuint);
138 fn glBindFramebufferEXT(target: GLenum, framebuffer: GLuint);
139 fn glFramebufferTexture2DEXT(
140 target: GLenum,
141 attachment: GLenum,
142 textarget: GLenum,
143 texture: GLuint,
144 level: GLint,
145 );
146 fn glCheckFramebufferStatusEXT(target: GLenum) -> GLenum;
147 fn glBlitFramebufferEXT(
148 sx0: GLint,
149 sy0: GLint,
150 sx1: GLint,
151 sy1: GLint,
152 dx0: GLint,
153 dy0: GLint,
154 dx1: GLint,
155 dy1: GLint,
156 mask: GLenum,
157 filter: GLenum,
158 );
159}
160
161/// An sRGB RGBA texture, deleted with its value.
162pub(super) struct Image {
163 name: GLuint,
164 size: [u32; 2],
165}
166
167impl Image {
168 fn new(size: [u32; 2], filter: GLint, pixels: Option<&[u8]>) -> Self {
169 let mut name = 0;
170 unsafe {
171 glGenTextures(1, &mut name);
172 glBindTexture(TEXTURE_2D, name);
173 glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, filter);
174 glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, filter);
175 glTexParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE);
176 glTexParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE);
177 glTexImage2D(
178 TEXTURE_2D,
179 0,
180 SRGB8_ALPHA8,
181 size[0] as GLsizei,
182 size[1] as GLsizei,
183 0,
184 RGBA,
185 UNSIGNED_BYTE,
186 pixels.map_or(ptr::null(), |pixels| pixels.as_ptr().cast()),
187 );
188 }
189 Self { name, size }
190 }
191}
192
193impl Drop for Image {
194 fn drop(&mut self) {
195 unsafe { glDeleteTextures(1, &self.name) };
196 }
197}
198
199/// An offscreen frame a window shows by `present`, the context's default framebuffer
200/// being no sRGB target.
201pub struct Target {
202 framebuffer: GLuint,
203 texture: Image,
204}
205
206impl Target {
207 /// Needs the renderer's context current, as every method here does.
208 pub fn new(size: [u32; 2]) -> Result<Self, String> {
209 let texture = Image::new(size, NEAREST, None);
210 let mut framebuffer = 0;
211 unsafe {
212 glGenFramebuffersEXT(1, &mut framebuffer);
213 glBindFramebufferEXT(FRAMEBUFFER, framebuffer);
214 glFramebufferTexture2DEXT(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, texture.name, 0);
215 let status = glCheckFramebufferStatusEXT(FRAMEBUFFER);
216 glBindFramebufferEXT(FRAMEBUFFER, 0);
217 if status != FRAMEBUFFER_COMPLETE {
218 glDeleteFramebuffersEXT(1, &framebuffer);
219 return Err(format!("Framebuffer incomplete: {status:#x}"));
220 }
221 }
222 Ok(Self {
223 framebuffer,
224 texture,
225 })
226 }
227
228 pub fn size(&self) -> [u32; 2] {
229 self.texture.size
230 }
231
232 /// Copies the frame to the context's window, which is the same size.
233 pub fn present(&self) {
234 let [width, height] = self.size().map(|side| side as GLint);
235 unsafe {
236 glBindFramebufferEXT(READ_FRAMEBUFFER, self.framebuffer);
237 glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0);
238 glBlitFramebufferEXT(
239 0,
240 0,
241 width,
242 height,
243 0,
244 0,
245 width,
246 height,
247 COLOR_BUFFER_BIT,
248 NEAREST as GLenum,
249 );
250 glBindFramebufferEXT(FRAMEBUFFER, 0);
251 }
252 }
253
254 /// sRGB-encoded RGBA rows, top first.
255 pub fn read_pixels(&self) -> Vec<u8> {
256 let [width, height] = self.size();
257 let mut pixels = vec![0; (width * height * 4) as usize];
258 unsafe {
259 glBindFramebufferEXT(FRAMEBUFFER, self.framebuffer);
260 glReadPixels(
261 0,
262 0,
263 width as GLsizei,
264 height as GLsizei,
265 RGBA,
266 UNSIGNED_BYTE,
267 pixels.as_mut_ptr().cast(),
268 );
269 glBindFramebufferEXT(FRAMEBUFFER, 0);
270 }
271 pixels
272 .chunks(width as usize * 4)
273 .rev()
274 .flatten()
275 .copied()
276 .collect()
277 }
278}
279
280impl Drop for Target {
281 fn drop(&mut self) {
282 unsafe { glDeleteFramebuffersEXT(1, &self.framebuffer) };
283 }
284}
285
286pub(super) struct Gpu {
287 program: GLuint,
288 buffer: GLuint,
289 atlas: Image,
290 max_texture: u32,
291}
292
293/// Each attribute's name, component count and offset, bound to its index in the shader.
294const ATTRIBUTES: [(&CStr, GLint, usize); 6] = [
295 (c"position", 2, offset_of!(Vertex, position)),
296 (c"uv", 2, offset_of!(Vertex, uv)),
297 (c"color", 4, offset_of!(Vertex, color)),
298 (c"local", 2, offset_of!(Vertex, local)),
299 (c"shape", 4, offset_of!(Vertex, shape)),
300 (c"stroke", 1, offset_of!(Vertex, stroke)),
301];
302
303impl Renderer {
304 /// Draws with the OpenGL context current on this thread, which must stay current
305 /// whenever the renderer or a `Target` is used.
306 pub fn new() -> Result<Self, String> {
307 let source = include_str!("../draw.glsl");
308 let program = unsafe {
309 let vertex = compile(
310 VERTEX_SHADER,
311 &format!("#version 120\n#define VERTEX\n{source}"),
312 )?;
313 let fragment = compile(FRAGMENT_SHADER, &format!("#version 120\n{source}"))?;
314 let program = glCreateProgram();
315 glAttachShader(program, vertex);
316 glAttachShader(program, fragment);
317 for (index, (name, _, _)) in ATTRIBUTES.iter().enumerate() {
318 glBindAttribLocation(program, index as GLuint, name.as_ptr());
319 }
320 glLinkProgram(program);
321 let mut linked = 0;
322 glGetProgramiv(program, LINK_STATUS, &mut linked);
323 if linked == 0 {
324 return Err(format!(
325 "Linking the draw shader failed: {}",
326 log(program, glGetProgramiv, glGetProgramInfoLog)
327 ));
328 }
329 program
330 };
331 let mut buffer = 0;
332 let mut max_texture = 0;
333 unsafe {
334 glGenBuffers(1, &mut buffer);
335 glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture);
336 }
337 Ok(Self::with_gpu(Gpu {
338 program,
339 buffer,
340 atlas: Image::new([ATLAS_SIZE; 2], NEAREST, None),
341 max_texture: max_texture as u32,
342 }))
343 }
344
345 /// The widest and tallest texture the driver takes, in pixels.
346 pub fn max_texture_dimension(&self) -> u32 {
347 self.gpu.max_texture
348 }
349
350 pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) {
351 unsafe {
352 glBindTexture(TEXTURE_2D, self.gpu.atlas.name);
353 glTexSubImage2D(
354 TEXTURE_2D,
355 0,
356 origin[0] as GLint,
357 origin[1] as GLint,
358 size[0] as GLsizei,
359 size[1] as GLsizei,
360 RGBA,
361 UNSIGNED_BYTE,
362 rgba.as_ptr().cast(),
363 );
364 }
365 }
366
367 pub(super) fn upload_image(&self, image: &RasterImage) -> Image {
368 Image::new(image.size, LINEAR, Some(image.pixels()))
369 }
370
371 /// Clears `target` to linear `clear` and draws the prepared batches.
372 pub(super) fn submit(&mut self, target: &Target, clear: [f32; 4]) {
373 let [width, height] = target.size().map(|side| side as GLsizei);
374 let gpu = &self.gpu;
375 unsafe {
376 glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer);
377 glEnable(FRAMEBUFFER_SRGB);
378 glViewport(0, 0, width, height);
379 glDisable(SCISSOR_TEST);
380 glClearColor(clear[0], clear[1], clear[2], clear[3]);
381 glClear(COLOR_BUFFER_BIT);
382 glUseProgram(gpu.program);
383 glUniform1i(glGetUniformLocation(gpu.program, c"atlas".as_ptr()), 0);
384 glActiveTexture(TEXTURE0);
385 glBindBuffer(ARRAY_BUFFER, gpu.buffer);
386 glBufferData(
387 ARRAY_BUFFER,
388 size_of_val(self.vertices.as_slice()) as isize,
389 self.vertices.as_ptr().cast(),
390 STREAM_DRAW,
391 );
392 for (index, (_, components, offset)) in ATTRIBUTES.iter().enumerate() {
393 glEnableVertexAttribArray(index as GLuint);
394 glVertexAttribPointer(
395 index as GLuint,
396 *components,
397 FLOAT,
398 0,
399 size_of::<Vertex>() as GLsizei,
400 ptr::without_provenance(*offset),
401 );
402 }
403 glEnable(BLEND);
404 glEnable(SCISSOR_TEST);
405 for batch in &self.batches {
406 let [x, y, w, h] = batch.scissor.map(|value| value as GLint);
407 glScissor(x, height - y - h, w, h);
408 let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend {
409 Blend::Over => (
410 &gpu.atlas,
411 [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA],
412 ),
413 Blend::Image(id) => (
414 &self.images[&id].texture,
415 [ONE, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA],
416 ),
417 Blend::Erase => (
418 &gpu.atlas,
419 [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA],
420 ),
421 };
422 glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha);
423 glBindTexture(TEXTURE_2D, texture.name);
424 glDrawArrays(
425 TRIANGLES,
426 batch.vertices.start as GLint,
427 batch.vertices.len() as GLsizei,
428 );
429 }
430 glDisable(SCISSOR_TEST);
431 glDisable(BLEND);
432 glBindFramebufferEXT(FRAMEBUFFER, 0);
433 }
434 }
435}
436
437unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> {
438 unsafe {
439 let shader = glCreateShader(kind);
440 let text = CString::new(source).expect("The shader has no NUL");
441 glShaderSource(shader, 1, &text.as_ptr(), ptr::null());
442 glCompileShader(shader);
443 let mut compiled = 0;
444 glGetShaderiv(shader, COMPILE_STATUS, &mut compiled);
445 if compiled == 0 {
446 return Err(format!(
447 "Compiling the draw shader failed: {}",
448 log(shader, glGetShaderiv, glGetShaderInfoLog)
449 ));
450 }
451 Ok(shader)
452 }
453}
454
455unsafe fn log(
456 object: GLuint,
457 get: unsafe extern "C" fn(GLuint, GLenum, *mut GLint),
458 read: unsafe extern "C" fn(GLuint, GLsizei, *mut GLsizei, *mut c_char),
459) -> String {
460 unsafe {
461 let mut length = 0;
462 get(object, INFO_LOG_LENGTH, &mut length);
463 let mut text = vec![0u8; length.max(1) as usize];
464 read(object, length, ptr::null_mut(), text.as_mut_ptr().cast());
465 String::from_utf8_lossy(&text)
466 .trim_end_matches('\0')
467 .to_owned()
468 }
469}
crates/draw/src/render/webgpu.rs created+265
...@@ -0,0 +1,265 @@
1//! Submission through wgpu: Metal, Vulkan or GL, whichever the platform offers.
2use super::*;
3
4/// What a frame is drawn into.
5pub type Target = wgpu::TextureView;
6
7pub(super) type Image = wgpu::BindGroup;
8
9pub(super) struct Gpu {
10 pipeline: wgpu::RenderPipeline,
11 image_pipeline: wgpu::RenderPipeline,
12 erase_pipeline: wgpu::RenderPipeline,
13 atlas: wgpu::Texture,
14 bind_group: wgpu::BindGroup,
15 image_sampler: wgpu::Sampler,
16 vertex_buffer: wgpu::Buffer,
17}
18
19impl Renderer {
20 pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self {
21 let shader = device.create_shader_module(wgpu::include_wgsl!("../draw.wgsl"));
22 let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
23 label: Some("Draw texture"),
24 entries: &[
25 wgpu::BindGroupLayoutEntry {
26 binding: 0,
27 visibility: wgpu::ShaderStages::FRAGMENT,
28 ty: wgpu::BindingType::Texture {
29 sample_type: wgpu::TextureSampleType::Float { filterable: true },
30 view_dimension: wgpu::TextureViewDimension::D2,
31 multisampled: false,
32 },
33 count: None,
34 },
35 wgpu::BindGroupLayoutEntry {
36 binding: 1,
37 visibility: wgpu::ShaderStages::FRAGMENT,
38 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
39 count: None,
40 },
41 ],
42 });
43 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
44 label: Some("Draw"),
45 bind_group_layouts: &[Some(&binding_layout)],
46 ..Default::default()
47 });
48 let make_pipeline = |blend| {
49 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
50 label: Some("Draw"),
51 layout: Some(&pipeline_layout),
52 vertex: wgpu::VertexState {
53 module: &shader,
54 entry_point: Some("vertex"),
55 compilation_options: Default::default(),
56 buffers: &[Some(wgpu::VertexBufferLayout {
57 array_stride: size_of::<Vertex>() as u64,
58 step_mode: wgpu::VertexStepMode::Vertex,
59 attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32],
60 })],
61 },
62 fragment: Some(wgpu::FragmentState {
63 module: &shader,
64 entry_point: Some("fragment"),
65 compilation_options: Default::default(),
66 targets: &[Some(wgpu::ColorTargetState {
67 format,
68 blend: Some(blend),
69 write_mask: wgpu::ColorWrites::ALL,
70 })],
71 }),
72 primitive: Default::default(),
73 depth_stencil: None,
74 multisample: Default::default(),
75 multiview_mask: None,
76 cache: None,
77 })
78 };
79 // Coverage accumulates in alpha, leaving premultiplied colour over a transparent clear.
80 let pipeline = make_pipeline(wgpu::BlendState {
81 color: wgpu::BlendState::ALPHA_BLENDING.color,
82 alpha: wgpu::BlendComponent::OVER,
83 });
84 let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
85 let erase = wgpu::BlendComponent {
86 src_factor: wgpu::BlendFactor::Zero,
87 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
88 operation: wgpu::BlendOperation::Add,
89 };
90 let erase_pipeline = make_pipeline(wgpu::BlendState {
91 color: erase,
92 alpha: erase,
93 });
94 let atlas = device.create_texture(&wgpu::TextureDescriptor {
95 label: Some("Glyph atlas"),
96 size: wgpu::Extent3d {
97 width: ATLAS_SIZE,
98 height: ATLAS_SIZE,
99 depth_or_array_layers: 1,
100 },
101 mip_level_count: 1,
102 sample_count: 1,
103 dimension: wgpu::TextureDimension::D2,
104 format: wgpu::TextureFormat::Rgba8UnormSrgb,
105 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
106 view_formats: &[],
107 });
108 let view = atlas.create_view(&Default::default());
109 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
110 mag_filter: wgpu::FilterMode::Nearest,
111 min_filter: wgpu::FilterMode::Nearest,
112 ..Default::default()
113 });
114 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
115 label: Some("Glyph atlas"),
116 layout: &pipeline.get_bind_group_layout(0),
117 entries: &[
118 wgpu::BindGroupEntry {
119 binding: 0,
120 resource: wgpu::BindingResource::TextureView(&view),
121 },
122 wgpu::BindGroupEntry {
123 binding: 1,
124 resource: wgpu::BindingResource::Sampler(&sampler),
125 },
126 ],
127 });
128 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
129 label: Some("Draw vertices"),
130 size: VERTEX_BUFFER_BYTES,
131 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
132 mapped_at_creation: false,
133 });
134 let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
135 label: Some("Draw image filtering"),
136 mag_filter: wgpu::FilterMode::Linear,
137 min_filter: wgpu::FilterMode::Linear,
138 ..Default::default()
139 });
140 Self::with_gpu(
141 device,
142 queue,
143 Gpu {
144 pipeline,
145 image_pipeline,
146 erase_pipeline,
147 atlas,
148 bind_group,
149 image_sampler,
150 vertex_buffer,
151 },
152 )
153 }
154
155 /// The widest and tallest texture the device takes, in pixels.
156 pub fn max_texture_dimension(&self) -> u32 {
157 self.device.limits().max_texture_dimension_2d
158 }
159
160 pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) {
161 self.queue.write_texture(
162 wgpu::TexelCopyTextureInfo {
163 origin: wgpu::Origin3d {
164 x: origin[0],
165 y: origin[1],
166 z: 0,
167 },
168 ..self.gpu.atlas.as_image_copy()
169 },
170 rgba,
171 wgpu::TexelCopyBufferLayout {
172 offset: 0,
173 bytes_per_row: Some(size[0] * 4),
174 rows_per_image: Some(size[1]),
175 },
176 wgpu::Extent3d {
177 width: size[0],
178 height: size[1],
179 depth_or_array_layers: 1,
180 },
181 );
182 }
183
184 pub(super) fn upload_image(&self, image: &RasterImage) -> Image {
185 let size = wgpu::Extent3d {
186 width: image.size[0],
187 height: image.size[1],
188 depth_or_array_layers: 1,
189 };
190 let texture = self.device.create_texture(&wgpu::TextureDescriptor {
191 label: Some("Draw image"),
192 size,
193 mip_level_count: 1,
194 sample_count: 1,
195 dimension: wgpu::TextureDimension::D2,
196 format: wgpu::TextureFormat::Rgba8UnormSrgb,
197 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
198 view_formats: &[],
199 });
200 self.queue.write_texture(
201 texture.as_image_copy(),
202 image.pixels(),
203 wgpu::TexelCopyBufferLayout {
204 offset: 0,
205 bytes_per_row: Some(image.size[0] * 4),
206 rows_per_image: Some(image.size[1]),
207 },
208 size,
209 );
210 self.device.create_bind_group(&wgpu::BindGroupDescriptor {
211 label: Some("Draw image"),
212 layout: &self.gpu.pipeline.get_bind_group_layout(0),
213 entries: &[
214 wgpu::BindGroupEntry {
215 binding: 0,
216 resource: wgpu::BindingResource::TextureView(
217 &texture.create_view(&Default::default()),
218 ),
219 },
220 wgpu::BindGroupEntry {
221 binding: 1,
222 resource: wgpu::BindingResource::Sampler(&self.gpu.image_sampler),
223 },
224 ],
225 })
226 }
227
228 /// Clears `target` to linear `clear` and draws the prepared batches.
229 pub(super) fn submit(&mut self, target: &Target, clear: [f32; 4]) {
230 let gpu = &self.gpu;
231 self.queue
232 .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices));
233 let mut encoder = self.device.create_command_encoder(&Default::default());
234 {
235 let [r, g, b, a] = clear.map(f64::from);
236 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
237 label: Some("Draw"),
238 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
239 view: target,
240 depth_slice: None,
241 resolve_target: None,
242 ops: wgpu::Operations {
243 load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }),
244 store: wgpu::StoreOp::Store,
245 },
246 })],
247 ..Default::default()
248 });
249 pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..));
250 for batch in &self.batches {
251 let [x, y, width, height] = batch.scissor;
252 pass.set_scissor_rect(x, y, width, height);
253 let (pipeline, binding) = match batch.blend {
254 Blend::Over => (&gpu.pipeline, &gpu.bind_group),
255 Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture),
256 Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group),
257 };
258 pass.set_pipeline(pipeline);
259 pass.set_bind_group(0, binding, &[]);
260 pass.draw(batch.vertices.clone(), 0..1);
261 }
262 }
263 self.queue.submit([encoder.finish()]);
264 }
265}
crates/notebook/README.md+3
...@@ -60,6 +60,9 @@ failures; `notify` runs on a background thread whenever an event waits....@@ -60,6 +60,9 @@ failures; `notify` runs on a background thread whenever an event waits.
60removes; `release(id, archive, Resolution)` ends an uncertain attempt. The author an60removes; `release(id, archive, Resolution)` ends an uncertain attempt. The author an
61edit names is the host's: the app passes the account's full name.61edit names is the host's: the app passes the account's full name.
62`import_page` creates a page holding a copy of another; `delete_pages` removes pages.62`import_page` creates a page holding a copy of another; `delete_pages` removes pages.
63`sync_status()` gives when the section file was last reached, why it could not be since
64and how many edits wait for it. `set_offline(true)` works offline as OneNote does: the
65worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`.
6366
64`Section::resume(file, replica, notify)` starts from an owned `Replica` without67`Section::resume(file, replica, notify)` starts from an owned `Replica` without
65consulting the remote file; with the `smb` feature, `Section::resume_smb(path,68consulting the remote file; with the `smb` feature, `Section::resume_smb(path,
crates/notebook/src/session.rs+60-2
...@@ -1028,6 +1028,20 @@ pub enum Event {...@@ -1028,6 +1028,20 @@ pub enum Event {
10281028
1029type Notify = Arc<dyn Fn() + Send + Sync>;1029type Notify = Arc<dyn Fn() + Send + Sync>;
10301030
1031/// How a section's synchronization stands, for the host to show.
1032#[derive(Debug)]
1033pub struct SyncStatus {
1034 /// FILETIME of the last synchronization step that reached the section file.
1035 pub synced: Option<u64>,
1036 /// Why the last step failed, until one reaches the section file again.
1037 pub error: Option<io::Error>,
1038 /// Edits the section file does not hold yet, uncertain attempts included.
1039 pub queued: u64,
1040}
1041
1042/// The last attempt's outcome; `None` before the first.
1043type Observed = Option<Option<io::Error>>;
1044
1031/// A section file with its replica and background publication. `Send + Sync`: edits1045/// A section file with its replica and background publication. `Send + Sync`: edits
1032/// arrive from any thread without waiting, and `notify` wakes the host when events wait.1046/// arrive from any thread without waiting, and `notify` wakes the host when events wait.
1033pub struct Section {1047pub struct Section {
...@@ -1037,6 +1051,7 @@ pub struct Section {...@@ -1037,6 +1051,7 @@ pub struct Section {
1037 events: Mutex<Receiver<Event>>,1051 events: Mutex<Receiver<Event>>,
1038 sender: Sender<Event>,1052 sender: Sender<Event>,
1039 notify: Notify,1053 notify: Notify,
1054 observed: Arc<Mutex<Observed>>,
1040}1055}
10411056
1042impl Section {1057impl Section {
...@@ -1129,9 +1144,24 @@ impl Section {...@@ -1129,9 +1144,24 @@ impl Section {
1129 notify();1144 notify();
1130 }1145 }
1131 });1146 });
1147 let observed: Arc<Mutex<Observed>> = Arc::new(Mutex::new(None));
1132 let worker = {1148 let worker = {
1133 let (sender, notify) = (sender.clone(), Arc::clone(&notify));1149 let (sender, notify) = (sender.clone(), Arc::clone(&notify));
1150 let observed = Arc::clone(&observed);
1134 replica.start_sync(Duration::from_secs(2), connect, move |result| {1151 replica.start_sync(Duration::from_secs(2), connect, move |result| {
1152 let error = result.as_ref().err().map(|error| match error {
1153 Error::RemoteIo(error) => io::Error::new(error.kind(), error.to_string()),
1154 Error::Remote(error) => io::Error::new(error.error.kind(), error.to_string()),
1155 error => io::Error::other(error.to_string()),
1156 });
1157 // Reaching the file as the last attempt did is no news to the host.
1158 let mut changed = false;
1159 if let Ok(mut observed) = observed.lock() {
1160 changed = observed
1161 .as_ref()
1162 .is_none_or(|last| last.is_some() != error.is_some());
1163 *observed = Some(error);
1164 }
1135 let mut events = Vec::new();1165 let mut events = Vec::new();
1136 match result {1166 match result {
1137 Ok(synced) => {1167 Ok(synced) => {
...@@ -1157,7 +1187,8 @@ impl Section {...@@ -1157,7 +1187,8 @@ impl Section {
1157 ))),1187 ))),
1158 Err(error) => events.push(Event::Failed(error.to_string())),1188 Err(error) => events.push(Event::Failed(error.to_string())),
1159 }1189 }
1160 if !events.is_empty() && events.into_iter().all(|event| sender.send(event).is_ok())1190 if (changed || !events.is_empty())
1191 && events.into_iter().all(|event| sender.send(event).is_ok())
1161 {1192 {
1162 notify();1193 notify();
1163 }1194 }
...@@ -1170,6 +1201,7 @@ impl Section {...@@ -1170,6 +1201,7 @@ impl Section {
1170 events: Mutex::new(events),1201 events: Mutex::new(events),
1171 sender,1202 sender,
1172 notify,1203 notify,
1204 observed,
1173 })1205 })
1174 }1206 }
11751207
...@@ -1332,13 +1364,39 @@ impl Section {...@@ -1332,13 +1364,39 @@ impl Section {
1332 .unwrap_or_default()1364 .unwrap_or_default()
1333 }1365 }
13341366
1335 /// Requests a synchronization attempt now.1367 /// Requests a synchronization attempt now, working offline included (Sync Now).
1336 pub fn wake(&self) {1368 pub fn wake(&self) {
1337 if let Some(worker) = &self.worker {1369 if let Some(worker) = &self.worker {
1338 worker.wake();1370 worker.wake();
1339 }1371 }
1340 }1372 }
13411373
1374 /// Stops or resumes synchronizing: working offline, edits queue until `wake` or until
1375 /// working online again (OneNote's Work Offline).
1376 pub fn set_offline(&self, offline: bool) {
1377 if let Some(worker) = &self.worker {
1378 worker.set_offline(offline);
1379 }
1380 }
1381
1382 /// When the section file was last reached, why it could not be since, and what waits
1383 /// for it. `notify` runs when it is first reached and when an error comes or goes.
1384 pub fn sync_status(&self) -> Result<SyncStatus> {
1385 let queued = self.replica.recovery_summary()?.queued_edits;
1386 let observed = self
1387 .observed
1388 .lock()
1389 .map_err(|_| io::Error::other("Synchronization observer panicked"))?;
1390 Ok(SyncStatus {
1391 synced: self.worker.as_ref().and_then(SyncWorker::synced),
1392 error: observed
1393 .as_ref()
1394 .and_then(Option::as_ref)
1395 .map(|error| io::Error::new(error.kind(), error.to_string())),
1396 queued,
1397 })
1398 }
1399
1342 /// The replica, which other threads may read pages from while the section is open.1400 /// The replica, which other threads may read pages from while the section is open.
1343 pub fn replica(&self) -> &Arc<Replica> {1401 pub fn replica(&self) -> &Arc<Replica> {
1344 &self.replica1402 &self.replica
crates/notebook/src/worker.rs+38-2
...@@ -4,7 +4,7 @@ use std::{...@@ -4,7 +4,7 @@ use std::{
4 hash::BuildHasher,4 hash::BuildHasher,
5 sync::{5 sync::{
6 Arc,6 Arc,
7 atomic::{AtomicBool, Ordering},7 atomic::{AtomicBool, AtomicU64, Ordering},
8 mpsc::{self, SyncSender},8 mpsc::{self, SyncSender},
9 },9 },
10 thread::{self, JoinHandle},10 thread::{self, JoinHandle},
...@@ -13,6 +13,11 @@ use std::{...@@ -13,6 +13,11 @@ use std::{
1313
14pub(super) struct Signal {14pub(super) struct Signal {
15 stopped: AtomicBool,15 stopped: AtomicBool,
16 offline: AtomicBool,
17 /// FILETIME of the last step or poll that reached the remote; 0 before one has.
18 synced: AtomicU64,
19 /// Asked for by `SyncWorker::wake`: steps run while offline until one leaves nothing to do.
20 requested: AtomicBool,
16 sender: SyncSender<()>,21 sender: SyncSender<()>,
17}22}
1823
...@@ -31,9 +36,23 @@ pub struct SyncWorker {...@@ -31,9 +36,23 @@ pub struct SyncWorker {
31}36}
3237
33impl SyncWorker {38impl SyncWorker {
34 /// Requests a retry, for example after a network reachability change.39 /// Requests a retry, for example after a network reachability change. Working
40 /// offline, it synchronizes once, as OneNote's Sync Now does.
35 /// A pending contention backoff finishes before processing the notification.41 /// A pending contention backoff finishes before processing the notification.
36 pub fn wake(&self) {42 pub fn wake(&self) {
43 self.signal.requested.store(true, Ordering::Release);
44 self.signal.wake();
45 }
46
47 /// When a step or poll last reached the remote.
48 pub fn synced(&self) -> Option<u64> {
49 Some(self.signal.synced.load(Ordering::Acquire)).filter(|time| *time != 0)
50 }
51
52 /// Working offline, the worker neither connects nor steps: local edits stay queued
53 /// until `wake`, or until working online again.
54 pub fn set_offline(&self, offline: bool) {
55 self.signal.offline.store(offline, Ordering::Release);
37 self.signal.wake();56 self.signal.wake();
38 }57 }
3958
...@@ -94,6 +113,9 @@ impl Replica {...@@ -94,6 +113,9 @@ impl Replica {
94 let (sender, receiver) = mpsc::sync_channel(1);113 let (sender, receiver) = mpsc::sync_channel(1);
95 let signal = Arc::new(Signal {114 let signal = Arc::new(Signal {
96 stopped: AtomicBool::new(false),115 stopped: AtomicBool::new(false),
116 offline: AtomicBool::new(false),
117 synced: AtomicU64::new(0),
118 requested: AtomicBool::new(false),
97 sender,119 sender,
98 });120 });
99 let replica = Arc::clone(self);121 let replica = Arc::clone(self);
...@@ -108,9 +130,19 @@ impl Replica {...@@ -108,9 +130,19 @@ impl Replica {
108 // hears that the remote is reachable and what state the queue is in.130 // hears that the remote is reachable and what state the queue is in.
109 let mut reported = false;131 let mut reported = false;
110 while !worker_signal.stopped.load(Ordering::Acquire) {132 while !worker_signal.stopped.load(Ordering::Acquire) {
133 if worker_signal.offline.load(Ordering::Acquire)
134 && !worker_signal.requested.load(Ordering::Acquire)
135 {
136 remote = None;
137 reported = false;
138 let _ = receiver.recv();
139 continue;
140 }
111 let result = match remote.as_mut() {141 let result = match remote.as_mut() {
112 Some(remote) => {142 Some(remote) => {
113 if reported && replica.settled(remote).unwrap_or(false) {143 if reported && replica.settled(remote).unwrap_or(false) {
144 worker_signal.requested.store(false, Ordering::Release);
145 worker_signal.synced.store(crate::now(), Ordering::Release);
114 let _ = receiver.recv_timeout(interval);146 let _ = receiver.recv_timeout(interval);
115 continue;147 continue;
116 }148 }
...@@ -126,6 +158,9 @@ impl Replica {...@@ -126,6 +158,9 @@ impl Replica {
126 Err(error) => Err(Error::RemoteIo(error)),158 Err(error) => Err(Error::RemoteIo(error)),
127 },159 },
128 };160 };
161 if result.is_ok() {
162 worker_signal.synced.store(crate::now(), Ordering::Release);
163 }
129 observe(&result);164 observe(&result);
130 match result {165 match result {
131 Ok(Synced {166 Ok(Synced {
...@@ -168,6 +203,7 @@ impl Replica {...@@ -168,6 +203,7 @@ impl Replica {
168 Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {}203 Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {}
169 Err(error) => return Err(error),204 Err(error) => return Err(error),
170 }205 }
206 worker_signal.requested.store(false, Ordering::Release);
171 if !worker_signal.stopped.load(Ordering::Acquire) {207 if !worker_signal.stopped.load(Ordering::Acquire) {
172 let _ = receiver.recv_timeout(interval);208 let _ = receiver.recv_timeout(interval);
173 }209 }
crates/notebook/tests/session.rs+83-1
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
2mod ops;2mod ops;
3use notebook::{3use notebook::{
4 EditStatus, Recovery, Resolution,4 EditStatus, Recovery, Resolution,
5 session::{Event, Notebook, Section},5 session::{Event, Notebook, Section, SyncStatus},
6};6};
7use onestore::{7use onestore::{
8 ExGuid,8 ExGuid,
...@@ -99,6 +99,18 @@ fn published(section: &Section, id: u64) {...@@ -99,6 +99,18 @@ fn published(section: &Section, id: u64) {
99 }99 }
100}100}
101101
102fn status_until(section: &Section, accept: impl Fn(&SyncStatus) -> bool) -> SyncStatus {
103 let deadline = Instant::now() + Duration::from_secs(20);
104 loop {
105 let status = section.sync_status().unwrap();
106 if accept(&status) {
107 return status;
108 }
109 assert!(Instant::now() < deadline, "the status stayed {status:?}");
110 std::thread::sleep(Duration::from_millis(20));
111 }
112}
113
102fn stored_page(file: &Path, space: ExGuid) -> Page {114fn stored_page(file: &Path, space: ExGuid) -> Page {
103 model_ops::page_of(&onestore::read_file(file).unwrap(), space)115 model_ops::page_of(&onestore::read_file(file).unwrap(), space)
104}116}
...@@ -917,3 +929,73 @@ fn page_versions_restore_and_delete_through_the_session() {...@@ -917,3 +929,73 @@ fn page_versions_restore_and_delete_through_the_session() {
917 assert_eq!(page.objects, old.objects);929 assert_eq!(page.objects, old.objects);
918 section.close().unwrap();930 section.close().unwrap();
919}931}
932
933#[test]
934fn working_offline_queues_edits_until_sync_now_or_working_online() {
935 let directory = tempfile::tempdir().unwrap();
936 let file = directory.path().join("notes.one");
937 let cache = directory.path().join("cache");
938 std::fs::write(
939 &file,
940 onestore::create_section("notes.one", "Original", "Author").unwrap(),
941 )
942 .unwrap();
943 let (section, _) = open(&file, &cache);
944 let space = section.pages().unwrap()[0].0;
945 let before = section.page(space).unwrap();
946 let reached = status_until(&section, |status| status.synced.is_some());
947 assert!(reached.error.is_none());
948 assert_eq!(reached.queued, 0);
949
950 section.set_offline(true);
951 let id = typed(&section, space, &before, 0..0, "Offline ");
952 std::thread::sleep(Duration::from_millis(500));
953 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
954 assert_eq!(section.sync_status().unwrap().queued, 1);
955 assert_eq!(stored_page(&file, space), before);
956
957 // Sync Now publishes while working offline.
958 section.wake();
959 published(&section, id);
960 let after = edited(&before, "Offline ");
961 assert_same(stored_page(&file, space), &after);
962 let synced = status_until(&section, |status| status.queued == 0);
963 assert!(synced.synced > reached.synced);
964
965 let next = typed(&section, space, &after, 0..0, "Again ");
966 std::thread::sleep(Duration::from_millis(500));
967 assert_eq!(section.status(next).unwrap(), Some(EditStatus::Pending));
968 section.set_offline(false);
969 published(&section, next);
970 status_until(&section, |status| status.queued == 0);
971 section.close().unwrap();
972}
973
974#[test]
975fn a_missing_section_file_reports_its_error_until_it_returns() {
976 let directory = tempfile::tempdir().unwrap();
977 let file = directory.path().join("notes.one");
978 let away = directory.path().join("away.one");
979 let cache = directory.path().join("cache");
980 std::fs::write(
981 &file,
982 onestore::create_section("notes.one", "Original", "Author").unwrap(),
983 )
984 .unwrap();
985 let (section, notified) = open(&file, &cache);
986 status_until(&section, |status| status.synced.is_some());
987 std::fs::rename(&file, &away).unwrap();
988 section.wake();
989 let failed = status_until(&section, |status| status.error.is_some());
990 assert_eq!(
991 failed.error.map(|error| error.kind()),
992 Some(std::io::ErrorKind::NotFound)
993 );
994 let before = notified.load(Ordering::SeqCst);
995 std::fs::rename(&away, &file).unwrap();
996 section.wake();
997 let back = status_until(&section, |status| status.error.is_none());
998 assert!(back.synced > failed.synced);
999 assert!(notified.load(Ordering::SeqCst) > before);
1000 section.close().unwrap();
1001}
crates/snowbound/Cargo.toml+7-2
...@@ -4,9 +4,14 @@ version = "0.1.0"...@@ -4,9 +4,14 @@ version = "0.1.0"
4edition = "2024"4edition = "2024"
5publish = false5publish = false
66
7[features]
8default = ["wgpu"]
9# Paints through wgpu; without it through OpenGL 2.1, for Mac OS X 10.6.
10wgpu = ["draw/wgpu", "dep:wgpu"]
11
7[dependencies]12[dependencies]
8canvas = { path = "../canvas", features = ["interaction"] }13canvas = { path = "../canvas", features = ["interaction"] }
9draw = { path = "../draw" }14draw = { path = "../draw", default-features = false, features = ["render"] }
10ui = { path = "../ui" }15ui = { path = "../ui" }
11png = "0.18"16png = "0.18"
12onestore = { path = "../onestore" }17onestore = { path = "../onestore" }
...@@ -14,7 +19,7 @@ notebook = { path = "../notebook", features = ["smb"] }...@@ -14,7 +19,7 @@ notebook = { path = "../notebook", features = ["smb"] }
14serde = { version = "1", features = ["derive"] }19serde = { version = "1", features = ["derive"] }
15serde_json = "1"20serde_json = "1"
16pollster = "0.4"21pollster = "0.4"
17wgpu.workspace = true22wgpu = { workspace = true, optional = true }
18parley.workspace = true23parley.workspace = true
19winit = "0.30.13"24winit = "0.30.13"
20arboard = { version = "3.6.1", default-features = false }25arboard = { version = "3.6.1", default-features = false }
crates/snowbound/src/art.rs-19
...@@ -46,41 +46,22 @@ pub const UNDO: &[&str] = art!("icons/undo");...@@ -46,41 +46,22 @@ pub const UNDO: &[&str] = art!("icons/undo");
46pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");46pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");
47pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out");47pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out");
4848
49// The toolbar layout's new buttons (resources/toolbar-spec.md); each loses its `expect`
50// when its button lands.
51#[expect(dead_code, reason = "Its toolbar button has not landed")]
52pub const BACK: &[&str] = art!("icons/back");49pub const BACK: &[&str] = art!("icons/back");
53#[expect(dead_code, reason = "Its toolbar button has not landed")]
54pub const COPY: &[&str] = art!("icons/copy");50pub const COPY: &[&str] = art!("icons/copy");
55#[expect(dead_code, reason = "Its toolbar button has not landed")]
56pub const CUT: &[&str] = art!("icons/cut");51pub const CUT: &[&str] = art!("icons/cut");
57#[expect(dead_code, reason = "Its toolbar button has not landed")]
58pub const DATE_TIME: &[&str] = art!("icons/date-time");52pub const DATE_TIME: &[&str] = art!("icons/date-time");
59#[expect(dead_code, reason = "Its toolbar button has not landed")]
60pub const FIND_TAGS: &[&str] = art!("icons/find-tags");53pub const FIND_TAGS: &[&str] = art!("icons/find-tags");
61#[expect(dead_code, reason = "Its toolbar button has not landed")]
62pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter");54pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter");
63#[expect(dead_code, reason = "Its toolbar button has not landed")]
64pub const FORWARD: &[&str] = art!("icons/forward");55pub const FORWARD: &[&str] = art!("icons/forward");
65#[expect(dead_code, reason = "Its toolbar button has not landed")]
66pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view");56pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view");
67#[expect(dead_code, reason = "Its toolbar button has not landed")]
68pub const INSERT_SPACE: &[&str] = art!("icons/insert-space");57pub const INSERT_SPACE: &[&str] = art!("icons/insert-space");
69#[expect(dead_code, reason = "Its toolbar button has not landed")]
70pub const PAGE_COLOR: &[&str] = art!("icons/page-color");58pub const PAGE_COLOR: &[&str] = art!("icons/page-color");
71#[expect(dead_code, reason = "Its toolbar button has not landed")]
72pub const PASTE: &[&str] = art!("icons/paste");59pub const PASTE: &[&str] = art!("icons/paste");
73#[expect(dead_code, reason = "Its toolbar button has not landed")]
74pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio");60pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio");
75#[expect(dead_code, reason = "Its toolbar button has not landed")]
76pub const RECORD_VIDEO: &[&str] = art!("icons/record-video");61pub const RECORD_VIDEO: &[&str] = art!("icons/record-video");
77#[expect(dead_code, reason = "Its toolbar button has not landed")]
78pub const RULE_LINES: &[&str] = art!("icons/rule-lines");62pub const RULE_LINES: &[&str] = art!("icons/rule-lines");
79#[expect(dead_code, reason = "Its toolbar button has not landed")]
80pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping");63pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping");
81#[expect(dead_code, reason = "Its toolbar button has not landed")]
82pub const SPELLING: &[&str] = art!("icons/spelling");64pub const SPELLING: &[&str] = art!("icons/spelling");
83#[expect(dead_code, reason = "Its toolbar button has not landed")]
84pub const SYMBOL: &[&str] = art!("icons/symbol");65pub const SYMBOL: &[&str] = art!("icons/symbol");
8566
86pub const TAG_REMEMBER: &[&str] = art!("tags/remember");67pub const TAG_REMEMBER: &[&str] = art!("tags/remember");
crates/snowbound/src/commands.rs+139-5
...@@ -25,6 +25,7 @@ pub enum Id {...@@ -25,6 +25,7 @@ pub enum Id {
25 Cut,25 Cut,
26 Copy,26 Copy,
27 Paste,27 Paste,
28 FormatPainter,
28 SelectAll,29 SelectAll,
29 Find,30 Find,
30 Search,31 Search,
...@@ -36,14 +37,21 @@ pub enum Id {...@@ -36,14 +37,21 @@ pub enum Id {
36 Sidebar,37 Sidebar,
37 PageList,38 PageList,
38 DarkPages,39 DarkPages,
40 FullPageView,
41 PageColor,
39 Table,42 Table,
40 Picture,43 Picture,
44 ScreenClipping,
41 Attachment,45 Attachment,
42 Link,46 Link,
47 InsertSpace,
43 Equation,48 Equation,
49 Symbol,
44 Date,50 Date,
45 Time,51 Time,
46 DateTime,52 DateTime,
53 RecordAudio,
54 RecordVideo,
47 Toggle(Toggle),55 Toggle(Toggle),
48 Highlight,56 Highlight,
49 FontColor,57 FontColor,
...@@ -69,6 +77,10 @@ pub enum Choice {...@@ -69,6 +77,10 @@ pub enum Choice {
69 /// A COLORREF, or `None` for automatic.77 /// A COLORREF, or `None` for automatic.
70 Color(Option<u32>),78 Color(Option<u32>),
71 List(Option<ListStyle>),79 List(Option<ListStyle>),
80 /// An empty table of rows by columns.
81 Table([usize; 2]),
82 /// An index into `PAGE_COLORS`, or `None` for no colour.
83 PageColor(Option<usize>),
72}84}
7385
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]86#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
...@@ -161,6 +173,9 @@ macro_rules! tag {...@@ -161,6 +173,9 @@ macro_rules! tag {
161 ($tag:expr, $digit:literal) => {173 ($tag:expr, $digit:literal) => {
162 row!(Id::Tag($tag), $tag.label(), &[cmd($digit)], &[cmd($digit)])174 row!(Id::Tag($tag), $tag.label(), &[cmd($digit)], &[cmd($digit)])
163 };175 };
176 ($tag:expr) => {
177 row!(Id::Tag($tag), $tag.label(), NONE, NONE)
178 };
164}179}
165180
166const NONE: &[Chord] = &[];181const NONE: &[Chord] = &[];
...@@ -206,6 +221,7 @@ pub const COMMANDS: &[Command] = &[...@@ -206,6 +221,7 @@ pub const COMMANDS: &[Command] = &[
206 row!(Id::Cut, "Cut", &[cmd('x')], &[cmd('x')]),221 row!(Id::Cut, "Cut", &[cmd('x')], &[cmd('x')]),
207 row!(Id::Copy, "Copy", &[cmd('c')], &[cmd('c')]),222 row!(Id::Copy, "Copy", &[cmd('c')], &[cmd('c')]),
208 row!(Id::Paste, "Paste", &[cmd('v')], &[cmd('v')]),223 row!(Id::Paste, "Paste", &[cmd('v')], &[cmd('v')]),
224 row!(Id::FormatPainter, "Format Painter", NONE, NONE),
209 row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]),225 row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]),
210 row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]),226 row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]),
211 row!(Id::Search, "Search Notebooks", &[cmd('e')], &[cmd('e')]),227 row!(Id::Search, "Search Notebooks", &[cmd('e')], &[cmd('e')]),
...@@ -239,16 +255,27 @@ pub const COMMANDS: &[Command] = &[...@@ -239,16 +255,27 @@ pub const COMMANDS: &[Command] = &[
239 row!(Id::Sidebar, "Sidebar", &[cmd('s').control()], NONE),255 row!(Id::Sidebar, "Sidebar", &[cmd('s').control()], NONE),
240 row!(Id::PageList, "Page List", NONE, NONE),256 row!(Id::PageList, "Page List", NONE, NONE),
241 row!(Id::DarkPages, "Dark Pages", NONE, NONE),257 row!(Id::DarkPages, "Dark Pages", NONE, NONE),
258 // AppKit's Enter Full Screen takes Control-Command-F.
259 row!(
260 Id::FullPageView,
261 "Full Page View",
262 NONE,
263 &[named(NamedKey::F11)]
264 ),
265 row!(Id::PageColor, "Page Color", NONE, NONE),
242 row!(Id::Table, "Table", NONE, NONE),266 row!(Id::Table, "Table", NONE, NONE),
243 row!(Id::Picture, "Picture…", NONE, NONE),267 row!(Id::Picture, "Picture…", NONE, NONE),
268 row!(Id::ScreenClipping, "Screen Clipping", NONE, NONE),
244 row!(Id::Attachment, "Attach File…", NONE, NONE),269 row!(Id::Attachment, "Attach File…", NONE, NONE),
245 row!(Id::Link, "Link…", &[cmd('k')], &[cmd('k')]),270 row!(Id::Link, "Link…", &[cmd('k')], &[cmd('k')]),
271 row!(Id::InsertSpace, "Insert Space", NONE, NONE),
246 row!(272 row!(
247 Id::Equation,273 Id::Equation,
248 "Equation",274 "Equation",
249 &[key('=').control()],275 &[key('=').control()],
250 &[key('=').option()],276 &[key('=').option()],
251 ),277 ),
278 row!(Id::Symbol, "Symbol", NONE, NONE),
252 row!(Id::Date, "Date", NONE, &[key('d').option().shift()]),279 row!(Id::Date, "Date", NONE, &[key('d').option().shift()]),
253 row!(Id::Time, "Time", NONE, &[key('t').option().shift()]),280 row!(Id::Time, "Time", NONE, &[key('t').option().shift()]),
254 row!(281 row!(
...@@ -257,6 +284,8 @@ pub const COMMANDS: &[Command] = &[...@@ -257,6 +284,8 @@ pub const COMMANDS: &[Command] = &[
257 NONE,284 NONE,
258 &[key('f').option().shift()]285 &[key('f').option().shift()]
259 ),286 ),
287 row!(Id::RecordAudio, "Record Audio", NONE, NONE),
288 row!(Id::RecordVideo, "Record Video", NONE, NONE),
260 row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]),289 row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]),
261 row!(290 row!(
262 Id::Toggle(Toggle::Italic),291 Id::Toggle(Toggle::Italic),
...@@ -338,6 +367,26 @@ pub const COMMANDS: &[Command] = &[...@@ -338,6 +367,26 @@ pub const COMMANDS: &[Command] = &[
338 tag!(NoteTag::Contact, '7'),367 tag!(NoteTag::Contact, '7'),
339 tag!(NoteTag::Address, '8'),368 tag!(NoteTag::Address, '8'),
340 tag!(NoteTag::PhoneNumber, '9'),369 tag!(NoteTag::PhoneNumber, '9'),
370 tag!(NoteTag::WebSiteToVisit),
371 tag!(NoteTag::Idea),
372 tag!(NoteTag::Password),
373 tag!(NoteTag::Critical),
374 tag!(NoteTag::ProjectA),
375 tag!(NoteTag::ProjectB),
376 tag!(NoteTag::MovieToSee),
377 tag!(NoteTag::BookToRead),
378 tag!(NoteTag::MusicToListenTo),
379 tag!(NoteTag::SourceForArticle),
380 tag!(NoteTag::RememberForBlog),
381 tag!(NoteTag::DiscussWithPersonA),
382 tag!(NoteTag::DiscussWithPersonB),
383 tag!(NoteTag::DiscussWithManager),
384 tag!(NoteTag::SendInEmail),
385 tag!(NoteTag::ScheduleMeeting),
386 tag!(NoteTag::CallBack),
387 tag!(NoteTag::ToDoPriority1),
388 tag!(NoteTag::ToDoPriority2),
389 tag!(NoteTag::ClientRequest),
341 row!(390 row!(
342 Id::RemoveTags,391 Id::RemoveTags,
343 "Remove Tag",392 "Remove Tag",
...@@ -393,7 +442,9 @@ pub fn title(choice: Choice) -> String {...@@ -393,7 +442,9 @@ pub fn title(choice: Choice) -> String {
393 .iter()442 .iter()
394 .find(|(listed, _)| *listed == color)443 .find(|(listed, _)| *listed == color)
395 .map_or_else(String::new, |(_, name)| (*name).to_owned()),444 .map_or_else(String::new, |(_, name)| (*name).to_owned()),
396 Choice::Color(_) | Choice::List(_) => unreachable!("Only the toolbar offers these"),445 Choice::Color(_) | Choice::List(_) | Choice::Table(_) | Choice::PageColor(_) => {
446 unreachable!("Only the toolbar offers these")
447 }
397 }448 }
398}449}
399450
...@@ -574,6 +625,8 @@ impl State {...@@ -574,6 +625,8 @@ impl State {
574 Choice::Font(name) => return checked(format.font.as_ref() == Some(name)),625 Choice::Font(name) => return checked(format.font.as_ref() == Some(name)),
575 Choice::Size(size) => return checked(format.font_size == Some(*size)),626 Choice::Size(size) => return checked(format.font_size == Some(*size)),
576 Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text),627 Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text),
628 Choice::Table(_) => Id::Table,
629 Choice::PageColor(_) => Id::PageColor,
577 };630 };
578 match id {631 match id {
579 Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::Help => enabled(!modal),632 Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::Help => enabled(!modal),
...@@ -595,6 +648,10 @@ impl State {...@@ -595,6 +648,10 @@ impl State {
595 checked: session.shown_history == Some(session.space),648 checked: session.shown_history == Some(session.space),
596 })649 })
597 }650 }
651 Id::FullPageView => Status {
652 enabled: !welcome && !modal,
653 checked: self.full_page,
654 },
598 Id::Undo => enabled(writable && !field && self.view.editor.can_undo()),655 Id::Undo => enabled(writable && !field && self.view.editor.can_undo()),
599 Id::Redo => enabled(writable && !field && self.view.editor.can_redo()),656 Id::Redo => enabled(writable && !field && self.view.editor.can_redo()),
600 Id::Cut => enabled(writable && selected),657 Id::Cut => enabled(writable && selected),
...@@ -616,8 +673,18 @@ impl State {...@@ -616,8 +673,18 @@ impl State {
616 enabled: !modal,673 enabled: !modal,
617 checked: !self.light_pages,674 checked: !self.light_pages,
618 },675 },
619 Id::Table | Id::Picture | Id::Attachment => enabled(false),676 Id::FormatPainter => Status {
620 Id::Link677 enabled: text,
678 checked: self.painter.is_some(),
679 },
680 Id::PageColor => enabled(writable && session.is_some()),
681 Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")),
682 // Attach File awaits #23, Record Audio and Video #36, and Insert Space a drag mode.
683 Id::Attachment | Id::RecordAudio | Id::RecordVideo | Id::InsertSpace => enabled(false),
684 Id::Table
685 | Id::Picture
686 | Id::Symbol
687 | Id::Link
621 | Id::Equation688 | Id::Equation
622 | Id::Date689 | Id::Date
623 | Id::Time690 | Id::Time
...@@ -638,7 +705,7 @@ impl State {...@@ -638,7 +705,7 @@ impl State {
638705
639 /// Applies `formatting`, remembering its font, colour or list style as the toolbar's706 /// Applies `formatting`, remembering its font, colour or list style as the toolbar's
640 /// last pick.707 /// last pick.
641 fn format(&mut self, formatting: Formatting) -> Result<(), Box<dyn Error>> {708 pub(crate) fn format(&mut self, formatting: Formatting) -> Result<(), Box<dyn Error>> {
642 let kept = self.toolbar.clone();709 let kept = self.toolbar.clone();
643 let pens = &mut self.toolbar;710 let pens = &mut self.toolbar;
644 match &formatting {711 match &formatting {
...@@ -661,6 +728,26 @@ impl State {...@@ -661,6 +728,26 @@ impl State {
661 Ok(())728 Ok(())
662 }729 }
663730
731 /// Puts an encoded picture after the caret's paragraph at its size on a 96 dpi screen,
732 /// as OneNote 2010 inserts one.
733 pub(crate) fn insert_picture(&mut self, bytes: Vec<u8>) -> Result<(), Box<dyn Error>> {
734 let pixels = match draw::RasterImage::measure(&bytes) {
735 Ok(pixels) => pixels,
736 Err(_) => {
737 platform::alert(
738 "Couldn't insert the picture",
739 "Choose a PNG, JPEG or GIF picture.",
740 );
741 return Ok(());
742 }
743 };
744 let response = self
745 .view
746 .insert_picture(bytes, pixels.map(|side| side as f32 * 0.75))?;
747 self.respond(response);
748 Ok(())
749 }
750
664 /// The notebook the open section, or the notebook showing none, belongs to.751 /// The notebook the open section, or the notebook showing none, belongs to.
665 fn notebook(&self) -> Option<&Arc<crate::Library>> {752 fn notebook(&self) -> Option<&Arc<crate::Library>> {
666 self.session753 self.session
...@@ -682,6 +769,15 @@ impl State {...@@ -682,6 +769,15 @@ impl State {
682 Choice::Highlight(color) => return format(self, Formatting::Highlight(color)),769 Choice::Highlight(color) => return format(self, Formatting::Highlight(color)),
683 Choice::Color(color) => return format(self, Formatting::Color(color)),770 Choice::Color(color) => return format(self, Formatting::Color(color)),
684 Choice::List(style) => return format(self, Formatting::List(style)),771 Choice::List(style) => return format(self, Formatting::List(style)),
772 Choice::Table([rows, columns]) => {
773 let response = self.view.insert_table(rows, columns)?;
774 self.respond(response);
775 return Ok(());
776 }
777 Choice::PageColor(color) => {
778 let color = color.map(|index| canvas::template::PAGE_COLORS[index].1);
779 return self.color_page(color);
780 }
685 };781 };
686 let field = self.ui.focused().filter(|focus| *focus != page());782 let field = self.ui.focused().filter(|focus| *focus != page());
687 let response = match id {783 let response = match id {
...@@ -740,6 +836,13 @@ impl State {...@@ -740,6 +836,13 @@ impl State {
740 return Ok(());836 return Ok(());
741 }837 }
742 Id::Paste => Work::Page(Request::Paste),838 Id::Paste => Work::Page(Request::Paste),
839 Id::FormatPainter => {
840 self.painter = match self.painter {
841 Some(_) => None,
842 None => Some(self.view.editor.painted_format()?),
843 };
844 return Ok(());
845 }
743 Id::SelectAll => {846 Id::SelectAll => {
744 match field {847 match field {
745 Some(field) => self.ui.focus_all(field),848 Some(field) => self.ui.focus_all(field),
...@@ -779,13 +882,44 @@ impl State {...@@ -779,13 +882,44 @@ impl State {
779 self.pages_open = !self.pages_open;882 self.pages_open = !self.pages_open;
780 return Ok(());883 return Ok(());
781 }884 }
885 Id::FullPageView => {
886 self.full_page = !self.full_page;
887 return Ok(());
888 }
889 // The menu bar and keys open the toolbar's gallery.
890 Id::Table | Id::PageColor => {
891 let name = if id == Id::Table {
892 "table"
893 } else {
894 "page color"
895 };
896 self.ui.open_popup(crate::toolbar_popup(name));
897 return Ok(());
898 }
899 Id::Picture => {
900 let Some(path) = platform::pick_picture() else {
901 return Ok(());
902 };
903 return self.insert_picture(std::fs::read(path)?);
904 }
905 Id::ScreenClipping => {
906 #[cfg(target_os = "macos")]
907 platform::clip_screen(self.proxy.clone());
908 return Ok(());
909 }
910 Id::Symbol => {
911 platform::character_palette();
912 return Ok(());
913 }
782 Id::DarkPages => {914 Id::DarkPages => {
783 self.light_pages = !self.light_pages;915 self.light_pages = !self.light_pages;
784 self.follow_color_scheme();916 self.follow_color_scheme();
785 self.save_settings();917 self.save_settings();
786 return Ok(());918 return Ok(());
787 }919 }
788 Id::Table | Id::Picture | Id::Attachment => return Ok(()),920 Id::Attachment | Id::RecordAudio | Id::RecordVideo | Id::InsertSpace => {
921 return Ok(());
922 }
789 Id::Link => {923 Id::Link => {
790 self.open_link_dialog();924 self.open_link_dialog();
791 return Ok(());925 return Ok(());
crates/snowbound/src/history.rs-3
...@@ -165,7 +165,6 @@ impl State {...@@ -165,7 +165,6 @@ impl State {
165 session165 session
166 .section166 .section
167 .restore_version(page, version, &self.author)?;167 .restore_version(page, version, &self.author)?;
168 session.status = "Saving";
169 session.refresh_history()?;168 session.refresh_history()?;
170 self.commands.push(Command::OpenPage(page));169 self.commands.push(Command::OpenPage(page));
171 Ok(())170 Ok(())
...@@ -178,7 +177,6 @@ impl State {...@@ -178,7 +177,6 @@ impl State {
178 ) -> Result<(), Box<dyn Error>> {177 ) -> Result<(), Box<dyn Error>> {
179 let session = self.session.as_mut().ok_or("No section is open")?;178 let session = self.session.as_mut().ok_or("No section is open")?;
180 session.section.delete_versions(&[(page, vec![version])])?;179 session.section.delete_versions(&[(page, vec![version])])?;
181 session.status = "Saving";
182 session.refresh_history()?;180 session.refresh_history()?;
183 self.commands.push(Command::OpenPage(page));181 self.commands.push(Command::OpenPage(page));
184 Ok(())182 Ok(())
...@@ -221,7 +219,6 @@ impl State {...@@ -221,7 +219,6 @@ impl State {
221 .collect();219 .collect();
222 if !all.is_empty() {220 if !all.is_empty() {
223 session.section.delete_versions(&all)?;221 session.section.delete_versions(&all)?;
224 session.status = "Saving";
225 }222 }
226 session.refresh_history()?;223 session.refresh_history()?;
227 if session.version.is_some() {224 if session.version.is_some() {
crates/snowbound/src/library.rs+32-3
...@@ -7,10 +7,25 @@ use std::{...@@ -7,10 +7,25 @@ use std::{
7 error::Error,7 error::Error,
8 io,8 io,
9 path::{Path, PathBuf},9 path::{Path, PathBuf},
10 sync::Arc,10 sync::{
11 Arc,
12 atomic::{AtomicBool, Ordering},
13 },
11 time::Duration,14 time::Duration,
12};15};
1316
17/// OneNote's Work Offline, which like OneNote's holds for every notebook.
18static OFFLINE: AtomicBool = AtomicBool::new(false);
19
20pub fn offline() -> bool {
21 OFFLINE.load(Ordering::Relaxed)
22}
23
24/// Works offline or online again; sections opened from now on follow.
25pub fn set_offline(offline: bool) {
26 OFFLINE.store(offline, Ordering::Relaxed);
27}
28
14/// How long an SMB request may take before the share counts as unreachable.29/// How long an SMB request may take before the share counts as unreachable.
15const TIMEOUT: Duration = Duration::from_secs(10);30const TIMEOUT: Duration = Duration::from_secs(10);
16/// The largest section file read whole over SMB.31/// The largest section file read whole over SMB.
...@@ -243,7 +258,7 @@ impl Library {...@@ -243,7 +258,7 @@ impl Library {
243 path: &str,258 path: &str,
244 notify: impl Fn() + Send + 'static,259 notify: impl Fn() + Send + 'static,
245 ) -> Result<Section, Box<dyn Error>> {260 ) -> Result<Section, Box<dyn Error>> {
246 Ok(match (&self.notebook, &self.server) {261 let section = match (&self.notebook, &self.server) {
247 (Ok(Some(notebook)), Some(server)) => {262 (Ok(Some(notebook)), Some(server)) => {
248 let identity = self263 let identity = self
249 .catalog_section(notebook.catalog(), path)264 .catalog_section(notebook.catalog(), path)
...@@ -268,7 +283,21 @@ impl Library {...@@ -268,7 +283,21 @@ impl Library {
268 (Ok(Some(notebook)), None) => notebook.section(path, notify)?,283 (Ok(Some(notebook)), None) => notebook.section(path, notify)?,
269 (Ok(None), _) => Section::open(path, &self.cache, notify)?,284 (Ok(None), _) => Section::open(path, &self.cache, notify)?,
270 (Err(error), _) => return Err(error.clone().into()),285 (Err(error), _) => return Err(error.clone().into()),
271 })286 };
287 section.set_offline(offline());
288 Ok(section)
289 }
290
291 /// How the notebook's files are reached.
292 pub fn transport(&self) -> String {
293 match (&self.server, &self.notice) {
294 (Some(server), _) => format!(
295 "Snowbound’s SMB client, {}/{}",
296 server.mount.server, server.mount.share
297 ),
298 (None, Some(_)) => "The system’s mount of the share".to_owned(),
299 (None, None) => "Folder on this computer".to_owned(),
300 }
272 }301 }
273302
274 /// The file identity, in hex, of the section at catalog `path`.303 /// The file identity, in hex, of the section at catalog `path`.
crates/snowbound/src/linux.rs+277-15
...@@ -45,7 +45,7 @@ pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventL...@@ -45,7 +45,7 @@ pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventL
45 QUIT.set(event_loop.create_proxy())45 QUIT.set(event_loop.create_proxy())
46 .expect("Only one application event loop is created");46 .expect("Only one application event loop is created");
47 watch_settings(event_loop.create_proxy());47 watch_settings(event_loop.create_proxy());
48 if std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|desktop| desktop.contains("KDE")) {48 if desktop() == Desktop::Kde {
49 follow_breeze_radius(event_loop.create_proxy());49 follow_breeze_radius(event_loop.create_proxy());
50 }50 }
51 Ok(event_loop)51 Ok(event_loop)
...@@ -198,14 +198,10 @@ pub fn install_backdrop(_: &Window) -> bool {...@@ -198,14 +198,10 @@ pub fn install_backdrop(_: &Window) -> bool {
198/// suit `appearance`: KWin's from the KDE colour scheme, or on GNOME the Adwaita header bar198/// suit `appearance`: KWin's from the KDE colour scheme, or on GNOME the Adwaita header bar
199/// that winit's frame and GNOME's both draw.199/// that winit's frame and GNOME's both draw.
200pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {200pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {
201 let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();201 let fills = match desktop() {
202 let on = |name: &str| desktops.split(':').any(|desktop| desktop == name);202 Desktop::Kde => kde_titlebar(&kconfig("kdeglobals")),
203 let fills = if on("KDE") {203 Desktop::Gnome => adwaita_titlebar(appearance),
204 kde_titlebar(&kconfig("kdeglobals"))204 Desktop::Other => return None,
205 } else if on("GNOME") {
206 adwaita_titlebar(appearance)
207 } else {
208 return None;
209 }205 }
210 .map(|[red, green, blue]| draw::srgb(red, green, blue));206 .map(|[red, green, blue]| draw::srgb(red, green, blue));
211 // Past mid grey, as against a colour scheme chosen in Options, the theme's own strip.207 // Past mid grey, as against a colour scheme chosen in Options, the theme's own strip.
...@@ -214,6 +210,184 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {...@@ -214,6 +210,184 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {
214 (dark == (appearance == Theme::Dark)).then_some(fills)210 (dark == (appearance == Theme::Dark)).then_some(fills)
215}211}
216212
213#[derive(Clone, Copy, Debug, PartialEq)]
214enum Desktop {
215 Gnome,
216 Kde,
217 Other,
218}
219
220/// The desktop the session runs, whose look and motion the chrome follows.
221fn desktop() -> Desktop {
222 static DESKTOP: OnceLock<Desktop> = OnceLock::new();
223 *DESKTOP.get_or_init(|| {
224 let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
225 let on = |name: &str| desktops.split(':').any(|desktop| desktop == name);
226 if on("KDE") {
227 Desktop::Kde
228 } else if on("GNOME") {
229 Desktop::Gnome
230 } else {
231 Desktop::Other
232 }
233 })
234}
235
236/// Menus in `appearance` as the desktop draws its own: libadwaita's popover menus on GNOME
237/// and Breeze's on KDE, each moving as that desktop moves them.
238pub fn menu(appearance: Theme) -> Option<ui::Menu> {
239 match desktop() {
240 Desktop::Gnome => {
241 // GNOME's interface font, as "Family Size".
242 let points = portal_setting("org.gnome.desktop.interface", "font-name")
243 .and_then(|font| String::try_from(font).ok())
244 .and_then(|font| font.rsplit_once(' ')?.1.parse().ok());
245 Some(adwaita_menu(appearance, points.unwrap_or(11.0)))
246 }
247 Desktop::Kde => Some(breeze_menu(
248 appearance,
249 &kconfig("kdeglobals"),
250 &kconfig("kwinrc"),
251 )),
252 Desktop::Other => None,
253 }
254}
255
256/// libadwaita 1.7's `popover.menu` at `points`, which GTK 4 shows and hides at once.
257fn adwaita_menu(appearance: Theme, points: f32) -> ui::Menu {
258 let dark = appearance == Theme::Dark;
259 let fill = if dark { [0x36, 0x36, 0x3a] } else { [0xff; 3] };
260 // `--popover-fg-color`, and `currentColor` mixed with transparency at each strength.
261 let (ink, strength) = if dark {
262 ([0xff; 3], 1.0)
263 } else {
264 ([0, 0, 6], 0.8)
265 };
266 let ink = |alpha: f32| linear(over(ink, strength * alpha, fill));
267 ui::Menu {
268 fill: linear(fill),
269 border: linear(over([0; 3], 0.14, fill)),
270 shadows: vec![
271 css_shadow(0.09, 5.0, 1.0, 1.0),
272 css_shadow(0.05, 14.0, 2.0, 3.0),
273 ],
274 text: ink(1.0),
275 // The accelerator's `--dim-opacity`, and `--disabled-opacity`.
276 dim: ink(0.55),
277 disabled: ink(0.5),
278 highlight: ink(0.1),
279 highlight_border: None,
280 // `$border_color`.
281 rule: ink(0.15),
282 font_size: points * 4.0 / 3.0,
283 radius: 15.0,
284 pad: 6.0,
285 row: 32.0,
286 row_radius: 9.0,
287 row_pad: 12.0,
288 rule_band: 13.0,
289 rule_inset: 0.0,
290 motion: ui::PopupMotion::Cut,
291 }
292}
293
294/// Breeze's `QMenu` in `appearance`, coloured by kdeglobals `files` where the scheme suits
295/// it, and faded by KWin's Fading Popups unless kwinrc `kwin` turns them off.
296fn breeze_menu(appearance: Theme, files: &[String], kwin: &[String]) -> ui::Menu {
297 let entries = kconfig_entries(files);
298 let color = |group, key| kde_color(entries.get(&(group, key))?);
299 // Breeze Light's or Breeze Dark's window, its text, a view and the focus decoration.
300 let stock = if appearance == Theme::Dark {
301 [[32, 35, 38], [252; 3], [20, 22, 24], [61, 174, 233]]
302 } else {
303 [[239, 240, 241], [35, 38, 41], [255; 3], [61, 174, 233]]
304 };
305 let scheme = [
306 ("Colors:Window", "BackgroundNormal"),
307 ("Colors:Window", "ForegroundNormal"),
308 ("Colors:View", "BackgroundNormal"),
309 ("Colors:View", "DecorationFocus"),
310 ];
311 let mut colors: [[u8; 3]; 4] = std::array::from_fn(|index| {
312 let (group, key) = scheme[index];
313 color(group, key).unwrap_or(stock[index])
314 });
315 let window = linear(colors[0]);
316 let dark = 0.2126 * window[0] + 0.7152 * window[1] + 0.0722 * window[2] < 0.18;
317 if dark != (appearance == Theme::Dark) {
318 colors = stock;
319 }
320 let [window, text, view, focus] = colors;
321 // As `Helper::frameBackgroundColor`, `frameOutlineColor` and `focusOutlineColor` mix them.
322 let fill = over(view, 0.3, window);
323 let outline = linear(over(text, 0.2, window));
324 let kwin = kconfig_entries(kwin);
325 let factor = entries
326 .get(&("KDE", "AnimationDurationFactor"))
327 .and_then(|factor| factor.parse().ok())
328 .unwrap_or(1.0_f32);
329 let fades = kwin
330 .get(&("Plugins", "fadingpopupsEnabled"))
331 .is_none_or(|enabled| *enabled != "false");
332 let points = entries
333 .get(&("General", "font"))
334 .and_then(|font| font.split(',').nth(1)?.parse().ok())
335 .unwrap_or(10.0_f32);
336 ui::Menu {
337 fill: linear(fill),
338 border: outline,
339 // `ShadowHelper`'s default large shadow, which KWin draws around the menu.
340 shadows: vec![
341 css_shadow(0.22, 20.0, 5.0, 0.0),
342 css_shadow(0.12, 10.0, 2.0, 0.0),
343 ],
344 text: linear(text),
345 // Accelerators draw at 70% opacity; disabled text approximates the scheme's fade.
346 dim: linear(over(text, 0.7, fill)),
347 disabled: linear(over(text, 0.35, fill)),
348 highlight: linear(over(focus, 0.3, fill)),
349 highlight_border: Some(linear(over(text, 0.15, focus))),
350 rule: outline,
351 font_size: points * 4.0 / 3.0,
352 radius: 5.0,
353 pad: 4.0,
354 row: 26.0,
355 row_radius: 5.0,
356 // `MenuItem_MarginWidth` and `MenuItem_ExtraLeftMargin`.
357 row_pad: 9.0,
358 rule_band: 7.0,
359 rule_inset: 5.0,
360 motion: if fades && factor > 0.0 {
361 ui::PopupMotion::Fade([0.15 * factor, 0.6 * factor])
362 } else {
363 ui::PopupMotion::Cut
364 },
365 }
366}
367
368/// `top` over `under` at `alpha`, blended in sRGB as GTK and Qt paint.
369fn over(top: [u8; 3], alpha: f32, under: [u8; 3]) -> [u8; 3] {
370 std::array::from_fn(|channel| {
371 let [top, under] = [top[channel], under[channel]].map(f32::from);
372 (under + (top - under) * alpha).round() as u8
373 })
374}
375
376fn linear([red, green, blue]: [u8; 3]) -> [f32; 4] {
377 draw::srgb(red, green, blue)
378}
379
380/// A black `box-shadow` at `alpha`, as dark over white as GTK and Qt blend it in sRGB.
381fn css_shadow(alpha: f32, blur: f32, drop: f32, spread: f32) -> ui::Shadow {
382 let [left, ..] = linear(over([0; 3], alpha, [0xff; 3]));
383 ui::Shadow {
384 color: [0.0, 0.0, 0.0, 1.0 - left],
385 blur,
386 drop,
387 spread,
388 }
389}
390
217/// KConfig file `name` from the system's configuration directories up to the user's, in391/// KConfig file `name` from the system's configuration directories up to the user's, in
218/// rising precedence.392/// rising precedence.
219fn kconfig(name: &str) -> Vec<String> {393fn kconfig(name: &str) -> Vec<String> {
...@@ -373,8 +547,10 @@ pub fn update_menu(_: impl FnOnce() -> Vec<crate::commands::Status>) {}...@@ -373,8 +547,10 @@ pub fn update_menu(_: impl FnOnce() -> Vec<crate::commands::Status>) {}
373547
374pub fn clear_marked_text(_: &Window) {}548pub fn clear_marked_text(_: &Window) {}
375549
550#[cfg(feature = "wgpu")]
376pub fn configure_presentation(_: &wgpu::Surface<'_>) {}551pub fn configure_presentation(_: &wgpu::Surface<'_>) {}
377552
553#[cfg(feature = "wgpu")]
378pub fn commit_presentation(_: &Window) {}554pub fn commit_presentation(_: &Window) {}
379555
380/// GTK's default double-click time.556/// GTK's default double-click time.
...@@ -426,6 +602,16 @@ fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) {...@@ -426,6 +602,16 @@ fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) {
426}602}
427603
428fn portal_color_scheme() -> Option<bool> {604fn portal_color_scheme() -> Option<bool> {
605 let scheme = portal_setting("org.freedesktop.appearance", "color-scheme")?;
606 match u32::try_from(scheme).ok()? {
607 1 => Some(true),
608 2 => Some(false),
609 _ => None,
610 }
611}
612
613/// Setting `key` in `namespace` from the settings portal.
614fn portal_setting(namespace: &str, key: &str) -> Option<zbus::zvariant::OwnedValue> {
429 use zbus::zvariant::{OwnedValue, Value};615 use zbus::zvariant::{OwnedValue, Value};
430 // A missing portal must not hold up the window for D-Bus's 25 s default.616 // A missing portal must not hold up the window for D-Bus's 25 s default.
431 let connection = zbus::blocking::connection::Builder::session()617 let connection = zbus::blocking::connection::Builder::session()
...@@ -440,7 +626,7 @@ fn portal_color_scheme() -> Option<bool> {...@@ -440,7 +626,7 @@ fn portal_color_scheme() -> Option<bool> {
440 "org.freedesktop.portal.Settings",626 "org.freedesktop.portal.Settings",
441 )627 )
442 .ok()?;628 .ok()?;
443 let key = ("org.freedesktop.appearance", "color-scheme");629 let key = (namespace, key);
444 // Portals before ReadOne answer Read, with the value in a second variant.630 // Portals before ReadOne answer Read, with the value in a second variant.
445 let reply: OwnedValue = proxy631 let reply: OwnedValue = proxy
446 .call("ReadOne", &key)632 .call("ReadOne", &key)
...@@ -450,11 +636,7 @@ fn portal_color_scheme() -> Option<bool> {...@@ -450,11 +636,7 @@ fn portal_color_scheme() -> Option<bool> {
450 while let Value::Value(inner) = value {636 while let Value::Value(inner) = value {
451 value = *inner;637 value = *inner;
452 }638 }
453 match u32::try_from(value).ok()? {639 value.try_into_owned().ok()
454 1 => Some(true),
455 2 => Some(false),
456 _ => None,
457 }
458}640}
459641
460pub fn cache_dir() -> Option<PathBuf> {642pub fn cache_dir() -> Option<PathBuf> {
...@@ -793,6 +975,41 @@ fn dialog<const Z: usize, const K: usize>(...@@ -793,6 +975,41 @@ fn dialog<const Z: usize, const K: usize>(
793 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))975 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))
794}976}
795977
978/// Asks for a picture to insert.
979pub fn pick_picture() -> Option<PathBuf> {
980 let patterns = crate::PICTURE_TYPES
981 .map(|kind| format!("*.{kind}"))
982 .join(" ");
983 let asked = dialog(
984 [
985 "--file-selection",
986 "--title=Insert Picture",
987 &format!("--file-filter=Pictures | {patterns}"),
988 ],
989 [
990 "--getopenfilename",
991 ".",
992 &patterns,
993 "--title",
994 "Insert Picture",
995 ],
996 );
997 asked
998 .unwrap_or_else(|error| {
999 eprintln!("{error}");
1000 None
1001 })
1002 .filter(|path| !path.is_empty())
1003 .map(PathBuf::from)
1004}
1005
1006/// Opens GNOME's character map, whose picks the user copies in.
1007pub fn character_palette() {
1008 if let Err(error) = Command::new("gnome-characters").spawn() {
1009 eprintln!("Cannot open the character map: {error}");
1010 }
1011}
1012
796/// Opens `target`, a folder or a link's URL, with the desktop's handler.1013/// Opens `target`, a folder or a link's URL, with the desktop's handler.
797pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {1014pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
798 let target = target.as_ref();1015 let target = target.as_ref();
...@@ -963,6 +1180,51 @@ mod tests {...@@ -963,6 +1180,51 @@ mod tests {
963 );1180 );
964 }1181 }
9651182
1183 #[test]
1184 fn breeze_menus_take_the_scheme_s_colours_and_kwin_s_fade() {
1185 let srgb = |[red, green, blue]: [u8; 3]| draw::srgb(red, green, blue);
1186 let menu = breeze_menu(Theme::Light, &[], &[]);
1187 // As measured from Dolphin's context menu under Breeze Light.
1188 assert_eq!(menu.fill, srgb([0xf4, 0xf5, 0xf5]));
1189 assert_eq!(menu.border, srgb([0xc6, 0xc8, 0xc9]));
1190 assert_eq!(menu.highlight, srgb([0xbd, 0xe0, 0xf1]));
1191 assert_eq!(menu.highlight_border, Some(srgb([0x39, 0x9a, 0xcc])));
1192 assert_eq!(menu.font_size, 10.0 * 4.0 / 3.0);
1193 assert_eq!(menu.motion, ui::PopupMotion::Fade([0.15, 0.6]));
1194
1195 let settings = "[General]\nfont=Noto Sans,12,-1,5,400,0,0,0,0,0\n\
1196 [KDE]\nAnimationDurationFactor=0.5\n\
1197 [Colors:Window]\nBackgroundNormal=32,35,38\nForegroundNormal=252,252,252\n";
1198 let menu = breeze_menu(Theme::Dark, &[settings.into()], &[]);
1199 assert_eq!(menu.text, srgb([252; 3]));
1200 assert_eq!(menu.font_size, 16.0);
1201 assert_eq!(menu.motion, ui::PopupMotion::Fade([0.075, 0.3]));
1202 assert_eq!(
1203 breeze_menu(Theme::Light, &[settings.into()], &[]).fill,
1204 srgb([0xf4, 0xf5, 0xf5]),
1205 "a dark scheme under a light appearance takes Breeze Light's"
1206 );
1207
1208 let instant = "[KDE]\nAnimationDurationFactor=0\n";
1209 let unfaded = "[Plugins]\nfadingpopupsEnabled=false\n";
1210 for (kdeglobals, kwinrc) in [(instant, ""), ("", unfaded)] {
1211 let motion = breeze_menu(Theme::Light, &[kdeglobals.into()], &[kwinrc.into()]).motion;
1212 assert_eq!(motion, ui::PopupMotion::Cut);
1213 }
1214 }
1215
1216 #[test]
1217 fn adwaita_menus_mix_the_popover_s_ink_as_its_stylesheet_does() {
1218 let srgb = |[red, green, blue]: [u8; 3]| draw::srgb(red, green, blue);
1219 let light = adwaita_menu(Theme::Light, 11.0);
1220 assert_eq!(light.text, srgb([51, 51, 56]));
1221 assert_eq!(light.highlight, srgb([235; 3]));
1222 assert_eq!(light.border, srgb([219; 3]));
1223 let dark = adwaita_menu(Theme::Dark, 11.0);
1224 assert_eq!(dark.highlight, srgb([74, 74, 78]));
1225 assert_eq!(dark.motion, ui::PopupMotion::Cut);
1226 }
1227
966 #[test]1228 #[test]
967 fn kconfig_colours_read_as_channels_or_hex() {1229 fn kconfig_colours_read_as_channels_or_hex() {
968 assert_eq!(kde_color("222,224,226"), Some([222, 224, 226]));1230 assert_eq!(kde_color("222,224,226"), Some([222, 224, 226]));
crates/snowbound/src/macos.rs+60-4
...@@ -116,9 +116,15 @@ impl Clipboard {...@@ -116,9 +116,15 @@ impl Clipboard {
116/// AppKit draws the traffic lights.116/// AppKit draws the traffic lights.
117pub fn window_controls(_: &mut ui::Ui, _: &Window) {}117pub fn window_controls(_: &mut ui::Ui, _: &Window) {}
118118
119/// The app draws the title bar around the traffic lights.119/// Whether AppKit draws the title bar: before 10.10 content can't extend under it, and
120pub fn system_titlebar(_: &Window) -> bool {120/// otherwise the app draws it around the traffic lights.
121 false121pub fn system_titlebar(window: &Window) -> bool {
122 let window = ns_window(window);
123 unsafe {
124 let frame: NSRect = msg_send![&window, frame];
125 let content: NSRect = msg_send![&window, contentRectForFrameRect: frame];
126 content.size.height < frame.size.height
127 }
122}128}
123129
124unsafe extern "C" fn no_document_drag(130unsafe extern "C" fn no_document_drag(
...@@ -163,6 +169,11 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) {...@@ -163,6 +169,11 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) {
163 }169 }
164}170}
165171
172/// None: the kit's own menus stand in for AppKit's.
173pub fn menu(_: winit::window::Theme) -> Option<ui::Menu> {
174 None
175}
176
166/// Nothing the backdrop leaves to colour.177/// Nothing the backdrop leaves to colour.
167pub fn titlebar(_: winit::window::Theme) -> Option<[[f32; 4]; 2]> {178pub fn titlebar(_: winit::window::Theme) -> Option<[[f32; 4]; 2]> {
168 None179 None
...@@ -178,6 +189,10 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO...@@ -178,6 +189,10 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO
178/// through it to the view it lies in.189/// through it to the view it lies in.
179pub fn install_backdrop(window: &Window) -> bool {190pub fn install_backdrop(window: &Window) -> bool {
180 MainThreadMarker::new().expect("Views belong to the main thread");191 MainThreadMarker::new().expect("Views belong to the main thread");
192 // Before 10.10 AppKit has no materials.
193 let Some(effect) = AnyClass::get("NSVisualEffectView") else {
194 return false;
195 };
181 let RawWindowHandle::AppKit(handle) =196 let RawWindowHandle::AppKit(handle) =
182 window.window_handle().expect("Live AppKit window").as_raw()197 window.window_handle().expect("Live AppKit window").as_raw()
183 else {198 else {
...@@ -186,7 +201,6 @@ pub fn install_backdrop(window: &Window) -> bool {...@@ -186,7 +201,6 @@ pub fn install_backdrop(window: &Window) -> bool {
186 unsafe {201 unsafe {
187 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();202 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
188 let class = BACKDROP_CLASS.get_or_init(|| {203 let class = BACKDROP_CLASS.get_or_init(|| {
189 let effect = AnyClass::get("NSVisualEffectView").expect("AppKit is linked");
190 let mut class =204 let mut class =
191 ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class");205 ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class");
192 class.add_method(206 class.add_method(
...@@ -351,6 +365,46 @@ pub fn pick_new(title: &str, name: &str) -> Option<std::path::PathBuf> {...@@ -351,6 +365,46 @@ pub fn pick_new(title: &str, name: &str) -> Option<std::path::PathBuf> {
351 }365 }
352}366}
353367
368/// Asks for a picture to insert, with the system's open panel.
369pub fn pick_picture() -> Option<std::path::PathBuf> {
370 let mtm = MainThreadMarker::new().expect("Panels belong to the main thread");
371 unsafe {
372 let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm);
373 let types = crate::PICTURE_TYPES.map(NSString::from_str);
374 #[allow(deprecated, reason = "Allowed content types need macOS 11")]
375 panel.setAllowedFileTypes(Some(&objc2_foundation::NSArray::from_vec(types.into())));
376 panel.setPrompt(Some(&NSString::from_str("Insert")));
377 if panel.runModal() != objc2_app_kit::NSModalResponseOK {
378 return None;
379 }
380 let path = panel.URLs().firstObject()?.path()?;
381 Some(path.to_string().into())
382 }
383}
384
385/// Lets the user drag out part of the screen, then sends it as a PNG.
386pub fn clip_screen(proxy: EventLoopProxy<crate::UserEvent>) {
387 std::thread::spawn(move || {
388 let path = std::env::temp_dir().join(format!("snowbound-clip-{}.png", std::process::id()));
389 let taken = std::process::Command::new("/usr/sbin/screencapture")
390 .arg("-i")
391 .arg(&path)
392 .status()
393 .is_ok_and(|status| status.success());
394 // Escape leaves no file.
395 if taken && let Ok(bytes) = std::fs::read(&path) {
396 let _ = std::fs::remove_file(&path);
397 let _ = proxy.send_event(crate::UserEvent::Picture(bytes));
398 }
399 });
400}
401
402/// Shows the system's character palette, whose picks arrive as inserted text.
403pub fn character_palette() {
404 let mtm = MainThreadMarker::new().expect("AppKit belongs to the main thread");
405 NSApplication::sharedApplication(mtm).orderFrontCharacterPalette(None);
406}
407
354/// Tells the user something they asked for could not be done: `message`, then what to do.408/// Tells the user something they asked for could not be done: `message`, then what to do.
355pub fn alert(message: &str, detail: &str) {409pub fn alert(message: &str, detail: &str) {
356 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");410 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
...@@ -598,6 +652,7 @@ mod date_tests {...@@ -598,6 +652,7 @@ mod date_tests {
598 }652 }
599}653}
600654
655#[cfg(feature = "wgpu")]
601pub fn configure_presentation(surface: &wgpu::Surface<'_>) {656pub fn configure_presentation(surface: &wgpu::Surface<'_>) {
602 MainThreadMarker::new().expect("Layer presentation belongs to the main thread");657 MainThreadMarker::new().expect("Layer presentation belongs to the main thread");
603 // Retain wgpu's surface ownership while synchronizing presentation with AppKit resize transactions.658 // Retain wgpu's surface ownership while synchronizing presentation with AppKit resize transactions.
...@@ -612,6 +667,7 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) {...@@ -612,6 +667,7 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) {
612/// Commits a frame presented with the transaction: winit redraws after Core Animation's667/// Commits a frame presented with the transaction: winit redraws after Core Animation's
613/// commit observer, so otherwise the frame waits for the next event. A live resize leaves668/// commit observer, so otherwise the frame waits for the next event. A live resize leaves
614/// the commit to AppKit, which pairs the frame with the window's new size.669/// the commit to AppKit, which pairs the frame with the window's new size.
670#[cfg(feature = "wgpu")]
615pub fn commit_presentation(window: &Window) {671pub fn commit_presentation(window: &Window) {
616 let resizing: bool = unsafe { msg_send![&ns_window(window), inLiveResize] };672 let resizing: bool = unsafe { msg_send![&ns_window(window), inLiveResize] };
617 if !resizing {673 if !resizing {
crates/snowbound/src/main.rs+429-332
...@@ -20,6 +20,9 @@ mod screenshot;...@@ -20,6 +20,9 @@ mod screenshot;
20mod search;20mod search;
21mod settings;21mod settings;
22mod sidebar;22mod sidebar;
23#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")]
24mod surface;
25mod sync;
23mod templates;26mod templates;
2427
25use canvas::gpu::page::PageScene;28use canvas::gpu::page::PageScene;
...@@ -33,6 +36,7 @@ use canvas::{...@@ -33,6 +36,7 @@ use canvas::{
33use canvas::{36use canvas::{
34 gpu::{colorref, tag_sources},37 gpu::{colorref, tag_sources},
35 outline::TagIcon,38 outline::TagIcon,
39 template::PAGE_COLORS,
36};40};
37use draw::Renderer;41use draw::Renderer;
38use library::Library;42use library::Library;
...@@ -63,6 +67,8 @@ const TAB_ROW: f32 = 28.0;...@@ -63,6 +67,8 @@ const TAB_ROW: f32 = 28.0;
63/// Width of the section colour around the page.67/// Width of the section colour around the page.
64const FRAME: f32 = 6.0;68const FRAME: f32 = 6.0;
65const PAGE_LIST: f32 = 240.0;69const PAGE_LIST: f32 = 240.0;
70/// Extensions of the pictures Insert, Picture offers: those the page both stores and draws.
71const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"];
66/// Font sizes the size box offers, OneNote's list in points.72/// Font sizes the size box offers, OneNote's list in points.
67/// Fonts the font box offers first, OneNote's default leading.73/// Fonts the font box offers first, OneNote's default leading.
68const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"];74const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"];
...@@ -116,6 +122,9 @@ enum UserEvent {...@@ -116,6 +122,9 @@ enum UserEvent {
116 /// Text AppKit inserts outside key events, such as the character palette's.122 /// Text AppKit inserts outside key events, such as the character palette's.
117 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]123 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
118 InsertText(String),124 InsertText(String),
125 /// A picture to insert at the caret, such as a screen clipping.
126 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
127 Picture(Vec<u8>),
119 Accessibility(accesskit_winit::Event),128 Accessibility(accesskit_winit::Event),
120 /// The section's synchronization thread reported an event.129 /// The section's synchronization thread reported an event.
121 Sync,130 Sync,
...@@ -233,7 +242,8 @@ struct Session {...@@ -233,7 +242,8 @@ struct Session {
233 /// Spaces, titles and outline levels in section order.242 /// Spaces, titles and outline levels in section order.
234 pages: Vec<(ExGuid, String, u32)>,243 pages: Vec<(ExGuid, String, u32)>,
235 space: ExGuid,244 space: ExGuid,
236 status: &'static str,245 /// The section's sync status as last read.
246 sync: notebook::session::SyncStatus,
237 /// Each page's conflict pages, the versions a merge could not take.247 /// Each page's conflict pages, the versions a merge could not take.
238 conflicts: Vec<(ExGuid, Vec<onestore::ConflictPage>)>,248 conflicts: Vec<(ExGuid, Vec<onestore::ConflictPage>)>,
239 /// The page whose conflict pages the list shows beneath it.249 /// The page whose conflict pages the list shows beneath it.
...@@ -268,16 +278,6 @@ enum Bar {...@@ -268,16 +278,6 @@ enum Bar {
268}278}
269279
270impl Session {280impl Session {
271 /// Saving's state, and that the notebook opened through the system's mount of its
272 /// share where Snowbound's own SMB client could not sign in.
273 fn status(&self) -> String {
274 match (&self.library.notice, self.status) {
275 (None, status) => status.to_owned(),
276 (Some(_), "") => "Via mounted share".to_owned(),
277 (Some(_), status) => format!("{status} · Via mounted share"),
278 }
279 }
280
281 fn title(&self) -> &str {281 fn title(&self) -> &str {
282 match self.conflict(self.space) {282 match self.conflict(self.space) {
283 Some((_, version)) => &version.title,283 Some((_, version)) => &version.title,
...@@ -491,9 +491,7 @@ struct State {...@@ -491,9 +491,7 @@ struct State {
491 trail: navigation::Trail,491 trail: navigation::Trail,
492 /// Asks for a frame when a worker thread finishes something the page shows.492 /// Asks for a frame when a worker thread finishes something the page shows.
493 redraw: std::task::Waker,493 redraw: std::task::Waker,
494 instance: wgpu::Instance,494 surface: surface::Surface,
495 surface: wgpu::Surface<'static>,
496 config: wgpu::SurfaceConfiguration,
497 renderer: Renderer,495 renderer: Renderer,
498 ui: Ui,496 ui: Ui,
499 view: PageView,497 view: PageView,
...@@ -520,8 +518,6 @@ struct State {...@@ -520,8 +518,6 @@ struct State {
520 text_menu: Option<(canvas::interaction::Context, [f32; 2])>,518 text_menu: Option<(canvas::interaction::Context, [f32; 2])>,
521 color_scheme: settings::ColorScheme,519 color_scheme: settings::ColorScheme,
522 light_pages: bool,520 light_pages: bool,
523 /// Carries frames to the window where the system's backdrop shows through the strip.
524 translucent: Option<draw::Translucent>,
525 /// The strip's fill with the window focused and not, continuing the system's title bar.521 /// The strip's fill with the window focused and not, continuing the system's title bar.
526 titlebar: [[f32; 4]; 2],522 titlebar: [[f32; 4]; 2],
527 /// A section or group being renamed in the sidebar.523 /// A section or group being renamed in the sidebar.
...@@ -541,6 +537,10 @@ struct State {...@@ -541,6 +537,10 @@ struct State {
541 search: search::Search,537 search: search::Search,
542 /// Whether the page list is shown beside the page.538 /// Whether the page list is shown beside the page.
543 pages_open: bool,539 pages_open: bool,
540 /// Full Page View: the sidebar, section tabs and page list hidden around the page.
541 full_page: bool,
542 /// What Format Painter picked up, which the next selection made on the page takes.
543 painter: Option<onestore::document::Format>,
544 /// Installed font families, for the font box; listing them is slow.544 /// Installed font families, for the font box; listing them is slow.
545 fonts: Vec<String>,545 fonts: Vec<String>,
546 /// Fonts picked from the font box this session, latest first.546 /// Fonts picked from the font box this session, latest first.
...@@ -631,32 +631,8 @@ impl State {...@@ -631,32 +631,8 @@ impl State {
631 let access_adapter =631 let access_adapter =
632 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());632 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
633 window.set_visible(visible);633 window.set_visible(visible);
634 let instance = wgpu::Instance::new(634 let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?;
635 wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())),
636 );
637 let surface = instance.create_surface(window.clone())?;
638 platform::configure_presentation(&surface);
639 let adapter = instance
640 .request_adapter(&wgpu::RequestAdapterOptions {
641 compatible_surface: Some(&surface),
642 ..Default::default()
643 })
644 .await?;
645 let (device, queue) = adapter.request_device(&Default::default()).await?;
646 let size = window.inner_size();635 let size = window.inner_size();
647 let mut config = surface
648 .get_default_config(&adapter, size.width, size.height)
649 .ok_or("No supported canvas surface")?;
650 let translucent = backdrop.then(|| {
651 // What wgpu calls post-multiplied is Core Animation's non-opaque layer, which
652 // composites colour premultiplied in sRGB, as `Translucent` leaves it.
653 config.alpha_mode = wgpu::CompositeAlphaMode::PostMultiplied;
654 let window_format = config.format.remove_srgb_suffix();
655 config.view_formats.push(window_format);
656 draw::Translucent::new(&device, config.format, window_format)
657 });
658 surface.configure(&device, &config);
659 let renderer = Renderer::new(device, queue, config.format);
660 let mut engine = TextEngine::default();636 let mut engine = TextEngine::default();
661 for path in substitutes {637 for path in substitutes {
662 let target = engine638 let target = engine
...@@ -754,7 +730,7 @@ impl State {...@@ -754,7 +730,7 @@ impl State {
754 let dpr = window.scale_factor() as f32;730 let dpr = window.scale_factor() as f32;
755 window.set_ime_allowed(true);731 window.set_ime_allowed(true);
756 window.request_redraw();732 window.request_redraw();
757 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());733 eprintln!("Scale factor {dpr}");
758 window.set_theme(stored.color_scheme.theme());734 window.set_theme(stored.color_scheme.theme());
759 let appearance = stored735 let appearance = stored
760 .color_scheme736 .color_scheme
...@@ -791,9 +767,7 @@ impl State {...@@ -791,9 +767,7 @@ impl State {
791 window,767 window,
792 redraw,768 redraw,
793 proxy,769 proxy,
794 instance,
795 surface,770 surface,
796 config,
797 renderer,771 renderer,
798 ui,772 ui,
799 view: PageView::new(773 view: PageView::new(
...@@ -819,7 +793,6 @@ impl State {...@@ -819,7 +793,6 @@ impl State {
819 options: None,793 options: None,
820 color_scheme: stored.color_scheme,794 color_scheme: stored.color_scheme,
821 light_pages: stored.light_pages,795 light_pages: stored.light_pages,
822 translucent,
823 titlebar,796 titlebar,
824 renaming: None,797 renaming: None,
825 sectionless,798 sectionless,
...@@ -828,6 +801,8 @@ impl State {...@@ -828,6 +801,8 @@ impl State {
828 thumbnails: templates::Thumbnails::default(),801 thumbnails: templates::Thumbnails::default(),
829 search,802 search,
830 pages_open: true,803 pages_open: true,
804 full_page: false,
805 painter: None,
831 fonts,806 fonts,
832 recent_fonts: stored.recent_fonts,807 recent_fonts: stored.recent_fonts,
833 toolbar: stored.toolbar,808 toolbar: stored.toolbar,
...@@ -928,6 +903,7 @@ impl State {...@@ -928,6 +903,7 @@ impl State {
928 let (section, open_tab, open_page) = self.build()?;903 let (section, open_tab, open_page) = self.build()?;
929 self.options_dialog();904 self.options_dialog();
930 self.link_dialog()?;905 self.link_dialog()?;
906 self.sync_popup()?;
931 self.text_menu()?;907 self.text_menu()?;
932 self.ui.end();908 self.ui.end();
933 self.edges(section, open_tab, open_page);909 self.edges(section, open_tab, open_page);
...@@ -946,7 +922,7 @@ impl State {...@@ -946,7 +922,7 @@ impl State {
946922
947 /// Takes `appearance`'s colours.923 /// Takes `appearance`'s colours.
948 fn set_appearance(&mut self, appearance: winit::window::Theme) {924 fn set_appearance(&mut self, appearance: winit::window::Theme) {
949 self.ui.theme = theme(appearance, self.light_pages, self.translucent.is_some());925 self.ui.theme = theme(appearance, self.light_pages, self.surface.translucent());
950 self.titlebar = platform::titlebar(appearance).unwrap_or([self.ui.theme.strip; 2]);926 self.titlebar = platform::titlebar(appearance).unwrap_or([self.ui.theme.strip; 2]);
951 }927 }
952928
...@@ -1027,10 +1003,15 @@ impl State {...@@ -1027,10 +1003,15 @@ impl State {
1027 ..Spec::default()1003 ..Spec::default()
1028 },1004 },
1029 );1005 );
1006 let tabs = self.ui.id("tabs");
1007 let height = self
1008 .ui
1009 .animate(tabs, if self.full_page { 0.0 } else { TAB_ROW });
1030 let tab_row = self.ui.open(1010 let tab_row = self.ui.open(
1031 "tabs",1011 "tabs",
1032 Spec {1012 Spec {
1033 size: [fill(), px(TAB_ROW)],1013 flags: Flags::CLIP,
1014 size: [fill(), px(height)],
1034 fill: Some(theme.strip),1015 fill: Some(theme.strip),
1035 pad: [FRAME, 0.0],1016 pad: [FRAME, 0.0],
1036 ..Spec::default()1017 ..Spec::default()
...@@ -1216,6 +1197,12 @@ impl State {...@@ -1216,6 +1197,12 @@ impl State {
1216 self.ui.close();1197 self.ui.close();
1217 self.ui.close();1198 self.ui.close();
1218 self.context_menu();1199 self.context_menu();
1200 // No tab stands on the frame in Full Page View.
1201 let open_tab = if self.full_page {
1202 frame().child("no tab")
1203 } else {
1204 open_tab
1205 };
1219 Ok((section, open_tab, open_page))1206 Ok((section, open_tab, open_page))
1220 }1207 }
12211208
...@@ -1332,7 +1319,7 @@ impl State {...@@ -1332,7 +1319,7 @@ impl State {
1332 }1319 }
13331320
1334 /// The window's title bar beside the traffic lights: the application's icon, the1321 /// The window's title bar beside the traffic lights: the application's icon, the
1335 /// notebook's name and the saving status.1322 /// notebook's name and the sync status.
1336 fn title_bar(&mut self, theme: &Theme) {1323 fn title_bar(&mut self, theme: &Theme) {
1337 self.ui.open_as(1324 self.ui.open_as(
1338 strip(),1325 strip(),
...@@ -1374,23 +1361,14 @@ impl State {...@@ -1374,23 +1361,14 @@ impl State {
1374 },1361 },
1375 );1362 );
1376 if let Some(session) = &self.session {1363 if let Some(session) = &self.session {
1377 self.ui.leaf(1364 sync::control(&mut self.ui, session, theme, TITLE);
1378 "status",
1379 Spec {
1380 size: [fit(), px(TITLE)],
1381 text: Some(&session.status()),
1382 color: Some(theme.text_dim),
1383 pad: [12.0, 0.0],
1384 ..Spec::default()
1385 },
1386 );
1387 }1365 }
1388 platform::window_controls(&mut self.ui, &self.window);1366 platform::window_controls(&mut self.ui, &self.window);
1389 self.ui.close();1367 self.ui.close();
1390 }1368 }
13911369
1392 /// Two rows of formatting, tag, insert and zoom buttons, grouped as OneNote's Home1370 /// Two rows of navigation, clipboard, formatting, tag, insert and view buttons, grouped
1393 /// ribbon groups them.1371 /// as OneNote's ribbon groups them.
1394 fn toolbar(&mut self, theme: &Theme) -> Result<(), Box<dyn Error>> {1372 fn toolbar(&mut self, theme: &Theme) -> Result<(), Box<dyn Error>> {
1395 use canvas::editor::{1373 use canvas::editor::{
1396 Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,1374 Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
...@@ -1398,13 +1376,28 @@ impl State {...@@ -1398,13 +1376,28 @@ impl State {
1398 use commands::{Choice, Id as Cmd, shortcut};1376 use commands::{Choice, Id as Cmd, shortcut};
1399 // A focused picture has no text to show a format for.1377 // A focused picture has no text to show a format for.
1400 let state = self.format_state();1378 let state = self.format_state();
1379 let statuses: Vec<_> = commands::COMMANDS
1380 .iter()
1381 .map(|command| {
1382 (
1383 command.id,
1384 self.status(&Choice::Command(command.id), &state),
1385 )
1386 })
1387 .collect();
1388 let status_of = |id| {
1389 statuses
1390 .iter()
1391 .find(|(listed, _)| *listed == id)
1392 .map_or_else(commands::Status::default, |(_, status)| *status)
1393 };
1401 let fonts = &self.fonts;1394 let fonts = &self.fonts;
1402 let recent_fonts = &self.recent_fonts;1395 let recent_fonts = &self.recent_fonts;
1403 let pens = &self.toolbar;1396 let pens = &self.toolbar;
1404 let engine = &self.view.engine;1397 let engine = &self.view.engine;
1405 // Under the system's title bar the save status joins the toolbar.1398 // Under the system's title bar the sync status joins the toolbar.
1406 let status = platform::system_titlebar(&self.window)1399 let status = platform::system_titlebar(&self.window)
1407 .then(|| self.session.as_ref().map(Session::status))1400 .then_some(self.session.as_ref())
1408 .flatten();1401 .flatten();
1409 let ui = &mut self.ui;1402 let ui = &mut self.ui;
1410 let text = theme.text;1403 let text = theme.text;
...@@ -1420,16 +1413,80 @@ impl State {...@@ -1420,16 +1413,80 @@ impl State {
1420 ..Spec::default()1413 ..Spec::default()
1421 },1414 },
1422 );1415 );
1423 group(ui, "history", |ui| {1416 group(ui, "navigate", |ui| {
1424 if ui::shell::tool_button(ui, "undo", art::UNDO, text, false).clicked {1417 for (index, buttons) in [
1425 choice = Some(Choice::Command(Cmd::Undo));1418 [
1426 }1419 ("back", art::BACK, Cmd::Back),
1427 if ui::shell::tool_button(ui, "redo", art::REDO, text, false).clicked {1420 ("forward", art::FORWARD, Cmd::Forward),
1428 choice = Some(Choice::Command(Cmd::Redo));1421 ],
1422 [
1423 ("undo", art::UNDO, Cmd::Undo),
1424 ("redo", art::REDO, Cmd::Redo),
1425 ],
1426 ]
1427 .into_iter()
1428 .enumerate()
1429 {
1430 row(ui, index, |ui| {
1431 for (part, icon, id) in buttons {
1432 choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take());
1433 }
1434 });
1429 }1435 }
1430 });1436 });
1431 divider(ui, "history", theme);1437 divider(ui, "navigate", theme);
1432 let popup = |name: &str| Id::ROOT.child(("popup", name));1438 group(ui, "clipboard", |ui| {
1439 row(ui, 0, |ui| {
1440 let paste = ui.id("paste");
1441 if !status_of(Cmd::Paste).enabled {
1442 ui::shell::unavailable(ui, "paste", art::PASTE, text, true);
1443 } else if ui::shell::split_button(
1444 ui,
1445 "paste",
1446 art::PASTE,
1447 None,
1448 false,
1449 toolbar_popup("paste"),
1450 )
1451 .clicked
1452 {
1453 choice = Some(Choice::Command(Cmd::Paste));
1454 }
1455 tip(ui, Cmd::Paste);
1456 // Pasting keeps only the text until #25.
1457 let options = [
1458 ("Keep Source Formatting", false),
1459 ("Merge Formatting", false),
1460 ("Keep Text Only", true),
1461 ("Picture", false),
1462 ];
1463 let mut items = vec![ui::popup::Item {
1464 text: "Paste Options",
1465 heading: true,
1466 ..Default::default()
1467 }];
1468 items.extend(options.map(|(text, enabled)| ui::popup::Item {
1469 text,
1470 disabled: !enabled,
1471 ..Default::default()
1472 }));
1473 let anchor = ui::Anchor::Below(ui.rect(paste).unwrap_or_default());
1474 if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() {
1475 choice = Some(Choice::Command(Cmd::Paste));
1476 }
1477 choice = tool(ui, "cut", art::CUT, text, Cmd::Cut, status_of(Cmd::Cut))
1478 .or(choice.take());
1479 });
1480 row(ui, 1, |ui| {
1481 for (part, icon, id) in [
1482 ("copy", art::COPY, Cmd::Copy),
1483 ("painter", art::FORMAT_PAINTER, Cmd::FormatPainter),
1484 ] {
1485 choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take());
1486 }
1487 });
1488 });
1489 divider(ui, "clipboard", theme);
1433 let font = state.font.clone().unwrap_or_default();1490 let font = state.font.clone().unwrap_or_default();
1434 let size = state1491 let size = state
1435 .font_size1492 .font_size
...@@ -1438,7 +1495,7 @@ impl State {...@@ -1438,7 +1495,7 @@ impl State {
1438 row(ui, 0, |ui| {1495 row(ui, 0, |ui| {
1439 let combo = ui.id("font");1496 let combo = ui.id("font");
1440 if ui::shell::combo(ui, "font", &font, 120.0).pressed {1497 if ui::shell::combo(ui, "font", &font, 120.0).pressed {
1441 ui.open_popup(popup("font"));1498 ui.open_popup(toolbar_popup("font"));
1442 }1499 }
1443 // Each family is named with the substitute it shows in, and picked by its1500 // Each family is named with the substitute it shows in, and picked by its
1444 // own name; a family of None heads a group. Typing searches the full list.1501 // own name; a family of None heads a group. Typing searches the full list.
...@@ -1449,7 +1506,7 @@ impl State {...@@ -1449,7 +1506,7 @@ impl State {
1449 )1506 )
1450 };1507 };
1451 let mut choices: Vec<(String, Option<&str>, bool)> = Vec::new();1508 let mut choices: Vec<(String, Option<&str>, bool)> = Vec::new();
1452 if ui::popup::query(ui, popup("font")).is_none_or(str::is_empty) {1509 if ui::popup::query(ui, toolbar_popup("font")).is_none_or(str::is_empty) {
1453 choices.extend(FONTS.map(|name| (label(name), Some(name), false)));1510 choices.extend(FONTS.map(|name| (label(name), Some(name), false)));
1454 if !recent_fonts.is_empty() {1511 if !recent_fonts.is_empty() {
1455 choices.push(("Recent".to_owned(), None, true));1512 choices.push(("Recent".to_owned(), None, true));
...@@ -1477,18 +1534,18 @@ impl State {...@@ -1477,18 +1534,18 @@ impl State {
1477 .collect();1534 .collect();
1478 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());1535 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());
1479 if let Some(index) =1536 if let Some(index) =
1480 ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font"))1537 ui::popup::menu(ui, toolbar_popup("font"), anchor, &items, Some("Font"))
1481 && let Some(name) = choices[index].11538 && let Some(name) = choices[index].1
1482 {1539 {
1483 choice = Some(Choice::Font(name.to_owned()));1540 choice = Some(Choice::Font(name.to_owned()));
1484 }1541 }
1485 let combo = ui.id("size");1542 let combo = ui.id("size");
1486 if ui::shell::combo(ui, "size", &size, 44.0).pressed {1543 if ui::shell::combo(ui, "size", &size, 44.0).pressed {
1487 ui.open_popup(popup("size"));1544 ui.open_popup(toolbar_popup("size"));
1488 }1545 }
1489 // A size typed in the field joins the list, in half points as stored.1546 // A size typed in the field joins the list, in half points as stored.
1490 let mut sizes = SIZES.to_vec();1547 let mut sizes = SIZES.to_vec();
1491 if let Some(typed) = ui::popup::query(ui, popup("size"))1548 if let Some(typed) = ui::popup::query(ui, toolbar_popup("size"))
1492 .and_then(|query| query.trim().parse::<f32>().ok())1549 .and_then(|query| query.trim().parse::<f32>().ok())
1493 .map(|typed| (typed * 2.0).round() / 2.0)1550 .map(|typed| (typed * 2.0).round() / 2.0)
1494 .filter(|typed| onestore::FONT_SIZES.contains(typed) && !sizes.contains(typed))1551 .filter(|typed| onestore::FONT_SIZES.contains(typed) && !sizes.contains(typed))
...@@ -1506,7 +1563,8 @@ impl State {...@@ -1506,7 +1563,8 @@ impl State {
1506 })1563 })
1507 .collect();1564 .collect();
1508 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());1565 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());
1509 if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size))1566 if let Some(index) =
1567 ui::popup::menu(ui, toolbar_popup("size"), anchor, &items, Some(&size))
1510 {1568 {
1511 choice = Some(Choice::Size(sizes[index]));1569 choice = Some(Choice::Size(sizes[index]));
1512 }1570 }
...@@ -1518,24 +1576,26 @@ impl State {...@@ -1518,24 +1576,26 @@ impl State {
1518 art::BULLETS,1576 art::BULLETS,
1519 None,1577 None,
1520 state.bullets,1578 state.bullets,
1521 popup("bullets"),1579 toolbar_popup("bullets"),
1522 )1580 )
1523 .clicked1581 .clicked
1524 {1582 {
1525 choice = Some(Choice::Command(Cmd::Bullets));1583 choice = Some(Choice::Command(Cmd::Bullets));
1526 }1584 }
1585 tip(ui, Cmd::Bullets);
1527 if ui::shell::split_button(1586 if ui::shell::split_button(
1528 ui,1587 ui,
1529 "numbering",1588 "numbering",
1530 art::NUMBERING,1589 art::NUMBERING,
1531 None,1590 None,
1532 state.numbering,1591 state.numbering,
1533 popup("numbering"),1592 toolbar_popup("numbering"),
1534 )1593 )
1535 .clicked1594 .clicked
1536 {1595 {
1537 choice = Some(Choice::Command(Cmd::Numbering));1596 choice = Some(Choice::Command(Cmd::Numbering));
1538 }1597 }
1598 tip(ui, Cmd::Numbering);
1539 let current = |bullet| match state.list {1599 let current = |bullet| match state.list {
1540 Some(ListStyle::Bullet(place)) if bullet => Some(place),1600 Some(ListStyle::Bullet(place)) if bullet => Some(place),
1541 Some(ListStyle::Number(place)) if !bullet => Some(place),1601 Some(ListStyle::Number(place)) if !bullet => Some(place),
...@@ -1544,7 +1604,7 @@ impl State {...@@ -1544,7 +1604,7 @@ impl State {
1544 let anchor = ui::Anchor::Below(ui.rect(bullets).unwrap_or_default());1604 let anchor = ui::Anchor::Below(ui.rect(bullets).unwrap_or_default());
1545 if let Some(place) = list_gallery(1605 if let Some(place) = list_gallery(
1546 ui,1606 ui,
1547 popup("bullets"),1607 toolbar_popup("bullets"),
1548 anchor,1608 anchor,
1549 ["Recently Used Bullets", "Bullet Library"],1609 ["Recently Used Bullets", "Bullet Library"],
1550 (&pens.bullets, BULLET_LIBRARY.len()),1610 (&pens.bullets, BULLET_LIBRARY.len()),
...@@ -1570,7 +1630,7 @@ impl State {...@@ -1570,7 +1630,7 @@ impl State {
1570 let anchor = ui::Anchor::Below(ui.rect(numbering).unwrap_or_default());1630 let anchor = ui::Anchor::Below(ui.rect(numbering).unwrap_or_default());
1571 if let Some(place) = list_gallery(1631 if let Some(place) = list_gallery(
1572 ui,1632 ui,
1573 popup("numbering"),1633 toolbar_popup("numbering"),
1574 anchor,1634 anchor,
1575 ["Recently Used Number Formats", "Numbering Library"],1635 ["Recently Used Number Formats", "Numbering Library"],
1576 (&pens.numbering, NUMBER_LIBRARY.len()),1636 (&pens.numbering, NUMBER_LIBRARY.len()),
...@@ -1613,9 +1673,16 @@ impl State {...@@ -1613,9 +1673,16 @@ impl State {
1613 ) {1673 ) {
1614 choice = Some(Choice::List(place.map(ListStyle::Number)));1674 choice = Some(Choice::List(place.map(ListStyle::Number)));
1615 }1675 }
1616 if ui::shell::tool_button(ui, "clear", art::CLEAR_FORMATTING, text, false).clicked {1676 let clear = Cmd::ClearFormatting;
1617 choice = Some(Choice::Command(Cmd::ClearFormatting));1677 choice = tool(
1618 }1678 ui,
1679 "clear",
1680 art::CLEAR_FORMATTING,
1681 text,
1682 clear,
1683 status_of(clear),
1684 )
1685 .or(choice.take());
1619 });1686 });
1620 row(ui, 1, |ui| {1687 row(ui, 1, |ui| {
1621 for (part, icon, toggle) in [1688 for (part, icon, toggle) in [
...@@ -1624,12 +1691,16 @@ impl State {...@@ -1624,12 +1691,16 @@ impl State {
1624 ("underline", art::UNDERLINE, Toggle::Underline),1691 ("underline", art::UNDERLINE, Toggle::Underline),
1625 ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough),1692 ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough),
1626 ] {1693 ] {
1627 if ui::shell::tool_button(ui, part, icon, text, on(toggle)).clicked {1694 let id = Cmd::Toggle(toggle);
1628 choice = Some(Choice::Command(Cmd::Toggle(toggle)));1695 choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take());
1629 }
1630 }1696 }
1631 let script = ui.id("script");1697 let script = ui.id("script");
1632 let superscript = on(Toggle::Superscript);1698 let superscript = on(Toggle::Superscript);
1699 let command = Cmd::Toggle(if superscript {
1700 Toggle::Superscript
1701 } else {
1702 Toggle::Subscript
1703 });
1633 if ui::shell::split_button(1704 if ui::shell::split_button(
1634 ui,1705 ui,
1635 "script",1706 "script",
...@@ -1640,16 +1711,13 @@ impl State {...@@ -1640,16 +1711,13 @@ impl State {
1640 },1711 },
1641 None,1712 None,
1642 on(Toggle::Subscript) || superscript,1713 on(Toggle::Subscript) || superscript,
1643 popup("script"),1714 toolbar_popup("script"),
1644 )1715 )
1645 .clicked1716 .clicked
1646 {1717 {
1647 choice = Some(Choice::Command(Cmd::Toggle(if superscript {1718 choice = Some(Choice::Command(command));
1648 Toggle::Superscript
1649 } else {
1650 Toggle::Subscript
1651 })));
1652 }1719 }
1720 tip(ui, command);
1653 let scripts = [1721 let scripts = [
1654 (Toggle::Subscript, art::SUBSCRIPT),1722 (Toggle::Subscript, art::SUBSCRIPT),
1655 (Toggle::Superscript, art::SUPERSCRIPT),1723 (Toggle::Superscript, art::SUPERSCRIPT),
...@@ -1667,7 +1735,9 @@ impl State {...@@ -1667,7 +1735,9 @@ impl State {
1667 })1735 })
1668 .collect();1736 .collect();
1669 let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default());1737 let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default());
1670 if let Some(index) = ui::popup::menu(ui, popup("script"), anchor, &items, None) {1738 if let Some(index) =
1739 ui::popup::menu(ui, toolbar_popup("script"), anchor, &items, None)
1740 {
1671 choice = Some(Choice::Command(Cmd::Toggle(scripts[index].0)));1741 choice = Some(Choice::Command(Cmd::Toggle(scripts[index].0)));
1672 }1742 }
1673 // Each button applies its menu's last pick: the highlighter shows it in its1743 // Each button applies its menu's last pick: the highlighter shows it in its
...@@ -1691,19 +1761,22 @@ impl State {...@@ -1691,19 +1761,22 @@ impl State {
1691 ),1761 ),
1692 ] {1762 ] {
1693 let split = ui.id(part);1763 let split = ui.id(part);
1694 let button = ui::shell::split_button(ui, part, icon, bar, false, popup(part));1764 let button =
1765 ui::shell::split_button(ui, part, icon, bar, false, toolbar_popup(part));
1695 let highlight = part == "highlight";1766 let highlight = part == "highlight";
1767 let command = if highlight {
1768 Cmd::Highlight
1769 } else {
1770 Cmd::FontColor
1771 };
1696 if button.clicked {1772 if button.clicked {
1697 choice = Some(Choice::Command(if highlight {1773 choice = Some(Choice::Command(command));
1698 Cmd::Highlight
1699 } else {
1700 Cmd::FontColor
1701 }));
1702 }1774 }
1775 tip(ui, command);
1703 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();1776 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();
1704 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());1777 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());
1705 if let Some(chosen) =1778 if let Some(chosen) =
1706 ui::popup::colors(ui, popup(part), anchor, none, &colors, columns)1779 ui::popup::colors(ui, toolbar_popup(part), anchor, none, &colors, columns)
1707 {1780 {
1708 let color = chosen.and_then(|chosen| {1781 let color = chosen.and_then(|chosen| {
1709 swatches1782 swatches
...@@ -1719,11 +1792,11 @@ impl State {...@@ -1719,11 +1792,11 @@ impl State {
1719 });1792 });
1720 }1793 }
1721 }1794 }
1722 if ui::shell::tool_button(ui, "outdent", art::OUTDENT, text, false).clicked {1795 for (part, icon, id) in [
1723 choice = Some(Choice::Command(Cmd::Outdent));1796 ("outdent", art::OUTDENT, Cmd::Outdent),
1724 }1797 ("indent", art::INDENT, Cmd::Indent),
1725 if ui::shell::tool_button(ui, "indent", art::INDENT, text, false).clicked {1798 ] {
1726 choice = Some(Choice::Command(Cmd::Indent));1799 choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take());
1727 }1800 }
1728 let alignments = [1801 let alignments = [
1729 (Alignment::Left, art::ALIGN_LEFT),1802 (Alignment::Left, art::ALIGN_LEFT),
...@@ -1735,7 +1808,8 @@ impl State {...@@ -1735,7 +1808,8 @@ impl State {
1735 .iter()1808 .iter()
1736 .find(|(alignment, _)| Some(*alignment) == state.alignment)1809 .find(|(alignment, _)| Some(*alignment) == state.alignment)
1737 .unwrap_or(&alignments[0]);1810 .unwrap_or(&alignments[0]);
1738 let anchor = ui::shell::menu_button(ui, "align", current.1, popup("align"));1811 let anchor = ui::shell::menu_button(ui, "align", current.1, toolbar_popup("align"));
1812 tip(ui, Cmd::Align(current.0));
1739 // The current alignment starts highlighted, keeping its icon under the button's.1813 // The current alignment starts highlighted, keeping its icon under the button's.
1740 let items: Vec<_> = alignments1814 let items: Vec<_> = alignments
1741 .iter()1815 .iter()
...@@ -1748,50 +1822,38 @@ impl State {...@@ -1748,50 +1822,38 @@ impl State {
1748 ..Default::default()1822 ..Default::default()
1749 })1823 })
1750 .collect();1824 .collect();
1751 if let Some(index) = ui::popup::menu(ui, popup("align"), anchor, &items, None) {1825 if let Some(index) =
1826 ui::popup::menu(ui, toolbar_popup("align"), anchor, &items, None)
1827 {
1752 choice = Some(Choice::Command(Cmd::Align(alignments[index].0)));1828 choice = Some(Choice::Command(Cmd::Align(alignments[index].0)));
1753 }1829 }
1754 });1830 });
1755 });1831 });
1756 divider(ui, "text", theme);1832 divider(ui, "text", theme);
1757 let tags: [(&[&str], NoteTag); 9] = [1833 let tags = NoteTag::ALL.map(|tag| (tag_icon(tag), tag));
1758 (
1759 tag_sources(TagIcon::CheckBox { checked: false }),
1760 NoteTag::ToDo,
1761 ),
1762 (tag_sources(TagIcon::Star), NoteTag::Important),
1763 (tag_sources(TagIcon::Question), NoteTag::Question),
1764 (art::TAG_REMEMBER, NoteTag::RememberForLater),
1765 (art::TAG_DEFINITION, NoteTag::Definition),
1766 (tag_sources(TagIcon::Highlight), NoteTag::Highlight),
1767 (tag_sources(TagIcon::Contact), NoteTag::Contact),
1768 (tag_sources(TagIcon::Address), NoteTag::Address),
1769 (tag_sources(TagIcon::Phone), NoteTag::PhoneNumber),
1770 ];
1771 let all_tags = &tags;
1772 group(ui, "tags", |ui| {1834 group(ui, "tags", |ui| {
1773 for (index, tags) in tags.chunks(5).enumerate() {1835 // OneNote's first nine, Ctrl+1 to Ctrl+9, then the gallery's arrow.
1836 for (index, shown) in tags[..9].chunks(5).enumerate() {
1774 row(ui, index, |ui| {1837 row(ui, index, |ui| {
1775 for (column, (icon, tag)) in tags.iter().enumerate() {1838 for (column, (icon, tag)) in shown.iter().enumerate() {
1776 let lit = state.tags.contains(tag);1839 let id = Cmd::Tag(*tag);
1777 if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked {1840 choice =
1778 choice = Some(Choice::Command(Cmd::Tag(*tag)));1841 tool(ui, column, icon, [1.0; 4], id, status_of(id)).or(choice.take());
1779 }
1780 }1842 }
1781 if index == 1 {1843 if index == 1 {
1782 let more = ui.id("more");1844 let more = ui.id("more");
1783 let open = ui.popup_open(popup("tags"));1845 let open = ui.popup_open(toolbar_popup("tags"));
1784 if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open)1846 if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open)
1785 .pressed1847 .pressed
1786 {1848 {
1787 ui.open_popup(popup("tags"));1849 ui.open_popup(toolbar_popup("tags"));
1788 }1850 }
1789 let shortcuts: Vec<_> = all_tags1851 let shortcuts: Vec<_> = tags
1790 .iter()1852 .iter()
1791 .map(|(_, tag)| shortcut(Cmd::Tag(*tag)))1853 .map(|(_, tag)| shortcut(Cmd::Tag(*tag)))
1792 .collect();1854 .collect();
1793 let remove = shortcut(Cmd::RemoveTags);1855 let remove = shortcut(Cmd::RemoveTags);
1794 let mut items: Vec<_> = all_tags1856 let mut items: Vec<_> = tags
1795 .iter()1857 .iter()
1796 .zip(&shortcuts)1858 .zip(&shortcuts)
1797 .map(|((icon, tag), shortcut)| ui::popup::Item {1859 .map(|((icon, tag), shortcut)| ui::popup::Item {
...@@ -1802,19 +1864,33 @@ impl State {...@@ -1802,19 +1864,33 @@ impl State {
1802 ..Default::default()1864 ..Default::default()
1803 })1865 })
1804 .collect();1866 .collect();
1805 items.push(ui::popup::Item {1867 // Customize Tags awaits #8 and Find Tags its summary pane, #28.
1806 text: commands::command(Cmd::RemoveTags).title,1868 items.extend([
1807 shortcut: &remove,1869 ui::popup::Item {
1808 separated: true,1870 text: "Customize Tags…",
1809 ..Default::default()1871 separated: true,
1810 });1872 disabled: true,
1873 ..Default::default()
1874 },
1875 ui::popup::Item {
1876 text: commands::command(Cmd::RemoveTags).title,
1877 shortcut: &remove,
1878 ..Default::default()
1879 },
1880 ui::popup::Item {
1881 text: "Find Tags",
1882 icon: Some(art::FIND_TAGS),
1883 separated: true,
1884 disabled: true,
1885 ..Default::default()
1886 },
1887 ]);
1811 let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default());1888 let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default());
1812 if let Some(chosen) =1889 if let Some(chosen) =
1813 ui::popup::menu(ui, popup("tags"), anchor, &items, None)1890 ui::popup::menu(ui, toolbar_popup("tags"), anchor, &items, None)
1814 {1891 {
1815 choice = Some(Choice::Command(1892 choice = Some(Choice::Command(
1816 all_tags1893 tags.get(chosen)
1817 .get(chosen)
1818 .map_or(Cmd::RemoveTags, |(_, tag)| Cmd::Tag(*tag)),1894 .map_or(Cmd::RemoveTags, |(_, tag)| Cmd::Tag(*tag)),
1819 ));1895 ));
1820 }1896 }
...@@ -1824,30 +1900,42 @@ impl State {...@@ -1824,30 +1900,42 @@ impl State {
1824 });1900 });
1825 divider(ui, "tags", theme);1901 divider(ui, "tags", theme);
1826 group(ui, "insert", |ui| {1902 group(ui, "insert", |ui| {
1827 for (index, buttons) in [1903 row(ui, 0, |ui| {
1828 &[1904 if status_of(Cmd::Table).enabled {
1829 ("table", art::TABLE, Cmd::Table),1905 let anchor =
1906 ui::shell::menu_button(ui, "table", art::TABLE, toolbar_popup("table"));
1907 tip(ui, Cmd::Table);
1908 if let Some([columns, rows]) =
1909 ui::popup::table_picker(ui, toolbar_popup("table"), anchor, [10, 8])
1910 {
1911 choice = Some(Choice::Table([rows, columns]));
1912 }
1913 } else {
1914 ui::shell::unavailable(ui, "table", art::TABLE, text, true);
1915 }
1916 for (part, icon, id) in [
1830 ("picture", art::PICTURE, Cmd::Picture),1917 ("picture", art::PICTURE, Cmd::Picture),
1918 ("clipping", art::SCREEN_CLIPPING, Cmd::ScreenClipping),
1831 ("file", art::ATTACHMENT, Cmd::Attachment),1919 ("file", art::ATTACHMENT, Cmd::Attachment),
1832 ("link", art::LINK, Cmd::Link),1920 ("link", art::LINK, Cmd::Link),
1833 ][..],1921 ("space", art::INSERT_SPACE, Cmd::InsertSpace),
1834 &[1922 ] {
1923 choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take());
1924 }
1925 });
1926 row(ui, 1, |ui| {
1927 for (part, icon, id) in [
1835 ("date", art::CALENDAR, Cmd::Date),1928 ("date", art::CALENDAR, Cmd::Date),
1836 ("time", art::CLOCK, Cmd::Time),1929 ("time", art::CLOCK, Cmd::Time),
1930 ("date and time", art::DATE_TIME, Cmd::DateTime),
1837 ("equation", art::EQUATION, Cmd::Equation),1931 ("equation", art::EQUATION, Cmd::Equation),
1838 ],1932 ("symbol", art::SYMBOL, Cmd::Symbol),
1839 ]1933 ("audio", art::RECORD_AUDIO, Cmd::RecordAudio),
1840 .into_iter()1934 ("video", art::RECORD_VIDEO, Cmd::RecordVideo),
1841 .enumerate()1935 ] {
1842 {1936 choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take());
1843 row(ui, index, |ui| {1937 }
1844 for (part, icon, id) in buttons {1938 });
1845 if ui::shell::tool_button(ui, part, icon, text, false).clicked {
1846 choice = Some(Choice::Command(*id));
1847 }
1848 }
1849 });
1850 }
1851 });1939 });
1852 ui.leaf(1940 ui.leaf(
1853 "space",1941 "space",
...@@ -1856,25 +1944,93 @@ impl State {...@@ -1856,25 +1944,93 @@ impl State {
1856 ..Spec::default()1944 ..Spec::default()
1857 },1945 },
1858 );1946 );
1859 if let Some(status) = status {1947 if let Some(session) = status {
1860 ui.leaf(1948 sync::control(ui, session, theme, ui::shell::TOOL);
1861 "status",
1862 Spec {
1863 size: [fit(), px(ui::shell::TOOL)],
1864 text: Some(&status),
1865 color: Some(theme.text_dim),
1866 center: true,
1867 ..Spec::default()
1868 },
1869 );
1870 }1949 }
1871 let zoom = self.view.zoom();1950 let zoom = self.view.zoom();
1872 let label = format!("{:.0}%", zoom * 100.0);1951 let label = format!("{:.0}%", zoom * 100.0);
1873 group(ui, "zoom", |ui| {1952 group(ui, "view", |ui| {
1874 row(ui, 0, |ui| {1953 row(ui, 0, |ui| {
1875 if ui::shell::tool_button(ui, "out", art::ZOOM_OUT, text, false).clicked {1954 if status_of(Cmd::PageColor).enabled {
1876 choice = Some(Choice::Command(Cmd::ZoomOut));1955 let anchor = ui::shell::menu_button(
1956 ui,
1957 "page color",
1958 art::PAGE_COLOR,
1959 toolbar_popup("page color"),
1960 );
1961 tip(ui, Cmd::PageColor);
1962 let swatches: Vec<_> = PAGE_COLORS
1963 .iter()
1964 .map(|(_, color)| colorref(*color))
1965 .collect();
1966 if let Some(chosen) = ui::popup::colors(
1967 ui,
1968 toolbar_popup("page color"),
1969 anchor,
1970 "No Color",
1971 &swatches,
1972 4,
1973 ) {
1974 let index = chosen.and_then(|chosen| {
1975 swatches.iter().position(|swatch| *swatch == chosen)
1976 });
1977 choice = Some(Choice::PageColor(index));
1978 }
1979 } else {
1980 ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true);
1981 }
1982 // Nothing reads or writes rule lines yet.
1983 ui::shell::unavailable(ui, "rule lines", art::RULE_LINES, text, true);
1984 let full = Cmd::FullPageView;
1985 choice = tool(
1986 ui,
1987 "full page",
1988 art::FULL_PAGE_VIEW,
1989 text,
1990 full,
1991 status_of(full),
1992 )
1993 .or(choice.take());
1994 let more = ui.id("more");
1995 let open = ui.popup_open(toolbar_popup("view"));
1996 if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open).pressed {
1997 ui.open_popup(toolbar_popup("view"));
1998 }
1999 let views = [Cmd::Sidebar, Cmd::PageList, Cmd::DarkPages];
2000 let mut items: Vec<_> = views
2001 .iter()
2002 .map(|id| ui::popup::Item {
2003 text: commands::command(*id).title,
2004 checked: status_of(*id).checked,
2005 disabled: !status_of(*id).enabled,
2006 ..Default::default()
2007 })
2008 .collect();
2009 // Spelling awaits a platform spell checker.
2010 items.push(ui::popup::Item {
2011 text: "Spelling",
2012 icon: Some(art::SPELLING),
2013 separated: true,
2014 disabled: true,
2015 ..Default::default()
2016 });
2017 let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default());
2018 if let Some(chosen) =
2019 ui::popup::menu(ui, toolbar_popup("view"), anchor, &items, None)
2020 {
2021 choice = Some(Choice::Command(views[chosen]));
1877 }2022 }
2023 });
2024 row(ui, 1, |ui| {
2025 choice = tool(
2026 ui,
2027 "out",
2028 art::ZOOM_OUT,
2029 text,
2030 Cmd::ZoomOut,
2031 status_of(Cmd::ZoomOut),
2032 )
2033 .or(choice.take());
1878 let level = ui.leaf(2034 let level = ui.leaf(
1879 "level",2035 "level",
1880 Spec {2036 Spec {
...@@ -1890,9 +2046,16 @@ impl State {...@@ -1890,9 +2046,16 @@ impl State {
1890 if level.clicked {2046 if level.clicked {
1891 choice = Some(Choice::Command(Cmd::ActualSize));2047 choice = Some(Choice::Command(Cmd::ActualSize));
1892 }2048 }
1893 if ui::shell::tool_button(ui, "in", art::ZOOM_IN, text, false).clicked {2049 tip(ui, Cmd::ActualSize);
1894 choice = Some(Choice::Command(Cmd::ZoomIn));2050 choice = tool(
1895 }2051 ui,
2052 "in",
2053 art::ZOOM_IN,
2054 text,
2055 Cmd::ZoomIn,
2056 status_of(Cmd::ZoomIn),
2057 )
2058 .or(choice.take());
1896 });2059 });
1897 });2060 });
1898 ui.close();2061 ui.close();
...@@ -1919,6 +2082,7 @@ impl State {...@@ -1919,6 +2082,7 @@ impl State {
1919 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked {2082 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked {
1920 self.commands.push(Command::NewPage { under: None });2083 self.commands.push(Command::NewPage { under: None });
1921 }2084 }
2085 tip(&mut self.ui, commands::Id::NewPage);
1922 let toggle = if self.pages_open {2086 let toggle = if self.pages_open {
1923 art::SIDEBAR_COLLAPSE2087 art::SIDEBAR_COLLAPSE
1924 } else {2088 } else {
...@@ -1927,6 +2091,7 @@ impl State {...@@ -1927,6 +2091,7 @@ impl State {
1927 if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked {2091 if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked {
1928 self.pages_open = !self.pages_open;2092 self.pages_open = !self.pages_open;
1929 }2093 }
2094 tip(&mut self.ui, commands::Id::PageList);
1930 self.ui.close();2095 self.ui.close();
1931 }2096 }
19322097
...@@ -1935,9 +2100,8 @@ impl State {...@@ -1935,9 +2100,8 @@ impl State {
1935 fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option<Id> {2100 fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option<Id> {
1936 let session = self.session.as_ref()?;2101 let session = self.session.as_ref()?;
1937 let panel = self.ui.id("panel");2102 let panel = self.ui.id("panel");
1938 let width = self2103 let open = self.pages_open && !self.full_page;
1939 .ui2104 let width = self.ui.animate(panel, if open { PAGE_LIST } else { 0.0 });
1940 .animate(panel, if self.pages_open { PAGE_LIST } else { 0.0 });
1941 self.ui.open(2105 self.ui.open(
1942 "panel",2106 "panel",
1943 Spec {2107 Spec {
...@@ -2007,7 +2171,16 @@ impl State {...@@ -2007,7 +2171,16 @@ impl State {
2007 if pressed {2171 if pressed {
2008 self.view.pointer_pressed(at)?2172 self.view.pointer_pressed(at)?
2009 } else {2173 } else {
2010 self.view.pointer_released()?2174 let response = self.view.pointer_released()?;
2175 let [anchor, focus] = self.view.editor.selection().positions;
2176 if anchor != focus
2177 && let Some(painted) = self.painter.take()
2178 {
2179 self.respond(response);
2180 self.format(canvas::editor::Formatting::Paint(painted))?;
2181 continue;
2182 }
2183 response
2011 }2184 }
2012 }2185 }
2013 ui::Event::Button {2186 ui::Event::Button {
...@@ -2097,7 +2270,6 @@ impl State {...@@ -2097,7 +2270,6 @@ impl State {
2097 Command::DeleteVersion { page, version } => {2270 Command::DeleteVersion { page, version } => {
2098 let session = self.session.as_mut().ok_or("No section is open")?;2271 let session = self.session.as_mut().ok_or("No section is open")?;
2099 session.section.delete_pages(&[version])?;2272 session.section.delete_pages(&[version])?;
2100 session.status = "Saving";
2101 session.refresh_conflicts()?;2273 session.refresh_conflicts()?;
2102 self.commands.push(Command::OpenPage(page));2274 self.commands.push(Command::OpenPage(page));
2103 }2275 }
...@@ -2550,7 +2722,6 @@ impl State {...@@ -2550,7 +2722,6 @@ impl State {
2550 .collect(),2722 .collect(),
2551 },2723 },
2552 )?;2724 )?;
2553 session.status = "Saving";
2554 // The page list shows the title as it is typed.2725 // The page list shows the title as it is typed.
2555 if self.view.editor.active_outline().title {2726 if self.view.editor.active_outline().title {
2556 session.pages = session.section.pages()?;2727 session.pages = session.section.pages()?;
...@@ -2588,9 +2759,6 @@ impl State {...@@ -2588,9 +2759,6 @@ impl State {
2588 let _ = self.view.editor.take_ops();2759 let _ = self.view.editor.take_ops();
2589 self.view.editor.cancel_composition(&mut self.view.engine)?;2760 self.view.editor.cancel_composition(&mut self.view.engine)?;
2590 platform::clear_marked_text(&self.window);2761 platform::clear_marked_text(&self.window);
2591 if let Some(session) = &mut self.session {
2592 session.status = "Not saving";
2593 }
2594 self.refresh()?;2762 self.refresh()?;
2595 platform::alert(2763 platform::alert(
2596 "Change not saved",2764 "Change not saved",
...@@ -2604,13 +2772,13 @@ impl State {...@@ -2604,13 +2772,13 @@ impl State {
2604 let Some(session) = &mut self.session else {2772 let Some(session) = &mut self.session else {
2605 return Ok(());2773 return Ok(());
2606 };2774 };
2607 let shown = session.status;2775 let shown = sync::label(&session.sync);
2608 let mut listed = false;2776 let mut listed = false;
2609 let mut changed = false;2777 let mut changed = false;
2610 let mut rejected = None;2778 let mut rejected = None;
2611 for event in session.section.events() {2779 for event in session.section.events() {
2612 use notebook::session::Event;2780 use notebook::session::Event;
2613 session.status = match event {2781 match event {
2614 Event::Changed(spaces) => {2782 Event::Changed(spaces) => {
2615 listed = true;2783 listed = true;
2616 changed |= spaces.contains(&session.space);2784 changed |= spaces.contains(&session.space);
...@@ -2619,7 +2787,6 @@ impl State {...@@ -2619,7 +2787,6 @@ impl State {
2619 session.section.replica(),2787 session.section.replica(),
2620 spaces,2788 spaces,
2621 );2789 );
2622 continue;
2623 }2790 }
2624 Event::Rejected { spaces, error } => {2791 Event::Rejected { spaces, error } => {
2625 if spaces.contains(&session.space) {2792 if spaces.contains(&session.space) {
...@@ -2627,21 +2794,13 @@ impl State {...@@ -2627,21 +2794,13 @@ impl State {
2627 } else {2794 } else {
2628 eprintln!("Saving failed: {error}");2795 eprintln!("Saving failed: {error}");
2629 }2796 }
2630 "Not saving"
2631 }
2632 Event::Attempt {
2633 status: notebook::EditStatus::Published { .. },
2634 ..
2635 } => "Saved",
2636 Event::Attempt { .. } => "Saving",
2637 Event::Unreachable(_) => "Offline",
2638 Event::Failed(error) => {
2639 eprintln!("Synchronization stopped: {error}");
2640 "Not saving"
2641 }2797 }
2642 };2798 Event::Failed(error) => eprintln!("Synchronization stopped: {error}"),
2799 Event::Attempt { .. } | Event::Unreachable(_) => {}
2800 }
2643 }2801 }
2644 if listed || changed || shown != session.status {2802 session.sync = session.section.sync_status()?;
2803 if listed || changed || shown != sync::label(&session.sync) {
2645 self.window.request_redraw();2804 self.window.request_redraw();
2646 }2805 }
2647 if listed {2806 if listed {
...@@ -2782,67 +2941,18 @@ impl State {...@@ -2782,67 +2941,18 @@ impl State {
2782 if let Some(path) = self.snapshot.take() {2941 if let Some(path) = self.snapshot.take() {
2783 self.snapshot(&path)?;2942 self.snapshot(&path)?;
2784 }2943 }
2785 if self.occluded || [self.config.width, self.config.height].contains(&0) {2944 if self.occluded {
2786 return Ok(());2945 return Ok(());
2787 }2946 }
2788 let mut reconfigure = false;2947 let Some(frame) = self.surface.frame(&self.renderer)? else {
2789 let frame = match self.surface.get_current_texture() {2948 return Ok(());
2790 wgpu::CurrentSurfaceTexture::Success(frame) => frame,
2791 wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
2792 reconfigure = true;
2793 frame
2794 }
2795 wgpu::CurrentSurfaceTexture::Outdated => {
2796 self.surface.configure(&self.renderer.device, &self.config);
2797 self.window.request_redraw();
2798 return Ok(());
2799 }
2800 wgpu::CurrentSurfaceTexture::Lost => {
2801 self.surface = self.instance.create_surface(self.window.clone())?;
2802 platform::configure_presentation(&self.surface);
2803 self.surface.configure(&self.renderer.device, &self.config);
2804 self.window.request_redraw();
2805 return Ok(());
2806 }
2807 wgpu::CurrentSurfaceTexture::Timeout => {
2808 trace_input(&"Surface timeout");
2809 return Ok(());
2810 }
2811 wgpu::CurrentSurfaceTexture::Occluded => {
2812 trace_input(&"Surface occluded");
2813 return Ok(());
2814 }
2815 wgpu::CurrentSurfaceTexture::Validation => {
2816 return Err("Canvas surface validation failed".into());
2817 }
2818 };2949 };
2819 lap("acquire", start);2950 lap("acquire", start);
2820 let size = [self.config.width, self.config.height];2951 self.paint(&frame.target)?;
2821 let target = self
2822 .translucent
2823 .as_mut()
2824 .map(|translucent| translucent.target(&self.renderer.device, size));
2825 self.paint(
2826 target
2827 .as_ref()
2828 .unwrap_or(&frame.texture.create_view(&Default::default())),
2829 )?;
2830 if let Some(translucent) = &self.translucent {
2831 let window = frame.texture.create_view(&wgpu::TextureViewDescriptor {
2832 format: Some(self.config.format.remove_srgb_suffix()),
2833 ..Default::default()
2834 });
2835 translucent.present(&self.renderer.device, &self.renderer.queue, &window);
2836 }
2837 self.window.pre_present_notify();
2838 let start = Instant::now();2952 let start = Instant::now();
2839 self.renderer.queue.present(frame);2953 self.surface.present(&self.renderer, frame);
2840 platform::commit_presentation(&self.window);
2841 lap("present", start);2954 lap("present", start);
2842 trace_input(&"Present submitted");2955 trace_input(&"Present submitted");
2843 if reconfigure {
2844 self.surface.configure(&self.renderer.device, &self.config);
2845 }
2846 Ok(())2956 Ok(())
2847 }2957 }
28482958
...@@ -2850,7 +2960,7 @@ impl State {...@@ -2850,7 +2960,7 @@ impl State {
2850 /// largest texture, so the surface renders scaled rather than failing.2960 /// largest texture, so the surface renders scaled rather than failing.
2851 fn scale(&self) -> f32 {2961 fn scale(&self) -> f32 {
2852 let size = self.window.inner_size();2962 let size = self.window.inner_size();
2853 let most = self.renderer.device.limits().max_texture_dimension_2d as f32;2963 let most = self.renderer.max_texture_dimension() as f32;
2854 let longest = size.width.max(size.height) as f32;2964 let longest = size.width.max(size.height) as f32;
2855 self.window.scale_factor() as f32 * (most / longest).min(1.0)2965 self.window.scale_factor() as f32 * (most / longest).min(1.0)
2856 }2966 }
...@@ -2865,7 +2975,7 @@ impl State {...@@ -2865,7 +2975,7 @@ impl State {
2865 }2975 }
28662976
2867 /// Paints the interface with the page in its box.2977 /// Paints the interface with the page in its box.
2868 fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box<dyn Error>> {2978 fn paint(&mut self, target: &draw::Target) -> Result<(), Box<dyn Error>> {
2869 let start = Instant::now();2979 let start = Instant::now();
2870 let paper = self.paper();2980 let paper = self.paper();
2871 self.view.update_pictures(paper, &self.redraw);2981 self.view.update_pictures(paper, &self.redraw);
...@@ -2904,12 +3014,7 @@ impl State {...@@ -2904,12 +3014,7 @@ impl State {
2904 trace_input(&("Draw", viewport.origin, viewport.scale));3014 trace_input(&("Draw", viewport.origin, viewport.scale));
2905 let start = Instant::now();3015 let start = Instant::now();
2906 self.renderer3016 self.renderer
2907 .draw(3017 .draw(target, self.surface.size, self.ui.theme.strip, &layers)
2908 target,
2909 [self.config.width, self.config.height],
2910 self.ui.theme.strip,
2911 &layers,
2912 )
2913 .map_err(|error| format!("Canvas drawing failed: {error:?}"))?;3018 .map_err(|error| format!("Canvas drawing failed: {error:?}"))?;
2914 lap("render", start);3019 lap("render", start);
2915 Ok(())3020 Ok(())
...@@ -2917,68 +3022,10 @@ impl State {...@@ -2917,68 +3022,10 @@ impl State {
29173022
2918 /// Writes the frame to a PNG, so a covered window can be reviewed.3023 /// Writes the frame to a PNG, so a covered window can be reviewed.
2919 fn snapshot(&mut self, path: &Path) -> Result<(), Box<dyn Error>> {3024 fn snapshot(&mut self, path: &Path) -> Result<(), Box<dyn Error>> {
2920 let size = [self.config.width, self.config.height];3025 let size = self.surface.size;
2921 let device = &self.renderer.device;3026 let offscreen = self.surface.offscreen(&self.renderer)?;
2922 let texture = device.create_texture(&wgpu::TextureDescriptor {3027 self.paint(&offscreen.target)?;
2923 label: Some("Snapshot"),3028 let pixels = self.surface.read(&self.renderer, offscreen)?;
2924 size: wgpu::Extent3d {
2925 width: size[0],
2926 height: size[1],
2927 depth_or_array_layers: 1,
2928 },
2929 mip_level_count: 1,
2930 sample_count: 1,
2931 dimension: wgpu::TextureDimension::D2,
2932 format: self.config.format,
2933 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2934 view_formats: &[],
2935 });
2936 let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
2937 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
2938 label: Some("Snapshot readback"),
2939 size: u64::from(row) * u64::from(size[1]),
2940 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2941 mapped_at_creation: false,
2942 });
2943 self.paint(&texture.create_view(&Default::default()))?;
2944 let mut encoder = self
2945 .renderer
2946 .device
2947 .create_command_encoder(&Default::default());
2948 encoder.copy_texture_to_buffer(
2949 texture.as_image_copy(),
2950 wgpu::TexelCopyBufferInfo {
2951 buffer: &buffer,
2952 layout: wgpu::TexelCopyBufferLayout {
2953 offset: 0,
2954 bytes_per_row: Some(row),
2955 rows_per_image: Some(size[1]),
2956 },
2957 },
2958 texture.size(),
2959 );
2960 self.renderer.queue.submit([encoder.finish()]);
2961 buffer.map_async(wgpu::MapMode::Read, .., |_| {});
2962 self.renderer.device.poll(wgpu::PollType::Wait {
2963 submission_index: None,
2964 timeout: Some(std::time::Duration::from_secs(5)),
2965 })?;
2966 let bgra = matches!(
2967 self.config.format,
2968 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
2969 );
2970 let pixels: Vec<u8> = buffer
2971 .get_mapped_range(..)?
2972 .chunks_exact(row as usize)
2973 .flat_map(|line| line[..size[0] as usize * 4].chunks_exact(4))
2974 .flat_map(|pixel| {
2975 if bgra {
2976 [pixel[2], pixel[1], pixel[0], pixel[3]]
2977 } else {
2978 [pixel[0], pixel[1], pixel[2], pixel[3]]
2979 }
2980 })
2981 .collect();
2982 let partial = path.with_extension("partial");3029 let partial = path.with_extension("partial");
2983 let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]);3030 let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]);
2984 encoder.set_color(png::ColorType::Rgba);3031 encoder.set_color(png::ColorType::Rgba);
...@@ -3062,12 +3109,13 @@ impl State {...@@ -3062,12 +3109,13 @@ impl State {
3062}3109}
30633110
3064/// The interface's colours in `appearance`, on white pages when `light_pages`, and over the3111/// The interface's colours in `appearance`, on white pages when `light_pages`, and over the
3065/// system's material where it shows through as a `backdrop`.3112/// system's material where it shows through as a `backdrop`; menus as the desktop's.
3066fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> Theme {3113fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> Theme {
3067 let mut theme = match appearance {3114 let mut theme = match appearance {
3068 winit::window::Theme::Dark => Theme::dark(),3115 winit::window::Theme::Dark => Theme::dark(),
3069 winit::window::Theme::Light => Theme::light(),3116 winit::window::Theme::Light => Theme::light(),
3070 };3117 };
3118 theme.desktop_menu = platform::menu(appearance);
3071 if light_pages {3119 if light_pages {
3072 let light = Theme::light();3120 let light = Theme::light();
3073 [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink];3121 [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink];
...@@ -3667,6 +3715,47 @@ fn row(ui: &mut Ui, part: usize, buttons: impl FnOnce(&mut Ui)) {...@@ -3667,6 +3715,47 @@ fn row(ui: &mut Ui, part: usize, buttons: impl FnOnce(&mut Ui)) {
3667 ui.close();3715 ui.close();
3668}3716}
36693717
3718/// A tooltip naming command `id` and its chord on the box built last. It names the command,
3719/// not a dialog it opens, so it leaves off the menu's ellipsis.
3720/// A tool button for command `id`, lit while `status` checks it and faded while it does not
3721/// apply; returns the command when clicked.
3722fn tool(
3723 ui: &mut Ui,
3724 part: impl std::hash::Hash,
3725 icon: &'static [&'static str],
3726 tint: [f32; 4],
3727 id: commands::Id,
3728 status: commands::Status,
3729) -> Option<commands::Choice> {
3730 if !status.enabled {
3731 ui::shell::unavailable(ui, part, icon, tint, false);
3732 return None;
3733 }
3734 let clicked = ui::shell::tool_button(ui, part, icon, tint, status.checked).clicked;
3735 tip(ui, id);
3736 clicked.then_some(commands::Choice::Command(id))
3737}
3738
3739/// The artwork the toolbar shows for `tag`.
3740fn tag_icon(tag: canvas::editor::NoteTag) -> &'static [&'static str] {
3741 use canvas::editor::NoteTag;
3742 match tag {
3743 NoteTag::RememberForLater => art::TAG_REMEMBER,
3744 NoteTag::Definition => art::TAG_DEFINITION,
3745 tag => tag_sources(TagIcon::of(tag.shape(), false).unwrap_or(TagIcon::Other)),
3746 }
3747}
3748
3749/// The toolbar's popup `name`, which the menu bar opens too.
3750fn toolbar_popup(name: &str) -> Id {
3751 Id::ROOT.child(("popup", name))
3752}
3753
3754fn tip(ui: &mut Ui, id: commands::Id) {
3755 let title = commands::command(id).title.trim_end_matches('…');
3756 ui::popup::tooltip(ui, title, &commands::shortcut(id), None);
3757}
3758
3670/// The line between two toolbar groups.3759/// The line between two toolbar groups.
3671fn divider(ui: &mut Ui, part: &str, theme: &Theme) {3760fn divider(ui: &mut Ui, part: &str, theme: &Theme) {
3672 ui.leaf(3761 ui.leaf(
...@@ -3701,6 +3790,7 @@ fn read_session(...@@ -3701,6 +3790,7 @@ fn read_session(
3701 let page = section.page(space)?;3790 let page = section.page(space)?;
3702 let conflicts = section.conflicts()?;3791 let conflicts = section.conflicts()?;
3703 let history = section.versions()?;3792 let history = section.versions()?;
3793 let sync = section.sync_status()?;
3704 Ok((3794 Ok((
3705 Session {3795 Session {
3706 section,3796 section,
...@@ -3709,7 +3799,7 @@ fn read_session(...@@ -3709,7 +3799,7 @@ fn read_session(
3709 tab,3799 tab,
3710 pages,3800 pages,
3711 space,3801 space,
3712 status: "",3802 sync,
3713 conflicts,3803 conflicts,
3714 shown: None,3804 shown: None,
3715 change: None,3805 change: None,
...@@ -3792,6 +3882,15 @@ impl App {...@@ -3792,6 +3882,15 @@ impl App {
3792impl ApplicationHandler<UserEvent> for App {3882impl ApplicationHandler<UserEvent> for App {
3793 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {3883 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
3794 let event = match event {3884 let event = match event {
3885 UserEvent::Picture(bytes) => {
3886 if let Some(state) = &mut self.state {
3887 if let Err(error) = state.insert_picture(bytes) {
3888 eprintln!("{error}");
3889 }
3890 state.window.request_redraw();
3891 }
3892 return;
3893 }
3795 UserEvent::InsertText(text) => {3894 UserEvent::InsertText(text) => {
3796 if let Some(state) = &mut self.state {3895 if let Some(state) = &mut self.state {
3797 if state.ui.focused() == Some(page()) {3896 if state.ui.focused() == Some(page()) {
...@@ -3943,11 +4042,9 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3943,11 +4042,9 @@ impl ApplicationHandler<UserEvent> for App {
3943 WindowEvent::Resized(size) => {4042 WindowEvent::Resized(size) => {
3944 if size.width > 0 && size.height > 0 {4043 if size.width > 0 && size.height > 0 {
3945 let ratio = state.scale() / scale;4044 let ratio = state.scale() / scale;
3946 state.config.width = (size.width as f32 * ratio) as u32;4045 state.surface.size =
3947 state.config.height = (size.height as f32 * ratio) as u32;4046 [size.width, size.height].map(|side| (side as f32 * ratio) as u32);
3948 state4047 state.surface.configure(&state.renderer);
3949 .surface
3950 .configure(&state.renderer.device, &state.config);
3951 }4048 }
3952 // Present inside AppKit's resize transaction; a redraw on the next turn4049 // Present inside AppKit's resize transaction; a redraw on the next turn
3953 // lets the window show the previous frame at the new size.4050 // lets the window show the previous frame at the new size.
crates/snowbound/src/manage.rs+17-4
...@@ -116,7 +116,6 @@ impl State {...@@ -116,7 +116,6 @@ impl State {
116 ops,116 ops,
117 },117 },
118 )?;118 )?;
119 session.status = "Saving";
120 self.title_focus = Some(space);119 self.title_focus = Some(space);
121 self.commands.push(Command::OpenPage(space));120 self.commands.push(Command::OpenPage(space));
122 Ok(())121 Ok(())
...@@ -130,11 +129,26 @@ impl State {...@@ -130,11 +129,26 @@ impl State {
130 ) -> Result<(), Box<dyn Error>> {129 ) -> Result<(), Box<dyn Error>> {
131 self.persist()?;130 self.persist()?;
132 let session = self.session.as_ref().ok_or("No section is open")?;131 let session = self.session.as_ref().ok_or("No section is open")?;
133 let space = session.space;132 let ops = template_ops(&session.section.page(session.space)?, choice)?;
134 let ops = template_ops(&session.section.page(space)?, choice)?;133 self.edit_page(ops)
134 }
135
136 /// View, Page Color: gives the open page `color`, a COLORREF, or none; its art stays.
137 pub(crate) fn color_page(&mut self, color: Option<u32>) -> Result<(), Box<dyn Error>> {
138 if self.view.editor.page_color() == color {
139 return Ok(());
140 }
141 self.persist()?;
142 self.edit_page(vec![PageOp::Color(color)])
143 }
144
145 /// Applies `ops` to the open page as one edit and shows the result.
146 fn edit_page(&mut self, ops: Vec<PageOp>) -> Result<(), Box<dyn Error>> {
135 if ops.is_empty() {147 if ops.is_empty() {
136 return Ok(());148 return Ok(());
137 }149 }
150 let session = self.session.as_ref().ok_or("No section is open")?;
151 let space = session.space;
138 session.section.apply(152 session.section.apply(
139 &self.author,153 &self.author,
140 Edit {154 Edit {
...@@ -410,7 +424,6 @@ impl State {...@@ -410,7 +424,6 @@ impl State {
410 ops: vec![Op::Section(SectionOp::Pages(edits))],424 ops: vec![Op::Section(SectionOp::Pages(edits))],
411 },425 },
412 )?;426 )?;
413 session.status = "Saving";
414 session.pages = session.section.pages()?;427 session.pages = session.section.pages()?;
415 Ok(())428 Ok(())
416 }429 }
crates/snowbound/src/menubar.rs+32
...@@ -74,6 +74,7 @@ const MENUS: &[Item] = &[...@@ -74,6 +74,7 @@ const MENUS: &[Item] = &[
74 C(Id::Cut),74 C(Id::Cut),
75 C(Id::Copy),75 C(Id::Copy),
76 C(Id::Paste),76 C(Id::Paste),
77 C(Id::FormatPainter),
77 C(Id::SelectAll),78 C(Id::SelectAll),
78 S,79 S,
79 C(Id::Find),80 C(Id::Find),
...@@ -98,8 +99,11 @@ const MENUS: &[Item] = &[...@@ -98,8 +99,11 @@ const MENUS: &[Item] = &[
98 C(Id::ZoomOut),99 C(Id::ZoomOut),
99 C(Id::ActualSize),100 C(Id::ActualSize),
100 S,101 S,
102 C(Id::FullPageView),
101 C(Id::Sidebar),103 C(Id::Sidebar),
102 C(Id::PageList),104 C(Id::PageList),
105 S,
106 C(Id::PageColor),
103 C(Id::DarkPages),107 C(Id::DarkPages),
104 ],108 ],
105 ),109 ),
...@@ -108,8 +112,11 @@ const MENUS: &[Item] = &[...@@ -108,8 +112,11 @@ const MENUS: &[Item] = &[
108 &[112 &[
109 C(Id::Table),113 C(Id::Table),
110 C(Id::Picture),114 C(Id::Picture),
115 C(Id::ScreenClipping),
111 C(Id::Link),116 C(Id::Link),
117 S,
112 C(Id::Equation),118 C(Id::Equation),
119 C(Id::Symbol),
113 S,120 S,
114 C(Id::Date),121 C(Id::Date),
115 C(Id::Time),122 C(Id::Time),
...@@ -158,6 +165,31 @@ const MENUS: &[Item] = &[...@@ -158,6 +165,31 @@ const MENUS: &[Item] = &[
158 C(Id::Tag(NoteTag::Contact)),165 C(Id::Tag(NoteTag::Contact)),
159 C(Id::Tag(NoteTag::Address)),166 C(Id::Tag(NoteTag::Address)),
160 C(Id::Tag(NoteTag::PhoneNumber)),167 C(Id::Tag(NoteTag::PhoneNumber)),
168 Item::Menu(
169 "More Tags",
170 &[
171 C(Id::Tag(NoteTag::WebSiteToVisit)),
172 C(Id::Tag(NoteTag::Idea)),
173 C(Id::Tag(NoteTag::Password)),
174 C(Id::Tag(NoteTag::Critical)),
175 C(Id::Tag(NoteTag::ProjectA)),
176 C(Id::Tag(NoteTag::ProjectB)),
177 C(Id::Tag(NoteTag::MovieToSee)),
178 C(Id::Tag(NoteTag::BookToRead)),
179 C(Id::Tag(NoteTag::MusicToListenTo)),
180 C(Id::Tag(NoteTag::SourceForArticle)),
181 C(Id::Tag(NoteTag::RememberForBlog)),
182 C(Id::Tag(NoteTag::DiscussWithPersonA)),
183 C(Id::Tag(NoteTag::DiscussWithPersonB)),
184 C(Id::Tag(NoteTag::DiscussWithManager)),
185 C(Id::Tag(NoteTag::SendInEmail)),
186 C(Id::Tag(NoteTag::ScheduleMeeting)),
187 C(Id::Tag(NoteTag::CallBack)),
188 C(Id::Tag(NoteTag::ToDoPriority1)),
189 C(Id::Tag(NoteTag::ToDoPriority2)),
190 C(Id::Tag(NoteTag::ClientRequest)),
191 ],
192 ),
161 S,193 S,
162 C(Id::RemoveTags),194 C(Id::RemoveTags),
163 ],195 ],
crates/snowbound/src/rename.rs-1
...@@ -193,7 +193,6 @@ impl State {...@@ -193,7 +193,6 @@ impl State {
193 }],193 }],
194 },194 },
195 )?;195 )?;
196 session.status = "Saving";
197 session.pages = session.section.pages()?;196 session.pages = session.section.pages()?;
198 self.edited(vec![space]);197 self.edited(vec![space]);
199 self.refresh()198 self.refresh()
crates/snowbound/src/screenshot.rs+2-2
...@@ -28,9 +28,9 @@ impl State {...@@ -28,9 +28,9 @@ impl State {
28 .ok_or("SNOWBOUND_SCREENSHOT_SIZE must be WIDTHxHEIGHT in points.")?,28 .ok_or("SNOWBOUND_SCREENSHOT_SIZE must be WIDTHxHEIGHT in points.")?,
29 Err(_) => SIZE,29 Err(_) => SIZE,
30 };30 };
31 let most = self.renderer.device.limits().max_texture_dimension_2d as f32;31 let most = self.renderer.max_texture_dimension() as f32;
32 let scale = SCALE.min(most / size[0].max(size[1]));32 let scale = SCALE.min(most / size[0].max(size[1]));
33 [self.config.width, self.config.height] = size.map(|side| (side * scale).max(1.0) as u32);33 self.surface.size = size.map(|side| (side * scale).max(1.0) as u32);
34 self.renderer.clear_glyph_cache();34 self.renderer.clear_glyph_cache();
35 self.app_icon = platform::app_icon((16.0 * scale) as u32);35 self.app_icon = platform::app_icon((16.0 * scale) as u32);
36 let response = self.view.scale_factor_changed(scale)?;36 let response = self.view.scale_factor_changed(scale)?;
crates/snowbound/src/search.rs+2-4
...@@ -788,9 +788,6 @@ impl State {...@@ -788,9 +788,6 @@ impl State {
788 let [left, top, right, bottom] = rect;788 let [left, top, right, bottom] = rect;
789 let anchor = Anchor::Over([left - 4.0, top - 4.0, right + 4.0, bottom + 4.0]);789 let anchor = Anchor::Over([left - 4.0, top - 4.0, right + 4.0, bottom + 4.0]);
790 let width = RESULTS.max(right - left);790 let width = RESULTS.max(right - left);
791 // The box takes the toolbar's box's place at once and widens as the dropdown comes in.
792 let open = self.ui.opening(results(), anchor).unwrap_or(1.0);
793 let field_width = right - left + (width - 8.0 - (right - left)) * open;
794 self.ui.open_as(791 self.ui.open_as(
795 results(),792 results(),
796 Spec {793 Spec {
...@@ -809,8 +806,9 @@ impl State {...@@ -809,8 +806,9 @@ impl State {
809 self.ui.open(806 self.ui.open(
810 "box",807 "box",
811 Spec {808 Spec {
809 // Takes the toolbar's box's place at once, widening with the dropdown.
812 flags: Flags::STILL,810 flags: Flags::STILL,
813 size: [px(field_width), px(ui::shell::TOOL)],811 size: [fill(), px(ui::shell::TOOL)],
814 fill: Some(theme.base),812 fill: Some(theme.base),
815 border: Some(theme.accent),813 border: Some(theme.accent),
816 radius: 4.0,814 radius: 4.0,
crates/snowbound/src/sidebar.rs+6-1
...@@ -616,7 +616,11 @@ impl crate::State {...@@ -616,7 +616,11 @@ impl crate::State {
616 }616 }
617 let width = self.ui.animate(617 let width = self.ui.animate(
618 self.ui.id("sidebar"),618 self.ui.id("sidebar"),
619 if self.sidebar { WIDTH } else { 0.0 },619 if self.sidebar && !self.full_page {
620 WIDTH
621 } else {
622 0.0
623 },
620 );624 );
621 self.ui.open(625 self.ui.open(
622 "sidebar",626 "sidebar",
...@@ -735,6 +739,7 @@ impl crate::State {...@@ -735,6 +739,7 @@ impl crate::State {
735 self.sidebar = !self.sidebar;739 self.sidebar = !self.sidebar;
736 self.save_settings();740 self.save_settings();
737 }741 }
742 crate::tip(&mut self.ui, crate::commands::Id::Sidebar);
738 self.ui.close();743 self.ui.close();
739 }744 }
740745
crates/snowbound/src/surface.rs created+221
...@@ -0,0 +1,221 @@
1//! Frames reach the window through wgpu.
2
3use crate::{platform, trace_input};
4use draw::Renderer;
5use std::{error::Error, sync::Arc};
6use winit::window::Window;
7
8pub struct Surface {
9 /// Device pixels frames and snapshots are drawn at; `configure` gives the window it.
10 pub size: [u32; 2],
11 window: Arc<Window>,
12 instance: wgpu::Instance,
13 surface: wgpu::Surface<'static>,
14 config: wgpu::SurfaceConfiguration,
15 /// Carries frames to the window where the system's backdrop shows through the strip.
16 translucent: Option<draw::Translucent>,
17}
18
19pub struct Frame {
20 pub target: wgpu::TextureView,
21 texture: wgpu::SurfaceTexture,
22 reconfigure: bool,
23}
24
25pub struct Offscreen {
26 pub target: wgpu::TextureView,
27 texture: wgpu::Texture,
28}
29
30impl Surface {
31 /// With `backdrop`, frames leave the system's material showing through their
32 /// transparent pixels.
33 pub async fn new(
34 window: Arc<Window>,
35 backdrop: bool,
36 ) -> Result<(Self, Renderer), Box<dyn Error>> {
37 let instance = wgpu::Instance::new(
38 wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())),
39 );
40 let surface = instance.create_surface(window.clone())?;
41 platform::configure_presentation(&surface);
42 let adapter = instance
43 .request_adapter(&wgpu::RequestAdapterOptions {
44 compatible_surface: Some(&surface),
45 ..Default::default()
46 })
47 .await?;
48 let (device, queue) = adapter.request_device(&Default::default()).await?;
49 let size = window.inner_size();
50 let mut config = surface
51 .get_default_config(&adapter, size.width, size.height)
52 .ok_or("No supported canvas surface")?;
53 let translucent = backdrop.then(|| {
54 // What wgpu calls post-multiplied is Core Animation's non-opaque layer, which
55 // composites colour premultiplied in sRGB, as `Translucent` leaves it.
56 config.alpha_mode = wgpu::CompositeAlphaMode::PostMultiplied;
57 let window_format = config.format.remove_srgb_suffix();
58 config.view_formats.push(window_format);
59 draw::Translucent::new(&device, config.format, window_format)
60 });
61 surface.configure(&device, &config);
62 eprintln!("Canvas GPU: {:?}", adapter.get_info());
63 let renderer = Renderer::new(device, queue, config.format);
64 Ok((
65 Self {
66 size: [config.width, config.height],
67 window,
68 instance,
69 surface,
70 config,
71 translucent,
72 },
73 renderer,
74 ))
75 }
76
77 /// Whether frames leave the system's backdrop showing.
78 pub fn translucent(&self) -> bool {
79 self.translucent.is_some()
80 }
81
82 pub fn configure(&mut self, renderer: &Renderer) {
83 [self.config.width, self.config.height] = self.size;
84 self.surface.configure(&renderer.device, &self.config);
85 }
86
87 /// The window's next frame, or none to skip this one.
88 pub fn frame(&mut self, renderer: &Renderer) -> Result<Option<Frame>, Box<dyn Error>> {
89 if self.size.contains(&0) {
90 return Ok(None);
91 }
92 let (texture, reconfigure) = match self.surface.get_current_texture() {
93 wgpu::CurrentSurfaceTexture::Success(texture) => (texture, false),
94 wgpu::CurrentSurfaceTexture::Suboptimal(texture) => (texture, true),
95 wgpu::CurrentSurfaceTexture::Outdated => {
96 self.configure(renderer);
97 self.window.request_redraw();
98 return Ok(None);
99 }
100 wgpu::CurrentSurfaceTexture::Lost => {
101 self.surface = self.instance.create_surface(self.window.clone())?;
102 platform::configure_presentation(&self.surface);
103 self.configure(renderer);
104 self.window.request_redraw();
105 return Ok(None);
106 }
107 wgpu::CurrentSurfaceTexture::Timeout => {
108 trace_input(&"Surface timeout");
109 return Ok(None);
110 }
111 wgpu::CurrentSurfaceTexture::Occluded => {
112 trace_input(&"Surface occluded");
113 return Ok(None);
114 }
115 wgpu::CurrentSurfaceTexture::Validation => {
116 return Err("Canvas surface validation failed".into());
117 }
118 };
119 let target = match &mut self.translucent {
120 Some(translucent) => translucent.target(&renderer.device, self.size),
121 None => texture.texture.create_view(&Default::default()),
122 };
123 Ok(Some(Frame {
124 target,
125 texture,
126 reconfigure,
127 }))
128 }
129
130 pub fn present(&mut self, renderer: &Renderer, frame: Frame) {
131 if let Some(translucent) = &self.translucent {
132 let window = frame
133 .texture
134 .texture
135 .create_view(&wgpu::TextureViewDescriptor {
136 format: Some(self.config.format.remove_srgb_suffix()),
137 ..Default::default()
138 });
139 translucent.present(&renderer.device, &renderer.queue, &window);
140 }
141 self.window.pre_present_notify();
142 renderer.queue.present(frame.texture);
143 platform::commit_presentation(&self.window);
144 if frame.reconfigure {
145 self.configure(renderer);
146 }
147 }
148
149 /// A target the size of the window's frames that `read` reads back.
150 pub fn offscreen(&self, renderer: &Renderer) -> Result<Offscreen, Box<dyn Error>> {
151 let texture = renderer.device.create_texture(&wgpu::TextureDescriptor {
152 label: Some("Snapshot"),
153 size: wgpu::Extent3d {
154 width: self.size[0],
155 height: self.size[1],
156 depth_or_array_layers: 1,
157 },
158 mip_level_count: 1,
159 sample_count: 1,
160 dimension: wgpu::TextureDimension::D2,
161 format: self.config.format,
162 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
163 view_formats: &[],
164 });
165 Ok(Offscreen {
166 target: texture.create_view(&Default::default()),
167 texture,
168 })
169 }
170
171 /// The offscreen target's sRGB RGBA rows, top first.
172 pub fn read(
173 &self,
174 renderer: &Renderer,
175 offscreen: Offscreen,
176 ) -> Result<Vec<u8>, Box<dyn Error>> {
177 let size = self.size;
178 let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
179 let buffer = renderer.device.create_buffer(&wgpu::BufferDescriptor {
180 label: Some("Snapshot readback"),
181 size: u64::from(row) * u64::from(size[1]),
182 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
183 mapped_at_creation: false,
184 });
185 let mut encoder = renderer.device.create_command_encoder(&Default::default());
186 encoder.copy_texture_to_buffer(
187 offscreen.texture.as_image_copy(),
188 wgpu::TexelCopyBufferInfo {
189 buffer: &buffer,
190 layout: wgpu::TexelCopyBufferLayout {
191 offset: 0,
192 bytes_per_row: Some(row),
193 rows_per_image: Some(size[1]),
194 },
195 },
196 offscreen.texture.size(),
197 );
198 renderer.queue.submit([encoder.finish()]);
199 buffer.map_async(wgpu::MapMode::Read, .., |_| {});
200 renderer.device.poll(wgpu::PollType::Wait {
201 submission_index: None,
202 timeout: Some(std::time::Duration::from_secs(5)),
203 })?;
204 let bgra = matches!(
205 self.config.format,
206 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
207 );
208 Ok(buffer
209 .get_mapped_range(..)?
210 .chunks_exact(row as usize)
211 .flat_map(|line| line[..size[0] as usize * 4].chunks_exact(4))
212 .flat_map(|pixel| {
213 if bgra {
214 [pixel[2], pixel[1], pixel[0], pixel[3]]
215 } else {
216 [pixel[0], pixel[1], pixel[2], pixel[3]]
217 }
218 })
219 .collect())
220 }
221}
crates/snowbound/src/surface_gl.rs created+114
...@@ -0,0 +1,114 @@
1//! Frames reach the window through an OpenGL context on its view, where wgpu has no
2//! backend: Mac OS X 10.6. Frames draw into an sRGB target and are copied to the view.
3
4use draw::{Renderer, Target};
5use objc2::{
6 ffi::NSInteger,
7 msg_send, msg_send_id,
8 rc::{Allocated, Retained},
9 runtime::{AnyClass, AnyObject},
10};
11use std::{error::Error, sync::Arc};
12use winit::{
13 raw_window_handle::{HasWindowHandle, RawWindowHandle},
14 window::Window,
15};
16
17pub struct Surface {
18 /// Device pixels frames and snapshots are drawn at; `configure` gives the window it.
19 pub size: [u32; 2],
20 context: Retained<AnyObject>,
21 /// The frame target, kept between frames.
22 target: Option<Target>,
23}
24
25pub struct Frame {
26 pub target: Target,
27}
28
29pub type Offscreen = Frame;
30
31impl Surface {
32 /// No backdrop shows through an OpenGL view.
33 pub async fn new(
34 window: Arc<Window>,
35 _backdrop: bool,
36 ) -> Result<(Self, Renderer), Box<dyn Error>> {
37 let RawWindowHandle::AppKit(handle) = window.window_handle()?.as_raw() else {
38 unreachable!()
39 };
40 // NSOpenGLPFADoubleBuffer, NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize 24,
41 // NSOpenGLPFAAlphaSize 8.
42 let attributes: [u32; 7] = [5, 73, 8, 24, 11, 8, 0];
43 let context = unsafe {
44 let class = |name| AnyClass::get(name).expect("AppKit is linked");
45 let format: Allocated<AnyObject> = msg_send_id![class("NSOpenGLPixelFormat"), alloc];
46 let format: Option<Retained<AnyObject>> =
47 msg_send_id![format, initWithAttributes: attributes.as_ptr()];
48 let format = format.ok_or("No accelerated OpenGL pixel format")?;
49 let context: Allocated<AnyObject> = msg_send_id![class("NSOpenGLContext"), alloc];
50 let context: Option<Retained<AnyObject>> = msg_send_id![
51 context,
52 initWithFormat: &*format,
53 shareContext: std::ptr::null::<AnyObject>()
54 ];
55 let context = context.ok_or("No OpenGL context")?;
56 let view = handle.ns_view.as_ptr().cast::<AnyObject>();
57 let _: () = msg_send![&context, setView: view];
58 let _: () = msg_send![&context, makeCurrentContext];
59 // NSOpenGLCPSwapInterval: present on the display's refresh.
60 let interval: i32 = 1;
61 let _: () = msg_send![&context, setValues: &interval, forParameter: 222 as NSInteger];
62 context
63 };
64 let renderer = Renderer::new()?;
65 eprintln!("Canvas GPU: OpenGL");
66 let size = window.inner_size();
67 Ok((
68 Self {
69 size: [size.width, size.height],
70 context,
71 target: None,
72 },
73 renderer,
74 ))
75 }
76
77 pub fn translucent(&self) -> bool {
78 false
79 }
80
81 pub fn configure(&mut self, _: &Renderer) {
82 let _: () = unsafe { msg_send![&self.context, update] };
83 }
84
85 /// The window's next frame, or none to skip this one.
86 pub fn frame(&mut self, _: &Renderer) -> Result<Option<Frame>, Box<dyn Error>> {
87 if self.size.contains(&0) {
88 return Ok(None);
89 }
90 let target = match self.target.take() {
91 Some(target) if target.size() == self.size => target,
92 _ => Target::new(self.size)?,
93 };
94 Ok(Some(Frame { target }))
95 }
96
97 pub fn present(&mut self, _: &Renderer, frame: Frame) {
98 frame.target.present();
99 let _: () = unsafe { msg_send![&self.context, flushBuffer] };
100 self.target = Some(frame.target);
101 }
102
103 /// A target the size of the window's frames that `read` reads back.
104 pub fn offscreen(&self, _: &Renderer) -> Result<Offscreen, Box<dyn Error>> {
105 Ok(Frame {
106 target: Target::new(self.size)?,
107 })
108 }
109
110 /// The offscreen target's sRGB RGBA rows, top first.
111 pub fn read(&self, _: &Renderer, offscreen: Offscreen) -> Result<Vec<u8>, Box<dyn Error>> {
112 Ok(offscreen.target.read_pixels())
113 }
114}
crates/snowbound/src/sync.rs created+224
...@@ -0,0 +1,224 @@
1//! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook
2//! Synchronization for the open section.
3
4use crate::{Session, State, filetime, library, platform};
5use notebook::session::SyncStatus;
6use std::{error::Error, io::ErrorKind};
7use ui::{Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px};
8
9const WIDTH: f32 = 380.0;
10
11fn id() -> Id {
12 Id::ROOT.child("sync-status")
13}
14
15fn button() -> Id {
16 Id::ROOT.child("sync-button")
17}
18
19/// The status's label, and what the reader can do about an error.
20fn describe(sync: &SyncStatus) -> (&'static str, Option<&'static str>) {
21 use ErrorKind::*;
22 if library::offline() {
23 return (
24 "Working offline",
25 Some("Changes stay on this computer until you sync."),
26 );
27 }
28 match sync.error.as_ref().map(std::io::Error::kind) {
29 Some(PermissionDenied | ReadOnlyFilesystem) => (
30 "Unable to sync",
31 Some(
32 "You can’t change this notebook where it’s stored. Changes stay on this computer.",
33 ),
34 ),
35 Some(WouldBlock | ResourceBusy) => (
36 "Section in use",
37 Some("Someone else is saving this section. Sync continues when they finish."),
38 ),
39 Some(
40 NotFound | ConnectionRefused | ConnectionReset | ConnectionAborted | NotConnected
41 | TimedOut | HostUnreachable | NetworkUnreachable | NetworkDown | BrokenPipe
42 | AddrNotAvailable,
43 ) => (
44 "Not connected",
45 Some("Changes stay on this computer and sync when the notebook is back."),
46 ),
47 Some(_) => ("Unable to sync", None),
48 None if sync.synced.is_none() || sync.queued > 0 => ("Syncing…", None),
49 None => ("Up to date", None),
50 }
51}
52
53pub(crate) fn label(sync: &SyncStatus) -> &'static str {
54 describe(sync).0
55}
56
57/// A FILETIME as OneNote's "Last sync": the time, and the date too before today.
58fn when(time: u64) -> String {
59 let [_, clock] = platform::date_text(time);
60 let day = platform::short_date(time);
61 if day == platform::short_date(filetime()) {
62 clock
63 } else {
64 format!("{day} {clock}")
65 }
66}
67
68/// The status beside the window's controls, which opens the popup.
69pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, height: f32) {
70 let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline();
71 ui.open_as(
72 button(),
73 Spec {
74 flags: Flags::CLICKABLE,
75 size: [fit(), px(height)],
76 text: Some(label(&session.sync)),
77 color: Some(if strong { theme.text } else { theme.text_dim }),
78 hover_fill: Some(theme.hover()),
79 radius: 4.0,
80 pad: [8.0, 0.0],
81 center: true,
82 ..Spec::default()
83 },
84 );
85 ui.close();
86 if ui.signal(button()).clicked {
87 if ui.popup_open(id()) {
88 ui.close_popup(id());
89 } else {
90 ui.open_popup(id());
91 }
92 }
93}
94
95impl State {
96 /// Builds the sync status popup while it is open.
97 pub(crate) fn sync_popup(&mut self) -> Result<(), Box<dyn Error>> {
98 let ui = &mut self.ui;
99 let Some(session) = &mut self.session else {
100 ui.close_popup(id());
101 return Ok(());
102 };
103 if !ui.popup_open(id()) {
104 return Ok(());
105 }
106 // Read afresh while shown, so the last sync time follows each poll.
107 session.sync = session.section.sync_status()?;
108 let theme = ui.theme.clone();
109 let offline = library::offline();
110 let (progress, advice) = describe(&session.sync);
111 ui.open_as(
112 id(),
113 Spec {
114 axis: Axis::Y,
115 size: [px(WIDTH), children()],
116 fill: Some(theme.popup),
117 border: Some(theme.chip),
118 shadow: Some(theme.shadow),
119 radius: 8.0,
120 pad: [14.0, 10.0],
121 gap: 2.0,
122 anchor: Some(Anchor::Below(ui.rect(button()).unwrap_or_default())),
123 ..Spec::default()
124 },
125 );
126 let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4], bold: bool| {
127 ui.leaf(
128 part,
129 Spec {
130 size: [fill(), fit()],
131 text: Some(text),
132 color: Some(color),
133 bold,
134 overflow: Overflow::Wrap,
135 pad: [0.0, 3.0],
136 ..Spec::default()
137 },
138 );
139 };
140 let row = |ui: &mut Ui, key: &str, value: &str| {
141 ui.open(
142 key,
143 Spec {
144 size: [fill(), children()],
145 gap: 8.0,
146 ..Spec::default()
147 },
148 );
149 ui.leaf(
150 "key",
151 Spec {
152 size: [px(92.0), px(theme.font_size * 1.6)],
153 text: Some(key),
154 color: Some(theme.text_dim),
155 ..Spec::default()
156 },
157 );
158 ui.leaf(
159 "value",
160 Spec {
161 size: [fill(), px(theme.font_size * 1.6)],
162 text: Some(value),
163 overflow: Overflow::Ellipsis,
164 ..Spec::default()
165 },
166 );
167 ui.close();
168 };
169 text(ui, "title", "Notebook Sync Status", theme.text, true);
170 row(ui, "Notebook", &session.library.name);
171 row(ui, "Location", &session.library.location);
172 row(ui, "Connection", &session.library.transport());
173 row(ui, "Section", &session.tabs[session.tab].name);
174 row(ui, "Progress", progress);
175 if let Some(synced) = session.sync.synced {
176 row(ui, "Last sync", &when(synced));
177 }
178 if session.sync.queued > 0 {
179 let queued = match session.sync.queued {
180 1 => "1 change".to_owned(),
181 count => format!("{count} changes"),
182 };
183 row(ui, "Not yet synced", &queued);
184 }
185 if let Some(advice) = advice {
186 text(ui, "advice", advice, theme.text, false);
187 }
188 if let Some(error) = &session.sync.error {
189 text(ui, "error", &error.to_string(), theme.text_dim, false);
190 }
191 if let Some(notice) = &session.library.notice {
192 let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}");
193 text(ui, "notice", &notice, theme.text_dim, false);
194 }
195 ui.open(
196 "controls",
197 Spec {
198 size: [fill(), children()],
199 pad: [0.0, 6.0],
200 gap: 8.0,
201 ..Spec::default()
202 },
203 );
204 let toggled = ui::check_box(ui, "offline", "Work offline", offline).clicked;
205 ui.leaf(
206 "space",
207 Spec {
208 size: [fill(), px(1.0)],
209 ..Spec::default()
210 },
211 );
212 let now = ui::button(ui, "sync-now", "Sync Now").clicked;
213 ui.close();
214 ui.close();
215 if toggled {
216 library::set_offline(!offline);
217 session.section.set_offline(!offline);
218 }
219 if now {
220 session.section.wake();
221 }
222 Ok(())
223 }
224}
crates/ui/Cargo.toml+2-1
...@@ -5,12 +5,13 @@ edition = "2024"...@@ -5,12 +5,13 @@ edition = "2024"
5publish = false5publish = false
66
7[dependencies]7[dependencies]
8draw = { path = "../draw" }8draw = { path = "../draw", default-features = false, features = ["render"] }
9nucleo-matcher = "0.3.1"9nucleo-matcher = "0.3.1"
10parley.workspace = true10parley.workspace = true
11winit = "0.30.13"11winit = "0.30.13"
1212
13[dev-dependencies]13[dev-dependencies]
14draw = { path = "../draw", features = ["wgpu"] }
14pollster = "0.4"15pollster = "0.4"
15png = "0.18"16png = "0.18"
16wgpu.workspace = true17wgpu.workspace = true
crates/ui/src/layout.rs+14-3
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1use crate::{Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, text::Texts};1use crate::{Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, text::Texts};
2use std::collections::HashMap;2use std::collections::HashMap;
33
4/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from4/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from
...@@ -19,7 +19,13 @@ pub(crate) fn solve(...@@ -19,7 +19,13 @@ pub(crate) fn solve(
19 }19 }
20 for node in nodes.iter_mut() {20 for node in nodes.iter_mut() {
21 node.computed[axis] = match node.size[axis].size {21 node.computed[axis] = match node.size[axis].size {
22 Size::Pixels(pixels) => pixels,22 Size::Pixels(pixels) => match node.anchor {
23 Some(Anchor::Over(rect)) if axis == 0 => {
24 let from = rect[2] - rect[0];
25 from + (pixels - from) * node.open
26 }
27 _ => pixels,
28 },
23 Size::Text => {29 Size::Text => {
24 let content = if axis == 0 {30 let content = if axis == 0 {
25 node.content_width()31 node.content_width()
...@@ -89,7 +95,12 @@ pub(crate) fn solve(...@@ -89,7 +95,12 @@ pub(crate) fn solve(
89 let mut cursor = nodes[index].pad[axis];95 let mut cursor = nodes[index].pad[axis];
90 for child in nodes[index].children.clone() {96 for child in nodes[index].children.clone() {
91 nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor {97 nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor {
92 anchor.place(axis, nodes[child].computed[axis], window)98 let shown = nodes[child].computed[axis];
99 let size = match nodes[child].size[axis].size {
100 Size::Pixels(pixels) => pixels,
101 _ => shown,
102 };
103 anchor.place(axis, size, shown, window)
93 } else if nodes[child].flags.contains(Flags::FLOAT) {104 } else if nodes[child].flags.contains(Flags::FLOAT) {
94 nodes[child].position[axis]105 nodes[child].position[axis]
95 } else if along(&nodes[index], axis) {106 } else if along(&nodes[index], axis) {
crates/ui/src/lib.rs+152-36
...@@ -12,7 +12,7 @@ mod theme;...@@ -12,7 +12,7 @@ mod theme;
12mod widgets;12mod widgets;
1313
14pub use list::{List, Row, Rows, list};14pub use list::{List, Row, Rows, list};
15pub use theme::{Section, Shades, Theme};15pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme};
16pub use widgets::{button, check_box, edit_key, edit_modifiers, scrollbar, text_field};16pub use widgets::{button, check_box, edit_key, edit_modifiers, scrollbar, text_field};
1717
18use draw::{18use draw::{
...@@ -46,6 +46,12 @@ const ICON_GAP: f32 = 6.0;...@@ -46,6 +46,12 @@ const ICON_GAP: f32 = 6.0;
46/// Seconds a dialog and other popups take to open and to close.46/// Seconds a dialog and other popups take to open and to close.
47const DIALOG: [f32; 2] = [0.24, 0.16];47const DIALOG: [f32; 2] = [0.24, 0.16];
48const POPUP: [f32; 2] = [0.16, 0.12];48const POPUP: [f32; 2] = [0.16, 0.12];
49/// Seconds the pointer rests on a control before its tooltip shows, as Windows' tooltips
50/// wait a double-click interval; after one shows, others show at once for `TIP_WARM`
51/// seconds, and one showing cold fades in over `TIP_FADE`.
52const TIP_DELAY: f32 = 0.5;
53const TIP_WARM: f32 = 0.5;
54const TIP_FADE: f32 = 0.1;
4955
50/// A box's identity across frames: its parent's id combined with a builder-chosen part.56/// A box's identity across frames: its parent's id combined with a builder-chosen part.
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]57#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
...@@ -200,8 +206,9 @@ pub enum Anchor {...@@ -200,8 +206,9 @@ pub enum Anchor {
200206
201impl Anchor {207impl Anchor {
202 /// Where a popup `size` long on `axis` starts in a window `room` long: past the208 /// Where a popup `size` long on `axis` starts in a window `room` long: past the
203 /// anchor on the axis it opens along, level with it otherwise.209 /// anchor on the axis it opens along, level with it otherwise. Shown only `shown` long
204 fn place(self, axis: usize, size: f32, room: f32) -> f32 {210 /// as it opens, it keeps the edge it would have at full size.
211 fn place(self, axis: usize, size: f32, shown: f32, room: f32) -> f32 {
205 let (rect, along) = match self {212 let (rect, along) = match self {
206 Anchor::Below(rect) => (rect, Some(1)),213 Anchor::Below(rect) => (rect, Some(1)),
207 Anchor::Right(rect) => (rect, Some(0)),214 Anchor::Right(rect) => (rect, Some(0)),
...@@ -218,15 +225,16 @@ impl Anchor {...@@ -218,15 +225,16 @@ impl Anchor {
218 if first + size <= room {225 if first + size <= room {
219 first226 first
220 } else if second >= 0.0 {227 } else if second >= 0.0 {
221 second228 second + size - shown
222 } else {229 } else {
223 first.min(room - size).max(0.0)230 first.min(room - size).max(0.0) + size - shown
224 }231 }
225 }232 }
226233
227 /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: it234 /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: it
228 /// grows out of the anchor as it fades in, and a dialog swings up into place.235 /// grows out of the anchor as it fades in where it `grows`, and a dialog swings up into
229 fn motion(self, rect: [f32; 4], open: f32) -> Motion {236 /// place.
237 fn motion(self, rect: [f32; 4], open: f32, grows: bool) -> Motion {
230 let pivot = match self {238 let pivot = match self {
231 Anchor::Below(anchor) => [anchor[0], anchor[3]],239 Anchor::Below(anchor) => [anchor[0], anchor[3]],
232 Anchor::Right(anchor) => [anchor[2], anchor[1]],240 Anchor::Right(anchor) => [anchor[2], anchor[1]],
...@@ -235,8 +243,11 @@ impl Anchor {...@@ -235,8 +243,11 @@ impl Anchor {
235 Anchor::Dialog => [(rect[0] + rect[2]) / 2.0, rect[1]],243 Anchor::Dialog => [(rect[0] + rect[2]) / 2.0, rect[1]],
236 };244 };
237 let (from, tilt) = match self {245 let (from, tilt) = match self {
246 _ if !grows => (1.0, 0.0),
238 Anchor::Dialog => (0.95, 0.4),247 Anchor::Dialog => (0.95, 0.4),
239 Anchor::Over(_) => (0.94, 0.25),248 // Over a box, the popup widens out of it in layout instead of growing.
249 Anchor::Over(_) => (1.0, 0.2),
250 Anchor::Point(_) => (0.94, 0.2),
240 _ => (0.94, 0.0),251 _ => (0.94, 0.0),
241 };252 };
242 Motion {253 Motion {
...@@ -290,7 +301,8 @@ pub struct Spec<'a> {...@@ -290,7 +301,8 @@ pub struct Spec<'a> {
290 pub fill: Option<[f32; 4]>,301 pub fill: Option<[f32; 4]>,
291 /// The fill's colour at the bottom, fading from `fill` at the top.302 /// The fill's colour at the bottom, fading from `fill` at the top.
292 pub gradient: Option<[f32; 4]>,303 pub gradient: Option<[f32; 4]>,
293 /// A soft shadow of the box's outline, painted beneath it.304 /// A soft shadow of the box's outline, painted beneath it; a popup other than a dialog
305 /// casts the theme's menu shadows instead.
294 pub shadow: Option<[f32; 4]>,306 pub shadow: Option<[f32; 4]>,
295 /// How far the box draws inside its leading, top, trailing and bottom edges: fill,307 /// How far the box draws inside its leading, top, trailing and bottom edges: fill,
296 /// border, shadow, icon and label; the pointer still finds the whole box.308 /// border, shadow, icon and label; the pointer still finds the whole box.
...@@ -445,6 +457,8 @@ struct Built {...@@ -445,6 +457,8 @@ struct Built {
445 offset: [f32; 2],457 offset: [f32; 2],
446 cursor: Option<CursorIcon>,458 cursor: Option<CursorIcon>,
447 anchor: Option<Anchor>,459 anchor: Option<Anchor>,
460 /// How far open a popup over a box is, as it widens out of the box.
461 open: f32,
448 motion: Option<Motion>,462 motion: Option<Motion>,
449 /// Rectangles relative to the box, painted over its fill.463 /// Rectangles relative to the box, painted over its fill.
450 marks: Vec<([f32; 4], [f32; 4], f32)>,464 marks: Vec<([f32; 4], [f32; 4], f32)>,
...@@ -489,6 +503,15 @@ struct Popup {...@@ -489,6 +503,15 @@ struct Popup {
489 opened: Instant,503 opened: Instant,
490}504}
491505
506/// The tooltip of the box under the pointer, the last frame it was built, and when it
507/// shows: never while None, after a press or the wheel, until the pointer leaves the box.
508#[derive(Clone, Copy)]
509struct Tip {
510 id: Id,
511 frame: u64,
512 due: Option<Instant>,
513}
514
492/// A popup's painting as it last showed, fading out since it closed.515/// A popup's painting as it last showed, fading out since it closed.
493struct Closing {516struct Closing {
494 id: Id,517 id: Id,
...@@ -591,6 +614,9 @@ pub struct Ui {...@@ -591,6 +614,9 @@ pub struct Ui {
591 /// `popups_painted`.614 /// `popups_painted`.
592 painted: Vec<(Id, Anchor, [f32; 4], std::ops::Range<usize>)>,615 painted: Vec<(Id, Anchor, [f32; 4], std::ops::Range<usize>)>,
593 closing: Vec<Closing>,616 closing: Vec<Closing>,
617 tip: Option<Tip>,
618 /// When a tooltip last showed.
619 warm: Option<Instant>,
594 animating: bool,620 animating: bool,
595 /// This frame routed input, whose effects the builder may only have seen after boxes621 /// This frame routed input, whose effects the builder may only have seen after boxes
596 /// built before it read their state; one more frame shows them.622 /// built before it read their state; one more frame shows them.
...@@ -633,6 +659,8 @@ impl Ui {...@@ -633,6 +659,8 @@ impl Ui {
633 popups_painted: 0,659 popups_painted: 0,
634 painted: Vec::new(),660 painted: Vec::new(),
635 closing: Vec::new(),661 closing: Vec::new(),
662 tip: None,
663 warm: None,
636 animating: false,664 animating: false,
637 routed: false,665 routed: false,
638 caret: None,666 caret: None,
...@@ -742,6 +770,12 @@ impl Ui {...@@ -742,6 +770,12 @@ impl Ui {
742 }770 }
743771
744 fn route(&mut self, event: Event) {772 fn route(&mut self, event: Event) {
773 if matches!(event, Event::Button { pressed: true, .. } | Event::Wheel(_)) {
774 if let Some(tip) = &mut self.tip {
775 tip.due = None;
776 }
777 self.warm = None;
778 }
745 match event {779 match event {
746 Event::PointerMoved(point) => {780 Event::PointerMoved(point) => {
747 self.pointer = Some(point);781 self.pointer = Some(point);
...@@ -997,10 +1031,18 @@ impl Ui {...@@ -997,10 +1031,18 @@ impl Ui {
997 } else {1031 } else {
998 *self.stack.last().unwrap()1032 *self.stack.last().unwrap()
999 };1033 };
1034 let open = match spec.anchor {
1035 Some(anchor @ Anchor::Over(_)) if self.popup_motion(anchor) == PopupMotion::Grow => {
1036 self.opening(id, anchor).unwrap_or(1.0)
1037 }
1038 _ => 1.0,
1039 };
1000 let index = self.nodes.len();1040 let index = self.nodes.len();
1001 self.states.entry(id).or_default().touched = self.frame;1041 self.states.entry(id).or_default().touched = self.frame;
1002 self.nodes1042 self.nodes.push(Built {
1003 .push(Built::new(id, parent, spec, label, self.theme.text));1043 open,
1044 ..Built::new(id, parent, spec, label, self.theme.text)
1045 });
1004 self.nodes[parent].children.push(index);1046 self.nodes[parent].children.push(index);
1005 self.stack.push(index);1047 self.stack.push(index);
1006 id1048 id
...@@ -1116,6 +1158,13 @@ impl Ui {...@@ -1116,6 +1158,13 @@ impl Ui {
1116 {1158 {
1117 self.close_from(gone);1159 self.close_from(gone);
1118 }1160 }
1161 if let Some(tip) = self.tip.filter(|tip| tip.frame != self.frame) {
1162 if tip.due.is_some_and(|due| due <= self.now) {
1163 self.warm = Some(self.now);
1164 }
1165 self.tip = None;
1166 }
1167 let tip = self.tip.map(|tip| tip.id.child("tooltip"));
1119 let last = std::mem::take(&mut self.display);1168 let last = std::mem::take(&mut self.display);
1120 let last_popups = self.popups_painted;1169 let last_popups = self.popups_painted;
1121 self.hits.clear();1170 self.hits.clear();
...@@ -1129,9 +1178,13 @@ impl Ui {...@@ -1129,9 +1178,13 @@ impl Ui {
1129 continue;1178 continue;
1130 };1179 };
1131 let (id, rect) = (node.id, node.rect);1180 let (id, rect) = (node.id, node.rect);
1181 if Some(id) == tip {
1182 continue;
1183 }
1132 if let Some(open) = self.opening(id, anchor) {1184 if let Some(open) = self.opening(id, anchor) {
1133 self.scrim(anchor, open);1185 self.scrim(anchor, open);
1134 self.nodes[index].motion = Some(anchor.motion(rect, open));1186 let grows = self.popup_motion(anchor) == PopupMotion::Grow;
1187 self.nodes[index].motion = Some(anchor.motion(rect, open, grows));
1135 }1188 }
1136 let from = self.display.len() - self.popups_painted;1189 let from = self.display.len() - self.popups_painted;
1137 self.paint(index, None, None);1190 self.paint(index, None, None);
...@@ -1167,23 +1220,47 @@ impl Ui {...@@ -1167,23 +1220,47 @@ impl Ui {
1167 closed: self.now,1220 closed: self.now,
1168 });1221 });
1169 }1222 }
1170 let closing = std::mem::take(&mut self.closing);1223 for closing in std::mem::take(&mut self.closing) {
1171 for closing in &closing {1224 let motion = self.popup_motion(closing.anchor);
1172 let shut = self.progress(closing.closed, closing.anchor.durations()[1]);1225 let open = match motion {
1173 let open = (1.0 - shut).powi(3);1226 PopupMotion::Grow => {
1227 (1.0 - self.progress(closing.closed, closing.anchor.durations()[1])).powi(3)
1228 }
1229 PopupMotion::Cut => 0.0,
1230 PopupMotion::Fade([_, close]) => {
1231 (1.0 - self.progress(closing.closed, close)).powi(4)
1232 }
1233 };
1234 if open == 0.0 {
1235 continue;
1236 }
1174 self.scrim(closing.anchor, open);1237 self.scrim(closing.anchor, open);
1175 let motion = closing.anchor.motion(closing.rect, open);1238 let grows = motion == PopupMotion::Grow;
1239 let motion = closing.anchor.motion(closing.rect, open, grows);
1176 self.display.push(Display::Motion(Some(motion)));1240 self.display.push(Display::Motion(Some(motion)));
1177 self.display.extend(closing.display.iter().cloned());1241 self.display.extend(closing.display.iter().cloned());
1178 self.display.push(Display::Motion(None));1242 self.display.push(Display::Motion(None));
1243 if !self.painted.iter().any(|(shown, ..)| *shown == closing.id) {
1244 self.closing.push(closing);
1245 }
1246 }
1247 // Over everything, closing popups included; it goes at once, without closing.
1248 if let Some(index) = self.nodes[0]
1249 .children
1250 .iter()
1251 .copied()
1252 .find(|index| Some(self.nodes[*index].id) == tip)
1253 && let Some(due) = self.tip.and_then(|tip| tip.due)
1254 {
1255 let opacity = self.progress(due, TIP_FADE);
1256 self.nodes[index].motion = Some(Motion {
1257 zoom: 1.0,
1258 pivot: [0.0; 2],
1259 tilt: 0.0,
1260 opacity,
1261 });
1262 self.paint(index, None, None);
1179 }1263 }
1180 self.closing = closing
1181 .into_iter()
1182 .filter(|closing| {
1183 self.progress(closing.closed, closing.anchor.durations()[1]) < 1.0
1184 && !self.painted.iter().any(|(shown, ..)| *shown == closing.id)
1185 })
1186 .collect();
1187 self.modal = if self.popups.is_empty() { 0 } else { beneath };1264 self.modal = if self.popups.is_empty() { 0 } else { beneath };
1188 for id in [&mut self.hover, &mut self.active, &mut self.focus] {1265 for id in [&mut self.hover, &mut self.active, &mut self.focus] {
1189 if id.is_some_and(|id| !self.states.contains_key(&id)) {1266 if id.is_some_and(|id| !self.states.contains_key(&id)) {
...@@ -1195,7 +1272,20 @@ impl Ui {...@@ -1195,7 +1272,20 @@ impl Ui {
1195 /// How far open popup `id` beside `anchor` shows this frame, from 0 to 1, while open.1272 /// How far open popup `id` beside `anchor` shows this frame, from 0 to 1, while open.
1196 pub fn opening(&mut self, id: Id, anchor: Anchor) -> Option<f32> {1273 pub fn opening(&mut self, id: Id, anchor: Anchor) -> Option<f32> {
1197 let opened = self.popups.iter().find(|popup| popup.id == id)?.opened;1274 let opened = self.popups.iter().find(|popup| popup.id == id)?.opened;
1198 Some(1.0 - (1.0 - self.progress(opened, anchor.durations()[0])).powi(3))1275 Some(match self.popup_motion(anchor) {
1276 PopupMotion::Grow => 1.0 - (1.0 - self.progress(opened, anchor.durations()[0])).powi(3),
1277 PopupMotion::Cut => 1.0,
1278 PopupMotion::Fade([open, _]) => self.progress(opened, open),
1279 })
1280 }
1281
1282 /// How popups beside `anchor` open and close; dialogs always swing as Snowbound's own.
1283 fn popup_motion(&self, anchor: Anchor) -> PopupMotion {
1284 if anchor == Anchor::Dialog {
1285 PopupMotion::Grow
1286 } else {
1287 self.theme.menu().motion
1288 }
1199 }1289 }
12001290
1201 /// How far through an animation `duration` seconds long that began at `start` this1291 /// How far through an animation `duration` seconds long that began at `start` this
...@@ -1255,18 +1345,43 @@ impl Ui {...@@ -1255,18 +1345,43 @@ impl Ui {
1255 rect[3] - bottom,1345 rect[3] - bottom,
1256 ];1346 ];
1257 let size = [painted[2] - painted[0], painted[3] - painted[1]];1347 let size = [painted[2] - painted[0], painted[3] - painted[1]];
1258 if let Some(shadow) = node.shadow {1348 if let Some(color) = node.shadow {
1259 let [spread, drop] = if node.anchor.is_some() {1349 let (own, menu);
1260 POPUP_SHADOW1350 let shadows: &[Shadow] = match node.anchor {
1261 } else {1351 Some(Anchor::Dialog) | None => {
1262 SHADOW1352 let [blur, drop] = if node.anchor.is_some() {
1353 POPUP_SHADOW
1354 } else {
1355 SHADOW
1356 };
1357 own = [Shadow {
1358 color,
1359 blur,
1360 drop,
1361 spread: 0.0,
1362 }];
1363 &own
1364 }
1365 Some(_) => {
1366 menu = self.theme.menu();
1367 &menu.shadows
1368 }
1263 };1369 };
1264 self.display.push(Display::Path {1370 for &Shadow {
1265 data: outline(node.shape, size, node.radius),1371 color,
1266 origin: [painted[0], painted[1] + drop],1372 blur,
1267 style: PathStyle::Shadow(spread),1373 drop,
1268 colors: [shadow; 2],1374 spread,
1269 });1375 } in shadows
1376 {
1377 let grown = size.map(|side| side + 2.0 * spread);
1378 self.display.push(Display::Path {
1379 data: outline(node.shape, grown, node.radius + spread),
1380 origin: [painted[0] - spread, painted[1] + drop - spread],
1381 style: PathStyle::Shadow(blur),
1382 colors: [color; 2],
1383 });
1384 }
1270 }1385 }
1271 if node.shape == Shape::Rounded {1386 if node.shape == Shape::Rounded {
1272 if fill.is_some() || border.is_some() {1387 if fill.is_some() || border.is_some() {
...@@ -1581,6 +1696,7 @@ impl Built {...@@ -1581,6 +1696,7 @@ impl Built {
1581 offset: spec.offset,1696 offset: spec.offset,
1582 cursor: spec.cursor,1697 cursor: spec.cursor,
1583 anchor: spec.anchor,1698 anchor: spec.anchor,
1699 open: 1.0,
1584 motion: None,1700 motion: None,
1585 marks: Vec::new(),1701 marks: Vec::new(),
1586 computed: [0.0; 2],1702 computed: [0.0; 2],
crates/ui/src/popup.rs+290-60
...@@ -3,32 +3,28 @@...@@ -3,32 +3,28 @@
3//! frame it is, closing the popup.3//! frame it is, closing the popup.
44
5use crate::{5use crate::{
6 Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Popup, Row, Rows, Spec, Ui, children,6 Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Menu, Overflow, Popup, Row, Rows, Spec,
7 fill, fit, list::GUTTER, mix, px, text_field,7 TIP_DELAY, TIP_FADE, TIP_WARM, Tip, Ui, children, fill, fit, list::GUTTER, mix, px, text_field,
8};8};
9use nucleo_matcher::{9use nucleo_matcher::{
10 Config, Matcher, Utf32Str,10 Config, Matcher, Utf32Str,
11 pattern::{AtomKind, CaseMatching, Normalization, Pattern},11 pattern::{AtomKind, CaseMatching, Normalization, Pattern},
12};12};
13use std::cell::RefCell;13use std::{cell::RefCell, time::Duration};
14use winit::keyboard::{Key, NamedKey};14use winit::keyboard::{Key, NamedKey};
1515
16pub(crate) const CHECK: &[&str] = &[include_str!("../assets/check.svg")];16pub(crate) const CHECK: &[&str] = &[include_str!("../assets/check.svg")];
17/// Inset of a popup's contents, and its distance from what it drops down from.17/// Inset of a popup's contents, and its distance from what it drops down from.
18const PAD: f32 = 4.0;18pub(crate) const PAD: f32 = 4.0;
19/// A palette's rows and filter field, and a menu's more compact rows.19/// A palette's rows and filter field, and Snowbound's more compact menu rows.
20const ROW: f32 = 26.0;20const ROW: f32 = 26.0;
21pub(crate) const MENU_ROW: f32 = 22.0;21pub(crate) const MENU_ROW: f32 = 22.0;
22/// Inset of a menu row's icon and text.
23const ROW_PAD: f32 = 8.0;
24/// How far a menu's icons sit inside its leading edge.
25pub(crate) const ICON_INSET: f32 = PAD + ROW_PAD;
26/// Rows a list shows before it scrolls.22/// Rows a list shows before it scrolls.
27const ROWS: f32 = 12.0;23const ROWS: f32 = 12.0;
28/// Height of the rule between groups.
29const RULE: f32 = 9.0;
30const NARROWEST: f32 = 140.0;24const NARROWEST: f32 = 140.0;
31const PALETTE: f32 = 560.0;25const PALETTE: f32 = 560.0;
26/// Where a tooltip's description wraps.
27const TIP_WIDTH: f32 = 280.0;
32/// A colour grid's cell, around its swatch.28/// A colour grid's cell, around its swatch.
33const CELL: f32 = 22.0;29const CELL: f32 = 22.0;
34/// Seconds a popup takes to ease to the height of its results.30/// Seconds a popup takes to ease to the height of its results.
...@@ -83,10 +79,16 @@ pub fn menu(...@@ -83,10 +79,16 @@ pub fn menu(
83 if !ui.popup_open(id) {79 if !ui.popup_open(id) {
84 return None;80 return None;
85 }81 }
82 let style = ui.theme.menu();
83 let mut measure = |text| {
84 ui.texts
85 .label(text, style.font_size, false, None, ui.frame)
86 .size[0]
87 };
86 let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| {88 let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| {
87 [89 [
88 text.max(ui.measure(item.text)[0]),90 text.max(measure(item.text)),
89 shortcut.max(ui.measure(item.shortcut)[0]),91 shortcut.max(measure(item.shortcut)),
90 ]92 ]
91 });93 });
92 let icons = items.iter().any(|item| item.icon.is_some() || item.checked);94 let icons = items.iter().any(|item| item.icon.is_some() || item.checked);
...@@ -101,10 +103,10 @@ pub fn menu(...@@ -101,10 +103,10 @@ pub fn menu(
101 } else {103 } else {
102 0.0104 0.0
103 }105 }
104 + 2.0 * (PAD + 8.0);106 + 2.0 * (style.pad + style.row_pad);
105 let least = match anchor {107 let least = match anchor {
106 Anchor::Below(rect) => rect[2] - rect[0],108 Anchor::Below(rect) => rect[2] - rect[0],
107 Anchor::Over(rect) => rect[2] - rect[0] + 2.0 * PAD,109 Anchor::Over(rect) => rect[2] - rect[0] + 2.0 * style.pad,
108 _ => 0.0,110 _ => 0.0,
109 };111 };
110 choose(112 choose(
...@@ -114,10 +116,98 @@ pub fn menu(...@@ -114,10 +116,98 @@ pub fn menu(
114 items,116 items,
115 filter,117 filter,
116 width.max(least).max(NARROWEST),118 width.max(least).max(NARROWEST),
117 MENU_ROW,119 style.row,
118 )120 )
119}121}
120122
123/// Shows `title`, with the `keys` that run it and a `description` under it, in a tooltip
124/// below the box built last while the pointer rests on it or on a box inside it: after a
125/// delay, or at once while another has just shown. A press or the wheel hides it until the
126/// pointer leaves.
127pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) {
128 let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else {
129 return;
130 };
131 // The boxes built since are the box's own.
132 let hovered = ui
133 .hover
134 .is_some_and(|hover| ui.nodes[index..].iter().any(|node| node.id == hover));
135 if !hovered || ui.active.is_some() {
136 return;
137 }
138 let id = ui.nodes[index].id;
139 let now = ui.now;
140 let showing = ui
141 .tip
142 .is_some_and(|tip| tip.due.is_some_and(|due| due <= now));
143 let warm = showing
144 || ui
145 .warm
146 .is_some_and(|warm| now.saturating_duration_since(warm).as_secs_f32() < TIP_WARM);
147 let due = match ui.tip {
148 Some(tip) if tip.id == id => tip.due,
149 // Shown at once, already faded in.
150 _ if warm => Some(
151 now.checked_sub(Duration::from_secs_f32(TIP_FADE))
152 .unwrap_or(now),
153 ),
154 _ => Some(now + Duration::from_secs_f32(TIP_DELAY)),
155 };
156 ui.tip = Some(Tip {
157 id,
158 frame: ui.frame,
159 due,
160 });
161 let Some(due) = due else {
162 return;
163 };
164 if now < due {
165 ui.wake = Some(ui.wake.map_or(due, |wake| wake.min(due)));
166 return;
167 }
168 let title = if keys.is_empty() {
169 title.to_owned()
170 } else {
171 format!("{title} ({keys})")
172 };
173 let [left, top, right, bottom] = ui.rect(id).unwrap_or_default();
174 let theme = &ui.theme;
175 let spec = Spec {
176 axis: Axis::Y,
177 fill: Some(theme.popup),
178 border: Some(theme.chip),
179 shadow: Some(theme.shadow),
180 radius: 4.0,
181 pad: [8.0, 5.0],
182 gap: 3.0,
183 anchor: Some(Anchor::Below([left, top, right, bottom + PAD])),
184 ..Spec::default()
185 };
186 ui.open_as(id.child("tooltip"), spec);
187 ui.leaf(
188 "title",
189 Spec {
190 size: [fit(), fit()],
191 text: Some(&title),
192 bold: true,
193 ..Spec::default()
194 },
195 );
196 if let Some(description) = description {
197 let width = ui.measure(description)[0].min(TIP_WIDTH);
198 ui.leaf(
199 "description",
200 Spec {
201 size: [px(width), fit()],
202 text: Some(description),
203 overflow: Overflow::Wrap,
204 ..Spec::default()
205 },
206 );
207 }
208 ui.close();
209}
210
121/// Builds popup `id` as a command palette across the top of the window while it is open,211/// Builds popup `id` as a command palette across the top of the window while it is open,
122/// listing `commands` that match what is typed, most relevant first. Returns the index of212/// listing `commands` that match what is typed, most relevant first. Returns the index of
123/// the command chosen.213/// the command chosen.
...@@ -157,19 +247,11 @@ fn choose(...@@ -157,19 +247,11 @@ fn choose(
157 let mut query = std::mem::take(&mut popup.query);247 let mut query = std::mem::take(&mut popup.query);
158 let mut highlight = popup.highlight;248 let mut highlight = popup.highlight;
159 let theme = ui.theme.clone();249 let theme = ui.theme.clone();
160 // Over a combo box, the field takes the box's place at once and widens as the popup250 let style = theme.menu();
161 // comes in around it.251 // Over a combo box, the field takes the box's place at once, widening with the popup.
162 let (flags, size) = match anchor {252 let (flags, height) = match anchor {
163 Anchor::Over(rect) => {253 Anchor::Over(rect) => (Flags::STILL, rect[3] - rect[1]),
164 let open = ui.opening(id, anchor).unwrap_or(1.0);254 _ => (Flags::default(), ROW),
165 let from = rect[2] - rect[0];
166 let to = width - 2.0 * PAD;
167 (
168 Flags::STILL,
169 [px(from + (to - from) * open), px(rect[3] - rect[1])],
170 )
171 }
172 _ => (Flags::default(), [fill(), px(ROW)]),
173 };255 };
174 surface(ui, id, anchor, width);256 surface(ui, id, anchor, width);
175 if let Some(placeholder) = filter {257 if let Some(placeholder) = filter {
...@@ -181,7 +263,7 @@ fn choose(...@@ -181,7 +263,7 @@ fn choose(
181 placeholder,263 placeholder,
182 Spec {264 Spec {
183 flags,265 flags,
184 size,266 size: [fill(), px(height)],
185 fill: Some(theme.base),267 fill: Some(theme.base),
186 border: Some(theme.accent),268 border: Some(theme.accent),
187 radius: 4.0,269 radius: 4.0,
...@@ -193,7 +275,7 @@ fn choose(...@@ -193,7 +275,7 @@ fn choose(
193 highlight = None;275 highlight = None;
194 }276 }
195 }277 }
196 let matches = Matches::new(items, &query);278 let matches = Matches::new(items, &query, style.rule_band);
197 // Unfiltered, the current or checked item starts highlighted; filtered, the best match.279 // Unfiltered, the current or checked item starts highlighted; filtered, the best match.
198 highlight = highlight.or_else(|| {280 highlight = highlight.or_else(|| {
199 let first = if query.is_empty() {281 let first = if query.is_empty() {
...@@ -207,7 +289,11 @@ fn choose(...@@ -207,7 +289,11 @@ fn choose(
207 first.map(|row| matches.key(row))289 first.map(|row| matches.key(row))
208 });290 });
209 let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]);291 let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]);
210 let field = if filter.is_some() { ROW + PAD } else { 0.0 };292 let field = if filter.is_some() {
293 ROW + style.pad
294 } else {
295 0.0
296 };
211 let content = matches.count() as f32 * row + matches.space_before(matches.count());297 let content = matches.count() as f32 * row + matches.space_before(matches.count());
212 let view = content298 let view = content
213 .max(row)299 .max(row)
...@@ -236,7 +322,8 @@ fn choose(...@@ -236,7 +322,8 @@ fn choose(
236 Spec {322 Spec {
237 size: [fill(), px(row)],323 size: [fill(), px(row)],
238 text: Some("No matches"),324 text: Some("No matches"),
239 color: Some(theme.text_dim),325 font_size: Some(style.font_size),
326 color: Some(style.dim),
240 pad: [8.0, 0.0],327 pad: [8.0, 0.0],
241 ..Spec::default()328 ..Spec::default()
242 },329 },
...@@ -254,12 +341,12 @@ fn choose(...@@ -254,12 +341,12 @@ fn choose(
254 id.child("rows"),341 id.child("rows"),
255 Spec {342 Spec {
256 size: [fill(), px(view)],343 size: [fill(), px(view)],
257 fill: Some(theme.popup),344 fill: Some(style.fill),
258 ..Spec::default()345 ..Spec::default()
259 },346 },
260 list,347 list,
261 &mut highlight,348 &mut highlight,
262 |ui, row| menu_row(ui, &theme, &matches, row),349 |ui, row| menu_row(ui, &style, &matches, row),
263 );350 );
264 let entered = keys.contains(&NamedKey::Enter);351 let entered = keys.contains(&NamedKey::Enter);
265 clicked.or_else(|| {352 clicked.or_else(|| {
...@@ -282,7 +369,7 @@ fn choose(...@@ -282,7 +369,7 @@ fn choose(
282369
283/// Builds a menu's row: the item's icon or check, its text and its shortcut, on the370/// Builds a menu's row: the item's icon or check, its text and its shortcut, on the
284/// highlight when selected, under a rule when it starts a group.371/// highlight when selected, under a rule when it starts a group.
285fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {372fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) {
286 let item = &matches.items[row.key as usize];373 let item = &matches.items[row.key as usize];
287 if row.index > 0 && matches.ruled && item.separated {374 if row.index > 0 && matches.ruled && item.separated {
288 ui.leaf(375 ui.leaf(
...@@ -290,24 +377,28 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {...@@ -290,24 +377,28 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
290 Spec {377 Spec {
291 flags: Flags::FLOAT,378 flags: Flags::FLOAT,
292 size: [fill(), px(1.0)],379 size: [fill(), px(1.0)],
293 position: [0.0, -(RULE + 1.0) / 2.0],380 position: [0.0, -(style.rule_band + 1.0) / 2.0],
294 fill: Some(theme.chip),381 inset: [style.rule_inset, 0.0, style.rule_inset, 0.0],
382 fill: Some(style.rule),
295 ..Spec::default()383 ..Spec::default()
296 },384 },
297 );385 );
298 }386 }
299 let color = if item.disabled || item.heading {387 let color = if item.heading {
300 theme.text_dim388 style.dim
389 } else if item.disabled {
390 style.disabled
301 } else {391 } else {
302 theme.text392 style.text
303 };393 };
304 ui.open(394 ui.open(
305 "item",395 "item",
306 Spec {396 Spec {
307 size: [fill(), fill()],397 size: [fill(), fill()],
308 fill: row.selected.then(|| theme.hover()),398 fill: row.selected.then_some(style.highlight),
309 radius: 4.0,399 border: style.highlight_border.filter(|_| row.selected),
310 pad: [ROW_PAD, 0.0],400 radius: style.row_radius,
401 pad: [style.row_pad, 0.0],
311 gap: ICON_GAP,402 gap: ICON_GAP,
312 ..Spec::default()403 ..Spec::default()
313 },404 },
...@@ -324,7 +415,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {...@@ -324,7 +415,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
324 size: [px(ICON), fill()],415 size: [px(ICON), fill()],
325 icon: if item.checked { Some(CHECK) } else { item.icon },416 icon: if item.checked { Some(CHECK) } else { item.icon },
326 color: Some(if item.disabled {417 color: Some(if item.disabled {
327 mix(tint, theme.popup, 0.5)418 mix(tint, style.fill, 0.5)
328 } else {419 } else {
329 tint420 tint
330 }),421 }),
...@@ -338,7 +429,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {...@@ -338,7 +429,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
338 size: [fill(), fill()],429 size: [fill(), fill()],
339 text: Some(item.text),430 text: Some(item.text),
340 font: item.font,431 font: item.font,
341 font_size: item.heading.then_some(theme.font_size - 2.0),432 font_size: Some(style.font_size - if item.heading { 2.0 } else { 0.0 }),
342 bold: item.heading,433 bold: item.heading,
343 color: Some(color),434 color: Some(color),
344 ..Spec::default()435 ..Spec::default()
...@@ -350,7 +441,8 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {...@@ -350,7 +441,8 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
350 Spec {441 Spec {
351 size: [fit(), fill()],442 size: [fit(), fill()],
352 text: Some(item.shortcut),443 text: Some(item.shortcut),
353 color: Some(theme.text_dim),444 font_size: Some(style.font_size),
445 color: Some(style.dim),
354 ..Spec::default()446 ..Spec::default()
355 },447 },
356 );448 );
...@@ -419,7 +511,12 @@ pub fn colors(...@@ -419,7 +511,12 @@ pub fn colors(
419511
420 let theme = ui.theme.clone();512 let theme = ui.theme.clone();
421 let lit = |index: usize| (highlight == Some(index)).then(|| theme.hover());513 let lit = |index: usize| (highlight == Some(index)).then(|| theme.hover());
422 surface(ui, id, anchor, columns as f32 * CELL + 2.0 * PAD);514 surface(
515 ui,
516 id,
517 anchor,
518 columns as f32 * CELL + 2.0 * ui.theme.menu().pad,
519 );
423 ui.open_as(520 ui.open_as(
424 cell(0),521 cell(0),
425 Spec {522 Spec {
...@@ -473,6 +570,130 @@ pub fn colors(...@@ -473,6 +570,130 @@ pub fn colors(
473 None570 None
474}571}
475572
573/// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open,
574/// lit from its corner to the cell pointed at, as Office's table picker is. Returns the
575/// columns and rows chosen.
576pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Option<[usize; 2]> {
577 if !ui.popup_open(id) {
578 return None;
579 }
580 let cell = |index: usize| id.child(("cell", index));
581 let keys = navigation(
582 ui,
583 &[id],
584 &[
585 NamedKey::ArrowLeft,
586 NamedKey::ArrowRight,
587 NamedKey::ArrowUp,
588 NamedKey::ArrowDown,
589 NamedKey::Enter,
590 ],
591 );
592 let [columns, rows] = size;
593 let mut highlight = state(ui, id).highlight.map(|cell| cell as usize);
594 let mut chosen = None;
595 for index in 0..columns * rows {
596 let signal = ui.signal(cell(index));
597 if signal.hovered && ui.moved {
598 highlight = Some(index);
599 }
600 if signal.clicked {
601 chosen = Some(index);
602 }
603 }
604 for key in keys {
605 let at = highlight.unwrap_or(0);
606 let [column, row] = [at % columns, at / columns];
607 highlight = Some(match key {
608 NamedKey::Enter => {
609 chosen = chosen.or(highlight);
610 continue;
611 }
612 _ if highlight.is_none() => 0,
613 NamedKey::ArrowLeft => at - usize::from(column > 0),
614 NamedKey::ArrowRight => at + usize::from(column + 1 < columns),
615 NamedKey::ArrowUp => at - if row > 0 { columns } else { 0 },
616 _ => at + if row + 1 < rows { columns } else { 0 },
617 });
618 }
619 let extent = |index: usize| [index % columns + 1, index / columns + 1];
620 if let Some(index) = chosen {
621 ui.close_popup(id);
622 return Some(extent(index));
623 }
624 let theme = ui.theme.clone();
625 let reach = highlight.map_or([0, 0], extent);
626 surface(
627 ui,
628 id,
629 anchor,
630 columns as f32 * CELL + 2.0 * ui.theme.menu().pad,
631 );
632 let heading = match highlight {
633 Some(_) => format!("{}x{} Table", reach[0], reach[1]),
634 None => "Insert Table".to_owned(),
635 };
636 ui.leaf(
637 "heading",
638 Spec {
639 size: [fill(), px(ROW)],
640 text: Some(&heading),
641 bold: true,
642 pad: [8.0, 0.0],
643 ..Spec::default()
644 },
645 );
646 ui.open(
647 "grid",
648 Spec {
649 axis: Axis::Y,
650 size: [fill(), children()],
651 ..Spec::default()
652 },
653 );
654 for row in 0..rows {
655 ui.open(
656 ("row", row),
657 Spec {
658 size: [fill(), px(CELL)],
659 ..Spec::default()
660 },
661 );
662 for column in 0..columns {
663 let lit = column < reach[0] && row < reach[1];
664 ui.open_as(
665 cell(row * columns + column),
666 Spec {
667 flags: Flags::CLICKABLE,
668 size: [px(CELL), px(CELL)],
669 pad: [3.0, 3.0],
670 ..Spec::default()
671 },
672 );
673 ui.leaf(
674 "square",
675 Spec {
676 size: [fill(), fill()],
677 fill: Some(if lit { theme.hover() } else { theme.base }),
678 border: Some(if lit {
679 theme.accent
680 } else {
681 mix(theme.text, theme.popup, 0.7)
682 }),
683 radius: 2.0,
684 ..Spec::default()
685 },
686 );
687 ui.close();
688 }
689 ui.close();
690 }
691 ui.close();
692 ui.close();
693 state(ui, id).highlight = highlight.map(|cell| cell as u64);
694 None
695}
696
476/// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, each697/// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, each
477/// a heading and a count, in rows of `columns` cells `size` large, with cell `current`698/// a heading and a count, in rows of `columns` cells `size` large, with cell `current`
478/// outlined. `cell` builds a cell's contents from its index, counted through the groups.699/// outlined. `cell` builds a cell's contents from its index, counted through the groups.
...@@ -533,7 +754,12 @@ pub fn gallery(...@@ -533,7 +754,12 @@ pub fn gallery(
533 return Some(index);754 return Some(index);
534 }755 }
535 let theme = ui.theme.clone();756 let theme = ui.theme.clone();
536 surface(ui, id, anchor, columns as f32 * size[0] + 2.0 * PAD);757 surface(
758 ui,
759 id,
760 anchor,
761 columns as f32 * size[0] + 2.0 * ui.theme.menu().pad,
762 );
537 let mut index = 0;763 let mut index = 0;
538 for (group, (heading, cells)) in groups.iter().enumerate() {764 for (group, (heading, cells)) in groups.iter().enumerate() {
539 ui.leaf(765 ui.leaf(
...@@ -584,29 +810,30 @@ pub fn gallery(...@@ -584,29 +810,30 @@ pub fn gallery(
584810
585/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it.811/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it.
586fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {812fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {
813 let style = ui.theme.menu();
814 let pad = style.pad;
587 let anchor = match anchor {815 let anchor = match anchor {
588 Anchor::Below([left, top, right, bottom]) => {816 Anchor::Below([left, top, right, bottom]) => {
589 Anchor::Below([left, top - PAD, right, bottom + PAD])817 Anchor::Below([left, top - PAD, right, bottom + PAD])
590 }818 }
591 // Level with the row it opens from, or with its contents over the box.819 // Level with the row it opens from, or with its contents over the box.
592 Anchor::Right([left, top, right, bottom]) => {820 Anchor::Right([left, top, right, bottom]) => {
593 Anchor::Right([left, top - PAD, right, bottom + PAD])821 Anchor::Right([left, top - pad, right, bottom + pad])
594 }822 }
595 Anchor::Over([left, top, right, bottom]) => {823 Anchor::Over([left, top, right, bottom]) => {
596 Anchor::Over([left - PAD, top - PAD, right + PAD, bottom + PAD])824 Anchor::Over([left - pad, top - pad, right + pad, bottom + pad])
597 }825 }
598 point => point,826 point => point,
599 };827 };
600 let theme = &ui.theme;
601 let spec = Spec {828 let spec = Spec {
602 axis: Axis::Y,829 axis: Axis::Y,
603 size: [px(width), children()],830 size: [px(width), children()],
604 fill: Some(theme.popup),831 fill: Some(style.fill),
605 border: Some(theme.chip),832 border: Some(style.border),
606 shadow: Some(theme.shadow),833 shadow: Some(ui.theme.shadow),
607 radius: 6.0,834 radius: style.radius,
608 pad: [PAD; 2],835 pad: [pad; 2],
609 gap: PAD,836 gap: pad,
610 anchor: Some(anchor),837 anchor: Some(anchor),
611 ..Spec::default()838 ..Spec::default()
612 };839 };
...@@ -661,11 +888,13 @@ pub(crate) struct Matches<'a> {...@@ -661,11 +888,13 @@ pub(crate) struct Matches<'a> {
661 /// Rules above each row and the end, counted from the top; only while unfiltered.888 /// Rules above each row and the end, counted from the top; only while unfiltered.
662 rules: Vec<u32>,889 rules: Vec<u32>,
663 ruled: bool,890 ruled: bool,
891 /// Height of the band a rule takes.
892 rule: f32,
664 icons: bool,893 icons: bool,
665}894}
666895
667impl<'a> Matches<'a> {896impl<'a> Matches<'a> {
668 pub(crate) fn new(items: &'a [Item<'a>], query: &str) -> Self {897 pub(crate) fn new(items: &'a [Item<'a>], query: &str, rule: f32) -> Self {
669 let pattern = Pattern::new(898 let pattern = Pattern::new(
670 query,899 query,
671 CaseMatching::Ignore,900 CaseMatching::Ignore,
...@@ -708,6 +937,7 @@ impl<'a> Matches<'a> {...@@ -708,6 +937,7 @@ impl<'a> Matches<'a> {
708 rows,937 rows,
709 rules,938 rules,
710 ruled,939 ruled,
940 rule,
711 icons: items.iter().any(|item| item.icon.is_some() || item.checked),941 icons: items.iter().any(|item| item.icon.is_some() || item.checked),
712 }942 }
713 }943 }
...@@ -732,6 +962,6 @@ impl Rows for Matches<'_> {...@@ -732,6 +962,6 @@ impl Rows for Matches<'_> {
732 }962 }
733963
734 fn space_before(&self, index: usize) -> f32 {964 fn space_before(&self, index: usize) -> f32 {
735 self.rules[index] as f32 * RULE965 self.rules[index] as f32 * self.rule
736 }966 }
737}967}
crates/ui/src/shell.rs+46-1
...@@ -222,6 +222,49 @@ pub fn tool_button(...@@ -222,6 +222,49 @@ pub fn tool_button(
222 )222 )
223}223}
224224
225/// A tool button, with a menu arrow where `menu`, whose command does not apply now: `icon`
226/// tinted by `tint` shows faded, and it takes no clicks.
227pub fn unavailable(
228 ui: &mut Ui,
229 part: impl Hash,
230 icon: &'static [&'static str],
231 tint: [f32; 4],
232 menu: bool,
233) {
234 let faded = |[red, green, blue, alpha]: [f32; 4]| [red, green, blue, alpha * 0.35];
235 let arrow = faded(ui.theme.text_dim);
236 ui.open(
237 part,
238 Spec {
239 size: [children(), px(TOOL)],
240 ..Spec::default()
241 },
242 );
243 ui.leaf(
244 "icon",
245 Spec {
246 size: [px(TOOL), px(TOOL)],
247 icon: Some(icon),
248 color: Some(faded(tint)),
249 center: true,
250 ..Spec::default()
251 },
252 );
253 if menu {
254 ui.leaf(
255 "arrow",
256 Spec {
257 size: [px(ARROW), px(TOOL)],
258 icon: Some(CHEVRON),
259 color: Some(arrow),
260 center: true,
261 ..Spec::default()
262 },
263 );
264 }
265 ui.close();
266}
267
225/// A button showing `icon`, lit while `on`, joined to an arrow that opens popup `menu`,268/// A button showing `icon`, lit while `on`, joined to an arrow that opens popup `menu`,
226/// with the colour it applies as a bar under the icon when given. Hovering the button fills269/// with the colour it applies as a bar under the icon when given. Hovering the button fills
227/// it alone; hovering the arrow, or its menu being open, outlines both as one control.270/// it alone; hovering the arrow, or its menu being open, outlines both as one control.
...@@ -359,7 +402,9 @@ pub fn menu_button(...@@ -359,7 +402,9 @@ pub fn menu_button(
359 ui.open_popup(menu);402 ui.open_popup(menu);
360 }403 }
361 let [left, top, right, bottom] = ui.rect(id).unwrap_or_default();404 let [left, top, right, bottom] = ui.rect(id).unwrap_or_default();
362 let shift = crate::popup::ICON_INSET - (TOOL - crate::ICON) / 2.0;405 let menu = ui.theme.menu();
406 // The menu's icons line up under the button's.
407 let shift = menu.pad + menu.row_pad - (TOOL - crate::ICON) / 2.0;
363 Anchor::Below([left - shift, top, right - shift, bottom])408 Anchor::Below([left - shift, top, right - shift, bottom])
364}409}
365410
crates/ui/src/tests.rs+183-1
...@@ -777,6 +777,74 @@ fn click(ui: &mut Ui, point: [f32; 2]) {...@@ -777,6 +777,74 @@ fn click(ui: &mut Ui, point: [f32; 2]) {
777 press(ui, at, false);777 press(ui, at, false);
778}778}
779779
780/// Two tool buttons with tooltips, the second at the window's trailing edge; returns
781/// their tooltips' rectangles.
782fn tip_frame(ui: &mut Ui) -> [Option<[f32; 4]>; 2] {
783 let mut tips = [Id::ROOT; 2];
784 frame(ui, |ui| {
785 for (index, x) in [0.0, 380.0].into_iter().enumerate() {
786 let id = ui.open(
787 ("tool", index),
788 Spec {
789 flags: Flags::CLICKABLE | Flags::FLOAT,
790 size: [px(20.0), px(20.0)],
791 position: [x, 0.0],
792 ..Spec::default()
793 },
794 );
795 ui.close();
796 popup::tooltip(ui, "Bold", "Ctrl+B", Some("Makes the selected text bold."));
797 tips[index] = id.child("tooltip");
798 }
799 });
800 tips.map(|tip| ui.rect(tip))
801}
802
803/// Frames the pointer rests through for `millis`, returning the tooltips as they end.
804fn rest(ui: &mut Ui, millis: u32) -> [Option<[f32; 4]>; 2] {
805 let mut tips = [None; 2];
806 for _ in 0..millis / 16 {
807 tips = tip_frame(ui);
808 }
809 tips
810}
811
812#[test]
813fn tooltips_wait_then_switch_at_once_and_hide_on_press_until_left() {
814 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
815 tip_frame(&mut ui);
816 ui.event(Event::PointerMoved([10.0, 10.0]));
817 assert_eq!(rest(&mut ui, 400), [None; 2]);
818 assert!(ui.wake_at().is_some(), "the delay wakes the host");
819 let [Some(first), None] = rest(&mut ui, 200) else {
820 panic!("the first tooltip shows after its delay");
821 };
822 assert_eq!(first[..2], [0.0, 20.0 + popup::PAD]);
823
824 ui.event(Event::PointerMoved([390.0, 10.0]));
825 let [None, Some(second)] = tip_frame(&mut ui) else {
826 panic!("moving to another shows its tooltip at once");
827 };
828 assert_eq!(second[2], 400.0, "kept inside the window");
829
830 press(&mut ui, Instant::now(), true);
831 press(&mut ui, Instant::now(), false);
832 assert_eq!(
833 rest(&mut ui, 1000),
834 [None; 2],
835 "a press hides it while hovered"
836 );
837 ui.event(Event::PointerMoved([200.0, 10.0]));
838 rest(&mut ui, 100);
839 ui.event(Event::PointerMoved([10.0, 10.0]));
840 assert_eq!(
841 rest(&mut ui, 100),
842 [None; 2],
843 "after a press it waits again"
844 );
845 assert!(rest(&mut ui, 500)[0].is_some());
846}
847
780#[test]848#[test]
781fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() {849fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() {
782 let mut ui = open_menu(None);850 let mut ui = open_menu(None);
...@@ -1062,6 +1130,40 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() {...@@ -1062,6 +1130,40 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() {
1062 assert_eq!(build(&mut ui), Some(None));1130 assert_eq!(build(&mut ui), Some(None));
1063}1131}
10641132
1133#[test]
1134fn table_pickers_choose_columns_and_rows_by_keys_or_a_click() {
1135 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1136 let grid = Id::ROOT.child("table");
1137 let build = |ui: &mut Ui| {
1138 let mut chosen = None;
1139 frame(ui, |ui| {
1140 chosen = popup::table_picker(ui, grid, BELOW, [4, 3]);
1141 });
1142 chosen
1143 };
1144 build(&mut ui);
1145 ui.open_popup(grid);
1146 build(&mut ui);
1147 for named in [
1148 NamedKey::ArrowDown,
1149 NamedKey::ArrowRight,
1150 NamedKey::ArrowRight,
1151 NamedKey::ArrowDown,
1152 NamedKey::ArrowDown,
1153 NamedKey::Enter,
1154 ] {
1155 ui.event(key(named));
1156 }
1157 assert_eq!(build(&mut ui), Some([3, 3]));
1158 ui.open_popup(grid);
1159 for _ in 0..40 {
1160 build(&mut ui);
1161 }
1162 let rect = ui.rect(grid.child(("cell", 5_usize))).unwrap();
1163 click(&mut ui, [rect[0] + 4.0, rect[1] + 4.0]);
1164 assert_eq!(build(&mut ui), Some([2, 2]));
1165}
1166
1065#[test]1167#[test]
1066fn popups_opened_within_a_popup_stay_above_it() {1168fn popups_opened_within_a_popup_stay_above_it() {
1067 let mut ui = open_menu(None);1169 let mut ui = open_menu(None);
...@@ -1369,7 +1471,7 @@ fn fonts() -> Vec<popup::Item<'static>> {...@@ -1369,7 +1471,7 @@ fn fonts() -> Vec<popup::Item<'static>> {
1369}1471}
13701472
1371fn ranked(items: &[popup::Item], query: &str) -> Vec<&'static str> {1473fn ranked(items: &[popup::Item], query: &str) -> Vec<&'static str> {
1372 let order = popup::Matches::new(items, query).order;1474 let order = popup::Matches::new(items, query, 9.0).order;
1373 order.into_iter().map(|index| FONTS[index]).collect()1475 order.into_iter().map(|index| FONTS[index]).collect()
1374}1476}
13751477
...@@ -1836,6 +1938,39 @@ fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks()...@@ -1836,6 +1938,39 @@ fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks()
1836 assert_eq!(build(&mut ui), Some(3));1938 assert_eq!(build(&mut ui), Some(3));
1837}1939}
18381940
1941#[test]
1942fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() {
1943 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1944 let fonts = fonts();
1945 let anchor = Anchor::Over([330.0, 10.0, 390.0, 30.0]);
1946 let build = |ui: &mut Ui| {
1947 frame(ui, |ui| {
1948 _ = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font"))
1949 })
1950 };
1951 build(&mut ui);
1952 ui.open_popup(menu_id());
1953 build(&mut ui);
1954 let field = menu_id().child("filter");
1955 let edges =
1956 |ui: &Ui| [ui.rect(field).unwrap(), ui.rect(menu_id()).unwrap()].map(|rect| rect[2]);
1957 let first = ui.rect(field).unwrap();
1958 assert_eq!(
1959 edges(&ui),
1960 [390.0, 394.0],
1961 "the trailing edges start on the box"
1962 );
1963 assert!(first[0] > 290.0, "the field starts near the box");
1964 for _ in 0..20 {
1965 build(&mut ui);
1966 }
1967 assert_eq!(edges(&ui), [390.0, 394.0], "the trailing edges hold");
1968 assert!(
1969 ui.rect(field).unwrap()[0] < first[0] - 30.0,
1970 "the field widens leftward"
1971 );
1972}
1973
1839fn built(ui: &Ui, id: Id) -> &Built {1974fn built(ui: &Ui, id: Id) -> &Built {
1840 ui.nodes.iter().find(|node| node.id == id).unwrap()1975 ui.nodes.iter().find(|node| node.id == id).unwrap()
1841}1976}
...@@ -2024,3 +2159,50 @@ fn galleries_choose_across_their_groups_by_keys_and_clicks() {...@@ -2024,3 +2159,50 @@ fn galleries_choose_across_their_groups_by_keys_and_clicks() {
2024 click(&mut ui, point);2159 click(&mut ui, point);
2025 assert_eq!(build(&mut ui), Some(1));2160 assert_eq!(build(&mut ui), Some(1));
2026}2161}
2162
2163#[test]
2164fn desktop_menus_cut_or_fade_without_growing() {
2165 let motion = |ui: &Ui| {
2166 ui.layers().iter().find_map(|layer| match layer {
2167 Layer::Primitives(primitives) => primitives.motion,
2168 Layer::Custom { .. } => None,
2169 })
2170 };
2171 let at = Anchor::Point([50.0, 50.0]);
2172 let styled = |motion| {
2173 let mut theme = Theme::light();
2174 theme.desktop_menu = Some(Menu {
2175 motion,
2176 ..theme.menu()
2177 });
2178 let mut ui = Ui::new(theme, DOUBLE_CLICK);
2179 menu_frame(&mut ui, at, None);
2180 ui.open_popup(menu_id());
2181 menu_frame(&mut ui, at, None);
2182 ui
2183 };
2184
2185 let mut ui = styled(PopupMotion::Cut);
2186 let shown = motion(&ui).unwrap();
2187 assert_eq!([shown.opacity, shown.zoom, shown.tilt], [1.0, 1.0, 0.0]);
2188 ui.close_popup(menu_id());
2189 menu_frame(&mut ui, at, None);
2190 assert_eq!(motion(&ui), None, "gone the frame it closes");
2191 assert!(!ui.wants_frame());
2192
2193 let mut ui = styled(PopupMotion::Fade([0.15, 0.6]));
2194 let first = motion(&ui).unwrap();
2195 assert!(first.opacity < 0.2 && first.zoom == 1.0 && first.tilt == 0.0);
2196 for _ in 0..10 {
2197 menu_frame(&mut ui, at, None);
2198 }
2199 assert_eq!(motion(&ui).map(|motion| motion.opacity), Some(1.0));
2200 ui.close_popup(menu_id());
2201 // 96 ms into the fade out, OutQuart leaves about half.
2202 for _ in 0..7 {
2203 menu_frame(&mut ui, at, None);
2204 }
2205 let closing = motion(&ui).unwrap();
2206 assert!((closing.opacity - (1.0 - 0.096 / 0.6_f32).powi(4)).abs() < 0.01);
2207 assert!(ui.wants_frame());
2208}
crates/ui/src/text.rs+8-3
...@@ -47,12 +47,17 @@ impl Texts {...@@ -47,12 +47,17 @@ impl Texts {
47 .context47 .context
48 .ranged_builder(&mut self.fonts, text, 1.0, false);48 .ranged_builder(&mut self.fonts, text, 1.0, false);
49 // Fontconfig configurations without a system-ui alias still have a sans-serif.49 // Fontconfig configurations without a system-ui alias still have a sans-serif.
50 let system =50 // Before 10.10 macOS's interface font is Lucida Grande, which fontique's system-ui
51 [GenericFamily::SystemUi, GenericFamily::SansSerif].map(FontFamilyName::Generic);51 // doesn't name.
52 let system = [
53 Some(FontFamilyName::Generic(GenericFamily::SystemUi)),
54 cfg!(target_os = "macos").then(|| FontFamilyName::named("Lucida Grande")),
55 Some(FontFamilyName::Generic(GenericFamily::SansSerif)),
56 ];
52 let families: Vec<_> = font57 let families: Vec<_> = font
53 .into_iter()58 .into_iter()
54 .map(FontFamilyName::named)59 .map(FontFamilyName::named)
55 .chain(system)60 .chain(system.into_iter().flatten())
56 .collect();61 .collect();
57 builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned(62 builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned(
58 families,63 families,
crates/ui/src/theme.rs+88
...@@ -32,6 +32,62 @@ pub struct Theme {...@@ -32,6 +32,62 @@ pub struct Theme {
32 pub shadow: [f32; 4],32 pub shadow: [f32; 4],
33 /// Saturation and lightness each of a section's colours takes from its hue.33 /// Saturation and lightness each of a section's colours takes from its hue.
34 pub shades: Shades,34 pub shades: Shades,
35 /// Menus as the desktop draws its own, where Snowbound follows it; see `Theme::menu`.
36 pub desktop_menu: Option<Menu>,
37}
38
39/// How menus and the other popups look and move. Colours are linear RGBA; sizes are
40/// logical pixels.
41#[derive(Clone, Debug, PartialEq)]
42pub struct Menu {
43 pub fill: [f32; 4],
44 pub border: [f32; 4],
45 /// The layers of the shadow a popup casts, painted in order.
46 pub shadows: Vec<Shadow>,
47 pub text: [f32; 4],
48 /// Shortcuts and headings.
49 pub dim: [f32; 4],
50 pub disabled: [f32; 4],
51 /// The row the pointer or keyboard is on, and its outline.
52 pub highlight: [f32; 4],
53 pub highlight_border: Option<[f32; 4]>,
54 /// The line between groups of rows.
55 pub rule: [f32; 4],
56 pub font_size: f32,
57 pub radius: f32,
58 /// Inset of the popup's contents.
59 pub pad: f32,
60 pub row: f32,
61 pub row_radius: f32,
62 /// Inset of a row's icon and text inside its highlight.
63 pub row_pad: f32,
64 /// Height of the band between groups, and how far the rule across it stays from the
65 /// rows' ends.
66 pub rule_band: f32,
67 pub rule_inset: f32,
68 pub motion: PopupMotion,
69}
70
71/// A soft shadow blurred `blur` wide, `drop` below its box and grown `spread` past it, as
72/// CSS's `box-shadow` takes them.
73#[derive(Clone, Copy, Debug, PartialEq)]
74pub struct Shadow {
75 pub color: [f32; 4],
76 pub blur: f32,
77 pub drop: f32,
78 pub spread: f32,
79}
80
81/// How popups other than dialogs open and close.
82#[derive(Clone, Copy, Debug, PartialEq)]
83pub enum PopupMotion {
84 /// Growing out of what they open from as they fade, Snowbound's own.
85 Grow,
86 /// At once, as GTK 4 shows its popovers.
87 Cut,
88 /// Opacity alone: in linearly over the first seconds, out over the second easing as
89 /// OutQuart, as KWin's Fading Popups.
90 Fade([f32; 2]),
35}91}
3692
37/// The platform's caret and selection colours in a light or `dark` appearance.93/// The platform's caret and selection colours in a light or `dark` appearance.
...@@ -101,6 +157,7 @@ impl Theme {...@@ -101,6 +157,7 @@ impl Theme {
101 edge: [0.30, 0.16],157 edge: [0.30, 0.16],
102 accent: [0.55, 0.55],158 accent: [0.55, 0.55],
103 },159 },
160 desktop_menu: None,
104 }161 }
105 }162 }
106163
...@@ -129,6 +186,7 @@ impl Theme {...@@ -129,6 +186,7 @@ impl Theme {
129 edge: [0.30, 0.52],186 edge: [0.30, 0.52],
130 accent: [0.60, 0.45],187 accent: [0.60, 0.45],
131 },188 },
189 desktop_menu: None,
132 }190 }
133 }191 }
134192
...@@ -145,6 +203,36 @@ impl Theme {...@@ -145,6 +203,36 @@ impl Theme {
145 }203 }
146 }204 }
147205
206 /// Menus as the desktop draws them, or Snowbound's own.
207 pub fn menu(&self) -> Menu {
208 use crate::popup::{MENU_ROW, PAD};
209 self.desktop_menu.clone().unwrap_or_else(|| Menu {
210 fill: self.popup,
211 border: self.chip,
212 shadows: vec![Shadow {
213 color: self.shadow,
214 blur: crate::POPUP_SHADOW[0],
215 drop: crate::POPUP_SHADOW[1],
216 spread: 0.0,
217 }],
218 text: self.text,
219 dim: self.text_dim,
220 disabled: self.text_dim,
221 highlight: self.hover(),
222 highlight_border: None,
223 rule: self.chip,
224 font_size: self.font_size,
225 radius: 6.0,
226 pad: PAD,
227 row: MENU_ROW,
228 row_radius: 4.0,
229 row_pad: 8.0,
230 rule_band: 9.0,
231 rule_inset: 0.0,
232 motion: PopupMotion::Grow,
233 })
234 }
235
148 /// Hovered controls: the accent over the base.236 /// Hovered controls: the accent over the base.
149 pub fn hover(&self) -> [f32; 4] {237 pub fn hover(&self) -> [f32; 4] {
150 crate::mix(self.base, self.accent, 0.35)238 crate::mix(self.base, self.accent, 0.35)
crates/ui/src/widgets.rs+1
...@@ -348,6 +348,7 @@ pub fn edit_key(key: &Key) -> edit::Key {...@@ -348,6 +348,7 @@ pub fn edit_key(key: &Key) -> edit::Key {
348 NamedKey::End => Edit::End,348 NamedKey::End => Edit::End,
349 NamedKey::PageUp => Edit::PageUp,349 NamedKey::PageUp => Edit::PageUp,
350 NamedKey::PageDown => Edit::PageDown,350 NamedKey::PageDown => Edit::PageDown,
351 NamedKey::F11 => Edit::F11,
351 NamedKey::Alt352 NamedKey::Alt
352 | NamedKey::AltGraph353 | NamedKey::AltGraph
353 | NamedKey::Control354 | NamedKey::Control
platform/snow-leopard/Cargo.lock+1377-75
...@@ -2,18 +2,72 @@...@@ -2,18 +2,72 @@
2# It is not intended for manual editing.2# It is not intended for manual editing.
3version = 43version = 4
44
5[[package]]
6name = "ab_glyph"
7version = "0.2.32"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
10dependencies = [
11 "ab_glyph_rasterizer",
12 "owned_ttf_parser",
13]
14
15[[package]]
16name = "ab_glyph_rasterizer"
17version = "0.1.10"
18source = "registry+https://github.com/rust-lang/crates.io-index"
19checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
20
5[[package]]21[[package]]
6name = "adler2"22name = "adler2"
7version = "2.0.1"23version = "2.0.1"
8source = "registry+https://github.com/rust-lang/crates.io-index"24source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"25checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
1026
27[[package]]
28name = "ahash"
29version = "0.8.12"
30source = "registry+https://github.com/rust-lang/crates.io-index"
31checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
32dependencies = [
33 "cfg-if",
34 "getrandom 0.3.4",
35 "once_cell",
36 "version_check",
37 "zerocopy",
38]
39
11[[package]]40[[package]]
12name = "allocator-api2"41name = "allocator-api2"
13version = "0.2.21"42version = "0.2.21"
14source = "registry+https://github.com/rust-lang/crates.io-index"43source = "registry+https://github.com/rust-lang/crates.io-index"
15checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"44checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
1645
46[[package]]
47name = "android-activity"
48version = "0.6.1"
49source = "registry+https://github.com/rust-lang/crates.io-index"
50checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
51dependencies = [
52 "android-properties",
53 "bitflags 2.13.1",
54 "cc",
55 "jni",
56 "libc",
57 "log",
58 "ndk",
59 "ndk-context",
60 "ndk-sys",
61 "num_enum",
62 "thiserror 2.0.21",
63]
64
65[[package]]
66name = "android-properties"
67version = "0.2.2"
68source = "registry+https://github.com/rust-lang/crates.io-index"
69checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
70
17[[package]]71[[package]]
18name = "android_system_properties"72name = "android_system_properties"
19version = "0.1.6"73version = "0.1.6"
...@@ -23,12 +77,24 @@ dependencies = [...@@ -23,12 +77,24 @@ dependencies = [
23 "libc",77 "libc",
24]78]
2579
80[[package]]
81name = "arrayref"
82version = "0.3.9"
83source = "registry+https://github.com/rust-lang/crates.io-index"
84checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
85
26[[package]]86[[package]]
27name = "arrayvec"87name = "arrayvec"
28version = "0.7.8"88version = "0.7.8"
29source = "registry+https://github.com/rust-lang/crates.io-index"89source = "registry+https://github.com/rust-lang/crates.io-index"
30checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"90checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
3191
92[[package]]
93name = "as-raw-xcb-connection"
94version = "1.0.1"
95source = "registry+https://github.com/rust-lang/crates.io-index"
96checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
97
32[[package]]98[[package]]
33name = "ash"99name = "ash"
34version = "0.38.0+1.3.281"100version = "0.38.0+1.3.281"
...@@ -38,6 +104,12 @@ dependencies = [...@@ -38,6 +104,12 @@ dependencies = [
38 "libloading",104 "libloading",
39]105]
40106
107[[package]]
108name = "atomic-waker"
109version = "1.1.2"
110source = "registry+https://github.com/rust-lang/crates.io-index"
111checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
112
41[[package]]113[[package]]
42name = "autocfg"114name = "autocfg"
43version = "1.5.1"115version = "1.5.1"
...@@ -59,19 +131,34 @@ version = "0.9.1"...@@ -59,19 +131,34 @@ version = "0.9.1"
59source = "registry+https://github.com/rust-lang/crates.io-index"131source = "registry+https://github.com/rust-lang/crates.io-index"
60checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"132checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
61133
134[[package]]
135name = "bitflags"
136version = "1.3.2"
137source = "registry+https://github.com/rust-lang/crates.io-index"
138checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
139
62[[package]]140[[package]]
63name = "bitflags"141name = "bitflags"
64version = "2.13.1"142version = "2.13.1"
65source = "registry+https://github.com/rust-lang/crates.io-index"143source = "registry+https://github.com/rust-lang/crates.io-index"
66checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"144checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
67145
146[[package]]
147name = "block2"
148version = "0.5.1"
149source = "registry+https://github.com/rust-lang/crates.io-index"
150checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
151dependencies = [
152 "objc2 0.5.2",
153]
154
68[[package]]155[[package]]
69name = "block2"156name = "block2"
70version = "0.6.2"157version = "0.6.2"
71source = "registry+https://github.com/rust-lang/crates.io-index"158source = "registry+https://github.com/rust-lang/crates.io-index"
72checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"159checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
73dependencies = [160dependencies = [
74 "objc2",161 "objc2 0.6.4",
75]162]
76163
77[[package]]164[[package]]
...@@ -106,6 +193,50 @@ version = "0.1.0"...@@ -106,6 +193,50 @@ version = "0.1.0"
106source = "registry+https://github.com/rust-lang/crates.io-index"193source = "registry+https://github.com/rust-lang/crates.io-index"
107checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"194checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
108195
196[[package]]
197name = "bytes"
198version = "1.12.1"
199source = "registry+https://github.com/rust-lang/crates.io-index"
200checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
201
202[[package]]
203name = "calloop"
204version = "0.13.0"
205source = "registry+https://github.com/rust-lang/crates.io-index"
206checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
207dependencies = [
208 "bitflags 2.13.1",
209 "log",
210 "polling",
211 "rustix 0.38.44",
212 "slab",
213 "thiserror 1.0.69",
214]
215
216[[package]]
217name = "calloop-wayland-source"
218version = "0.3.0"
219source = "registry+https://github.com/rust-lang/crates.io-index"
220checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
221dependencies = [
222 "calloop",
223 "rustix 0.38.44",
224 "wayland-backend",
225 "wayland-client",
226]
227
228[[package]]
229name = "cc"
230version = "1.5.1"
231source = "registry+https://github.com/rust-lang/crates.io-index"
232checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
233dependencies = [
234 "find-msvc-tools",
235 "jobserver",
236 "libc",
237 "shlex",
238]
239
109[[package]]240[[package]]
110name = "cfg-if"241name = "cfg-if"
111version = "1.0.5"242version = "1.0.5"
...@@ -135,6 +266,65 @@ version = "1.1.0"...@@ -135,6 +266,65 @@ version = "1.1.0"
135source = "registry+https://github.com/rust-lang/crates.io-index"266source = "registry+https://github.com/rust-lang/crates.io-index"
136checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"267checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
137268
269[[package]]
270name = "combine"
271version = "4.6.8"
272source = "registry+https://github.com/rust-lang/crates.io-index"
273checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
274dependencies = [
275 "bytes",
276 "memchr",
277]
278
279[[package]]
280name = "concurrent-queue"
281version = "2.5.0"
282source = "registry+https://github.com/rust-lang/crates.io-index"
283checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
284dependencies = [
285 "crossbeam-utils",
286]
287
288[[package]]
289name = "core-foundation"
290version = "0.9.4"
291source = "registry+https://github.com/rust-lang/crates.io-index"
292checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
293dependencies = [
294 "core-foundation-sys",
295 "libc",
296]
297
298[[package]]
299name = "core-foundation-sys"
300version = "0.8.7"
301source = "registry+https://github.com/rust-lang/crates.io-index"
302checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
303
304[[package]]
305name = "core-graphics"
306version = "0.23.2"
307source = "registry+https://github.com/rust-lang/crates.io-index"
308checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
309dependencies = [
310 "bitflags 1.3.2",
311 "core-foundation",
312 "core-graphics-types",
313 "foreign-types",
314 "libc",
315]
316
317[[package]]
318name = "core-graphics-types"
319version = "0.1.3"
320source = "registry+https://github.com/rust-lang/crates.io-index"
321checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
322dependencies = [
323 "bitflags 1.3.2",
324 "core-foundation",
325 "libc",
326]
327
138[[package]]328[[package]]
139name = "crc32fast"329name = "crc32fast"
140version = "1.5.2"330version = "1.5.2"
...@@ -144,20 +334,38 @@ dependencies = [...@@ -144,20 +334,38 @@ dependencies = [
144 "cfg-if",334 "cfg-if",
145]335]
146336
337[[package]]
338name = "crossbeam-utils"
339version = "0.8.23"
340source = "registry+https://github.com/rust-lang/crates.io-index"
341checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
342
147[[package]]343[[package]]
148name = "crunchy"344name = "crunchy"
149version = "0.2.4"345version = "0.2.4"
150source = "registry+https://github.com/rust-lang/crates.io-index"346source = "registry+https://github.com/rust-lang/crates.io-index"
151checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"347checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
152348
349[[package]]
350name = "cursor-icon"
351version = "1.2.0"
352source = "registry+https://github.com/rust-lang/crates.io-index"
353checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
354
355[[package]]
356name = "dispatch"
357version = "0.2.0"
358source = "registry+https://github.com/rust-lang/crates.io-index"
359checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
360
153[[package]]361[[package]]
154name = "dispatch2"362name = "dispatch2"
155version = "0.3.1"363version = "0.3.1"
156source = "registry+https://github.com/rust-lang/crates.io-index"364source = "registry+https://github.com/rust-lang/crates.io-index"
157checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"365checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
158dependencies = [366dependencies = [
159 "bitflags",367 "bitflags 2.13.1",
160 "objc2",368 "objc2 0.6.4",
161]369]
162370
163[[package]]371[[package]]
...@@ -189,6 +397,18 @@ dependencies = [...@@ -189,6 +397,18 @@ dependencies = [
189 "litrs",397 "litrs",
190]398]
191399
400[[package]]
401name = "downcast-rs"
402version = "1.2.1"
403source = "registry+https://github.com/rust-lang/crates.io-index"
404checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
405
406[[package]]
407name = "dpi"
408version = "0.1.2"
409source = "registry+https://github.com/rust-lang/crates.io-index"
410checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
411
192[[package]]412[[package]]
193name = "draw"413name = "draw"
194version = "0.1.0"414version = "0.1.0"
...@@ -208,6 +428,16 @@ version = "1.0.2"...@@ -208,6 +428,16 @@ version = "1.0.2"
208source = "registry+https://github.com/rust-lang/crates.io-index"428source = "registry+https://github.com/rust-lang/crates.io-index"
209checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"429checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
210430
431[[package]]
432name = "errno"
433version = "0.3.14"
434source = "registry+https://github.com/rust-lang/crates.io-index"
435checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
436dependencies = [
437 "libc",
438 "windows-sys 0.61.2",
439]
440
211[[package]]441[[package]]
212name = "fax"442name = "fax"
213version = "0.2.7"443version = "0.2.7"
...@@ -223,6 +453,12 @@ dependencies = [...@@ -223,6 +453,12 @@ dependencies = [
223 "simd-adler32",453 "simd-adler32",
224]454]
225455
456[[package]]
457name = "find-msvc-tools"
458version = "0.1.14"
459source = "registry+https://github.com/rust-lang/crates.io-index"
460checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
461
226[[package]]462[[package]]
227name = "flate2"463name = "flate2"
228version = "1.1.10"464version = "1.1.10"
...@@ -257,10 +493,10 @@ dependencies = [...@@ -257,10 +493,10 @@ dependencies = [
257 "hashbrown 0.17.1",493 "hashbrown 0.17.1",
258 "linebender_resource_handle",494 "linebender_resource_handle",
259 "memmap2",495 "memmap2",
260 "objc2",496 "objc2 0.6.4",
261 "objc2-core-foundation",497 "objc2-core-foundation",
262 "objc2-core-text",498 "objc2-core-text",
263 "objc2-foundation",499 "objc2-foundation 0.3.2",
264 "parlance",500 "parlance",
265 "read-fonts",501 "read-fonts",
266 "roxmltree",502 "roxmltree",
...@@ -270,6 +506,33 @@ dependencies = [...@@ -270,6 +506,33 @@ dependencies = [
270 "yeslogic-fontconfig-sys",506 "yeslogic-fontconfig-sys",
271]507]
272508
509[[package]]
510name = "foreign-types"
511version = "0.5.0"
512source = "registry+https://github.com/rust-lang/crates.io-index"
513checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
514dependencies = [
515 "foreign-types-macros",
516 "foreign-types-shared",
517]
518
519[[package]]
520name = "foreign-types-macros"
521version = "0.2.4"
522source = "registry+https://github.com/rust-lang/crates.io-index"
523checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
524dependencies = [
525 "proc-macro2",
526 "quote",
527 "syn 3.0.6",
528]
529
530[[package]]
531name = "foreign-types-shared"
532version = "0.3.1"
533source = "registry+https://github.com/rust-lang/crates.io-index"
534checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
535
273[[package]]536[[package]]
274name = "futures-core"537name = "futures-core"
275version = "0.3.34"538version = "0.3.34"
...@@ -294,6 +557,39 @@ dependencies = [...@@ -294,6 +557,39 @@ dependencies = [
294 "slab",557 "slab",
295]558]
296559
560[[package]]
561name = "gethostname"
562version = "1.1.0"
563source = "registry+https://github.com/rust-lang/crates.io-index"
564checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
565dependencies = [
566 "rustix 1.1.5",
567 "windows-link",
568]
569
570[[package]]
571name = "getrandom"
572version = "0.3.4"
573source = "registry+https://github.com/rust-lang/crates.io-index"
574checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
575dependencies = [
576 "cfg-if",
577 "libc",
578 "r-efi 5.3.0",
579 "wasip2",
580]
581
582[[package]]
583name = "getrandom"
584version = "0.4.3"
585source = "registry+https://github.com/rust-lang/crates.io-index"
586checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
587dependencies = [
588 "cfg-if",
589 "libc",
590 "r-efi 6.0.0",
591]
592
297[[package]]593[[package]]
298name = "gif"594name = "gif"
299version = "0.14.2"595version = "0.14.2"
...@@ -304,10 +600,6 @@ dependencies = [...@@ -304,10 +600,6 @@ dependencies = [
304 "weezl",600 "weezl",
305]601]
306602
307[[package]]
308name = "gl21"
309version = "0.0.0"
310
311[[package]]603[[package]]
312name = "gl_generator"604name = "gl_generator"
313version = "0.14.0"605version = "0.14.0"
...@@ -350,7 +642,7 @@ dependencies = [...@@ -350,7 +642,7 @@ dependencies = [
350 "hashbrown 0.16.1",642 "hashbrown 0.16.1",
351 "log",643 "log",
352 "presser",644 "presser",
353 "thiserror",645 "thiserror 2.0.21",
354 "windows",646 "windows",
355]647]
356648
...@@ -372,7 +664,7 @@ version = "0.12.0"...@@ -372,7 +664,7 @@ version = "0.12.0"
372source = "registry+https://github.com/rust-lang/crates.io-index"664source = "registry+https://github.com/rust-lang/crates.io-index"
373checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"665checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
374dependencies = [666dependencies = [
375 "bitflags",667 "bitflags 2.13.1",
376 "bytemuck",668 "bytemuck",
377 "read-fonts",669 "read-fonts",
378 "smallvec",670 "smallvec",
...@@ -402,6 +694,12 @@ dependencies = [...@@ -402,6 +694,12 @@ dependencies = [
402name = "hello-snow-leopard"694name = "hello-snow-leopard"
403version = "0.0.0"695version = "0.0.0"
404696
697[[package]]
698name = "hermit-abi"
699version = "0.5.3"
700source = "registry+https://github.com/rust-lang/crates.io-index"
701checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
702
405[[package]]703[[package]]
406name = "icu_collections"704name = "icu_collections"
407version = "2.3.0"705version = "2.3.0"
...@@ -558,6 +856,36 @@ dependencies = [...@@ -558,6 +856,36 @@ dependencies = [
558 "hashbrown 0.17.1",856 "hashbrown 0.17.1",
559]857]
560858
859[[package]]
860name = "jni"
861version = "0.22.4"
862source = "registry+https://github.com/rust-lang/crates.io-index"
863checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
864dependencies = [
865 "cfg-if",
866 "combine",
867 "jni-macros",
868 "jni-sys 0.4.1",
869 "log",
870 "simd_cesu8",
871 "thiserror 2.0.21",
872 "walkdir",
873 "windows-link",
874]
875
876[[package]]
877name = "jni-macros"
878version = "0.22.4"
879source = "registry+https://github.com/rust-lang/crates.io-index"
880checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
881dependencies = [
882 "proc-macro2",
883 "quote",
884 "rustc_version",
885 "simd_cesu8",
886 "syn 2.0.119",
887]
888
561[[package]]889[[package]]
562name = "jni-sys"890name = "jni-sys"
563version = "0.3.1"891version = "0.3.1"
...@@ -586,6 +914,16 @@ dependencies = [...@@ -586,6 +914,16 @@ dependencies = [
586 "syn 2.0.119",914 "syn 2.0.119",
587]915]
588916
917[[package]]
918name = "jobserver"
919version = "0.1.35"
920source = "registry+https://github.com/rust-lang/crates.io-index"
921checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
922dependencies = [
923 "getrandom 0.4.3",
924 "libc",
925]
926
589[[package]]927[[package]]
590name = "js-sys"928name = "js-sys"
591version = "0.3.105"929version = "0.3.105"
...@@ -636,12 +974,36 @@ version = "0.2.16"...@@ -636,12 +974,36 @@ version = "0.2.16"
636source = "registry+https://github.com/rust-lang/crates.io-index"974source = "registry+https://github.com/rust-lang/crates.io-index"
637checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"975checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
638976
977[[package]]
978name = "libredox"
979version = "0.1.25"
980source = "registry+https://github.com/rust-lang/crates.io-index"
981checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
982dependencies = [
983 "bitflags 2.13.1",
984 "libc",
985 "plain",
986 "redox_syscall 0.9.4",
987]
988
639[[package]]989[[package]]
640name = "linebender_resource_handle"990name = "linebender_resource_handle"
641version = "0.1.1"991version = "0.1.1"
642source = "registry+https://github.com/rust-lang/crates.io-index"992source = "registry+https://github.com/rust-lang/crates.io-index"
643checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"993checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
644994
995[[package]]
996name = "linux-raw-sys"
997version = "0.4.15"
998source = "registry+https://github.com/rust-lang/crates.io-index"
999checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
1000
1001[[package]]
1002name = "linux-raw-sys"
1003version = "0.12.1"
1004source = "registry+https://github.com/rust-lang/crates.io-index"
1005checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
1006
645[[package]]1007[[package]]
646name = "litemap"1008name = "litemap"
647version = "0.8.3"1009version = "0.8.3"
...@@ -722,7 +1084,7 @@ checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"...@@ -722,7 +1084,7 @@ checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
722dependencies = [1084dependencies = [
723 "arrayvec",1085 "arrayvec",
724 "bit-set",1086 "bit-set",
725 "bitflags",1087 "bitflags 2.13.1",
726 "cfg-if",1088 "cfg-if",
727 "cfg_aliases",1089 "cfg_aliases",
728 "codespan-reporting",1090 "codespan-reporting",
...@@ -736,7 +1098,7 @@ dependencies = [...@@ -736,7 +1098,7 @@ dependencies = [
736 "once_cell",1098 "once_cell",
737 "rustc-hash",1099 "rustc-hash",
738 "spirv",1100 "spirv",
739 "thiserror",1101 "thiserror 2.0.21",
740 "unicode-ident",1102 "unicode-ident",
741]1103]
7421104
...@@ -749,9 +1111,30 @@ dependencies = [...@@ -749,9 +1111,30 @@ dependencies = [
749 "hashbrown 0.17.1",1111 "hashbrown 0.17.1",
750 "indexmap",1112 "indexmap",
751 "rustc-hash",1113 "rustc-hash",
752 "thiserror",1114 "thiserror 2.0.21",
1115]
1116
1117[[package]]
1118name = "ndk"
1119version = "0.9.0"
1120source = "registry+https://github.com/rust-lang/crates.io-index"
1121checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
1122dependencies = [
1123 "bitflags 2.13.1",
1124 "jni-sys 0.3.1",
1125 "log",
1126 "ndk-sys",
1127 "num_enum",
1128 "raw-window-handle",
1129 "thiserror 1.0.69",
753]1130]
7541131
1132[[package]]
1133name = "ndk-context"
1134version = "0.1.1"
1135source = "registry+https://github.com/rust-lang/crates.io-index"
1136checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
1137
755[[package]]1138[[package]]
756name = "ndk-sys"1139name = "ndk-sys"
757version = "0.6.0+11769913"1140version = "0.6.0+11769913"
...@@ -761,6 +1144,16 @@ dependencies = [...@@ -761,6 +1144,16 @@ dependencies = [
761 "jni-sys 0.3.1",1144 "jni-sys 0.3.1",
762]1145]
7631146
1147[[package]]
1148name = "nucleo-matcher"
1149version = "0.3.1"
1150source = "registry+https://github.com/rust-lang/crates.io-index"
1151checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
1152dependencies = [
1153 "memchr",
1154 "unicode-segmentation",
1155]
1156
764[[package]]1157[[package]]
765name = "num-traits"1158name = "num-traits"
766version = "0.2.19"1159version = "0.2.19"
...@@ -772,55 +1165,168 @@ dependencies = [...@@ -772,55 +1165,168 @@ dependencies = [
772]1165]
7731166
774[[package]]1167[[package]]
775name = "objc2"1168name = "num_enum"
776version = "0.6.4"1169version = "0.7.6"
777source = "registry+https://github.com/rust-lang/crates.io-index"1170source = "registry+https://github.com/rust-lang/crates.io-index"
778checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"1171checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
779dependencies = [1172dependencies = [
780 "objc2-encode",1173 "num_enum_derive",
1174 "rustversion",
781]1175]
7821176
783[[package]]1177[[package]]
784name = "objc2-app-kit"1178name = "num_enum_derive"
785version = "0.3.2"1179version = "0.7.6"
786source = "registry+https://github.com/rust-lang/crates.io-index"1180source = "registry+https://github.com/rust-lang/crates.io-index"
787checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"1181checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
788dependencies = [1182dependencies = [
789 "bitflags",1183 "proc-macro-crate",
790 "objc2",1184 "proc-macro2",
791 "objc2-foundation",1185 "quote",
1186 "syn 2.0.119",
792]1187]
7931188
794[[package]]1189[[package]]
795name = "objc2-core-foundation"1190name = "objc-sys"
796version = "0.3.2"1191version = "0.3.5"
797source = "registry+https://github.com/rust-lang/crates.io-index"1192source = "registry+https://github.com/rust-lang/crates.io-index"
798checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"1193checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
799dependencies = [
800 "bitflags",
801 "dispatch2",
802 "objc2",
803]
8041194
805[[package]]1195[[package]]
806name = "objc2-core-graphics"1196name = "objc2"
807version = "0.3.2"1197version = "0.5.2"
808source = "registry+https://github.com/rust-lang/crates.io-index"1198source = "registry+https://github.com/rust-lang/crates.io-index"
809checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"1199checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
810dependencies = [1200dependencies = [
811 "dispatch2",1201 "objc-sys",
812 "objc2",1202 "objc2-encode",
813 "objc2-core-foundation",1203]
1204
1205[[package]]
1206name = "objc2"
1207version = "0.6.4"
1208source = "registry+https://github.com/rust-lang/crates.io-index"
1209checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
1210dependencies = [
1211 "objc2-encode",
1212]
1213
1214[[package]]
1215name = "objc2-app-kit"
1216version = "0.2.2"
1217source = "registry+https://github.com/rust-lang/crates.io-index"
1218checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
1219dependencies = [
1220 "bitflags 2.13.1",
1221 "block2 0.5.1",
1222 "libc",
1223 "objc2 0.5.2",
1224 "objc2-core-data",
1225 "objc2-core-image",
1226 "objc2-foundation 0.2.2",
1227 "objc2-quartz-core 0.2.2",
1228]
1229
1230[[package]]
1231name = "objc2-app-kit"
1232version = "0.3.2"
1233source = "registry+https://github.com/rust-lang/crates.io-index"
1234checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
1235dependencies = [
1236 "objc2 0.6.4",
1237 "objc2-foundation 0.3.2",
1238]
1239
1240[[package]]
1241name = "objc2-cloud-kit"
1242version = "0.2.2"
1243source = "registry+https://github.com/rust-lang/crates.io-index"
1244checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
1245dependencies = [
1246 "bitflags 2.13.1",
1247 "block2 0.5.1",
1248 "objc2 0.5.2",
1249 "objc2-core-location",
1250 "objc2-foundation 0.2.2",
1251]
1252
1253[[package]]
1254name = "objc2-contacts"
1255version = "0.2.2"
1256source = "registry+https://github.com/rust-lang/crates.io-index"
1257checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
1258dependencies = [
1259 "block2 0.5.1",
1260 "objc2 0.5.2",
1261 "objc2-foundation 0.2.2",
1262]
1263
1264[[package]]
1265name = "objc2-core-data"
1266version = "0.2.2"
1267source = "registry+https://github.com/rust-lang/crates.io-index"
1268checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
1269dependencies = [
1270 "bitflags 2.13.1",
1271 "block2 0.5.1",
1272 "objc2 0.5.2",
1273 "objc2-foundation 0.2.2",
1274]
1275
1276[[package]]
1277name = "objc2-core-foundation"
1278version = "0.3.2"
1279source = "registry+https://github.com/rust-lang/crates.io-index"
1280checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
1281dependencies = [
1282 "bitflags 2.13.1",
1283 "dispatch2",
1284 "objc2 0.6.4",
1285]
1286
1287[[package]]
1288name = "objc2-core-graphics"
1289version = "0.3.2"
1290source = "registry+https://github.com/rust-lang/crates.io-index"
1291checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
1292dependencies = [
1293 "dispatch2",
1294 "objc2 0.6.4",
1295 "objc2-core-foundation",
814 "objc2-io-surface",1296 "objc2-io-surface",
815]1297]
8161298
1299[[package]]
1300name = "objc2-core-image"
1301version = "0.2.2"
1302source = "registry+https://github.com/rust-lang/crates.io-index"
1303checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
1304dependencies = [
1305 "block2 0.5.1",
1306 "objc2 0.5.2",
1307 "objc2-foundation 0.2.2",
1308 "objc2-metal 0.2.2",
1309]
1310
1311[[package]]
1312name = "objc2-core-location"
1313version = "0.2.2"
1314source = "registry+https://github.com/rust-lang/crates.io-index"
1315checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
1316dependencies = [
1317 "block2 0.5.1",
1318 "objc2 0.5.2",
1319 "objc2-contacts",
1320 "objc2-foundation 0.2.2",
1321]
1322
817[[package]]1323[[package]]
818name = "objc2-core-text"1324name = "objc2-core-text"
819version = "0.3.2"1325version = "0.3.2"
820source = "registry+https://github.com/rust-lang/crates.io-index"1326source = "registry+https://github.com/rust-lang/crates.io-index"
821checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"1327checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
822dependencies = [1328dependencies = [
823 "bitflags",1329 "bitflags 2.13.1",
824 "objc2-core-foundation",1330 "objc2-core-foundation",
825]1331]
8261332
...@@ -830,14 +1336,27 @@ version = "4.1.0"...@@ -830,14 +1336,27 @@ version = "4.1.0"
830source = "registry+https://github.com/rust-lang/crates.io-index"1336source = "registry+https://github.com/rust-lang/crates.io-index"
831checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"1337checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
8321338
1339[[package]]
1340name = "objc2-foundation"
1341version = "0.2.2"
1342source = "registry+https://github.com/rust-lang/crates.io-index"
1343checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
1344dependencies = [
1345 "bitflags 2.13.1",
1346 "block2 0.5.1",
1347 "dispatch",
1348 "libc",
1349 "objc2 0.5.2",
1350]
1351
833[[package]]1352[[package]]
834name = "objc2-foundation"1353name = "objc2-foundation"
835version = "0.3.2"1354version = "0.3.2"
836source = "registry+https://github.com/rust-lang/crates.io-index"1355source = "registry+https://github.com/rust-lang/crates.io-index"
837checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"1356checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
838dependencies = [1357dependencies = [
839 "bitflags",1358 "bitflags 2.13.1",
840 "objc2",1359 "objc2 0.6.4",
841 "objc2-core-foundation",1360 "objc2-core-foundation",
842]1361]
8431362
...@@ -847,21 +1366,58 @@ version = "0.3.2"...@@ -847,21 +1366,58 @@ version = "0.3.2"
847source = "registry+https://github.com/rust-lang/crates.io-index"1366source = "registry+https://github.com/rust-lang/crates.io-index"
848checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"1367checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
849dependencies = [1368dependencies = [
850 "bitflags",1369 "bitflags 2.13.1",
851 "objc2",1370 "objc2 0.6.4",
852 "objc2-core-foundation",1371 "objc2-core-foundation",
853]1372]
8541373
1374[[package]]
1375name = "objc2-link-presentation"
1376version = "0.2.2"
1377source = "registry+https://github.com/rust-lang/crates.io-index"
1378checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
1379dependencies = [
1380 "block2 0.5.1",
1381 "objc2 0.5.2",
1382 "objc2-app-kit 0.2.2",
1383 "objc2-foundation 0.2.2",
1384]
1385
1386[[package]]
1387name = "objc2-metal"
1388version = "0.2.2"
1389source = "registry+https://github.com/rust-lang/crates.io-index"
1390checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
1391dependencies = [
1392 "bitflags 2.13.1",
1393 "block2 0.5.1",
1394 "objc2 0.5.2",
1395 "objc2-foundation 0.2.2",
1396]
1397
855[[package]]1398[[package]]
856name = "objc2-metal"1399name = "objc2-metal"
857version = "0.3.2"1400version = "0.3.2"
858source = "registry+https://github.com/rust-lang/crates.io-index"1401source = "registry+https://github.com/rust-lang/crates.io-index"
859checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"1402checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
860dependencies = [1403dependencies = [
861 "bitflags",1404 "bitflags 2.13.1",
862 "block2",1405 "block2 0.6.2",
863 "objc2",1406 "objc2 0.6.4",
864 "objc2-foundation",1407 "objc2-foundation 0.3.2",
1408]
1409
1410[[package]]
1411name = "objc2-quartz-core"
1412version = "0.2.2"
1413source = "registry+https://github.com/rust-lang/crates.io-index"
1414checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
1415dependencies = [
1416 "bitflags 2.13.1",
1417 "block2 0.5.1",
1418 "objc2 0.5.2",
1419 "objc2-foundation 0.2.2",
1420 "objc2-metal 0.2.2",
865]1421]
8661422
867[[package]]1423[[package]]
...@@ -870,12 +1426,67 @@ version = "0.3.2"...@@ -870,12 +1426,67 @@ version = "0.3.2"
870source = "registry+https://github.com/rust-lang/crates.io-index"1426source = "registry+https://github.com/rust-lang/crates.io-index"
871checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"1427checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
872dependencies = [1428dependencies = [
873 "bitflags",1429 "bitflags 2.13.1",
874 "objc2",1430 "objc2 0.6.4",
875 "objc2-core-foundation",1431 "objc2-core-foundation",
876 "objc2-core-graphics",1432 "objc2-core-graphics",
877 "objc2-foundation",1433 "objc2-foundation 0.3.2",
878 "objc2-metal",1434 "objc2-metal 0.3.2",
1435]
1436
1437[[package]]
1438name = "objc2-symbols"
1439version = "0.2.2"
1440source = "registry+https://github.com/rust-lang/crates.io-index"
1441checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
1442dependencies = [
1443 "objc2 0.5.2",
1444 "objc2-foundation 0.2.2",
1445]
1446
1447[[package]]
1448name = "objc2-ui-kit"
1449version = "0.2.2"
1450source = "registry+https://github.com/rust-lang/crates.io-index"
1451checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
1452dependencies = [
1453 "bitflags 2.13.1",
1454 "block2 0.5.1",
1455 "objc2 0.5.2",
1456 "objc2-cloud-kit",
1457 "objc2-core-data",
1458 "objc2-core-image",
1459 "objc2-core-location",
1460 "objc2-foundation 0.2.2",
1461 "objc2-link-presentation",
1462 "objc2-quartz-core 0.2.2",
1463 "objc2-symbols",
1464 "objc2-uniform-type-identifiers",
1465 "objc2-user-notifications",
1466]
1467
1468[[package]]
1469name = "objc2-uniform-type-identifiers"
1470version = "0.2.2"
1471source = "registry+https://github.com/rust-lang/crates.io-index"
1472checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
1473dependencies = [
1474 "block2 0.5.1",
1475 "objc2 0.5.2",
1476 "objc2-foundation 0.2.2",
1477]
1478
1479[[package]]
1480name = "objc2-user-notifications"
1481version = "0.2.2"
1482source = "registry+https://github.com/rust-lang/crates.io-index"
1483checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
1484dependencies = [
1485 "bitflags 2.13.1",
1486 "block2 0.5.1",
1487 "objc2 0.5.2",
1488 "objc2-core-location",
1489 "objc2-foundation 0.2.2",
879]1490]
8801491
881[[package]]1492[[package]]
...@@ -884,6 +1495,16 @@ version = "1.21.4"...@@ -884,6 +1495,16 @@ version = "1.21.4"
884source = "registry+https://github.com/rust-lang/crates.io-index"1495source = "registry+https://github.com/rust-lang/crates.io-index"
885checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"1496checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
8861497
1498[[package]]
1499name = "orbclient"
1500version = "0.3.55"
1501source = "registry+https://github.com/rust-lang/crates.io-index"
1502checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747"
1503dependencies = [
1504 "libc",
1505 "libredox",
1506]
1507
887[[package]]1508[[package]]
888name = "ordered-float"1509name = "ordered-float"
889version = "5.5.0"1510version = "5.5.0"
...@@ -893,6 +1514,15 @@ dependencies = [...@@ -893,6 +1514,15 @@ dependencies = [
893 "num-traits",1514 "num-traits",
894]1515]
8951516
1517[[package]]
1518name = "owned_ttf_parser"
1519version = "0.25.1"
1520source = "registry+https://github.com/rust-lang/crates.io-index"
1521checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
1522dependencies = [
1523 "ttf-parser",
1524]
1525
896[[package]]1526[[package]]
897name = "parking_lot"1527name = "parking_lot"
898version = "0.12.5"1528version = "0.12.5"
...@@ -911,7 +1541,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"...@@ -911,7 +1541,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
911dependencies = [1541dependencies = [
912 "cfg-if",1542 "cfg-if",
913 "libc",1543 "libc",
914 "redox_syscall",1544 "redox_syscall 0.5.18",
915 "smallvec",1545 "smallvec",
916 "windows-link",1546 "windows-link",
917]1547]
...@@ -960,6 +1590,32 @@ dependencies = [...@@ -960,6 +1590,32 @@ dependencies = [
960 "skrifa",1590 "skrifa",
961]1591]
9621592
1593[[package]]
1594name = "percent-encoding"
1595version = "2.3.2"
1596source = "registry+https://github.com/rust-lang/crates.io-index"
1597checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1598
1599[[package]]
1600name = "pin-project"
1601version = "1.1.13"
1602source = "registry+https://github.com/rust-lang/crates.io-index"
1603checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
1604dependencies = [
1605 "pin-project-internal",
1606]
1607
1608[[package]]
1609name = "pin-project-internal"
1610version = "1.1.13"
1611source = "registry+https://github.com/rust-lang/crates.io-index"
1612checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
1613dependencies = [
1614 "proc-macro2",
1615 "quote",
1616 "syn 2.0.119",
1617]
1618
963[[package]]1619[[package]]
964name = "pin-project-lite"1620name = "pin-project-lite"
965version = "0.2.17"1621version = "0.2.17"
...@@ -972,19 +1628,39 @@ version = "0.3.34"...@@ -972,19 +1628,39 @@ version = "0.3.34"
972source = "registry+https://github.com/rust-lang/crates.io-index"1628source = "registry+https://github.com/rust-lang/crates.io-index"
973checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"1629checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
9741630
1631[[package]]
1632name = "plain"
1633version = "0.2.3"
1634source = "registry+https://github.com/rust-lang/crates.io-index"
1635checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
1636
975[[package]]1637[[package]]
976name = "png"1638name = "png"
977version = "0.18.1"1639version = "0.18.1"
978source = "registry+https://github.com/rust-lang/crates.io-index"1640source = "registry+https://github.com/rust-lang/crates.io-index"
979checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"1641checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
980dependencies = [1642dependencies = [
981 "bitflags",1643 "bitflags 2.13.1",
982 "crc32fast",1644 "crc32fast",
983 "fdeflate",1645 "fdeflate",
984 "flate2",1646 "flate2",
985 "miniz_oxide 0.8.9",1647 "miniz_oxide 0.8.9",
986]1648]
9871649
1650[[package]]
1651name = "polling"
1652version = "3.11.0"
1653source = "registry+https://github.com/rust-lang/crates.io-index"
1654checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
1655dependencies = [
1656 "cfg-if",
1657 "concurrent-queue",
1658 "hermit-abi",
1659 "pin-project-lite",
1660 "rustix 1.1.5",
1661 "windows-sys 0.61.2",
1662]
1663
988[[package]]1664[[package]]
989name = "pollster"1665name = "pollster"
990version = "0.4.0"1666version = "0.4.0"
...@@ -1027,11 +1703,21 @@ checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"...@@ -1027,11 +1703,21 @@ checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
1027name = "probe"1703name = "probe"
1028version = "0.0.0"1704version = "0.0.0"
1029dependencies = [1705dependencies = [
1030 "gl21",1706 "draw",
1031 "objc2",1707 "objc2 0.6.4",
1032 "objc2-app-kit",1708 "objc2-app-kit 0.3.2",
1033 "objc2-foundation",1709 "objc2-foundation 0.3.2",
1034 "png",1710 "png",
1711 "ui",
1712]
1713
1714[[package]]
1715name = "proc-macro-crate"
1716version = "3.5.0"
1717source = "registry+https://github.com/rust-lang/crates.io-index"
1718checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
1719dependencies = [
1720 "toml_edit",
1035]1721]
10361722
1037[[package]]1723[[package]]
...@@ -1061,6 +1747,15 @@ version = "2.0.1"...@@ -1061,6 +1747,15 @@ version = "2.0.1"
1061source = "registry+https://github.com/rust-lang/crates.io-index"1747source = "registry+https://github.com/rust-lang/crates.io-index"
1062checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"1748checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
10631749
1750[[package]]
1751name = "quick-xml"
1752version = "0.41.0"
1753source = "registry+https://github.com/rust-lang/crates.io-index"
1754checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
1755dependencies = [
1756 "memchr",
1757]
1758
1064[[package]]1759[[package]]
1065name = "quote"1760name = "quote"
1066version = "1.0.47"1761version = "1.0.47"
...@@ -1070,6 +1765,18 @@ dependencies = [...@@ -1070,6 +1765,18 @@ dependencies = [
1070 "proc-macro2",1765 "proc-macro2",
1071]1766]
10721767
1768[[package]]
1769name = "r-efi"
1770version = "5.3.0"
1771source = "registry+https://github.com/rust-lang/crates.io-index"
1772checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1773
1774[[package]]
1775name = "r-efi"
1776version = "6.0.0"
1777source = "registry+https://github.com/rust-lang/crates.io-index"
1778checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1779
1073[[package]]1780[[package]]
1074name = "range-alloc"1781name = "range-alloc"
1075version = "0.1.5"1782version = "0.1.5"
...@@ -1088,10 +1795,10 @@ version = "1.1.0"...@@ -1088,10 +1795,10 @@ version = "1.1.0"
1088source = "registry+https://github.com/rust-lang/crates.io-index"1795source = "registry+https://github.com/rust-lang/crates.io-index"
1089checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"1796checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
1090dependencies = [1797dependencies = [
1091 "objc2",1798 "objc2 0.6.4",
1092 "objc2-core-foundation",1799 "objc2-core-foundation",
1093 "objc2-foundation",1800 "objc2-foundation 0.3.2",
1094 "objc2-quartz-core",1801 "objc2-quartz-core 0.3.2",
1095]1802]
10961803
1097[[package]]1804[[package]]
...@@ -1105,13 +1812,31 @@ dependencies = [...@@ -1105,13 +1812,31 @@ dependencies = [
1105 "once_cell",1812 "once_cell",
1106]1813]
11071814
1815[[package]]
1816name = "redox_syscall"
1817version = "0.4.1"
1818source = "registry+https://github.com/rust-lang/crates.io-index"
1819checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
1820dependencies = [
1821 "bitflags 1.3.2",
1822]
1823
1108[[package]]1824[[package]]
1109name = "redox_syscall"1825name = "redox_syscall"
1110version = "0.5.18"1826version = "0.5.18"
1111source = "registry+https://github.com/rust-lang/crates.io-index"1827source = "registry+https://github.com/rust-lang/crates.io-index"
1112checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"1828checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1113dependencies = [1829dependencies = [
1114 "bitflags",1830 "bitflags 2.13.1",
1831]
1832
1833[[package]]
1834name = "redox_syscall"
1835version = "0.9.4"
1836source = "registry+https://github.com/rust-lang/crates.io-index"
1837checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
1838dependencies = [
1839 "bitflags 2.13.1",
1115]1840]
11161841
1117[[package]]1842[[package]]
...@@ -1121,6 +1846,7 @@ dependencies = [...@@ -1121,6 +1846,7 @@ dependencies = [
1121 "draw",1846 "draw",
1122 "png",1847 "png",
1123 "pollster",1848 "pollster",
1849 "ui",
1124 "wgpu",1850 "wgpu",
1125]1851]
11261852
...@@ -1145,18 +1871,87 @@ version = "1.1.0"...@@ -1145,18 +1871,87 @@ version = "1.1.0"
1145source = "registry+https://github.com/rust-lang/crates.io-index"1871source = "registry+https://github.com/rust-lang/crates.io-index"
1146checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"1872checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
11471873
1874[[package]]
1875name = "rustc_version"
1876version = "0.4.1"
1877source = "registry+https://github.com/rust-lang/crates.io-index"
1878checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1879dependencies = [
1880 "semver",
1881]
1882
1883[[package]]
1884name = "rustix"
1885version = "0.38.44"
1886source = "registry+https://github.com/rust-lang/crates.io-index"
1887checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
1888dependencies = [
1889 "bitflags 2.13.1",
1890 "errno",
1891 "libc",
1892 "linux-raw-sys 0.4.15",
1893 "windows-sys 0.59.0",
1894]
1895
1896[[package]]
1897name = "rustix"
1898version = "1.1.5"
1899source = "registry+https://github.com/rust-lang/crates.io-index"
1900checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
1901dependencies = [
1902 "bitflags 2.13.1",
1903 "errno",
1904 "libc",
1905 "linux-raw-sys 0.12.1",
1906 "windows-sys 0.61.2",
1907]
1908
1148[[package]]1909[[package]]
1149name = "rustversion"1910name = "rustversion"
1150version = "1.0.23"1911version = "1.0.23"
1151source = "registry+https://github.com/rust-lang/crates.io-index"1912source = "registry+https://github.com/rust-lang/crates.io-index"
1152checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"1913checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
11531914
1915[[package]]
1916name = "same-file"
1917version = "1.0.6"
1918source = "registry+https://github.com/rust-lang/crates.io-index"
1919checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1920dependencies = [
1921 "winapi-util",
1922]
1923
1924[[package]]
1925name = "scoped-tls"
1926version = "1.0.1"
1927source = "registry+https://github.com/rust-lang/crates.io-index"
1928checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
1929
1154[[package]]1930[[package]]
1155name = "scopeguard"1931name = "scopeguard"
1156version = "1.2.0"1932version = "1.2.0"
1157source = "registry+https://github.com/rust-lang/crates.io-index"1933source = "registry+https://github.com/rust-lang/crates.io-index"
1158checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"1934checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
11591935
1936[[package]]
1937name = "sctk-adwaita"
1938version = "0.10.1"
1939source = "registry+https://github.com/rust-lang/crates.io-index"
1940checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
1941dependencies = [
1942 "ab_glyph",
1943 "log",
1944 "memmap2",
1945 "smithay-client-toolkit",
1946 "tiny-skia",
1947]
1948
1949[[package]]
1950name = "semver"
1951version = "1.0.28"
1952source = "registry+https://github.com/rust-lang/crates.io-index"
1953checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1954
1160[[package]]1955[[package]]
1161name = "serde"1956name = "serde"
1162version = "1.0.229"1957version = "1.0.229"
...@@ -1187,12 +1982,34 @@ dependencies = [...@@ -1187,12 +1982,34 @@ dependencies = [
1187 "syn 3.0.6",1982 "syn 3.0.6",
1188]1983]
11891984
1985[[package]]
1986name = "shlex"
1987version = "2.0.1"
1988source = "registry+https://github.com/rust-lang/crates.io-index"
1989checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1990
1190[[package]]1991[[package]]
1191name = "simd-adler32"1992name = "simd-adler32"
1192version = "0.3.10"1993version = "0.3.10"
1193source = "registry+https://github.com/rust-lang/crates.io-index"1994source = "registry+https://github.com/rust-lang/crates.io-index"
1194checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"1995checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
11951996
1997[[package]]
1998name = "simd_cesu8"
1999version = "1.2.0"
2000source = "registry+https://github.com/rust-lang/crates.io-index"
2001checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
2002dependencies = [
2003 "rustc_version",
2004 "simdutf8",
2005]
2006
2007[[package]]
2008name = "simdutf8"
2009version = "0.1.5"
2010source = "registry+https://github.com/rust-lang/crates.io-index"
2011checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
2012
1196[[package]]2013[[package]]
1197name = "skrifa"2014name = "skrifa"
1198version = "0.44.0"2015version = "0.44.0"
...@@ -1224,13 +2041,47 @@ version = "1.16.2"...@@ -1224,13 +2041,47 @@ version = "1.16.2"
1224source = "registry+https://github.com/rust-lang/crates.io-index"2041source = "registry+https://github.com/rust-lang/crates.io-index"
1225checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"2042checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
12262043
2044[[package]]
2045name = "smithay-client-toolkit"
2046version = "0.19.2"
2047source = "registry+https://github.com/rust-lang/crates.io-index"
2048checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
2049dependencies = [
2050 "bitflags 2.13.1",
2051 "calloop",
2052 "calloop-wayland-source",
2053 "cursor-icon",
2054 "libc",
2055 "log",
2056 "memmap2",
2057 "rustix 0.38.44",
2058 "thiserror 1.0.69",
2059 "wayland-backend",
2060 "wayland-client",
2061 "wayland-csd-frame",
2062 "wayland-cursor",
2063 "wayland-protocols",
2064 "wayland-protocols-wlr",
2065 "wayland-scanner",
2066 "xkeysym",
2067]
2068
2069[[package]]
2070name = "smol_str"
2071version = "0.2.2"
2072source = "registry+https://github.com/rust-lang/crates.io-index"
2073checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
2074dependencies = [
2075 "serde",
2076]
2077
1227[[package]]2078[[package]]
1228name = "spirv"2079name = "spirv"
1229version = "0.4.0+sdk-1.4.341.0"2080version = "0.4.0+sdk-1.4.341.0"
1230source = "registry+https://github.com/rust-lang/crates.io-index"2081source = "registry+https://github.com/rust-lang/crates.io-index"
1231checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"2082checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
1232dependencies = [2083dependencies = [
1233 "bitflags",2084 "bitflags 2.13.1",
1234]2085]
12352086
1236[[package]]2087[[package]]
...@@ -1245,6 +2096,12 @@ version = "1.1.0"...@@ -1245,6 +2096,12 @@ version = "1.1.0"
1245source = "registry+https://github.com/rust-lang/crates.io-index"2096source = "registry+https://github.com/rust-lang/crates.io-index"
1246checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"2097checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
12472098
2099[[package]]
2100name = "strict-num"
2101version = "0.1.1"
2102source = "registry+https://github.com/rust-lang/crates.io-index"
2103checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
2104
1248[[package]]2105[[package]]
1249name = "swash"2106name = "swash"
1250version = "0.2.10"2107version = "0.2.10"
...@@ -1298,13 +2155,33 @@ dependencies = [...@@ -1298,13 +2155,33 @@ dependencies = [
1298 "winapi-util",2155 "winapi-util",
1299]2156]
13002157
2158[[package]]
2159name = "thiserror"
2160version = "1.0.69"
2161source = "registry+https://github.com/rust-lang/crates.io-index"
2162checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
2163dependencies = [
2164 "thiserror-impl 1.0.69",
2165]
2166
1301[[package]]2167[[package]]
1302name = "thiserror"2168name = "thiserror"
1303version = "2.0.21"2169version = "2.0.21"
1304source = "registry+https://github.com/rust-lang/crates.io-index"2170source = "registry+https://github.com/rust-lang/crates.io-index"
1305checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"2171checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
1306dependencies = [2172dependencies = [
1307 "thiserror-impl",2173 "thiserror-impl 2.0.21",
2174]
2175
2176[[package]]
2177name = "thiserror-impl"
2178version = "1.0.69"
2179source = "registry+https://github.com/rust-lang/crates.io-index"
2180checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
2181dependencies = [
2182 "proc-macro2",
2183 "quote",
2184 "syn 2.0.119",
1308]2185]
13092186
1310[[package]]2187[[package]]
...@@ -1332,6 +2209,31 @@ dependencies = [...@@ -1332,6 +2209,31 @@ dependencies = [
1332 "zune-jpeg",2209 "zune-jpeg",
1333]2210]
13342211
2212[[package]]
2213name = "tiny-skia"
2214version = "0.11.4"
2215source = "registry+https://github.com/rust-lang/crates.io-index"
2216checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
2217dependencies = [
2218 "arrayref",
2219 "arrayvec",
2220 "bytemuck",
2221 "cfg-if",
2222 "log",
2223 "tiny-skia-path",
2224]
2225
2226[[package]]
2227name = "tiny-skia-path"
2228version = "0.11.4"
2229source = "registry+https://github.com/rust-lang/crates.io-index"
2230checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
2231dependencies = [
2232 "arrayref",
2233 "bytemuck",
2234 "strict-num",
2235]
2236
1335[[package]]2237[[package]]
1336name = "tinystr"2238name = "tinystr"
1337version = "0.8.4"2239version = "0.8.4"
...@@ -1343,12 +2245,80 @@ dependencies = [...@@ -1343,12 +2245,80 @@ dependencies = [
1343 "zerovec",2245 "zerovec",
1344]2246]
13452247
2248[[package]]
2249name = "toml_datetime"
2250version = "1.1.1+spec-1.1.0"
2251source = "registry+https://github.com/rust-lang/crates.io-index"
2252checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
2253dependencies = [
2254 "serde_core",
2255]
2256
2257[[package]]
2258name = "toml_edit"
2259version = "0.25.15+spec-1.1.0"
2260source = "registry+https://github.com/rust-lang/crates.io-index"
2261checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
2262dependencies = [
2263 "indexmap",
2264 "toml_datetime",
2265 "toml_parser",
2266 "winnow",
2267]
2268
2269[[package]]
2270name = "toml_parser"
2271version = "1.1.3+spec-1.1.0"
2272source = "registry+https://github.com/rust-lang/crates.io-index"
2273checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
2274dependencies = [
2275 "winnow",
2276]
2277
2278[[package]]
2279name = "tracing"
2280version = "0.1.44"
2281source = "registry+https://github.com/rust-lang/crates.io-index"
2282checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2283dependencies = [
2284 "pin-project-lite",
2285 "tracing-core",
2286]
2287
2288[[package]]
2289name = "tracing-core"
2290version = "0.1.36"
2291source = "registry+https://github.com/rust-lang/crates.io-index"
2292checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2293
2294[[package]]
2295name = "ttf-parser"
2296version = "0.25.1"
2297source = "registry+https://github.com/rust-lang/crates.io-index"
2298checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
2299
2300[[package]]
2301name = "ui"
2302version = "0.1.0"
2303dependencies = [
2304 "draw",
2305 "nucleo-matcher",
2306 "parley",
2307 "winit",
2308]
2309
1346[[package]]2310[[package]]
1347name = "unicode-ident"2311name = "unicode-ident"
1348version = "1.0.26"2312version = "1.0.26"
1349source = "registry+https://github.com/rust-lang/crates.io-index"2313source = "registry+https://github.com/rust-lang/crates.io-index"
1350checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"2314checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
13512315
2316[[package]]
2317name = "unicode-segmentation"
2318version = "1.13.3"
2319source = "registry+https://github.com/rust-lang/crates.io-index"
2320checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
2321
1352[[package]]2322[[package]]
1353name = "unicode-width"2323name = "unicode-width"
1354version = "0.2.2"2324version = "0.2.2"
...@@ -1367,6 +2337,25 @@ version = "0.9.5"...@@ -1367,6 +2337,25 @@ version = "0.9.5"
1367source = "registry+https://github.com/rust-lang/crates.io-index"2337source = "registry+https://github.com/rust-lang/crates.io-index"
1368checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"2338checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
13692339
2340[[package]]
2341name = "walkdir"
2342version = "2.5.0"
2343source = "registry+https://github.com/rust-lang/crates.io-index"
2344checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
2345dependencies = [
2346 "same-file",
2347 "winapi-util",
2348]
2349
2350[[package]]
2351name = "wasip2"
2352version = "1.0.4+wasi-0.2.12"
2353source = "registry+https://github.com/rust-lang/crates.io-index"
2354checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
2355dependencies = [
2356 "wit-bindgen",
2357]
2358
1370[[package]]2359[[package]]
1371name = "wasm-bindgen"2360name = "wasm-bindgen"
1372version = "0.2.128"2361version = "0.2.128"
...@@ -1422,6 +2411,103 @@ dependencies = [...@@ -1422,6 +2411,103 @@ dependencies = [
1422 "unicode-ident",2411 "unicode-ident",
1423]2412]
14242413
2414[[package]]
2415name = "wayland-backend"
2416version = "0.3.17"
2417source = "registry+https://github.com/rust-lang/crates.io-index"
2418checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
2419dependencies = [
2420 "cc",
2421 "downcast-rs",
2422 "rustix 1.1.5",
2423 "scoped-tls",
2424 "smallvec",
2425 "wayland-sys",
2426]
2427
2428[[package]]
2429name = "wayland-client"
2430version = "0.31.15"
2431source = "registry+https://github.com/rust-lang/crates.io-index"
2432checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
2433dependencies = [
2434 "bitflags 2.13.1",
2435 "rustix 1.1.5",
2436 "wayland-backend",
2437 "wayland-scanner",
2438]
2439
2440[[package]]
2441name = "wayland-csd-frame"
2442version = "0.3.0"
2443source = "registry+https://github.com/rust-lang/crates.io-index"
2444checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
2445dependencies = [
2446 "bitflags 2.13.1",
2447 "cursor-icon",
2448 "wayland-backend",
2449]
2450
2451[[package]]
2452name = "wayland-cursor"
2453version = "0.31.14"
2454source = "registry+https://github.com/rust-lang/crates.io-index"
2455checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
2456dependencies = [
2457 "rustix 1.1.5",
2458 "wayland-client",
2459 "xcursor",
2460]
2461
2462[[package]]
2463name = "wayland-protocols"
2464version = "0.32.13"
2465source = "registry+https://github.com/rust-lang/crates.io-index"
2466checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
2467dependencies = [
2468 "bitflags 2.13.1",
2469 "wayland-backend",
2470 "wayland-client",
2471 "wayland-scanner",
2472]
2473
2474[[package]]
2475name = "wayland-protocols-plasma"
2476version = "0.3.12"
2477source = "registry+https://github.com/rust-lang/crates.io-index"
2478checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
2479dependencies = [
2480 "bitflags 2.13.1",
2481 "wayland-backend",
2482 "wayland-client",
2483 "wayland-protocols",
2484 "wayland-scanner",
2485]
2486
2487[[package]]
2488name = "wayland-protocols-wlr"
2489version = "0.3.12"
2490source = "registry+https://github.com/rust-lang/crates.io-index"
2491checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
2492dependencies = [
2493 "bitflags 2.13.1",
2494 "wayland-backend",
2495 "wayland-client",
2496 "wayland-protocols",
2497 "wayland-scanner",
2498]
2499
2500[[package]]
2501name = "wayland-scanner"
2502version = "0.31.11"
2503source = "registry+https://github.com/rust-lang/crates.io-index"
2504checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
2505dependencies = [
2506 "proc-macro2",
2507 "quick-xml",
2508 "quote",
2509]
2510
1425[[package]]2511[[package]]
1426name = "wayland-sys"2512name = "wayland-sys"
1427version = "0.31.11"2513version = "0.31.11"
...@@ -1444,6 +2530,16 @@ dependencies = [...@@ -1444,6 +2530,16 @@ dependencies = [
1444 "wasm-bindgen",2530 "wasm-bindgen",
1445]2531]
14462532
2533[[package]]
2534name = "web-time"
2535version = "1.1.0"
2536source = "registry+https://github.com/rust-lang/crates.io-index"
2537checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
2538dependencies = [
2539 "js-sys",
2540 "wasm-bindgen",
2541]
2542
1447[[package]]2543[[package]]
1448name = "weezl"2544name = "weezl"
1449version = "0.1.12"2545version = "0.1.12"
...@@ -1457,7 +2553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -1457,7 +2553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1457checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"2553checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
1458dependencies = [2554dependencies = [
1459 "arrayvec",2555 "arrayvec",
1460 "bitflags",2556 "bitflags 2.13.1",
1461 "bytemuck",2557 "bytemuck",
1462 "cfg-if",2558 "cfg-if",
1463 "cfg_aliases",2559 "cfg_aliases",
...@@ -1489,7 +2585,7 @@ dependencies = [...@@ -1489,7 +2585,7 @@ dependencies = [
1489 "arrayvec",2585 "arrayvec",
1490 "bit-set",2586 "bit-set",
1491 "bit-vec",2587 "bit-vec",
1492 "bitflags",2588 "bitflags 2.13.1",
1493 "bytemuck",2589 "bytemuck",
1494 "cfg_aliases",2590 "cfg_aliases",
1495 "document-features",2591 "document-features",
...@@ -1505,7 +2601,7 @@ dependencies = [...@@ -1505,7 +2601,7 @@ dependencies = [
1505 "raw-window-handle",2601 "raw-window-handle",
1506 "rustc-hash",2602 "rustc-hash",
1507 "smallvec",2603 "smallvec",
1508 "thiserror",2604 "thiserror 2.0.21",
1509 "wgpu-core-deps-apple",2605 "wgpu-core-deps-apple",
1510 "wgpu-core-deps-emscripten",2606 "wgpu-core-deps-emscripten",
1511 "wgpu-core-deps-windows-linux-android",2607 "wgpu-core-deps-windows-linux-android",
...@@ -1551,8 +2647,8 @@ dependencies = [...@@ -1551,8 +2647,8 @@ dependencies = [
1551 "arrayvec",2647 "arrayvec",
1552 "ash",2648 "ash",
1553 "bit-set",2649 "bit-set",
1554 "bitflags",2650 "bitflags 2.13.1",
1555 "block2",2651 "block2 0.6.2",
1556 "bytemuck",2652 "bytemuck",
1557 "cfg-if",2653 "cfg-if",
1558 "cfg_aliases",2654 "cfg_aliases",
...@@ -1568,12 +2664,12 @@ dependencies = [...@@ -1568,12 +2664,12 @@ dependencies = [
1568 "naga",2664 "naga",
1569 "naga-types",2665 "naga-types",
1570 "ndk-sys",2666 "ndk-sys",
1571 "objc2",2667 "objc2 0.6.4",
1572 "objc2-core-foundation",2668 "objc2-core-foundation",
1573 "objc2-core-graphics",2669 "objc2-core-graphics",
1574 "objc2-foundation",2670 "objc2-foundation 0.3.2",
1575 "objc2-metal",2671 "objc2-metal 0.3.2",
1576 "objc2-quartz-core",2672 "objc2-quartz-core 0.3.2",
1577 "once_cell",2673 "once_cell",
1578 "ordered-float",2674 "ordered-float",
1579 "parking_lot",2675 "parking_lot",
...@@ -1586,7 +2682,7 @@ dependencies = [...@@ -1586,7 +2682,7 @@ dependencies = [
1586 "renderdoc-sys",2682 "renderdoc-sys",
1587 "smallvec",2683 "smallvec",
1588 "static_assertions",2684 "static_assertions",
1589 "thiserror",2685 "thiserror 2.0.21",
1590 "wasm-bindgen",2686 "wasm-bindgen",
1591 "wayland-sys",2687 "wayland-sys",
1592 "web-sys",2688 "web-sys",
...@@ -1613,7 +2709,7 @@ version = "30.0.1"...@@ -1613,7 +2709,7 @@ version = "30.0.1"
1613source = "registry+https://github.com/rust-lang/crates.io-index"2709source = "registry+https://github.com/rust-lang/crates.io-index"
1614checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"2710checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
1615dependencies = [2711dependencies = [
1616 "bitflags",2712 "bitflags 2.13.1",
1617 "bytemuck",2713 "bytemuck",
1618 "js-sys",2714 "js-sys",
1619 "log",2715 "log",
...@@ -1629,7 +2725,7 @@ version = "0.1.11"...@@ -1629,7 +2725,7 @@ version = "0.1.11"
1629source = "registry+https://github.com/rust-lang/crates.io-index"2725source = "registry+https://github.com/rust-lang/crates.io-index"
1630checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"2726checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
1631dependencies = [2727dependencies = [
1632 "windows-sys",2728 "windows-sys 0.61.2",
1633]2729]
16342730
1635[[package]]2731[[package]]
...@@ -1733,6 +2829,24 @@ dependencies = [...@@ -1733,6 +2829,24 @@ dependencies = [
1733 "windows-link",2829 "windows-link",
1734]2830]
17352831
2832[[package]]
2833name = "windows-sys"
2834version = "0.52.0"
2835source = "registry+https://github.com/rust-lang/crates.io-index"
2836checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
2837dependencies = [
2838 "windows-targets",
2839]
2840
2841[[package]]
2842name = "windows-sys"
2843version = "0.59.0"
2844source = "registry+https://github.com/rust-lang/crates.io-index"
2845checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
2846dependencies = [
2847 "windows-targets",
2848]
2849
1736[[package]]2850[[package]]
1737name = "windows-sys"2851name = "windows-sys"
1738version = "0.61.2"2852version = "0.61.2"
...@@ -1742,6 +2856,22 @@ dependencies = [...@@ -1742,6 +2856,22 @@ dependencies = [
1742 "windows-link",2856 "windows-link",
1743]2857]
17442858
2859[[package]]
2860name = "windows-targets"
2861version = "0.52.6"
2862source = "registry+https://github.com/rust-lang/crates.io-index"
2863checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
2864dependencies = [
2865 "windows_aarch64_gnullvm",
2866 "windows_aarch64_msvc",
2867 "windows_i686_gnu",
2868 "windows_i686_gnullvm",
2869 "windows_i686_msvc",
2870 "windows_x86_64_gnu",
2871 "windows_x86_64_gnullvm",
2872 "windows_x86_64_msvc",
2873]
2874
1745[[package]]2875[[package]]
1746name = "windows-threading"2876name = "windows-threading"
1747version = "0.2.1"2877version = "0.2.1"
...@@ -1751,12 +2881,184 @@ dependencies = [...@@ -1751,12 +2881,184 @@ dependencies = [
1751 "windows-link",2881 "windows-link",
1752]2882]
17532883
2884[[package]]
2885name = "windows_aarch64_gnullvm"
2886version = "0.52.6"
2887source = "registry+https://github.com/rust-lang/crates.io-index"
2888checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
2889
2890[[package]]
2891name = "windows_aarch64_msvc"
2892version = "0.52.6"
2893source = "registry+https://github.com/rust-lang/crates.io-index"
2894checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
2895
2896[[package]]
2897name = "windows_i686_gnu"
2898version = "0.52.6"
2899source = "registry+https://github.com/rust-lang/crates.io-index"
2900checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
2901
2902[[package]]
2903name = "windows_i686_gnullvm"
2904version = "0.52.6"
2905source = "registry+https://github.com/rust-lang/crates.io-index"
2906checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
2907
2908[[package]]
2909name = "windows_i686_msvc"
2910version = "0.52.6"
2911source = "registry+https://github.com/rust-lang/crates.io-index"
2912checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2913
2914[[package]]
2915name = "windows_x86_64_gnu"
2916version = "0.52.6"
2917source = "registry+https://github.com/rust-lang/crates.io-index"
2918checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2919
2920[[package]]
2921name = "windows_x86_64_gnullvm"
2922version = "0.52.6"
2923source = "registry+https://github.com/rust-lang/crates.io-index"
2924checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2925
2926[[package]]
2927name = "windows_x86_64_msvc"
2928version = "0.52.6"
2929source = "registry+https://github.com/rust-lang/crates.io-index"
2930checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2931
2932[[package]]
2933name = "winit"
2934version = "0.30.13"
2935source = "registry+https://github.com/rust-lang/crates.io-index"
2936checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
2937dependencies = [
2938 "ahash",
2939 "android-activity",
2940 "atomic-waker",
2941 "bitflags 2.13.1",
2942 "block2 0.5.1",
2943 "bytemuck",
2944 "calloop",
2945 "cfg_aliases",
2946 "concurrent-queue",
2947 "core-foundation",
2948 "core-graphics",
2949 "cursor-icon",
2950 "dpi",
2951 "js-sys",
2952 "libc",
2953 "memmap2",
2954 "ndk",
2955 "objc2 0.5.2",
2956 "objc2-app-kit 0.2.2",
2957 "objc2-foundation 0.2.2",
2958 "objc2-ui-kit",
2959 "orbclient",
2960 "percent-encoding",
2961 "pin-project",
2962 "raw-window-handle",
2963 "redox_syscall 0.4.1",
2964 "rustix 0.38.44",
2965 "sctk-adwaita",
2966 "smithay-client-toolkit",
2967 "smol_str",
2968 "tracing",
2969 "unicode-segmentation",
2970 "wasm-bindgen",
2971 "wasm-bindgen-futures",
2972 "wayland-backend",
2973 "wayland-client",
2974 "wayland-protocols",
2975 "wayland-protocols-plasma",
2976 "web-sys",
2977 "web-time",
2978 "windows-sys 0.52.0",
2979 "x11-dl",
2980 "x11rb",
2981 "xkbcommon-dl",
2982]
2983
2984[[package]]
2985name = "winnow"
2986version = "1.0.4"
2987source = "registry+https://github.com/rust-lang/crates.io-index"
2988checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
2989dependencies = [
2990 "memchr",
2991]
2992
2993[[package]]
2994name = "wit-bindgen"
2995version = "0.57.1"
2996source = "registry+https://github.com/rust-lang/crates.io-index"
2997checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
2998
1754[[package]]2999[[package]]
1755name = "writeable"3000name = "writeable"
1756version = "0.6.4"3001version = "0.6.4"
1757source = "registry+https://github.com/rust-lang/crates.io-index"3002source = "registry+https://github.com/rust-lang/crates.io-index"
1758checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"3003checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
17593004
3005[[package]]
3006name = "x11-dl"
3007version = "2.21.0"
3008source = "registry+https://github.com/rust-lang/crates.io-index"
3009checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
3010dependencies = [
3011 "libc",
3012 "once_cell",
3013 "pkg-config",
3014]
3015
3016[[package]]
3017name = "x11rb"
3018version = "0.13.2"
3019source = "registry+https://github.com/rust-lang/crates.io-index"
3020checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
3021dependencies = [
3022 "as-raw-xcb-connection",
3023 "gethostname",
3024 "libc",
3025 "libloading",
3026 "once_cell",
3027 "rustix 1.1.5",
3028 "x11rb-protocol",
3029]
3030
3031[[package]]
3032name = "x11rb-protocol"
3033version = "0.13.2"
3034source = "registry+https://github.com/rust-lang/crates.io-index"
3035checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
3036
3037[[package]]
3038name = "xcursor"
3039version = "0.3.11"
3040source = "registry+https://github.com/rust-lang/crates.io-index"
3041checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
3042
3043[[package]]
3044name = "xkbcommon-dl"
3045version = "0.4.2"
3046source = "registry+https://github.com/rust-lang/crates.io-index"
3047checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
3048dependencies = [
3049 "bitflags 2.13.1",
3050 "dlib",
3051 "log",
3052 "once_cell",
3053 "xkeysym",
3054]
3055
3056[[package]]
3057name = "xkeysym"
3058version = "0.2.1"
3059source = "registry+https://github.com/rust-lang/crates.io-index"
3060checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
3061
1760[[package]]3062[[package]]
1761name = "xml-rs"3063name = "xml-rs"
1762version = "0.8.29"3064version = "0.8.29"
platform/snow-leopard/Cargo.toml+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1# Mac OS X 10.6 work, built with cargo.sh. Its own workspace so the 10.6 target1# 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.2# never enters the main workspace's builds.
3[workspace]3[workspace]
4members = ["gl21", "hello", "probe", "reference"]4members = ["hello", "probe", "reference"]
5resolver = "3"5resolver = "3"
66
7[workspace.dependencies]7[workspace.dependencies]
platform/snow-leopard/console/input.c created+48
...@@ -0,0 +1,48 @@
1// Posts input to the logged-in session as the HID system would, for driving the app
2// over ssh: `input click X Y`, `input type TEXT`, `input key KEYCODE [command]`.
3// Points are the display's, from its top left.
4#include <ApplicationServices/ApplicationServices.h>
5#include <stdlib.h>
6#include <string.h>
7#include <unistd.h>
8
9static void post(CGEventRef event) {
10 CGEventPost(kCGHIDEventTap, event);
11 CFRelease(event);
12 usleep(20000);
13}
14
15static void mouse(CGEventType type, CGPoint at) {
16 post(CGEventCreateMouseEvent(NULL, type, at, kCGMouseButtonLeft));
17}
18
19static void key(CGKeyCode code, CGEventFlags flags, const UniChar *text, UniCharCount length) {
20 for (int down = 1; down >= 0; down--) {
21 CGEventRef event = CGEventCreateKeyboardEvent(NULL, code, down);
22 CGEventSetFlags(event, flags);
23 if (length) CGEventKeyboardSetUnicodeString(event, length, text);
24 post(event);
25 }
26}
27
28int main(int argc, char **argv) {
29 if (argc == 4 && !strcmp(argv[1], "click")) {
30 CGPoint at = CGPointMake(atof(argv[2]), atof(argv[3]));
31 mouse(kCGEventMouseMoved, at);
32 mouse(kCGEventLeftMouseDown, at);
33 mouse(kCGEventLeftMouseUp, at);
34 } else if (argc == 3 && !strcmp(argv[1], "type")) {
35 CFStringRef text = CFStringCreateWithCString(NULL, argv[2], kCFStringEncodingUTF8);
36 for (CFIndex i = 0; i < CFStringGetLength(text); i++) {
37 UniChar c = CFStringGetCharacterAtIndex(text, i);
38 key(0, 0, &c, 1);
39 }
40 CFRelease(text);
41 } else if ((argc == 3 || argc == 4) && !strcmp(argv[1], "key")) {
42 key(atoi(argv[2]), argc == 4 && !strcmp(argv[3], "command") ? kCGEventFlagMaskCommand : 0, NULL, 0);
43 } else {
44 fprintf(stderr, "usage: input click X Y | input type TEXT | input key KEYCODE [command]\n");
45 return 2;
46 }
47 return 0;
48}
platform/snow-leopard/console/screen.c created+27
...@@ -0,0 +1,27 @@
1// Writes the main display to a PNG, for a session reached over ssh, where
2// screencapture exits 0 without writing a file. Wakes a sleeping display first: asleep,
3// it shows its last frame.
4#include <ApplicationServices/ApplicationServices.h>
5#include <CoreServices/CoreServices.h>
6#include <stdio.h>
7#include <string.h>
8#include <unistd.h>
9
10int main(int argc, char **argv) {
11 if (argc != 2) {
12 fprintf(stderr, "usage: screen OUT.png\n");
13 return 2;
14 }
15 UpdateSystemActivity(UsrActivity);
16 sleep(2);
17 CGImageRef image = CGDisplayCreateImage(CGMainDisplayID());
18 if (!image) {
19 fprintf(stderr, "screen: no image of the display\n");
20 return 1;
21 }
22 CFURLRef url = CFURLCreateFromFileSystemRepresentation(NULL, (const UInt8 *)argv[1], strlen(argv[1]), false);
23 CGImageDestinationRef file = CGImageDestinationCreateWithURL(url, CFSTR("public.png"), 1, NULL);
24 CGImageDestinationAddImage(file, image, NULL);
25 int ok = CGImageDestinationFinalize(file);
26 return ok ? 0 : 1;
27}
platform/snow-leopard/console/selectors.c created+45
...@@ -0,0 +1,45 @@
1// Reads selector names on stdin and prints those no class on this system implements,
2// instance or class side: calls a 10.6 binary could make that would raise at run time.
3// `strings BINARY` supplies candidates; names that were never selectors print too.
4// Method lists are read directly: asking a class resolves methods, which some raise.
5#include <dlfcn.h>
6#include <objc/runtime.h>
7#include <stdio.h>
8#include <stdlib.h>
9#include <string.h>
10
11static int compare(const void *a, const void *b) {
12 return strcmp(*(const char *const *)a, *(const char *const *)b);
13}
14
15int main(void) {
16 const char *frameworks[] = {"AppKit", "Carbon", "QuartzCore", "Security", "OpenGL"};
17 for (size_t i = 0; i < sizeof frameworks / sizeof *frameworks; i++) {
18 char path[256];
19 snprintf(path, sizeof path, "/System/Library/Frameworks/%s.framework/%s", frameworks[i], frameworks[i]);
20 dlopen(path, RTLD_NOW);
21 }
22 int count = objc_getClassList(NULL, 0);
23 Class *classes = malloc(sizeof(Class) * count);
24 objc_getClassList(classes, count);
25 size_t names = 0, capacity = 1 << 16;
26 const char **known = malloc(sizeof(char *) * capacity);
27 for (int i = 0; i < count * 2; i++) {
28 Class cls = i < count ? classes[i] : object_getClass((id)classes[i - count]);
29 unsigned methods = 0;
30 Method *list = class_copyMethodList(cls, &methods);
31 for (unsigned m = 0; m < methods; m++) {
32 if (names == capacity) known = realloc(known, sizeof(char *) * (capacity *= 2));
33 known[names++] = sel_getName(method_getName(list[m]));
34 }
35 free(list);
36 }
37 qsort(known, names, sizeof(char *), compare);
38 char line[1024];
39 while (fgets(line, sizeof line, stdin)) {
40 line[strcspn(line, "\n")] = 0;
41 const char *name = line;
42 if (!bsearch(&name, known, names, sizeof(char *), compare)) puts(line);
43 }
44 return 0;
45}
platform/snow-leopard/gl21/Cargo.toml deleted-6
...@@ -1,6 +0,0 @@
1[package]
2name = "gl21"
3version = "0.0.0"
4edition = "2024"
5publish = false
6description = "OpenGL 2.1 plus EXT_framebuffer_object, as Mac OS X 10.6 exports them."
platform/snow-leopard/gl21/src/draw.glsl deleted-102
...@@ -1,102 +0,0 @@
1// GLSL 1.20 port of crates/draw/src/draw.wgsl. GLSL 1.20 has no flat interpolation, so
2// the per-quad shape and stroke arrive interpolated between equal values: equality tests
3// on them use a tolerance.
4#ifdef VERTEX
5attribute vec2 position;
6attribute vec2 uv;
7attribute vec4 color;
8attribute vec2 local;
9attribute vec4 shape;
10attribute float stroke;
11#endif
12varying vec2 v_uv;
13varying vec4 v_color;
14varying vec2 v_local;
15varying vec4 v_shape;
16varying float v_stroke;
17
18#ifdef VERTEX
19void main() {
20 v_uv = uv;
21 v_color = color;
22 v_local = local;
23 v_shape = shape;
24 v_stroke = stroke;
25 gl_Position = vec4(position, 0.0, 1.0);
26}
27#else
28uniform sampler2D atlas;
29
30bool same(float a, float b) {
31 return abs(a - b) <= 1e-4 * max(1.0, max(abs(a), abs(b)));
32}
33
34float ellipse_arc(float angle, vec2 radius) {
35 if (same(radius.x, radius.y)) {
36 return angle * radius.x;
37 }
38 // Four-point Gauss-Legendre quadrature.
39 vec4 nodes = vec4(-0.86113631, -0.33998104, 0.33998104, 0.86113631);
40 vec4 weights = vec4(0.34785485, 0.65214515, 0.65214515, 0.34785485);
41 float half_angle = angle * 0.5;
42 float scale = max(radius.x, radius.y);
43 float sum = 0.0;
44 for (int i = 0; i < 4; i++) {
45 float t = half_angle * (nodes[i] + 1.0);
46 sum += weights[i] * length((radius / scale) * vec2(sin(t), cos(t)));
47 }
48 return half_angle * sum * scale;
49}
50
51void main() {
52 vec4 color = texture2D(atlas, v_uv) * v_color;
53 vec4 shape = v_shape;
54 if (shape.x > 0.0 && shape.y > 0.0) {
55 vec2 q = abs(v_local) - shape.xy + shape.zw;
56 float distance;
57 if (same(shape.z, shape.w)) {
58 distance = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - shape.z;
59 } else if (all(greaterThan(q, vec2(0.0)))) {
60 vec2 radius = max(shape.zw, vec2(0.0001));
61 vec2 normalized = q / radius;
62 float k0 = length(normalized);
63 distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20);
64 } else {
65 vec2 edge = abs(v_local) - shape.xy;
66 distance = max(edge.x, edge.y);
67 }
68 float coverage = clamp(0.5 - distance, 0.0, 1.0);
69 float width = abs(v_stroke);
70 if (width > 1e-6) {
71 coverage *= clamp(distance + width + 0.5, 0.0, 1.0);
72 }
73 if (v_stroke < -1e-6) {
74 vec2 radius = max(shape.zw - width * 0.5, vec2(0.0));
75 if (radius.x <= 1e-6 || radius.y <= 1e-6) {
76 radius = vec2(0.0);
77 }
78 vec2 straight = shape.xy - width * 0.5 - radius;
79 vec2 p = abs(v_local);
80 float quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius);
81 float along;
82 if (p.y <= straight.y) {
83 along = p.y;
84 } else if (p.x <= straight.x || radius.x == 0.0) {
85 along = quarter - p.x;
86 } else {
87 float angle = atan((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x);
88 along = straight.y + ellipse_arc(angle, radius);
89 }
90 if (v_local.x < 0.0) {
91 along = v_local.y < 0.0 ? 2.0 * quarter + along : 2.0 * quarter - along;
92 } else if (v_local.y < 0.0) {
93 along = 4.0 * quarter - along;
94 }
95 float phase = fract(along / (4.0 * width)) * (4.0 * width);
96 coverage *= clamp(width + 0.5 - abs(phase - 2.0 * width), 0.0, 1.0);
97 }
98 color *= coverage;
99 }
100 gl_FragColor = color;
101}
102#endif
platform/snow-leopard/gl21/src/lib.rs deleted-118
...@@ -1,118 +0,0 @@
1//! OpenGL 2.1 and the extensions every Mac OS X 10.6 GPU driver exposes, linked straight
2//! from OpenGL.framework, which exports them all; no loader is needed.
3#![allow(non_snake_case, clippy::missing_safety_doc)]
4
5pub mod painter;
6
7use std::ffi::{c_char, c_void};
8
9pub type GLenum = u32;
10pub type GLuint = u32;
11pub type GLint = i32;
12pub type GLsizei = i32;
13pub type GLfloat = f32;
14pub type GLboolean = u8;
15pub type GLbitfield = u32;
16pub type GLsizeiptr = isize;
17
18pub const NO_ERROR: GLenum = 0;
19pub const TRIANGLES: GLenum = 0x0004;
20pub const COLOR_BUFFER_BIT: GLbitfield = 0x4000;
21pub const BLEND: GLenum = 0x0BE2;
22pub const SCISSOR_TEST: GLenum = 0x0C11;
23pub const TEXTURE_2D: GLenum = 0x0DE1;
24pub const UNSIGNED_BYTE: GLenum = 0x1401;
25pub const FLOAT: GLenum = 0x1406;
26pub const RGBA: GLenum = 0x1908;
27pub const VENDOR: GLenum = 0x1F00;
28pub const RENDERER: GLenum = 0x1F01;
29pub const VERSION: GLenum = 0x1F02;
30pub const EXTENSIONS: GLenum = 0x1F03;
31pub const NEAREST: GLint = 0x2600;
32pub const LINEAR: GLint = 0x2601;
33pub const TEXTURE_MAG_FILTER: GLenum = 0x2800;
34pub const TEXTURE_MIN_FILTER: GLenum = 0x2801;
35pub const TEXTURE_WRAP_S: GLenum = 0x2802;
36pub const TEXTURE_WRAP_T: GLenum = 0x2803;
37pub const CLAMP_TO_EDGE: GLint = 0x812F;
38pub const ONE: GLenum = 1;
39pub const SRC_ALPHA: GLenum = 0x0302;
40pub const ONE_MINUS_SRC_ALPHA: GLenum = 0x0303;
41pub const TEXTURE0: GLenum = 0x84C0;
42pub const ARRAY_BUFFER: GLenum = 0x8892;
43pub const STREAM_DRAW: GLenum = 0x88E0;
44pub const FRAGMENT_SHADER: GLenum = 0x8B30;
45pub const VERTEX_SHADER: GLenum = 0x8B31;
46pub const COMPILE_STATUS: GLenum = 0x8B81;
47pub const LINK_STATUS: GLenum = 0x8B82;
48pub const INFO_LOG_LENGTH: GLenum = 0x8B84;
49pub const SRGB8_ALPHA8: GLint = 0x8C43;
50pub const READ_FRAMEBUFFER: GLenum = 0x8CA8;
51pub const DRAW_FRAMEBUFFER: GLenum = 0x8CA9;
52pub const FRAMEBUFFER_COMPLETE: GLenum = 0x8CD5;
53pub const COLOR_ATTACHMENT0: GLenum = 0x8CE0;
54pub const FRAMEBUFFER: GLenum = 0x8D40;
55pub const FRAMEBUFFER_SRGB: GLenum = 0x8DB9;
56
57#[link(name = "OpenGL", kind = "framework")]
58unsafe extern "C" {
59 pub fn glGetError() -> GLenum;
60 pub fn glGetString(name: GLenum) -> *const u8;
61 pub fn glEnable(cap: GLenum);
62 pub fn glDisable(cap: GLenum);
63 pub fn glViewport(x: GLint, y: GLint, width: GLsizei, height: GLsizei);
64 pub fn glScissor(x: GLint, y: GLint, width: GLsizei, height: GLsizei);
65 pub fn glClearColor(r: GLfloat, g: GLfloat, b: GLfloat, a: GLfloat);
66 pub fn glClear(mask: GLbitfield);
67 pub fn glFinish();
68 pub fn glFlush();
69 pub fn glBlendFuncSeparate(src_rgb: GLenum, dst_rgb: GLenum, src_a: GLenum, dst_a: GLenum);
70 pub fn glReadPixels(x: GLint, y: GLint, w: GLsizei, h: GLsizei, format: GLenum, ty: GLenum, data: *mut c_void);
71 pub fn glPixelStorei(name: GLenum, value: GLint);
72
73 pub fn glGenTextures(n: GLsizei, textures: *mut GLuint);
74 pub fn glDeleteTextures(n: GLsizei, textures: *const GLuint);
75 pub fn glBindTexture(target: GLenum, texture: GLuint);
76 pub fn glActiveTexture(unit: GLenum);
77 pub fn glTexParameteri(target: GLenum, name: GLenum, value: GLint);
78 pub fn glTexImage2D(target: GLenum, level: GLint, internal: GLint, w: GLsizei, h: GLsizei, border: GLint, format: GLenum, ty: GLenum, data: *const c_void);
79 pub fn glTexSubImage2D(target: GLenum, level: GLint, x: GLint, y: GLint, w: GLsizei, h: GLsizei, format: GLenum, ty: GLenum, data: *const c_void);
80
81 pub fn glGenBuffers(n: GLsizei, buffers: *mut GLuint);
82 pub fn glBindBuffer(target: GLenum, buffer: GLuint);
83 pub fn glBufferData(target: GLenum, size: GLsizeiptr, data: *const c_void, usage: GLenum);
84 pub fn glEnableVertexAttribArray(index: GLuint);
85 pub fn glVertexAttribPointer(index: GLuint, size: GLint, ty: GLenum, normalized: GLboolean, stride: GLsizei, offset: *const c_void);
86 pub fn glDrawArrays(mode: GLenum, first: GLint, count: GLsizei);
87
88 pub fn glCreateShader(kind: GLenum) -> GLuint;
89 pub fn glShaderSource(shader: GLuint, count: GLsizei, sources: *const *const c_char, lengths: *const GLint);
90 pub fn glCompileShader(shader: GLuint);
91 pub fn glGetShaderiv(shader: GLuint, name: GLenum, value: *mut GLint);
92 pub fn glGetShaderInfoLog(shader: GLuint, size: GLsizei, length: *mut GLsizei, log: *mut c_char);
93 pub fn glCreateProgram() -> GLuint;
94 pub fn glAttachShader(program: GLuint, shader: GLuint);
95 pub fn glBindAttribLocation(program: GLuint, index: GLuint, name: *const c_char);
96 pub fn glLinkProgram(program: GLuint);
97 pub fn glGetProgramiv(program: GLuint, name: GLenum, value: *mut GLint);
98 pub fn glGetProgramInfoLog(program: GLuint, size: GLsizei, length: *mut GLsizei, log: *mut c_char);
99 pub fn glUseProgram(program: GLuint);
100 pub fn glGetUniformLocation(program: GLuint, name: *const c_char) -> GLint;
101 pub fn glUniform1i(location: GLint, value: GLint);
102
103 pub fn glGenFramebuffersEXT(n: GLsizei, framebuffers: *mut GLuint);
104 pub fn glDeleteFramebuffersEXT(n: GLsizei, framebuffers: *const GLuint);
105 pub fn glBindFramebufferEXT(target: GLenum, framebuffer: GLuint);
106 pub fn glFramebufferTexture2DEXT(target: GLenum, attachment: GLenum, textarget: GLenum, texture: GLuint, level: GLint);
107 pub fn glCheckFramebufferStatusEXT(target: GLenum) -> GLenum;
108 pub fn glBlitFramebufferEXT(sx0: GLint, sy0: GLint, sx1: GLint, sy1: GLint, dx0: GLint, dy0: GLint, dx1: GLint, dy1: GLint, mask: GLbitfield, filter: GLenum);
109}
110
111/// A GL string such as `VERSION` or `EXTENSIONS`, empty without a current context.
112pub fn string(name: GLenum) -> String {
113 let text = unsafe { glGetString(name) };
114 if text.is_null() {
115 return String::new();
116 }
117 unsafe { std::ffi::CStr::from_ptr(text.cast()) }.to_string_lossy().into_owned()
118}
platform/snow-leopard/gl21/src/painter.rs deleted-272
...@@ -1,272 +0,0 @@
1//! The submission half of `draw::Renderer` on OpenGL 2.1: the atlas, image textures, one
2//! vertex buffer and the ported shader. `draw` keeps building vertices and batches; this
3//! draws them into an sRGB framebuffer object, as wgpu draws them into its target.
4use crate::*;
5use std::{ffi::CString, ops::Range, ptr};
6
7/// Laid out as `draw`'s vertex.
8#[repr(C)]
9#[derive(Clone, Copy, Debug, Default)]
10pub struct Vertex {
11 pub position: [f32; 2],
12 pub uv: [f32; 2],
13 pub color: [f32; 4],
14 pub local: [f32; 2],
15 pub shape: [f32; 4],
16 pub stroke: f32,
17}
18
19pub struct Batch<'a> {
20 pub vertices: Range<u32>,
21 /// None samples the atlas with straight alpha; an image is premultiplied.
22 pub image: Option<&'a Texture>,
23 /// Device pixels from the top left: `[x, y, width, height]`.
24 pub scissor: [u32; 4],
25}
26
27/// An sRGB RGBA texture.
28pub struct Texture {
29 name: GLuint,
30 size: [u32; 2],
31}
32
33impl Texture {
34 fn new(size: [u32; 2], filter: GLint, pixels: Option<&[u8]>) -> Self {
35 let mut name = 0;
36 unsafe {
37 glGenTextures(1, &mut name);
38 glBindTexture(TEXTURE_2D, name);
39 glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, filter);
40 glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, filter);
41 glTexParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE);
42 glTexParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE);
43 glTexImage2D(
44 TEXTURE_2D,
45 0,
46 SRGB8_ALPHA8,
47 size[0] as GLsizei,
48 size[1] as GLsizei,
49 0,
50 RGBA,
51 UNSIGNED_BYTE,
52 pixels.map_or(ptr::null(), |p| p.as_ptr().cast()),
53 );
54 }
55 Self { name, size }
56 }
57
58 /// Writes `rgba` rows, top first, at `origin`.
59 pub fn write(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) {
60 assert_eq!(rgba.len(), (size[0] * size[1] * 4) as usize);
61 unsafe {
62 glBindTexture(TEXTURE_2D, self.name);
63 glTexSubImage2D(
64 TEXTURE_2D,
65 0,
66 origin[0] as GLint,
67 origin[1] as GLint,
68 size[0] as GLsizei,
69 size[1] as GLsizei,
70 RGBA,
71 UNSIGNED_BYTE,
72 rgba.as_ptr().cast(),
73 );
74 }
75 }
76
77 pub fn size(&self) -> [u32; 2] {
78 self.size
79 }
80}
81
82impl Drop for Texture {
83 fn drop(&mut self) {
84 unsafe { glDeleteTextures(1, &self.name) };
85 }
86}
87
88/// An offscreen sRGB colour target; blending happens in linear light.
89pub struct Target {
90 framebuffer: GLuint,
91 texture: Texture,
92}
93
94impl Target {
95 pub fn new(size: [u32; 2]) -> Result<Self, String> {
96 let texture = Texture::new(size, NEAREST, None);
97 let mut framebuffer = 0;
98 unsafe {
99 glGenFramebuffersEXT(1, &mut framebuffer);
100 glBindFramebufferEXT(FRAMEBUFFER, framebuffer);
101 glFramebufferTexture2DEXT(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, texture.name, 0);
102 let status = glCheckFramebufferStatusEXT(FRAMEBUFFER);
103 glBindFramebufferEXT(FRAMEBUFFER, 0);
104 if status != FRAMEBUFFER_COMPLETE {
105 return Err(format!("framebuffer incomplete: {status:#x}"));
106 }
107 }
108 Ok(Self { framebuffer, texture })
109 }
110
111 pub fn size(&self) -> [u32; 2] {
112 self.texture.size
113 }
114
115 /// Copies the target to the current context's window framebuffer, same size.
116 pub fn present(&self) {
117 let [w, h] = self.texture.size.map(|v| v as GLint);
118 unsafe {
119 glBindFramebufferEXT(READ_FRAMEBUFFER, self.framebuffer);
120 glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0);
121 glBlitFramebufferEXT(0, 0, w, h, 0, 0, w, h, COLOR_BUFFER_BIT, NEAREST as GLenum);
122 glBindFramebufferEXT(FRAMEBUFFER, 0);
123 }
124 }
125
126 /// sRGB-encoded RGBA rows, top first.
127 pub fn read_pixels(&self) -> Vec<u8> {
128 let [w, h] = self.texture.size;
129 let mut pixels = vec![0u8; (w * h * 4) as usize];
130 unsafe {
131 glBindFramebufferEXT(FRAMEBUFFER, self.framebuffer);
132 glReadPixels(0, 0, w as GLsizei, h as GLsizei, RGBA, UNSIGNED_BYTE, pixels.as_mut_ptr().cast());
133 glBindFramebufferEXT(FRAMEBUFFER, 0);
134 }
135 pixels.chunks(w as usize * 4).rev().flatten().copied().collect()
136 }
137}
138
139impl Drop for Target {
140 fn drop(&mut self) {
141 unsafe { glDeleteFramebuffersEXT(1, &self.framebuffer) };
142 }
143}
144
145pub struct Painter {
146 program: GLuint,
147 buffer: GLuint,
148 pub atlas: Texture,
149}
150
151const ATTRIBUTES: [(&str, GLint, usize); 6] = [
152 ("position", 2, 0),
153 ("uv", 2, 8),
154 ("color", 4, 16),
155 ("local", 2, 32),
156 ("shape", 4, 40),
157 ("stroke", 1, 56),
158];
159
160impl Painter {
161 /// Needs a current context.
162 pub fn new(atlas_size: u32) -> Result<Self, String> {
163 let source = include_str!("draw.glsl");
164 let program = unsafe {
165 let vertex = compile(VERTEX_SHADER, &format!("#version 120\n#define VERTEX\n{source}"))?;
166 let fragment = compile(FRAGMENT_SHADER, &format!("#version 120\n{source}"))?;
167 let program = glCreateProgram();
168 glAttachShader(program, vertex);
169 glAttachShader(program, fragment);
170 for (index, (name, _, _)) in ATTRIBUTES.iter().enumerate() {
171 let name = CString::new(*name).unwrap();
172 glBindAttribLocation(program, index as GLuint, name.as_ptr());
173 }
174 glLinkProgram(program);
175 let mut linked = 0;
176 glGetProgramiv(program, LINK_STATUS, &mut linked);
177 if linked == 0 {
178 return Err(format!("link: {}", log(program, glGetProgramiv, glGetProgramInfoLog)));
179 }
180 program
181 };
182 let atlas = Texture::new([atlas_size; 2], NEAREST, None);
183 // The atlas's first texel is the white that solid fills sample.
184 atlas.write([0, 0], [1, 1], &[255; 4]);
185 let mut buffer = 0;
186 unsafe { glGenBuffers(1, &mut buffer) };
187 Ok(Self { program, buffer, atlas })
188 }
189
190 /// An image texture, filtered linearly; pixels are premultiplied rows, top first.
191 pub fn image(&self, size: [u32; 2], rgba: &[u8]) -> Texture {
192 Texture::new(size, LINEAR, Some(rgba))
193 }
194
195 /// Clears `target` to linear `clear` and draws the batches in order.
196 pub fn draw(&self, target: &Target, clear: [f32; 4], vertices: &[Vertex], batches: &[Batch<'_>]) {
197 let [width, height] = target.size().map(|v| v as GLsizei);
198 unsafe {
199 glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer);
200 glEnable(FRAMEBUFFER_SRGB);
201 glViewport(0, 0, width, height);
202 glDisable(SCISSOR_TEST);
203 glClearColor(clear[0], clear[1], clear[2], clear[3]);
204 glClear(COLOR_BUFFER_BIT);
205 glUseProgram(self.program);
206 glUniform1i(glGetUniformLocation(self.program, c"atlas".as_ptr()), 0);
207 glActiveTexture(TEXTURE0);
208 glBindBuffer(ARRAY_BUFFER, self.buffer);
209 glBufferData(ARRAY_BUFFER, size_of_val(vertices) as GLsizeiptr, vertices.as_ptr().cast(), STREAM_DRAW);
210 for (index, (_, size, offset)) in ATTRIBUTES.iter().enumerate() {
211 glEnableVertexAttribArray(index as GLuint);
212 glVertexAttribPointer(
213 index as GLuint,
214 *size,
215 FLOAT,
216 0,
217 size_of::<Vertex>() as GLsizei,
218 ptr::without_provenance(*offset),
219 );
220 }
221 glEnable(BLEND);
222 glEnable(SCISSOR_TEST);
223 for batch in batches {
224 let [x, y, w, h] = batch.scissor.map(|v| v as GLint);
225 glScissor(x, height - y - h, w, h);
226 let (texture, source) = match batch.image {
227 Some(image) => (image.name, ONE),
228 None => (self.atlas.name, SRC_ALPHA),
229 };
230 glBlendFuncSeparate(source, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA);
231 glBindTexture(TEXTURE_2D, texture);
232 glDrawArrays(
233 TRIANGLES,
234 batch.vertices.start as GLint,
235 batch.vertices.len() as GLsizei,
236 );
237 }
238 glDisable(SCISSOR_TEST);
239 glDisable(BLEND);
240 glBindFramebufferEXT(FRAMEBUFFER, 0);
241 }
242 }
243}
244
245unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> {
246 unsafe {
247 let shader = glCreateShader(kind);
248 let text = CString::new(source).unwrap();
249 glShaderSource(shader, 1, &text.as_ptr(), ptr::null());
250 glCompileShader(shader);
251 let mut compiled = 0;
252 glGetShaderiv(shader, COMPILE_STATUS, &mut compiled);
253 if compiled == 0 {
254 return Err(format!("compile: {}", log(shader, glGetShaderiv, glGetShaderInfoLog)));
255 }
256 Ok(shader)
257 }
258}
259
260unsafe fn log(
261 object: GLuint,
262 get: unsafe extern "C" fn(GLuint, GLenum, *mut GLint),
263 read: unsafe extern "C" fn(GLuint, GLsizei, *mut GLsizei, *mut std::ffi::c_char),
264) -> String {
265 unsafe {
266 let mut length = 0;
267 get(object, INFO_LOG_LENGTH, &mut length);
268 let mut text = vec![0u8; length.max(1) as usize];
269 read(object, length, ptr::null_mut(), text.as_mut_ptr().cast());
270 String::from_utf8_lossy(&text).trim_end_matches('\0').to_owned()
271 }
272}
platform/snow-leopard/link.sh+2-2
...@@ -17,8 +17,8 @@ for arg; do...@@ -17,8 +17,8 @@ for arg; do
17 if [ "$prev" = -framework ]; then17 if [ "$prev" = -framework ]; then
18 case "$arg" in18 case "$arg" in
19 CoreGraphics | CoreText | ImageIO | ColorSync) arg=ApplicationServices ;;19 CoreGraphics | CoreText | ImageIO | ColorSync) arg=ApplicationServices ;;
20 # Until draw's wgpu backend is optional: wgpu names Metal, which 10.6 lacks,20 # Test binaries whose dev-dependencies take draw's wgpu backend (ui, canvas)
21 # and nothing a 10.6 binary reaches calls into it.21 # name Metal, which 10.6 lacks; nothing they run on 10.6 calls into it.
22 Metal) arg=Foundation ;;22 Metal) arg=Foundation ;;
23 esac23 esac
24 fi24 fi
platform/snow-leopard/probe/Cargo.toml+8-7
...@@ -5,20 +5,21 @@ edition = "2024"...@@ -5,20 +5,21 @@ edition = "2024"
5publish = false5publish = false
66
7[dependencies]7[dependencies]
8gl21 = { path = "../gl21" }8draw = { path = "../../../crates/draw", default-features = false, features = ["render"] }
9objc2.workspace = true9objc2.workspace = true
10objc2-foundation = { workspace = true, features = ["std", "NSString", "NSAutoreleasePool", "NSObject", "NSZone", "NSGeometry", "NSDate", "NSRunLoop"] }10objc2-foundation = { workspace = true, features = ["std", "NSString", "NSObject", "NSZone"] }
11objc2-app-kit = { workspace = true, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSView", "NSOpenGL", "NSOpenGLView", "NSEvent", "NSRunningApplication", "NSGraphics"] }11objc2-app-kit = { workspace = true, features = ["std", "NSOpenGL"] }
12png = "0.18"12png = "0.18"
13ui = { path = "../../../crates/ui" }
1314
14[[bin]]15[[bin]]
15name = "probe"16name = "probe"
16path = "src/main.rs"17path = "src/main.rs"
1718
18[[bin]]
19name = "window-probe"
20path = "src/window.rs"
21
22[[bin]]19[[bin]]
23name = "render-probe"20name = "render-probe"
24path = "src/render.rs"21path = "src/render.rs"
22
23[[bin]]
24name = "text-probe"
25path = "src/text.rs"
platform/snow-leopard/probe/src/context.rs created+15
...@@ -0,0 +1,15 @@
1//! An offscreen OpenGL context, current on this thread.
2use objc2::{MainThreadMarker, rc::Retained};
3use objc2_app_kit::{NSOpenGLContext, NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize, NSOpenGLPixelFormat};
4
5pub fn current() -> Retained<NSOpenGLContext> {
6 let mtm = MainThreadMarker::new().unwrap();
7 let attributes = [NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize, 24, 0];
8 let format = unsafe {
9 NSOpenGLPixelFormat::initWithAttributes(mtm.alloc(), std::ptr::NonNull::new(attributes.as_ptr().cast_mut()).unwrap())
10 }
11 .expect("pixel format");
12 let context = NSOpenGLContext::initWithFormat_shareContext(mtm.alloc(), &format, None).expect("context");
13 context.makeCurrentContext();
14 context
15}
platform/snow-leopard/probe/src/main.rs+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1use objc2::{rc::{Retained, autoreleasepool}, runtime::NSObjectProtocol};1use objc2::rc::{Retained, autoreleasepool};
2use objc2_foundation::NSCopying;2use objc2_foundation::NSCopying;
3use objc2_foundation::{NSObject, NSString};3use objc2_foundation::{NSObject, NSString};
44
platform/snow-leopard/probe/src/render.rs+10-89
...@@ -1,102 +1,23 @@...@@ -1,102 +1,23 @@
1//! Paints `scene` through the GL 2.1 painter into an offscreen target and writes1//! Paints `scene` through `draw` over OpenGL into an offscreen target and writes
2//! `render-probe.png`. The vertices follow `draw::Renderer`'s quad and rounded-rectangle2//! `render-probe.png`.
3//! encoding at scale 1.
4#![allow(deprecated)] // OpenGL is the only GPU API 10.6 has.3#![allow(deprecated)] // OpenGL is the only GPU API 10.6 has.
4mod context;
5mod scene;5mod scene;
66
7use gl21::painter::{Batch, Painter, Target, Vertex};
8use objc2::MainThreadMarker;
9use objc2_app_kit::{NSOpenGLContext, NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize, NSOpenGLPixelFormat};
10use scene::Shape;
11
12const WHITE_UV: [f32; 4] = [0.5 / ATLAS as f32; 4];
13const ATLAS: u32 = 2048;
14
15fn quad(vertices: &mut Vec<Vertex>, rect: [f32; 4], uv: [f32; 4], color: [f32; 4]) {
16 let [w, h] = scene::SIZE.map(|v| v as f32);
17 let (x0, x1) = (rect[0] * 2.0 / w - 1.0, rect[2] * 2.0 / w - 1.0);
18 let (y0, y1) = (1.0 - rect[1] * 2.0 / h, 1.0 - rect[3] * 2.0 / h);
19 let [hx, hy] = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5];
20 for (position, uv, local) in [
21 ([x0, y0], [uv[0], uv[1]], [-hx, -hy]),
22 ([x0, y1], [uv[0], uv[3]], [-hx, hy]),
23 ([x1, y1], [uv[2], uv[3]], [hx, hy]),
24 ([x0, y0], [uv[0], uv[1]], [-hx, -hy]),
25 ([x1, y1], [uv[2], uv[3]], [hx, hy]),
26 ([x1, y0], [uv[2], uv[1]], [hx, -hy]),
27 ] {
28 vertices.push(Vertex { position, uv, color, local, ..Default::default() });
29 }
30}
31
32fn rounded(vertices: &mut Vec<Vertex>, rect: [f32; 4], radius: [f32; 2], stroke: Option<(f32, bool)>, colors: [[f32; 4]; 2]) {
33 let start = vertices.len();
34 quad(vertices, [rect[0] - 1.0, rect[1] - 1.0, rect[2] + 1.0, rect[3] + 1.0], WHITE_UV, colors[0]);
35 let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5];
36 let radius = if radius.contains(&0.0) { [0.0; 2] } else { [radius[0].min(half[0]), radius[1].min(half[1])] };
37 let width = stroke.map_or(0.0, |(width, dashed)| {
38 let width = width.min(half[0].min(half[1]));
39 if dashed { -width } else { width }
40 });
41 for vertex in &mut vertices[start..] {
42 vertex.shape = [half[0], half[1], radius[0], radius[1]];
43 vertex.stroke = width;
44 if vertex.local[1] > 0.0 {
45 vertex.color = colors[1];
46 }
47 }
48}
49
50fn main() {7fn main() {
51 objc2::rc::autoreleasepool(|_| run());8 objc2::rc::autoreleasepool(|_| run());
52}9}
5310
54fn run() {11fn run() {
55 let mtm = MainThreadMarker::new().unwrap();12 let _context = context::current();
56 let attributes = [NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize, 24, 0];13 let mut renderer = draw::Renderer::new().unwrap_or_else(|error| panic!("{error}"));
57 let format = unsafe {14 let target = draw::Target::new(scene::SIZE).unwrap();
58 NSOpenGLPixelFormat::initWithAttributes(mtm.alloc(), std::ptr::NonNull::new(attributes.as_ptr().cast_mut()).unwrap())15 let checker = scene::checker();
59 }16 let primitives = scene::primitives(&checker);
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();17 let started = std::time::Instant::now();
97 painter.draw(&target, scene::CLEAR, &vertices, &batches);18 renderer.draw(&target, scene::SIZE, scene::CLEAR, &[scene::layer(&primitives)]).unwrap();
98 let pixels = target.read_pixels();19 let pixels = target.read_pixels();
99 println!("drew {} vertices in {:?}; GL error {:#x}", vertices.len(), started.elapsed(), unsafe { gl21::glGetError() });20 println!("drew in {:?}", started.elapsed());
100 let file = std::fs::File::create("render-probe.png").unwrap();21 let file = std::fs::File::create("render-probe.png").unwrap();
101 let mut encoder = png::Encoder::new(file, scene::SIZE[0], scene::SIZE[1]);22 let mut encoder = png::Encoder::new(file, scene::SIZE[0], scene::SIZE[1]);
102 encoder.set_color(png::ColorType::Rgba);23 encoder.set_color(png::ColorType::Rgba);
platform/snow-leopard/probe/src/scene.rs+57-23
...@@ -1,40 +1,74 @@...@@ -1,40 +1,74 @@
1//! One test scene, painted by `draw` over wgpu (the `reference` crate) and by the GL 2.11//! One scene painted by `draw` over wgpu on the dev Mac (`reference`) and over OpenGL on
2//! painter on 10.6 (`render-probe`), whose PNGs `compare.py` diffs. Device pixels,2//! 10.6 (`render-probe`), whose PNGs `reference --compare` diffs. Device pixels, linear
3//! linear colours.3//! colours.
4#![allow(dead_code)] // Each binary paints part of it.
5use draw::{Layer, Primitive, RasterImage, Stroke};
46
5pub const SIZE: [u32; 2] = [400, 300];7pub const SIZE: [u32; 2] = [400, 300];
6pub const CLEAR: [f32; 4] = [0.85, 0.85, 0.85, 1.0];8pub const CLEAR: [f32; 4] = [0.85, 0.85, 0.85, 1.0];
79
8pub enum Shape {10/// A 4×4 opaque sRGB checker, stretched and filtered by the image path.
9 Rect { rect: [f32; 4], color: [f32; 4] },11pub fn checker() -> RasterImage {
10 /// `stroke` is a width and whether it is dashed.12 let pixels = (0..16)
11 Rounded { rect: [f32; 4], radius: [f32; 2], stroke: Option<(f32, bool)>, colors: [[f32; 4]; 2] },13 .flat_map(|i| if (i % 4 + i / 4) % 2 == 0 { [240, 200, 40, 255] } else { [30, 120, 90, 255] })
12 Image { rect: [f32; 4] },14 .collect();
15 RasterImage::new([4, 4], pixels).unwrap()
13}16}
1417
15pub fn shapes() -> Vec<Shape> {18pub fn primitives(checker: &RasterImage) -> Vec<Primitive<'_>> {
16 let red = [0.6, 0.05, 0.05, 1.0];19 let red = [0.6, 0.05, 0.05, 1.0];
17 let blue = [0.05, 0.1, 0.6, 1.0];20 let blue = [0.05, 0.1, 0.6, 1.0];
18 let green = [0.05, 0.4, 0.1, 1.0];21 let green = [0.05, 0.4, 0.1, 1.0];
19 let ink = [0.02, 0.02, 0.02, 1.0];22 let ink = [0.02, 0.02, 0.02, 1.0];
20 use Shape::*;23 use Primitive::*;
21 vec![24 vec![
22 Rect { rect: [20.0, 20.0, 120.0, 80.0], color: red },25 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] },26 Gradient { rect: [140.0, 20.0, 260.0, 80.0], radius: [12.0, 12.0], colors: [blue; 2] },
24 Rounded { rect: [280.0, 20.5, 380.0, 80.25], radius: [30.0, 12.0], stroke: None, colors: [green; 2] },27 Gradient { rect: [280.0, 20.5, 380.0, 80.25], radius: [30.0, 12.0], 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] },28 RoundedRect { rect: [20.0, 100.0, 120.0, 160.0], radius: [10.0, 10.0], stroke: Some(Stroke::Solid(3.0)), color: ink },
26 Rounded { rect: [140.0, 100.0, 260.0, 160.0], radius: [16.0, 16.0], stroke: Some((2.0, true)), colors: [ink; 2] },29 RoundedRect { rect: [140.0, 100.0, 260.0, 160.0], radius: [16.0, 16.0], stroke: Some(Stroke::Dashed(2.0)), color: ink },
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]] },30 Gradient { rect: [280.0, 100.0, 380.0, 160.0], radius: [8.0, 8.0], 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] },31 Image { image: checker, 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] },32 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] },33 RoundedRect { rect: [220.0, 180.0, 380.0, 280.0], radius: [30.0, 10.0], stroke: Some(Stroke::Dashed(3.0)), color: red },
31 ]34 ]
32}35}
3336
34/// A 4×4 opaque sRGB checker, stretched and filtered by the image path.37pub fn layer<'a>(primitives: &'a [Primitive<'a>]) -> Layer<'a> {
35pub fn checker() -> ([u32; 2], Vec<u8>) {38 Layer { scale: 1.0, origin: [0.0; 2], clip: None, backdrop: None, motion: None, primitives }
36 let pixels = (0..16)39}
37 .flat_map(|i| if (i % 4 + i / 4) % 2 == 0 { [240, 200, 40, 255] } else { [30, 120, 90, 255] })40
38 .collect();41/// Lines of interface text through `ui`: black on white like a Cocoa label, the same in
39 ([4, 4], pixels)42/// Arial, then the light theme's text on its strip. Logical pixels.
43pub const TEXT_SIZE: [u32; 2] = [640, 150];
44pub const TEXT: [&str; 2] = [
45 "Garden Kitchen Journal Reading Trips Home",
46 "Search All Notebooks Up to date 100% Calibri",
47];
48
49pub fn interface(scale: f32) -> ui::Ui {
50 let theme = ui::Theme::light();
51 let strip = theme.strip;
52 let mut ui = ui::Ui::new(theme, std::time::Duration::from_millis(500));
53 ui.begin(TEXT_SIZE.map(|side| side as f32), scale, std::time::Instant::now());
54 ui.open("page", ui::Spec { size: [ui::fill(), ui::fill()], axis: ui::Axis::Y, fill: Some([1.0; 4]), pad: [10.0, 6.0], gap: 6.0, ..Default::default() });
55 let black = Some([0.0, 0.0, 0.0, 1.0]);
56 for (index, text) in TEXT.into_iter().enumerate() {
57 ui.leaf(index, ui::Spec { size: [ui::fit(), ui::px(24.0)], text: Some(text), color: black, ..Default::default() });
58 }
59 ui.leaf("arial", ui::Spec { size: [ui::fit(), ui::px(24.0)], text: Some(TEXT[0]), font: Some("Arial"), color: black, ..Default::default() });
60 ui.leaf("strip", ui::Spec { size: [ui::fill(), ui::px(24.0)], text: Some(TEXT[1]), fill: Some(strip), ..Default::default() });
61 ui.close();
62 ui.end();
63 ui
64}
65
66pub fn interface_layers<'a>(layers: &'a [ui::Layer<'a>], scale: f32) -> Vec<Layer<'a>> {
67 layers
68 .iter()
69 .map(|layer| match layer {
70 ui::Layer::Primitives(primitives) => primitives.layer(scale),
71 ui::Layer::Custom { .. } => unreachable!(),
72 })
73 .collect()
40}74}
platform/snow-leopard/probe/src/text.rs created+20
...@@ -0,0 +1,20 @@
1//! Paints `scene::interface` through `draw` over OpenGL and writes `text-probe.png`, to
2//! set beside a Cocoa label's pixels.
3#![allow(deprecated)] // OpenGL is the only GPU API 10.6 has.
4mod context;
5mod scene;
6
7fn main() {
8 objc2::rc::autoreleasepool(|_| {
9 let _context = context::current();
10 let mut renderer = draw::Renderer::new().unwrap_or_else(|error| panic!("{error}"));
11 let target = draw::Target::new(scene::TEXT_SIZE).unwrap();
12 let ui = scene::interface(1.0);
13 let layers = ui.layers();
14 renderer.draw(&target, scene::TEXT_SIZE, [1.0; 4], &scene::interface_layers(&layers, 1.0)).unwrap();
15 let file = std::fs::File::create("text-probe.png").unwrap();
16 let mut encoder = png::Encoder::new(file, scene::TEXT_SIZE[0], scene::TEXT_SIZE[1]);
17 encoder.set_color(png::ColorType::Rgba);
18 encoder.write_header().unwrap().write_image_data(&target.read_pixels()).unwrap();
19 });
20}
platform/snow-leopard/probe/src/window.rs deleted-133
...@@ -1,133 +0,0 @@
1//! Opens a window with an OpenGL 2.1 view, paints a test pattern through an sRGB
2//! framebuffer object, presents it, and writes the framebuffer to `window-probe.png`.
3#![allow(deprecated)] // OpenGL is the only GPU API 10.6 has.
4use gl21::*;
5use objc2::{MainThreadMarker, rc::Retained};
6use objc2_app_kit::{
7 NSApplication, NSApplicationActivationPolicy, NSBackingStoreType, NSOpenGLPFAAccelerated,
8 NSOpenGLPFAAlphaSize, NSOpenGLPFAColorSize, NSOpenGLPFADoubleBuffer, NSOpenGLPixelFormat,
9 NSOpenGLView, NSWindow, NSWindowStyleMask,
10};
11use objc2_foundation::{NSDate, NSDefaultRunLoopMode, NSPoint, NSRect, NSSize, NSString};
12
13const SIZE: [i32; 2] = [480, 320];
14
15fn main() {
16 // 10.6 has no implicit pool on the main thread; AppKit autoreleases from the start.
17 objc2::rc::autoreleasepool(|_| run());
18}
19
20fn run() {
21 let mtm = MainThreadMarker::new().unwrap();
22 let app = NSApplication::sharedApplication(mtm);
23 app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
24 app.finishLaunching();
25
26 let frame = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(SIZE[0] as f64, SIZE[1] as f64));
27 let window = unsafe {
28 NSWindow::initWithContentRect_styleMask_backing_defer(
29 mtm.alloc(),
30 frame,
31 NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::Resizable,
32 NSBackingStoreType::Buffered,
33 false,
34 )
35 };
36 unsafe { window.setReleasedWhenClosed(false) };
37 window.setTitle(&NSString::from_str("Snowbound on Snow Leopard"));
38 let attributes = [
39 NSOpenGLPFADoubleBuffer,
40 NSOpenGLPFAAccelerated,
41 NSOpenGLPFAColorSize,
42 24,
43 NSOpenGLPFAAlphaSize,
44 8,
45 0,
46 ];
47 let format: Retained<NSOpenGLPixelFormat> = unsafe {
48 NSOpenGLPixelFormat::initWithAttributes(
49 mtm.alloc(),
50 std::ptr::NonNull::new(attributes.as_ptr().cast_mut()).unwrap(),
51 )
52 }
53 .expect("pixel format");
54 let view = NSOpenGLView::initWithFrame_pixelFormat(mtm.alloc(), frame, Some(&format)).unwrap();
55 window.setContentView(Some(&view));
56 window.makeKeyAndOrderFront(None);
57 app.activateIgnoringOtherApps(true);
58
59 // Let the window server map the window before painting into it.
60 let until = NSDate::dateWithTimeIntervalSinceNow(0.5);
61 while let Some(event) = unsafe {
62 app.nextEventMatchingMask_untilDate_inMode_dequeue(
63 objc2_app_kit::NSEventMask::Any,
64 Some(&until),
65 NSDefaultRunLoopMode,
66 true,
67 )
68 } {
69 app.sendEvent(&event);
70 }
71
72 let context = view.openGLContext().expect("context");
73 context.makeCurrentContext();
74 println!("GL_VENDOR {}", string(VENDOR));
75 println!("GL_RENDERER {}", string(RENDERER));
76 println!("GL_VERSION {}", string(VERSION));
77 let extensions = string(EXTENSIONS);
78 for wanted in ["GL_EXT_framebuffer_object", "GL_EXT_framebuffer_blit", "GL_EXT_framebuffer_sRGB", "GL_EXT_texture_sRGB", "GL_ARB_texture_non_power_of_two"] {
79 println!("{wanted:32} {}", extensions.split(' ').any(|e| e == wanted));
80 }
81
82 let pixels = unsafe { paint() };
83 unsafe {
84 glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0);
85 glBlitFramebufferEXT(0, 0, SIZE[0], SIZE[1], 0, 0, SIZE[0], SIZE[1], COLOR_BUFFER_BIT, NEAREST as GLenum);
86 }
87 context.flushBuffer();
88 println!("GL error after present: {:#x}", unsafe { glGetError() });
89
90 let file = std::fs::File::create("window-probe.png").unwrap();
91 let mut encoder = png::Encoder::new(file, SIZE[0] as u32, SIZE[1] as u32);
92 encoder.set_color(png::ColorType::Rgba);
93 encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
94 println!("wrote window-probe.png");
95}
96
97/// Clears an sRGB framebuffer object and fills three bands with scissored clears in
98/// linear colours; returns its pixels top row first.
99unsafe fn paint() -> Vec<u8> {
100 unsafe {
101 let mut texture = 0;
102 glGenTextures(1, &mut texture);
103 glBindTexture(TEXTURE_2D, texture);
104 glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, NEAREST);
105 glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, NEAREST);
106 glTexImage2D(TEXTURE_2D, 0, SRGB8_ALPHA8, SIZE[0], SIZE[1], 0, RGBA, UNSIGNED_BYTE, std::ptr::null());
107 let mut framebuffer = 0;
108 glGenFramebuffersEXT(1, &mut framebuffer);
109 glBindFramebufferEXT(FRAMEBUFFER, framebuffer);
110 glFramebufferTexture2DEXT(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, texture, 0);
111 assert_eq!(glCheckFramebufferStatusEXT(FRAMEBUFFER), FRAMEBUFFER_COMPLETE);
112 glEnable(FRAMEBUFFER_SRGB);
113 glViewport(0, 0, SIZE[0], SIZE[1]);
114 glClearColor(0.9, 0.9, 0.9, 1.0);
115 glClear(COLOR_BUFFER_BIT);
116 glEnable(SCISSOR_TEST);
117 // Linear 0.214 is sRGB 128: the middle band reads back as 128 if encoding works.
118 for (i, color) in [[0.8, 0.1, 0.1], [0.214, 0.214, 0.214], [0.1, 0.2, 0.8]].iter().enumerate() {
119 glScissor(40 + i as i32 * 140, 60, 120, 200);
120 glClearColor(color[0], color[1], color[2], 1.0);
121 glClear(COLOR_BUFFER_BIT);
122 }
123 glDisable(SCISSOR_TEST);
124 let mut pixels = vec![0u8; (SIZE[0] * SIZE[1] * 4) as usize];
125 glReadPixels(0, 0, SIZE[0], SIZE[1], RGBA, UNSIGNED_BYTE, pixels.as_mut_ptr().cast());
126 glBindFramebufferEXT(READ_FRAMEBUFFER, framebuffer);
127 let row = (SIZE[0] * 4) as usize;
128 let flipped: Vec<u8> = pixels.chunks(row).rev().flatten().copied().collect();
129 let middle = &flipped[(160 * SIZE[0] as usize + 250) * 4..][..4];
130 println!("middle band reads {middle:?} (128 means sRGB encode on write)");
131 flipped
132 }
133}
platform/snow-leopard/reference/Cargo.toml+1
...@@ -10,3 +10,4 @@ draw = { path = "../../../crates/draw" }...@@ -10,3 +10,4 @@ draw = { path = "../../../crates/draw" }
10wgpu = "30.0.1"10wgpu = "30.0.1"
11pollster = "0.4"11pollster = "0.4"
12png = "0.18"12png = "0.18"
13ui = { path = "../../../crates/ui" }
platform/snow-leopard/reference/src/main.rs+20-31
...@@ -1,18 +1,26 @@...@@ -1,18 +1,26 @@
1//! `reference OUT.png` paints the render-probe scene through `draw::Renderer` over wgpu;1//! `reference OUT.png` paints the render-probe scene through `draw` over wgpu,
2//! `reference --compare A.png B.png DIFF.png` reports how two renders differ and writes2//! `reference --text SCALE OUT.png` the text-probe scene; `reference --compare A.png B.png
3//! their difference, amplified.3//! DIFF.png` reports how two renders differ and writes their difference, amplified.
4#[path = "../../probe/src/scene.rs"]4#[path = "../../probe/src/scene.rs"]
5mod scene;5mod scene;
66
7use draw::{Layer, Primitive, RasterImage, Stroke};
8use scene::Shape;
9
10fn main() {7fn main() {
11 let args: Vec<String> = std::env::args().skip(1).collect();8 let args: Vec<String> = std::env::args().skip(1).collect();
12 match args.as_slice() {9 match args.as_slice() {
13 [flag, a, b, diff] if flag == "--compare" => compare(a, b, diff),10 [flag, a, b, diff] if flag == "--compare" => compare(a, b, diff),
14 [out] => paint(out),11 [flag, scale, out] if flag == "--text" => {
15 _ => panic!("usage: reference OUT.png | reference --compare A.png B.png DIFF.png"),12 let scale: f32 = scale.parse().unwrap();
13 let ui = scene::interface(scale);
14 let layers = ui.layers();
15 let size = scene::TEXT_SIZE.map(|side| (side as f32 * scale) as u32);
16 paint(out, size, [1.0; 4], &scene::interface_layers(&layers, scale));
17 }
18 [out] => {
19 let checker = scene::checker();
20 let primitives = scene::primitives(&checker);
21 paint(out, scene::SIZE, scene::CLEAR, &[scene::layer(&primitives)]);
22 }
23 _ => panic!("usage: reference [--text SCALE] OUT.png | reference --compare A.png B.png DIFF.png"),
16 }24 }
17}25}
1826
...@@ -47,31 +55,13 @@ fn write(path: &str, [width, height]: [u32; 2], pixels: &[u8]) {...@@ -47,31 +55,13 @@ fn write(path: &str, [width, height]: [u32; 2], pixels: &[u8]) {
47 encoder.write_header().unwrap().write_image_data(pixels).unwrap();55 encoder.write_header().unwrap().write_image_data(pixels).unwrap();
48}56}
4957
50fn paint(out: &str) {58fn paint(out: &str, size: [u32; 2], clear: [f32; 4], layers: &[draw::Layer<'_>]) {
51 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());59 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
52 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();60 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
53 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();61 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
54 let format = wgpu::TextureFormat::Rgba8UnormSrgb;62 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
55 let mut renderer = draw::Renderer::new(device.clone(), queue.clone(), format);63 let mut renderer = draw::Renderer::new(device.clone(), queue.clone(), format);
56 let (checker_size, checker) = scene::checker();64 let [width, height] = size;
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 {65 let texture = device.create_texture(&wgpu::TextureDescriptor {
76 label: None,66 label: None,
77 size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },67 size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
...@@ -82,9 +72,8 @@ fn paint(out: &str) {...@@ -82,9 +72,8 @@ fn paint(out: &str) {
82 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,72 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
83 view_formats: &[],73 view_formats: &[],
84 });74 });
85 let layer = Layer { scale: 1.0, origin: [0.0; 2], clip: None, backdrop: None, primitives: &primitives };
86 renderer75 renderer
87 .draw(&texture.create_view(&Default::default()), scene::SIZE, scene::CLEAR, &[layer])76 .draw(&texture.create_view(&Default::default()), size, clear, layers)
88 .unwrap();77 .unwrap();
89 let row = (width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);78 let row = (width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
90 let buffer = device.create_buffer(&wgpu::BufferDescriptor {79 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
...@@ -107,5 +96,5 @@ fn paint(out: &str) {...@@ -107,5 +96,5 @@ fn paint(out: &str) {
107 device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }).unwrap();96 device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }).unwrap();
108 let mapped = buffer.get_mapped_range(..).unwrap();97 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();98 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);99 write(out, size, &pixels);
111}100}
platform/snow-leopard/remote.sh+69-4
...@@ -4,6 +4,14 @@...@@ -4,6 +4,14 @@
4# remote.sh run [-C DIR] BINARY [ARGS...]4# remote.sh run [-C DIR] BINARY [ARGS...]
5# ship to /tmp/snow-leopard, run in DIR (default there),5# ship to /tmp/snow-leopard, run in DIR (default there),
6# print any new crash log6# print any new crash log
7# remote.sh start BINARY [ARGS...] ship and leave running there, output in NAME.log;
8# BINARY may be an .app bundle
9# remote.sh stop NAME end what start started (a bundle by its executable)
10# remote.sh screen LOCAL.png wake the display and capture it (screencapture
11# writes nothing from ssh)
12# remote.sh input ARGS... click X Y | type TEXT | key KEYCODE [command]
13# remote.sh selectors BINARY selector-like names BINARY holds that nothing on
14# 10.6 implements
7# remote.sh mirror PATH... copy repo paths to the same absolute paths there, so15# remote.sh mirror PATH... copy repo paths to the same absolute paths there, so
8# test binaries find fixtures by CARGO_MANIFEST_DIR16# test binaries find fixtures by CARGO_MANIFEST_DIR
9# remote.sh pull REMOTE [LOCAL] copy a file back (e.g. a screenshot)17# remote.sh pull REMOTE [LOCAL] copy a file back (e.g. a screenshot)
...@@ -14,6 +22,33 @@ host=${SNOW_LEOPARD_HOST:-osx10.6@10.0.0.1}...@@ -14,6 +22,33 @@ host=${SNOW_LEOPARD_HOST:-osx10.6@10.0.0.1}
14here=$(cd "$(dirname "$0")" && pwd)22here=$(cd "$(dirname "$0")" && pwd)
15dir=/tmp/snow-leopard23dir=/tmp/snow-leopard
1624
25ship() {
26 base=$(basename "$1")
27 if [ -d "$1" ]; then
28 (cd "$(dirname "$1")" && COPYFILE_DISABLE=1 tar czf - --no-mac-metadata --no-xattrs "$base") |
29 ssh "$host" "mkdir -p $dir && rm -rf '$dir/${base:?}' && tar xzf - -C $dir"
30 else
31 ssh "$host" "mkdir -p $dir && cat > $dir/$base && chmod +x $dir/$base" < "$1"
32 fi
33}
34
35# Builds a console/ tool against the 10.6 SDK and ships it.
36tool() {
37 sdk=$here/../../target/snow-leopard/MacOSX10.6.sdk
38 out=$here/../../target/snow-leopard/$1
39 if [ ! "$out" -nt "$here/console/$1.c" ]; then
40 clang -arch x86_64 -isysroot "$sdk" -mmacosx-version-min=10.6 -O2 -Wall \
41 -framework ApplicationServices -framework CoreServices -lobjc \
42 "$here/console/$1.c" -o "$out" 2>&1 | grep -v -e "MH_DYLIB_STUB" -e "no platform load" >&2 || true
43 fi
44 ship "$out"
45}
46
47quote() {
48 quoted=
49 for arg; do quoted="$quoted '$(printf %s "$arg" | sed "s/'/'\\\\''/g")'"; done
50}
51
17case "${1:-}" in52case "${1:-}" in
18run)53run)
19 shift54 shift
...@@ -22,9 +57,8 @@ run)...@@ -22,9 +57,8 @@ run)
22 binary=$157 binary=$1
23 shift58 shift
24 name=$(basename "$binary")59 name=$(basename "$binary")
25 ssh "$host" "mkdir -p $dir && cat > $dir/$name && chmod +x $dir/$name" < "$binary"60 ship "$binary"
26 quoted=61 quote "$@"
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.62 # 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=\$?63 ssh "$host" "touch $dir/.start && cd '$cwd' && status=0 && $dir/$name$quoted || status=\$?
30 if [ \$status -ge 128 ]; then64 if [ \$status -ge 128 ]; then
...@@ -35,6 +69,37 @@ run)...@@ -35,6 +69,37 @@ run)
35 fi69 fi
36 exit \$status" < /dev/null70 exit \$status" < /dev/null
37 ;;71 ;;
72start)
73 shift
74 name=$(basename "$1")
75 program=./$name
76 if [ -d "$1" ]; then
77 name=$(/usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' "$1/Contents/Info.plist")
78 program=./$(basename "$1")/Contents/MacOS/$name
79 fi
80 ship "$1"
81 shift
82 quote "$@"
83 ssh "$host" "cd $dir && (nohup $program$quoted < /dev/null > $name.log 2>&1 &)"
84 ;;
85stop)
86 ssh "$host" "kill \$(ps axo pid,command | awk '{ sub(\".*/\", \"\", \$2) } \$2 == \"$2\" { print \$1 }')"
87 ;;
88screen)
89 tool screen
90 ssh "$host" "$dir/screen $dir/screen.png && cat $dir/screen.png" > "$2"
91 ;;
92input)
93 shift
94 tool input
95 quote "$@"
96 ssh "$host" "$dir/input$quoted"
97 ;;
98selectors)
99 tool selectors
100 strings -a "$2" | grep -E '^[a-z][a-z]+([A-Z][a-z0-9]+)*[A-Z]?[A-Za-z0-9]*(:([A-Za-z][A-Za-z0-9]*:)*)?$' |
101 grep -E '[a-z]{2}[A-Z][a-z]|:$' | sort -u | ssh "$host" "$dir/selectors 2>/dev/null"
102 ;;
38mirror)103mirror)
39 shift104 shift
40 root=$(cd "$here/../.." && pwd)105 root=$(cd "$here/../.." && pwd)
...@@ -54,7 +119,7 @@ sh)...@@ -54,7 +119,7 @@ sh)
54 ssh "$host" "$@"119 ssh "$host" "$@"
55 ;;120 ;;
56*)121*)
57 sed -n '2,12s/^# \{0,1\}//p' "$0" >&2122 sed -n '2,20s/^# \{0,1\}//p' "$0" >&2
58 exit 2123 exit 2
59 ;;124 ;;
60esac125esac
platform/snow-leopard/rt/appkit.c created+146
...@@ -0,0 +1,146 @@
1// AppKit that winit, AccessKit and the app call and 10.6 lacks, added at load time with
2// the neutral answer: scale 1, no precise scrolling, no appearances, no tabbing, no full
3// screen. `remote.sh selectors` lists what a binary names that 10.6 lacks. Linked from
4// the archive by the appearance name winit imports, so only AppKit binaries carry it.
5// class_addMethod adds nothing where the system already has the method.
6#include <ApplicationServices/ApplicationServices.h>
7#include <objc/message.h>
8#include <objc/runtime.h>
9
10// 10.9's appearance name; it names the stub appearance below.
11const CFStringRef NSAppearanceNameAqua = CFSTR("NSAppearanceNameAqua");
12
13// Accessibility names from 10.7 to 10.13, which AccessKit imports. 10.6's VoiceOver
14// asks through the older attribute protocol, which never sees them.
15const CFStringRef NSAccessibilityAnnouncementKey = CFSTR("AXAnnouncementKey");
16const CFStringRef NSAccessibilityAnnouncementRequestedNotification = CFSTR("AXAnnouncementRequested");
17const CFStringRef NSAccessibilityLanguageTextAttribute = CFSTR("AXLanguageText");
18const CFStringRef NSAccessibilityPriorityKey = CFSTR("AXPriorityKey");
19const CFStringRef NSAccessibilitySwitchSubrole = CFSTR("AXSwitch");
20const CFStringRef NSAccessibilityTabButtonSubrole = CFSTR("AXTabButton");
21const CFStringRef NSAccessibilityTextAlignmentAttribute = CFSTR("AXTextAlignment");
22const CFStringRef NSAccessibilityToggleSubrole = CFSTR("AXToggle");
23
24extern void NSAccessibilityPostNotification(id element, CFStringRef notification);
25
26void NSAccessibilityPostNotificationWithUserInfo(id element, CFStringRef notification, id info) {
27 NSAccessibilityPostNotification(element, notification);
28}
29
30typedef id (*Send)(id, SEL);
31#define SEND(receiver, selector) ((Send)objc_msgSend)((id)(receiver), sel_registerName(selector))
32typedef double (*SendDouble)(id, SEL);
33
34static double one(id self, SEL _cmd) { return 1.0; }
35static void ignore(id self, SEL _cmd, id value) {}
36static BOOL no(id self, SEL _cmd) { return NO; }
37static long zero(id self, SEL _cmd) { return 0; }
38static id nothing(id self, SEL _cmd) { return nil; }
39
40static double delta_x(id self, SEL _cmd) { return ((SendDouble)objc_msgSend)(self, sel_registerName("deltaX")); }
41static double delta_y(id self, SEL _cmd) { return ((SendDouble)objc_msgSend)(self, sel_registerName("deltaY")); }
42
43// 10.7: the window's frame origin is where its base coordinates start.
44static CGRect rect_to_screen(id self, SEL _cmd, CGRect rect) {
45 CGRect frame = ((CGRect(*)(id, SEL))objc_msgSend_stret)(self, sel_registerName("frame"));
46 rect.origin.x += frame.origin.x;
47 rect.origin.y += frame.origin.y;
48 return rect;
49}
50
51static CGPoint point_from_screen(id self, SEL _cmd, CGPoint point) {
52 CGRect frame = ((CGRect(*)(id, SEL))objc_msgSend_stret)(self, sel_registerName("frame"));
53 return CGPointMake(point.x - frame.origin.x, point.y - frame.origin.y);
54}
55
56static id srgb_color(id self, SEL _cmd, CGFloat red, CGFloat green, CGFloat blue, CGFloat alpha) {
57 CGFloat components[] = {red, green, blue, alpha};
58 return ((id (*)(id, SEL, id, CGFloat *, long))objc_msgSend)(
59 self, sel_registerName("colorWithColorSpace:components:count:"),
60 SEND(objc_getClass("NSColorSpace"), "sRGBColorSpace"), components, 4);
61}
62
63// 10.10's label is what 10.6 calls the description attribute.
64static void set_accessibility_label(id self, SEL _cmd, id label) {
65 ((void (*)(id, SEL, id, CFStringRef))objc_msgSend)(
66 self, sel_registerName("accessibilitySetOverrideValue:forAttribute:"), label, CFSTR("AXDescription"));
67}
68
69static id appearance_class;
70
71static id stub_appearance(id self, SEL _cmd) {
72 static id shared;
73 if (!shared) shared = SEND(SEND(appearance_class, "alloc"), "init");
74 return shared;
75}
76
77static id aqua(id self, SEL _cmd) { return (id)NSAppearanceNameAqua; }
78static id best_match(id self, SEL _cmd, id names) { return (id)NSAppearanceNameAqua; }
79
80// 10.6 answers component accessors only for the named RGB spaces; later systems answer
81// them for any RGB colour space, such as the sRGB the app converts system colours to.
82static CGFloat component(id self, int index) {
83 CGFloat components[4] = {0};
84 ((void (*)(id, SEL, CGFloat *))objc_msgSend)(self, sel_registerName("getComponents:"), components);
85 return components[index];
86}
87static CGFloat red(id self, SEL _cmd) { return component(self, 0); }
88static CGFloat green(id self, SEL _cmd) { return component(self, 1); }
89static CGFloat blue(id self, SEL _cmd) { return component(self, 2); }
90
91static id black(id self, SEL _cmd) { return SEND(objc_getClass("NSColor"), "blackColor"); }
92static id secondary_selection(id self, SEL _cmd) {
93 return SEND(objc_getClass("NSColor"), "secondarySelectedControlColor");
94}
95
96#define RECT "{CGRect={CGPoint=dd}{CGSize=dd}}"
97
98static void add(const char *class_name, int meta, const char *selector, void *imp, const char *types) {
99 Class cls = (Class)objc_getClass(class_name);
100 if (meta) cls = object_getClass((id)cls);
101 class_addMethod(cls, sel_registerName(selector), (IMP)imp, types);
102}
103
104__attribute__((constructor)) static void polyfill(void) {
105 add("NSScreen", 0, "backingScaleFactor", one, "d@:");
106 add("NSWindow", 0, "backingScaleFactor", one, "d@:");
107 add("NSWindow", 0, "convertRectToScreen:", rect_to_screen, RECT "@:" RECT);
108 add("NSWindow", 0, "convertPointFromScreen:", point_from_screen, "{CGPoint=dd}@:{CGPoint=dd}");
109 add("NSWindow", 0, "performWindowDragWithEvent:", ignore, "v@:@");
110 add("NSWindow", 0, "toggleFullScreen:", ignore, "v@:@");
111 add("NSWindow", 0, "setTabbingMode:", ignore, "v@:q");
112 add("NSWindow", 0, "setTabbingIdentifier:", ignore, "v@:@");
113 add("NSWindow", 1, "setAllowsAutomaticWindowTabbing:", ignore, "v@:c");
114 add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c");
115 add("NSWindow", 0, "setTitleVisibility:", ignore, "v@:q");
116 add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c");
117 add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@");
118 add("NSDatePicker", 0, "setPresentsCalendarOverlay:", ignore, "v@:c");
119 add("NSEvent", 0, "hasPreciseScrollingDeltas", no, "c@:");
120 add("NSEvent", 0, "scrollingDeltaX", delta_x, "d@:");
121 add("NSEvent", 0, "scrollingDeltaY", delta_y, "d@:");
122 add("NSEvent", 0, "momentumPhase", zero, "Q@:");
123 add("NSEvent", 0, "phase", zero, "Q@:");
124 add("NSColorSpaceColor", 0, "redComponent", red, "d@:");
125 add("NSColorSpaceColor", 0, "greenComponent", green, "d@:");
126 add("NSColorSpaceColor", 0, "blueComponent", blue, "d@:");
127 add("NSColor", 1, "colorWithSRGBRed:green:blue:alpha:", srgb_color, "@@:dddd");
128 add("NSColor", 1, "textInsertionPointColor", black, "@@:");
129 add("NSColor", 1, "unemphasizedSelectedTextBackgroundColor", secondary_selection, "@@:");
130
131 // One appearance, Aqua, which windows have and nothing changes.
132 appearance_class = (id)objc_getClass("NSAppearance");
133 if (!appearance_class) {
134 Class cls = objc_allocateClassPair((Class)objc_getClass("NSObject"), "NSAppearance", 0);
135 objc_registerClassPair(cls);
136 appearance_class = (id)cls;
137 }
138 add("NSAppearance", 0, "name", aqua, "@@:");
139 add("NSAppearance", 0, "bestMatchFromAppearancesWithNames:", best_match, "@@:@");
140 add("NSAppearance", 1, "appearanceNamed:", nothing, "@@:@");
141 add("NSAppearance", 1, "currentAppearance", nothing, "@@:");
142 add("NSAppearance", 1, "setCurrentAppearance:", ignore, "v@:@");
143 add("NSWindow", 0, "appearance", nothing, "@@:");
144 add("NSWindow", 0, "setAppearance:", ignore, "v@:@");
145 add("NSWindow", 0, "effectiveAppearance", stub_appearance, "@@:");
146}
platform/snow-leopard/rt/objc.c+11
...@@ -37,3 +37,14 @@ void *objc_autoreleasePoolPush(void) {...@@ -37,3 +37,14 @@ void *objc_autoreleasePoolPush(void) {
37}37}
3838
39void objc_autoreleasePoolPop(void *pool) { SEND((id)pool, "drain"); }39void objc_autoreleasePoolPop(void *pool) { SEND((id)pool, "drain"); }
40
41// 10.6 has no zeroing weak references for non-GC code. These hold the object unretained
42// and never zero: sound only while the referent outlives the reference, as winit's view
43// and its window do.
44id objc_initWeak(id *location, id value) { return *location = value; }
45id objc_storeWeak(id *location, id value) { return *location = value; }
46id objc_loadWeakRetained(id *location) { return objc_retain(*location); }
47id objc_loadWeak(id *location) { return objc_autorelease(objc_loadWeakRetained(location)); }
48void objc_destroyWeak(id *location) { *location = nil; }
49void objc_copyWeak(id *to, id *from) { *to = *from; }
50void objc_moveWeak(id *to, id *from) { *to = *from; *from = nil; }
tools/canvas/build_macos.py+33-10
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1#!/usr/bin/env python31#!/usr/bin/env python3
2"""Build the local macOS canvas bundle."""2"""Build the local macOS canvas bundle, or with --snow-leopard the Mac OS X 10.6 one."""
3import argparse3import argparse
4import json4import json
5from pathlib import Path5from pathlib import Path
...@@ -7,32 +7,53 @@ import platform...@@ -7,32 +7,53 @@ import platform
7import plistlib7import plistlib
8import shutil8import shutil
9import subprocess9import subprocess
10import tempfile
1011
11parser = argparse.ArgumentParser(description=__doc__)12parser = argparse.ArgumentParser(description=__doc__)
12parser.add_argument('--release', action='store_true')13parser.add_argument('--release', action='store_true')
13parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')14parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')
14parser.add_argument('--bundle-id', help='Bundle identifier for the separate app')15parser.add_argument('--bundle-id', help='Bundle identifier for the separate app')
16parser.add_argument('--snow-leopard', action='store_true',
17 help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard')
15args = parser.parse_args()18args = parser.parse_args()
16if bool(args.output) != bool(args.bundle_id):19if bool(args.output) != bool(args.bundle_id):
17 parser.error('Use --output and --bundle-id together.')20 parser.error('Use --output and --bundle-id together.')
18if args.output and (args.output.suffix != '.app' or args.output.exists()):21if args.output and (args.output.suffix != '.app' or args.output.exists()):
19 parser.error('Choose a new output path ending in .app.')22 parser.error('Choose a new output path ending in .app.')
20root = Path(__file__).resolve().parents[2]23root = Path(__file__).resolve().parents[2]
21subprocess.run(['cargo', 'build', '-p', 'snowbound'] + (['--release'] if args.release else []), cwd=root, check=True)24profile = ['--release'] if args.release else []
22metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root))25if args.snow_leopard:
23target = Path(metadata['target_directory'])26 subprocess.run([root / 'platform/snow-leopard/cargo.sh', 'build', '-p', 'snowbound', '--no-default-features'] + profile,
27 cwd=root, check=True)
28 target = root / 'target/snow-leopard'
29 built = target / 'x86_64-apple-macosx10.6'
30else:
31 subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile, cwd=root, check=True)
32 metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root))
33 target = built = Path(metadata['target_directory'])
24bundle = args.output.resolve() if args.output else target / 'Snowbound.app'34bundle = args.output.resolve() if args.output else target / 'Snowbound.app'
25if args.output:35if args.output:
26 bundle.mkdir(parents=True, exist_ok=False)36 bundle.mkdir(parents=True, exist_ok=False)
27binary = bundle / 'Contents/MacOS/Snowbound'37binary = bundle / 'Contents/MacOS/Snowbound'
28binary.parent.mkdir(parents=True, exist_ok=True)38binary.parent.mkdir(parents=True, exist_ok=True)
29pending = binary.with_suffix('.next')39pending = binary.with_suffix('.next')
30shutil.copy2(target / ('release' if args.release else 'debug') / 'snowbound', pending)40shutil.copy2(built / ('release' if args.release else 'debug') / 'snowbound', pending)
31pending.replace(binary)41pending.replace(binary)
32icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia'42icons = root / 'crates/snowbound/assets/icon'
33resources = bundle / 'Contents/Resources'43resources = bundle / 'Contents/Resources'
34resources.mkdir(exist_ok=True)44resources.mkdir(exist_ok=True)
35shutil.copy2(root / 'crates/snowbound/assets/icon' / f'{icon}.icns', resources / 'Snowbound.icns')45if args.snow_leopard:
46 # 10.6 reads 256 and 512 pixel entries (ic08, ic09), which the checked-in icns lacks.
47 with tempfile.TemporaryDirectory() as scratch:
48 iconset = Path(scratch) / 'Snowbound.iconset'
49 iconset.mkdir()
50 for side in [16, 32, 128, 256, 512]:
51 subprocess.run(['sips', '-z', str(side), str(side), icons / 'Snowbound-SnowLeopard.png',
52 '--out', iconset / f'icon_{side}x{side}.png'], check=True, capture_output=True)
53 subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', resources / 'Snowbound.icns'], check=True)
54else:
55 icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia'
56 shutil.copy2(icons / f'{icon}.icns', resources / 'Snowbound.icns')
36(bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({57(bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({
37 'CFBundleExecutable': binary.name,58 'CFBundleExecutable': binary.name,
38 'CFBundleIdentifier': args.bundle_id or 'dev.snowbound.app',59 'CFBundleIdentifier': args.bundle_id or 'dev.snowbound.app',
...@@ -43,7 +64,9 @@ shutil.copy2(root / 'crates/snowbound/assets/icon' / f'{icon}.icns', resources /...@@ -43,7 +64,9 @@ shutil.copy2(root / 'crates/snowbound/assets/icon' / f'{icon}.icns', resources /
43 'NSHighResolutionCapable': True,64 'NSHighResolutionCapable': True,
44 'NSPrincipalClass': 'NSApplication',65 'NSPrincipalClass': 'NSApplication',
45 'CFBundleIconFile': 'Snowbound',66 'CFBundleIconFile': 'Snowbound',
46}))67} | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))
47subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True)68# 10.6 runs the bundle unsigned.
48subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True)69if not args.snow_leopard:
70 subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True)
71 subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True)
49print(bundle)72print(bundle)