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.
5959 focused and not: on KDE the colour scheme's header colours from
6060 `kdeglobals`, as KWin paints its title bars, and on GNOME winit's Adwaita
6161 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.
6266- zenity or kdialog provide the pickers and alerts. The XDG settings portal
6367 provides the colour scheme. Text conventions come from the C library's
6468 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
163163from its replica. On reconnect the queue publishes, or rebases and publishes,
164164through the same path as always. A failed directory read keeps the previous
165165catalog, 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
167170## Notebook structure
168171
arc/ui.md+2-1
......@@ -25,7 +25,8 @@ frame-rate independent, a retargeted animation continues smoothly from where
2525it is, and it settles fast. Popups open and close on short timed curves,
2626slow enough to follow: a menu grows out of the pointer or its button as it
2727fades 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
3031## 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`.
88 page per case (`c1-bullet`, `c8-split`, ...).
99- `tags.one`: a section holding one page with the nine Ctrl+1..9 default tags,
1010 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.
1114- `summaries/<case>-<step>.txt`: the page after each step, from COM
1215 `GetPageContent`, one line per paragraph:
1316 `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"
3434path = "src/bin/page_probe.rs"
3535
3636[dev-dependencies]
37draw = { path = "../draw", features = ["wgpu"] }
3738accesskit_consumer = "0.39.0"
3839pollster = "0.4"
3940wgpu.workspace = true
crates/canvas/src/editor.rs+221-27
......@@ -77,6 +77,14 @@ pub struct Selection {
7777 pub affinities: [Affinity; 2],
7878}
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
8088impl From<[TextPosition; 2]> for Selection {
8189 fn from(positions: [TextPosition; 2]) -> Self {
8290 Self {
......@@ -105,6 +113,9 @@ pub struct CanvasEditor {
105113 /// since, which `refresh` compares a changed stored page with; none once the editor
106114 /// holds an edit that could not be stored.
107115 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)>,
108119}
109120
110121/// Imported page state the editable content does not carry.
......@@ -433,6 +444,25 @@ impl TextOutline {
433444 .join("\n")
434445 }
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
436466 /// Highlight rectangles of `selection` in the outline, in its coordinates.
437467 pub fn range_rects(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
438468 let [anchor, focus] = selection.positions;
......@@ -712,6 +742,7 @@ struct Placement {
712742 position: [Option<f32>; 2],
713743}
714744
745#[derive(Clone)]
715746enum RestoreFocus {
716747 Outline(ExGuid),
717748 Caret {
......@@ -766,6 +797,8 @@ enum History {
766797 selection: Selection,
767798 focus: RestoreFocus,
768799 },
800 /// One undo step of edits to OneNote's page selection, undone last first.
801 Group(Vec<History>),
769802}
770803
771804impl CanvasEditor {
......@@ -800,6 +833,7 @@ impl CanvasEditor {
800833 pending: None,
801834 ops: Ok(Vec::new()),
802835 stored: None,
836 whole: None,
803837 })
804838 }
805839
......@@ -983,7 +1017,7 @@ impl CanvasEditor {
9831017 }
9841018 // Undoing an outline's creation focuses the outline focused before, if it is there.
9851019 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 {
9871021 History::Date(_) => date_changed,
9881022 History::Image { .. } | History::Picture { .. } => objects_changed,
9891023 History::Draft { outlines, .. } => changed.contains(&outlines[0].id),
......@@ -1000,6 +1034,11 @@ impl CanvasEditor {
10001034 History::Remove { outline, focus } => changed.contains(outline) || gone(focus),
10011035 History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus),
10021036 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),
10031042 };
10041043 fresh.undo = std::mem::take(&mut self.undo)
10051044 .into_iter()
......@@ -1347,6 +1386,7 @@ impl CanvasEditor {
13471386 pending: None,
13481387 ops: Ok(Vec::new()),
13491388 stored: None,
1389 whole: None,
13501390 })
13511391 }
13521392
......@@ -2000,33 +2040,24 @@ impl CanvasEditor {
20002040 }
20012041 self.finish_composition();
20022042 self.preferred_x = None;
2043 self.whole = None;
20032044 self.active_outline_mut().selection = selection;
20042045 Ok(())
20052046 }
20062047
20072048 pub fn select_all(&mut self) -> Result<(), EditError> {
2008 let paragraph = self
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 )
2049 self.select(self.active_outline().whole())
20252050 }
20262051
20272052 /// 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.
20292055 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 }
20302061 let outline = self.active_outline();
20312062 let [anchor, focus] = outline.selection.positions;
20322063 let (start, end) = (anchor.min(focus), anchor.max(focus));
......@@ -2055,17 +2086,138 @@ impl CanvasEditor {
20552086 .into_iter()
20562087 .map(|range| (first(range.start), last_shown(range))),
20572088 )
2058 .chain([(
2059 first(0),
2060 last_shown(0..outline.document.paragraphs().count()),
2061 )]);
2062 match units
2089 .chain(outline.title.then(|| {
2090 (
2091 first(0),
2092 last_shown(0..outline.document.paragraphs().count()),
2093 )
2094 }))
20632095 .filter_map(|(unit_start, unit_end)| Some([unit_start, unit_end?]))
2064 .find(|&unit| unit[0] <= start && end <= unit[1] && unit != [start, end])
2065 {
2096 .find(|&unit| unit[0] <= start && end <= unit[1] && unit != [start, end]);
2097 let (id, title) = (outline.id, outline.title);
2098 match units {
20662099 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));
20682149 }
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 })
20692221 }
20702222
20712223 pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> {
......@@ -2145,6 +2297,7 @@ impl CanvasEditor {
21452297 extend: bool,
21462298 ) -> Result<(), EditorError> {
21472299 self.finish_composition();
2300 self.whole = None;
21482301 if !extend && movement == Movement::Right && self.leave_equation()? {
21492302 return Ok(());
21502303 }
......@@ -2614,6 +2767,12 @@ impl CanvasEditor {
26142767
26152768 /// Types `text` at the selection; a space ends a typed URL, which becomes a link.
26162769 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 }
26172776 self.leave_link_code()?;
26182777 let start = self.active_outline().selection.positions[0]
26192778 .min(self.active_outline().selection.positions[1]);
......@@ -2640,6 +2799,12 @@ impl CanvasEditor {
26402799 text: &str,
26412800 language: u32,
26422801 ) -> 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 }
26432808 let lines = text
26442809 .split('\n')
26452810 .map(|line| line.strip_suffix('\r').unwrap_or(line))
......@@ -2888,6 +3053,10 @@ impl CanvasEditor {
28883053 /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/
28893054 /// xml/c4-*`, `c5b-*`). Joins pass over a collapsed paragraph's hidden children.
28903055 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 }
28913060 let selection = self.active_outline().selection;
28923061 let [anchor, focus] = selection.positions;
28933062 let mut range = anchor.min(focus)..anchor.max(focus);
......@@ -2975,6 +3144,9 @@ impl CanvasEditor {
29753144 engine: &mut TextEngine,
29763145 movement: Movement,
29773146 ) -> Result<bool, EditorError> {
3147 if self.page_selected() {
3148 return self.delete(engine, false);
3149 }
29783150 self.finish_composition();
29793151 let original = self.selection();
29803152 let preferred_x = self.preferred_x;
......@@ -3040,7 +3212,12 @@ impl CanvasEditor {
30403212 };
30413213 match self.apply_history(engine, history) {
30423214 Ok(inverse) => {
3215 let group = matches!(inverse, History::Group(_));
30433216 self.redo.push(inverse);
3217 // Undoing an edit to the page selection selects the page again.
3218 if group {
3219 self.select_page()?;
3220 }
30443221 Ok(true)
30453222 }
30463223 Err((history, error)) => {
......@@ -3113,6 +3290,7 @@ impl CanvasEditor {
31133290 History::Insert { index, .. } | History::Restore { index, .. } => {
31143291 *index <= self.outlines.len()
31153292 }
3293 History::Group(_) => true,
31163294 };
31173295 if !valid {
31183296 return Err((history, EditError::InvalidRange.into()));
......@@ -3376,6 +3554,19 @@ impl CanvasEditor {
33763554 self.active = Focus::Outline(index);
33773555 History::Remove { outline: id, focus }
33783556 }
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 }
33793570 };
33803571 self.preferred_x = None;
33813572 Ok(inverse)
......@@ -3396,6 +3587,9 @@ impl CanvasEditor {
33963587 if selected.start > selected.end {
33973588 return Err(EditError::InvalidRange.into());
33983589 }
3590 if self.page_selected() {
3591 self.grouped(|editor| editor.remove_page(engine, true))?;
3592 }
33993593 let range = self.marked_range().unwrap_or_else(|| {
34003594 self.active_outline().selection.positions[0]
34013595 .min(self.active_outline().selection.positions[1])
crates/canvas/src/editor/format.rs+247-35
......@@ -1,13 +1,14 @@
11//! OneNote's Home-ribbon commands: character formatting, lists, alignment and note tags.
22
33use super::*;
4use crate::layout::{DEFAULT_FONT, DEFAULT_FONT_SIZE};
45use onestore::document::{Format, Kind, Tag};
56use onestore::page::ParagraphContent;
67use onestore::page::text::new_id;
78
89/// The COLORREF OneNote stores for automatic colour, which overrides a style's colour.
910const AUTOMATIC: u32 = 0xff00_0000;
10/// The one checkable note tag shape the page draws.
11/// To Do's check box.
1112const CHECKBOX: u16 = 3;
1213
1314/// A toolbar command on the selection, or with only a caret, on the text typed there next.
......@@ -36,6 +37,9 @@ pub enum Formatting {
3637 RemoveTags,
3738 /// Checks the selected paragraphs' check boxes, or clears them when all are checked.
3839 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),
3943}
4044
4145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
......@@ -157,8 +161,9 @@ impl ListStyle {
157161 }
158162}
159163
160/// OneNote 2010's default tags, Ctrl+1 to Ctrl+9, as it stores their definitions
161/// (`corpus/structural-probe/tags.one`); declared in that order, their action types.
164/// OneNote 2010's default tags, the first nine on Ctrl+1 to Ctrl+9, as it stores their
165/// definitions (`corpus/structural-probe/tag-gallery.one`); declared in gallery order, their
166/// action types.
162167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163168pub enum NoteTag {
164169 ToDo,
......@@ -170,6 +175,26 @@ pub enum NoteTag {
170175 Contact,
171176 Address,
172177 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,
173198}
174199
175200/// What the selection shows on the toolbar.
......@@ -177,7 +202,7 @@ pub enum NoteTag {
177202pub struct FormatState {
178203 /// Attributes every selected character has.
179204 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.
181206 pub font: Option<String>,
182207 pub font_size: Option<f32>,
183208 /// The alignment every selected paragraph shares.
......@@ -225,7 +250,7 @@ impl Toggle {
225250}
226251
227252impl NoteTag {
228 pub(crate) const ALL: [Self; 9] = [
253 pub const ALL: [Self; 29] = [
229254 Self::ToDo,
230255 Self::Important,
231256 Self::Question,
......@@ -235,6 +260,26 @@ impl NoteTag {
235260 Self::Contact,
236261 Self::Address,
237262 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,
238283 ];
239284
240285 /// The tag's name, as OneNote stores and lists it.
......@@ -249,26 +294,71 @@ impl NoteTag {
249294 Self::Contact => "Contact",
250295 Self::Address => "Address",
251296 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,
252348 }
253349 }
254350
255351 pub(super) fn definition(self) -> Definition {
256 let (shape, highlight) = match self {
257 Self::ToDo => (CHECKBOX, None),
258 Self::Important => (13, None),
259 Self::Question => (15, 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),
352 let highlight = match self {
353 Self::RememberForLater => Some(0x0000_ffff),
354 Self::Definition => Some(0x0000_ff00),
355 _ => None,
266356 };
267357 Definition {
268358 kind: Kind::TagDefinition {
269359 label: Some(self.label().into()),
270360 action_type: Some(self as u16),
271 shape: Some(shape),
361 shape: Some(self.shape()),
272362 // The highlighting tags also set black text.
273363 color: highlight.map(|_| 0),
274364 highlight,
......@@ -278,6 +368,14 @@ impl NoteTag {
278368 }
279369}
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
281379/// Where a selection starts and ends, as text leaf identity and byte offset.
282380type Ends = [(ExGuid, usize); 2];
283381
......@@ -494,16 +592,37 @@ impl CanvasEditor {
494592 Some(&self.definitions.get(tag.definition.as_ref()?)?.kind)
495593 }
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> {
498598 let outline = self.active_outline();
499599 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)?;
500619 let nodes = &outline.document.container(container)?[range];
501620 let paragraphs = covered(nodes, ends)
502621 .map(|(node, _)| node)
503622 .collect::<Vec<_>>();
504623 let caret;
505624 let formats = if ends[0] == ends[1] {
506 let [anchor, focus] = outline.selection.positions;
625 let [anchor, focus] = selection.positions;
507626 caret = self.typing_format(anchor.min(focus))?;
508627 vec![&caret]
509628 } else {
......@@ -523,8 +642,16 @@ impl CanvasEditor {
523642 .all(|format| toggle.get(format) == Some(true))
524643 })
525644 .collect(),
526 font: common(formats.iter().map(|format| format.font.clone())),
527 font_size: common(formats.iter().map(|format| format.font_size)),
645 font: common(
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 ),
528655 alignment: common(paragraphs.iter().map(|node| {
529656 Some(
530657 match node.text().unwrap().text.spans()[0].format.alignment {
......@@ -555,6 +682,9 @@ impl CanvasEditor {
555682 engine: &mut TextEngine,
556683 command: Formatting,
557684 ) -> Result<(), EditorError> {
685 if self.page_selected() {
686 return self.format_page(engine, command);
687 }
558688 let outline = self.active_outline();
559689 let (id, title, selection) = (outline.id, outline.title, outline.selection);
560690 let (container, mut range, ends) = selected(&outline.document, selection)?;
......@@ -696,7 +826,7 @@ impl CanvasEditor {
696826 });
697827 }
698828 if !remove {
699 let checkable = shape == Some(CHECKBOX);
829 let checkable = checkable(shape);
700830 // Stored newest first.
701831 text.tags.insert(
702832 0,
......@@ -727,6 +857,7 @@ impl CanvasEditor {
727857 | Formatting::FontSize(_)
728858 | Formatting::Color(_)
729859 | Formatting::Highlight(_)
860 | Formatting::Paint(_)
730861 | Formatting::Clear => {
731862 let caret = (ends[0] == ends[1])
732863 .then(|| {
......@@ -766,6 +897,15 @@ impl CanvasEditor {
766897 Formatting::Highlight(color) => {
767898 format.highlight = color.or(base.highlight.map(|_| AUTOMATIC));
768899 }
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 }
769909 _ => {
770910 for toggle in Toggle::ALL {
771911 *toggle.slot(format) = toggle.get(base);
......@@ -787,6 +927,13 @@ impl CanvasEditor {
787927 let base = &bases[&node.id];
788928 let text = &mut node.text_mut().unwrap().text;
789929 *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 }
790937 }
791938 });
792939 }
......@@ -808,6 +955,40 @@ impl CanvasEditor {
808955}
809956
810957impl 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
811992 /// A click on a check box tag: checks the check boxes of paragraph `id` in outline
812993 /// `outline`, or clears them once all are checked, as one undo step that keeps the
813994 /// selection.
......@@ -857,10 +1038,7 @@ impl CanvasEditor {
8571038 let checkable = |tag: &Tag| {
8581039 matches!(
8591040 self.tag_kind(tag),
860 Some(Kind::TagDefinition {
861 shape: Some(CHECKBOX),
862 ..
863 })
1041 Some(Kind::TagDefinition { shape, .. }) if checkable(*shape)
8641042 )
8651043 };
8661044 let checked = self::covered(replacement, ends)
......@@ -920,7 +1098,7 @@ pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option<Defin
9201098/// and its Ctrl+. bullet store it (`corpus/paragraph-edit/reconciliation/keyboard`); a number
9211099/// also takes the text's font and language (`corpus/outline-edit/tree`).
9221100pub(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));
9241102 match style {
9251103 ListStyle::Number(index) => Definition {
9261104 kind: Kind::List {
......@@ -1765,9 +1943,9 @@ mod tests {
17651943 }
17661944
17671945 #[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() {
17691947 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");
17711949 let store = Store::parse(bytes).unwrap();
17721950 let index = RevisionIndex::parse(&store).unwrap();
17731951 let document = Document::parse(&index).unwrap();
......@@ -1789,12 +1967,14 @@ mod tests {
17891967 assert_eq!(editor.format_state().unwrap().tags, NoteTag::ALL);
17901968 let paragraph = &editor.active_outline().shaped.paragraphs[0];
17911969 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);
17921976 assert_eq!(
1793 paragraph
1794 .tags
1795 .iter()
1796 .map(|tag| tag.icon)
1797 .collect::<Vec<_>>(),
1977 icons[..7],
17981978 [
17991979 TagIcon::CheckBox { checked: false },
18001980 TagIcon::Star,
......@@ -1805,6 +1985,13 @@ mod tests {
18051985 TagIcon::Phone,
18061986 ]
18071987 );
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);
18081995 // Remember for later and Definition draw no symbol; the newer one's green marks the text.
18091996 assert!(
18101997 paragraph
......@@ -1885,8 +2072,8 @@ mod tests {
18852072 let settled = |mut page: Page| {
18862073 for object in &mut page.objects {
18872074 if let PageObject::Outline(outline) = object {
1888 for paragraph in &mut outline.paragraphs {
1889 for tag in &mut paragraph.text_mut().unwrap().tags {
2075 for text in outline.paragraphs.iter_mut().filter_map(|p| p.text_mut()) {
2076 for tag in &mut text.tags {
18902077 tag.extra_set = 0;
18912078 }
18922079 }
......@@ -1943,5 +2130,30 @@ mod tests {
19432130 tags: vec![NoteTag::ToDo, NoteTag::Question],
19442131 }
19452132 );
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);
19462158 }
19472159}
crates/canvas/src/editor/table.rs+152
......@@ -78,6 +78,116 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos
7878}
7979
8080impl 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
81191 pub fn tab(&mut self, engine: &mut TextEngine, backward: bool) -> Result<(), EditorError> {
82192 if self.active_outline().title && !backward {
83193 return self.leave_title(engine);
......@@ -399,6 +509,48 @@ mod tests {
399509 .collect()
400510 }
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
402554 #[test]
403555 fn tab_creates_splits_and_selects_native_cells() {
404556 let mut engine = TextEngine::default();
crates/canvas/src/interaction/mod.rs+199-45
......@@ -14,7 +14,7 @@ mod tests;
1414use crate::gpu::{Paper, Viewport, page::PageScene};
1515use crate::{
1616 date::DateField,
17 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Selection, TextOutline},
17 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Selection, TextOutline, Whole},
1818 layout::TextEngine,
1919};
2020use draw::{
......@@ -156,6 +156,7 @@ enum Drag {
156156#[derive(Clone, Copy)]
157157enum PointerFeedback<'a> {
158158 Hover(onestore::ExGuid),
159 /// An outline dragged this far, with the rest of the page selection it belongs to.
159160 Move(onestore::ExGuid, [f32; 2]),
160161 Resize(&'a TextOutline),
161162 /// A picture being moved or resized, drawn at this origin and size.
......@@ -384,6 +385,18 @@ impl PageView {
384385 ))
385386 }
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
387400 fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> {
388401 let Drag::Image {
389402 id,
......@@ -701,8 +714,8 @@ impl PageView {
701714 ..
702715 }) => Some(PointerFeedback::Resize(outline)),
703716 _ => self
704 .outline_preview()
705 .map(|(id, origin)| PointerFeedback::Move(id, origin))
717 .drag_delta()
718 .map(|(id, delta)| PointerFeedback::Move(id, delta))
706719 .or_else(|| {
707720 self.image_preview()
708721 .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size))
......@@ -901,7 +914,10 @@ impl PageView {
901914 }
902915 Some(Hit::Handle { id, grab }) => {
903916 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 }
905921 self.drag = Some(Drag::Outline {
906922 id,
907923 grab,
......@@ -975,7 +991,16 @@ impl PageView {
975991 let inside =
976992 (0..2).all(|axis| (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis]));
977993 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 };
9781002 let preview = self.outline_preview().filter(|_| inside);
1003 let delta = self.drag_delta().filter(|_| inside);
9791004 if let Some((id, origin, size)) = self.image_preview().filter(|_| inside || !moving_image) {
9801005 self.editor
9811006 .place_image(&mut self.engine, id, origin, size)?;
......@@ -988,7 +1013,14 @@ impl PageView {
9881013 self.editor
9891014 .resize(&mut self.engine, outline.bounds().width() as f32)?;
9901015 }
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 {
9921024 self.editor.move_outline(id, origin)?;
9931025 }
9941026 self.changed()
......@@ -1075,27 +1107,49 @@ impl PageView {
10751107 self.edited()
10761108 }
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.
10791112 pub fn copy(&mut self, cut: bool) -> Result<Response> {
1080 let [anchor, focus] = self.editor.selection().positions;
1081 let selected = self
1082 .editor
1083 .active_outline()
1084 .document()
1085 .slice(anchor.min(focus)..anchor.max(focus))?;
1086 let text = selected
1087 .iter()
1088 .map(|paragraph| {
1089 paragraph
1090 .project()
1091 .map(|projection| projection.text().text().to_owned())
1092 })
1093 .collect::<std::result::Result<Vec<_>, _>>()?
1094 .join("\n");
1113 let text = |outline: &TextOutline, selection: Selection| -> Result<String> {
1114 let [anchor, focus] = selection.positions;
1115 Ok(outline
1116 .document()
1117 .slice(anchor.min(focus)..anchor.max(focus))?
1118 .iter()
1119 .map(|paragraph| {
1120 paragraph
1121 .project()
1122 .map(|projection| projection.text().text().to_owned())
1123 })
1124 .collect::<std::result::Result<Vec<_>, _>>()?
1125 .join("\n"))
1126 };
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 };
10951147 if text.is_empty() {
10961148 return Ok(Response::default());
10971149 }
1098 if cut {
1150 if cut && page {
1151 self.editor.delete(&mut self.engine, false)?;
1152 } else if cut {
10991153 self.editor.insert(&mut self.engine, "")?;
11001154 }
11011155 Ok(Response {
......@@ -1177,6 +1231,36 @@ impl PageView {
11771231 self.edited()
11781232 }
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
11801264 /// Professional, or Linear with `linear`, on the equation at the caret.
11811265 pub fn switch_equation(&mut self, linear: bool) -> Result<Response> {
11821266 if linear {
......@@ -1205,6 +1289,10 @@ impl PageView {
12051289 /// Select All, widening the selection a unit at a time as OneNote 2010's Ctrl+A does.
12061290 pub fn widen_selection(&mut self) -> Result<Response> {
12071291 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 }
12081296 self.edited()
12091297 }
12101298
......@@ -1274,6 +1362,27 @@ impl PageView {
12741362 return self.changed();
12751363 }
12761364 }
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 }
12771386 if let Some(Command::MoveParagraphs { up }) = Command::from_key(key, self.modifiers)
12781387 && self.editor.marked_range().is_none()
12791388 {
......@@ -1288,6 +1397,13 @@ impl PageView {
12881397 Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]),
12891398 _ => None,
12901399 };
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 }
12911407 if let Some(delta) = delta {
12921408 let outline = self.editor.active_outline();
12931409 let origin = outline.origin();
......@@ -1604,6 +1720,15 @@ fn page_primitives<'a>(
16041720 paint: Paint,
16051721) -> Result<Vec<Primitive<'a>>> {
16061722 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 };
16071732 let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| {
16081733 // An emptied page outline leaves the editor but keeps its paint slot for undo.
16091734 let Some(outline) = editor.visible_outlines().find(|outline| outline.id == id) else {
......@@ -1613,13 +1738,19 @@ fn page_primitives<'a>(
16131738 Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized,
16141739 _ => outline,
16151740 };
1741 let [x, y] = outline.origin();
16161742 let origin = match preview {
1617 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,
1618 _ => outline.origin(),
1743 Some(PointerFeedback::Move(id, [dx, dy]))
1744 if id == outline.id || whole == Some(Whole::Page) && !outline.title =>
1745 {
1746 [x + dx, y + dy]
1747 }
1748 _ => [x, y],
16191749 };
16201750 // OneNote frames the title whether or not it is focused or hovered.
16211751 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)
16231754 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
16241755 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
16251756 {
......@@ -1628,14 +1759,16 @@ fn page_primitives<'a>(
16281759 [origin[0] + offset[0], origin[1] + offset[1]],
16291760 paint.pixel,
16301761 paint.colors.paper,
1762 selected(outline),
16311763 primitives,
16321764 );
16331765 }
16341766 append_outline(
16351767 (object_focus.is_none()
1636 && outline.id == editor.active_outline().id
1768 && outline.id == active
16371769 && !matches!(preview, Some(PointerFeedback::Resize(_))))
16381770 .then_some(editor),
1771 selected(outline),
16391772 outline,
16401773 [origin[0] + offset[0], origin[1] + offset[1]],
16411774 paint,
......@@ -1692,6 +1825,7 @@ fn page_primitives<'a>(
16921825 {
16931826 append_outline(
16941827 Some(editor),
1828 false,
16951829 outline,
16961830 outline.origin(),
16971831 paint,
......@@ -1892,22 +2026,26 @@ fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] {
18922026
18932027fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {
18942028 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;
18982029 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;
18992033 let (_, paragraph) = outline.layouts().last().unwrap();
19002034 let bottom = outline.origin()[1] + paragraph.origin[1] + paragraph.text.height();
2035 let top = bounds.y0 as f32 - inset;
19012036 return (
19022037 [
1903 bounds.x0 as f32 - inset - 6.0 * pixel,
1904 body_top,
1905 bounds.x1 as f32 + inset - 2.0 * pixel,
2038 bounds.x0 as f32 - inset - 4.5,
2039 top,
2040 bounds.x1 as f32 + inset - 1.5,
19062041 bottom + inset,
19072042 ],
1908 body_top,
2043 top,
19092044 );
19102045 }
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;
19112049 // OneNote widens the box leftward to hold the tag column and list markers; text and right
19122050 // edge stay.
19132051 let shaped = outline.shaped();
......@@ -1933,11 +2071,13 @@ fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {
19332071 )
19342072}
19352073
2074/// `selected` greys the body, as OneNote marks an outline Select All has selected whole.
19362075fn append_outline_chrome(
19372076 outline: &TextOutline,
19382077 origin: [f32; 2],
19392078 pixel: f32,
19402079 paper: crate::gpu::Paper,
2080 selected: bool,
19412081 primitives: &mut Vec<Primitive<'_>>,
19422082) {
19432083 let [x, y] = origin;
......@@ -1973,6 +2113,14 @@ fn append_outline_chrome(
19732113 stroke: None,
19742114 color: paper.shade(crate::gpu::colorref(0x00e5dee7)),
19752115 });
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 }
19762124 primitives.push(Primitive::RoundedRect {
19772125 rect: [left, top, right, bottom],
19782126 radius: [3.0 * pixel; 2],
......@@ -2008,8 +2156,11 @@ fn append_outline_chrome(
20082156 }
20092157}
20102158
2159/// `editor` paints its selection and caret in the focused outline; `selected` highlights all
2160/// the text of another.
20112161fn append_outline<'a>(
20122162 editor: Option<&'a CanvasEditor>,
2163 selected: bool,
20132164 outline: &'a TextOutline,
20142165 origin: [f32; 2],
20152166 paint: Paint,
......@@ -2052,18 +2203,21 @@ fn append_outline<'a>(
20522203 });
20532204 }
20542205 }
2055 if let Some(editor) = editor {
2056 for rect in editor.selection_rects()? {
2057 primitives.push(Primitive::Rect {
2058 rect: [
2059 rect.x0 as f32 + x,
2060 rect.y0 as f32 + y,
2061 rect.x1 as f32 + x,
2062 rect.y1 as f32 + y,
2063 ],
2064 color: colors.selection,
2065 });
2066 }
2206 let highlight = match editor {
2207 Some(editor) => editor.selection_rects()?,
2208 None if selected => outline.range_rects(outline.whole())?,
2209 None => Vec::new(),
2210 };
2211 for rect in highlight {
2212 primitives.push(Primitive::Rect {
2213 rect: [
2214 rect.x0 as f32 + x,
2215 rect.y0 as f32 + y,
2216 rect.x1 as f32 + x,
2217 rect.y1 as f32 + y,
2218 ],
2219 color: colors.selection,
2220 });
20672221 }
20682222 for (index, paragraph) in outline.shaped().visible(rows) {
20692223 outline.shaped().append_paragraph_primitives(
crates/canvas/src/interaction/tests.rs+362-2
......@@ -1,9 +1,11 @@
11use super::*;
2use crate::document::TextDocument;
2use crate::document::{TextDocument, TextPosition};
3use crate::editor::Toggle;
34use crate::gpu::painted_layout;
45use onestore::document::Format;
56use onestore::page::Page;
67use onestore::page::text::Paragraph;
8use std::collections::BTreeSet;
79use std::sync::Arc;
810
911const COLORS: TextColors = TextColors {
......@@ -143,6 +145,11 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
143145 assert_eq!(editor.active_outline().bounds().width(), 162.0);
144146 assert_eq!(editor.active_outline().snapshot().min_width, Some(162.0));
145147 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 );
146153 for (axis, (actual, expected)) in native_rect
147154 .iter()
148155 .zip([85.0, 98.0, 315.0, 134.0])
......@@ -621,7 +628,7 @@ fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() {
621628 assert_eq!(borders(None).len(), 1);
622629 let hovered = borders(Some(PointerFeedback::Hover(second)));
623630 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])));
625632 assert_eq!(moved[1][0] - hovered[1][0], 20.0);
626633 assert_eq!(moved[1][1] - hovered[1][1], 50.0);
627634 assert!(
......@@ -868,6 +875,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
868875 let mut primitives = Vec::new();
869876 append_outline(
870877 Some(&editor),
878 false,
871879 editor.active_outline(),
872880 [24.0, 48.0],
873881 Paint {
......@@ -941,6 +949,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {
941949 let mut primitives = Vec::new();
942950 append_outline(
943951 Some(&editor),
952 false,
944953 editor.active_outline(),
945954 [24.0, 48.0],
946955 Paint {
......@@ -2065,3 +2074,354 @@ fn an_empty_preedit_without_a_composition_changes_nothing() {
20652074 assert!(view.compose(String::new(), None).unwrap().changed);
20662075 assert!(view.editor.marked_range().is_none());
20672076}
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 {
102102
103103impl 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
105109/// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font
106110/// Licence files beside them, by the family each stands in for.
107111const BUNDLED: [(&str, &[&[u8]]); 4] = [
......@@ -283,7 +287,7 @@ impl TextEngine {
283287 for (index, span) in paragraph.spans().iter().enumerate() {
284288 let format = &span.format;
285289 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);
287291 if !size.is_finite() || size <= 0.0 {
288292 return Err(LayoutError::InvalidFontSize);
289293 }
......@@ -303,7 +307,7 @@ impl TextEngine {
303307 StyleProperty::FontFamily(FontFamily::List(
304308 [
305309 Some(FontFamilyName::named(
306 format.font.as_deref().unwrap_or("Arial"),
310 format.font.as_deref().unwrap_or(DEFAULT_FONT),
307311 )),
308312 // macOS ships STIX Two Math where Windows has Cambria Math.
309313 (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>> {
612612pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayout, LayoutError> {
613613 let nodes = Math::parse(paragraph).map_err(|_| LayoutError::UnsupportedContent)?;
614614 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);
616616 let color = format.color.filter(|color| *color != 0xff00_0000);
617617 // An equation line is at least as tall as a line of text at its size.
618618 let strut = engine.layout(
crates/canvas/src/outline.rs+28-17
......@@ -231,6 +231,30 @@ pub enum TagIcon {
231231 Other,
232232}
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
234258#[derive(Clone, Debug, PartialEq)]
235259pub struct ParagraphTag {
236260 pub icon: TagIcon,
......@@ -823,25 +847,12 @@ impl ParagraphLayout {
823847 let (tag, shape, label) = (tag.0, tag.1, tag.2);
824848 let icon = match shape {
825849 None => TagIcon::Flag,
826 Some(Some(0) | None) => continue,
827 Some(Some(3)) => TagIcon::CheckBox {
828 checked: tag.status & 1 != 0,
850 Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) {
851 Some(icon) => icon,
852 None => continue,
829853 },
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,
843854 };
844 let size = format.font_size.unwrap_or(11.0);
855 let size = format.font_size.unwrap_or(crate::layout::DEFAULT_FONT_SIZE);
845856 let side = ParagraphTag::side(size);
846857 tags.push(ParagraphTag {
847858 icon,
crates/canvas/tests/ops_differential.rs+31-2
......@@ -7,7 +7,7 @@
77
88use canvas::{
99 document::TextPosition,
10 editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle},
10 editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle, Whole},
1111 layout::TextEngine,
1212};
1313use draw::edit::Movement;
......@@ -91,11 +91,15 @@ enum Kind {
9191 Equation,
9292 Linear,
9393 Professional,
94 PageDelete,
95 PageType,
96 PageMove,
97 PageBold,
9498 Undo,
9599 Redo,
96100}
97101
98const KINDS: [Kind; 48] = [
102const KINDS: [Kind; 52] = [
99103 Kind::Type,
100104 Kind::TypeEnd,
101105 Kind::Compose,
......@@ -142,6 +146,10 @@ const KINDS: [Kind; 48] = [
142146 Kind::Equation,
143147 Kind::Linear,
144148 Kind::Professional,
149 Kind::PageDelete,
150 Kind::PageType,
151 Kind::PageMove,
152 Kind::PageBold,
145153 Kind::Undo,
146154 Kind::Redo,
147155];
......@@ -253,6 +261,18 @@ fn perform(
253261 |editor: &mut CanvasEditor, engine: &mut TextEngine, random: &mut Random, command| {
254262 range(editor, random) && editor.format(engine, command).is_ok()
255263 };
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 };
256276 match kind {
257277 Kind::Type => place(editor, middle) && editor.insert(engine, "ab").is_ok(),
258278 Kind::TypeEnd => place(editor, length) && editor.commit_text(engine, "z".into()).is_ok(),
......@@ -409,6 +429,15 @@ fn perform(
409429 Kind::Professional => {
410430 place(editor, middle) && editor.build_equation(engine).unwrap_or(false)
411431 }
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 }
412441 Kind::Undo => editor.undo(engine).unwrap_or(false),
413442 Kind::Redo => editor.redo(engine).unwrap_or(false),
414443 }
crates/draw/Cargo.toml+5-3
......@@ -5,9 +5,11 @@ edition = "2024"
55publish = false
66
77[features]
8default = ["render"]
9# The wgpu renderer; `edit` needs only parley.
10render = ["dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash", "dep:wgpu"]
8default = ["wgpu"]
9# The renderer; `edit` needs only parley. It submits through wgpu, or without it through
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
1214[dependencies]
1315parley.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 {
3232 End,
3333 PageUp,
3434 PageDown,
35 F11,
3536 /// Shift, Control, Option or Command pressed alone.
3637 Modifier,
3738 Other,
crates/draw/src/lib.rs+2-2
......@@ -1,6 +1,6 @@
11//! 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 transform
3//! and clip, and editing text within a laid-out line.
2//! images, rounded rectangles and pen strokes with wgpu or OpenGL, each layer with its
3//! own transform and clip, and editing text within a laid-out line.
44
55pub mod edit;
66#[cfg(feature = "render")]
crates/draw/src/render.rs+88-276
......@@ -1,10 +1,21 @@
1#[cfg(not(feature = "wgpu"))]
2mod gl;
13mod icon;
2
3pub use icon::Palette;
44mod text;
5#[cfg(feature = "wgpu")]
56mod 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;
717pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};
18#[cfg(feature = "wgpu")]
819pub use translucent::Translucent;
920
1021use bytemuck::{Pod, Zeroable};
......@@ -24,6 +35,7 @@ use swash::{
2435const ATLAS_SIZE: u32 = 2048;
2536const MAX_GLYPHS: usize = 8192;
2637const MAX_VERTICES: usize = 65_536;
38const VERTEX_BUFFER_BYTES: u64 = (MAX_VERTICES * size_of::<Vertex>()) as u64;
2739/// Decoded bytes of all images one frame may paint.
2840pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
2941const MAX_IMAGES: usize = 256;
......@@ -146,7 +158,7 @@ impl RasterImage {
146158}
147159
148160struct CachedImage {
149 binding: wgpu::BindGroup,
161 texture: backend::Image,
150162 bytes: u64,
151163 /// The texture goes when every copy of its image has.
152164 pixels: WeakBlob<u8>,
......@@ -190,6 +202,8 @@ enum AtlasKey {
190202 phase: [u8; 2],
191203 embolden: bool,
192204 skew: u32,
205 /// The ink's sRGB luminance in sixteenths, which `text_coverage` weights by.
206 tone: u8,
193207 },
194208 Icon {
195209 sources: &'static [&'static str],
......@@ -435,15 +449,11 @@ pub struct Occupancy {
435449}
436450
437451pub struct Renderer {
452 #[cfg(feature = "wgpu")]
438453 pub device: wgpu::Device,
454 #[cfg(feature = "wgpu")]
439455 pub queue: wgpu::Queue,
440 pipeline: wgpu::RenderPipeline,
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,
456 gpu: backend::Gpu,
447457 vertices: Vec<Vertex>,
448458 glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>,
449459 images: HashMap<u64, CachedImage>,
......@@ -454,150 +464,17 @@ pub struct Renderer {
454464}
455465
456466impl Renderer {
457 pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self {
458 let shader = device.create_shader_module(wgpu::include_wgsl!("draw.wgsl"));
459 let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
460 label: Some("Draw texture"),
461 entries: &[
462 wgpu::BindGroupLayoutEntry {
463 binding: 0,
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 {
467 fn with_gpu(
468 #[cfg(feature = "wgpu")] device: wgpu::Device,
469 #[cfg(feature = "wgpu")] queue: wgpu::Queue,
470 gpu: backend::Gpu,
471 ) -> Self {
472 let renderer = Self {
473 #[cfg(feature = "wgpu")]
592474 device,
475 #[cfg(feature = "wgpu")]
593476 queue,
594 pipeline,
595 image_pipeline,
596 erase_pipeline,
597 atlas,
598 bind_group,
599 image_sampler,
600 vertex_buffer,
477 gpu,
601478 vertices: Vec::new(),
602479 glyphs: HashMap::new(),
603480 images: HashMap::new(),
......@@ -605,7 +482,10 @@ impl Renderer {
605482 scaler: ScaleContext::with_max_entries(32),
606483 pen: [1, 0],
607484 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
609489 }
610490
611491 pub fn clear_glyph_cache(&mut self) {
......@@ -637,8 +517,8 @@ impl Renderer {
637517 batch_capacity_bytes: self.batches.capacity() * size_of::<Batch>(),
638518 glyph_capacity: self.glyphs.capacity(),
639519 image_capacity: self.images.capacity(),
640 atlas_bytes: u64::from(self.atlas.width()) * u64::from(self.atlas.height()) * 4,
641 vertex_buffer_bytes: self.vertex_buffer.size(),
520 atlas_bytes: u64::from(ATLAS_SIZE) * u64::from(ATLAS_SIZE) * 4,
521 vertex_buffer_bytes: VERTEX_BUFFER_BYTES,
642522 within_budget: self.glyphs.len() <= MAX_GLYPHS
643523 && self.images.len() <= MAX_IMAGES
644524 && image_bytes <= MAX_IMAGE_BYTES
......@@ -649,7 +529,7 @@ impl Renderer {
649529 /// Clears `target`, `size` device pixels, to linear `clear` and paints the layers in order.
650530 pub fn draw(
651531 &mut self,
652 target: &wgpu::TextureView,
532 target: &Target,
653533 size: [u32; 2],
654534 clear: [f32; 4],
655535 layers: &[Layer<'_>],
......@@ -702,39 +582,7 @@ impl Renderer {
702582 }
703583 }
704584 }
705 self.queue
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()]);
585 self.submit(target, clear);
738586 Ok(())
739587 }
740588
......@@ -892,11 +740,7 @@ impl Renderer {
892740 if self.images.contains_key(&image.id()) {
893741 return Ok(());
894742 }
895 if image
896 .size
897 .iter()
898 .any(|v| *v > self.device.limits().max_texture_dimension_2d)
899 {
743 if image.size.iter().any(|v| *v > self.max_texture_dimension()) {
900744 return Err(RenderError::ImageTooLarge);
901745 }
902746 let bytes = image.pixels().len() as u64;
......@@ -911,51 +755,10 @@ impl Renderer {
911755 .ok_or(RenderError::ImageBudget)?;
912756 self.images.remove(&id);
913757 }
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 });
955758 self.images.insert(
956759 image.id(),
957760 CachedImage {
958 binding,
761 texture: self.upload_image(image),
959762 bytes,
960763 pixels: image.pixels.downgrade(),
961764 },
......@@ -980,6 +783,12 @@ impl Renderer {
980783 if !size.is_finite() || size > ATLAS_SIZE as f32 {
981784 return Err(RenderError::AtlasFull);
982785 }
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;
983792 for glyph in run.glyphs {
984793 let x = glyph.x * scale + origin[0];
985794 let y = glyph.y * scale + origin[1];
......@@ -1000,6 +809,7 @@ impl Renderer {
1000809 phase,
1001810 embolden: run.embolden,
1002811 skew: run.skew.unwrap_or(0.0).to_bits(),
812 tone,
1003813 };
1004814 let cached = if let Some(cached) = self.glyphs.get(&key) {
1005815 *cached
......@@ -1035,7 +845,15 @@ impl Renderer {
1035845 Angle::from_degrees(0.0),
1036846 )));
1037847 }
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 });
1039857 let cached = if let Some(image) =
1040858 image.filter(|i| i.placement.width > 0 && i.placement.height > 0)
1041859 {
......@@ -1068,12 +886,7 @@ impl Renderer {
1068886 (glyph.x + glyph.width) as f32 / atlas_size,
1069887 (glyph.y + glyph.height) as f32 / atlas_size,
1070888 ],
1071 if glyph.color {
1072 [1.0; 4]
1073 } else {
1074 run.color
1075 .map_or(ink, |color| legible(color, space.backdrop, run.backdrop))
1076 },
889 if glyph.color { [1.0; 4] } else { color },
1077890 )?;
1078891 }
1079892 }
......@@ -1127,27 +940,7 @@ impl Renderer {
1127940 .collect(),
1128941 _ => image.data,
1129942 };
1130 self.queue.write_texture(
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 );
943 self.write_atlas([cached.x, cached.y], [p.width, p.height], &rgba);
1151944 self.pen[0] += p.width + 1;
1152945 self.row_height = self.row_height.max(p.height);
1153946 Ok(cached)
......@@ -1558,6 +1351,32 @@ fn srgb_byte(value: f32) -> u8 {
15581351 (srgb * 255.0).round() as u8
15591352}
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
15611380/// Least OKLab lightness difference between text and the backdrop it stays legible on.
15621381const LEGIBLE: f32 = 0.4;
15631382
......@@ -1609,18 +1428,11 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] {
16091428
16101429/// Linear RGBA of an opaque sRGB colour.
16111430pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {
1612 let linear = |byte: u8| {
1613 let value = f32::from(byte) / 255.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]
1431 let [red, green, blue] = [red, green, blue].map(|byte| linear(f32::from(byte) / 255.0));
1432 [red, green, blue, 1.0]
16211433}
16221434
1623#[cfg(test)]
1435#[cfg(all(test, feature = "wgpu"))]
16241436mod tests {
16251437 use super::*;
16261438 use parley::{
......@@ -2032,8 +1844,8 @@ mod tests {
20321844 let mut moment = 0.0_f64;
20331845 for y in 212..252 {
20341846 for x in (40 + 48 * step)..(72 + 48 * step) {
2035 let value = captures[0][(y * 512 + x) * 4];
2036 let coverage = 1.0 - f64::from(srgb(value, 0, 0)[0]);
1847 // Black text over white leaves the rasterized coverage sRGB-encoded.
1848 let coverage = 1.0 - f64::from(captures[0][(y * 512 + x) * 4]) / 255.0;
20371849 weight += coverage;
20381850 moment += coverage * y as f64;
20391851 }
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.
6060removes; `release(id, archive, Resolution)` ends an uncertain attempt. The author an
6161edit names is the host's: the app passes the account's full name.
6262`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
6467`Section::resume(file, replica, notify)` starts from an owned `Replica` without
6568consulting 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 {
10281028
10291029type 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
10311045/// A section file with its replica and background publication. `Send + Sync`: edits
10321046/// arrive from any thread without waiting, and `notify` wakes the host when events wait.
10331047pub struct Section {
......@@ -1037,6 +1051,7 @@ pub struct Section {
10371051 events: Mutex<Receiver<Event>>,
10381052 sender: Sender<Event>,
10391053 notify: Notify,
1054 observed: Arc<Mutex<Observed>>,
10401055}
10411056
10421057impl Section {
......@@ -1129,9 +1144,24 @@ impl Section {
11291144 notify();
11301145 }
11311146 });
1147 let observed: Arc<Mutex<Observed>> = Arc::new(Mutex::new(None));
11321148 let worker = {
11331149 let (sender, notify) = (sender.clone(), Arc::clone(&notify));
1150 let observed = Arc::clone(&observed);
11341151 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 }
11351165 let mut events = Vec::new();
11361166 match result {
11371167 Ok(synced) => {
......@@ -1157,7 +1187,8 @@ impl Section {
11571187 ))),
11581188 Err(error) => events.push(Event::Failed(error.to_string())),
11591189 }
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())
11611192 {
11621193 notify();
11631194 }
......@@ -1170,6 +1201,7 @@ impl Section {
11701201 events: Mutex::new(events),
11711202 sender,
11721203 notify,
1204 observed,
11731205 })
11741206 }
11751207
......@@ -1332,13 +1364,39 @@ impl Section {
13321364 .unwrap_or_default()
13331365 }
13341366
1335 /// Requests a synchronization attempt now.
1367 /// Requests a synchronization attempt now, working offline included (Sync Now).
13361368 pub fn wake(&self) {
13371369 if let Some(worker) = &self.worker {
13381370 worker.wake();
13391371 }
13401372 }
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
13421400 /// The replica, which other threads may read pages from while the section is open.
13431401 pub fn replica(&self) -> &Arc<Replica> {
13441402 &self.replica
crates/notebook/src/worker.rs+38-2
......@@ -4,7 +4,7 @@ use std::{
44 hash::BuildHasher,
55 sync::{
66 Arc,
7 atomic::{AtomicBool, Ordering},
7 atomic::{AtomicBool, AtomicU64, Ordering},
88 mpsc::{self, SyncSender},
99 },
1010 thread::{self, JoinHandle},
......@@ -13,6 +13,11 @@ use std::{
1313
1414pub(super) struct Signal {
1515 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,
1621 sender: SyncSender<()>,
1722}
1823
......@@ -31,9 +36,23 @@ pub struct SyncWorker {
3136}
3237
3338impl 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.
3541 /// A pending contention backoff finishes before processing the notification.
3642 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);
3756 self.signal.wake();
3857 }
3958
......@@ -94,6 +113,9 @@ impl Replica {
94113 let (sender, receiver) = mpsc::sync_channel(1);
95114 let signal = Arc::new(Signal {
96115 stopped: AtomicBool::new(false),
116 offline: AtomicBool::new(false),
117 synced: AtomicU64::new(0),
118 requested: AtomicBool::new(false),
97119 sender,
98120 });
99121 let replica = Arc::clone(self);
......@@ -108,9 +130,19 @@ impl Replica {
108130 // hears that the remote is reachable and what state the queue is in.
109131 let mut reported = false;
110132 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 }
111141 let result = match remote.as_mut() {
112142 Some(remote) => {
113143 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);
114146 let _ = receiver.recv_timeout(interval);
115147 continue;
116148 }
......@@ -126,6 +158,9 @@ impl Replica {
126158 Err(error) => Err(Error::RemoteIo(error)),
127159 },
128160 };
161 if result.is_ok() {
162 worker_signal.synced.store(crate::now(), Ordering::Release);
163 }
129164 observe(&result);
130165 match result {
131166 Ok(Synced {
......@@ -168,6 +203,7 @@ impl Replica {
168203 Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {}
169204 Err(error) => return Err(error),
170205 }
206 worker_signal.requested.store(false, Ordering::Release);
171207 if !worker_signal.stopped.load(Ordering::Acquire) {
172208 let _ = receiver.recv_timeout(interval);
173209 }
crates/notebook/tests/session.rs+83-1
......@@ -2,7 +2,7 @@
22mod ops;
33use notebook::{
44 EditStatus, Recovery, Resolution,
5 session::{Event, Notebook, Section},
5 session::{Event, Notebook, Section, SyncStatus},
66};
77use onestore::{
88 ExGuid,
......@@ -99,6 +99,18 @@ fn published(section: &Section, id: u64) {
9999 }
100100}
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
102114fn stored_page(file: &Path, space: ExGuid) -> Page {
103115 model_ops::page_of(&onestore::read_file(file).unwrap(), space)
104116}
......@@ -917,3 +929,73 @@ fn page_versions_restore_and_delete_through_the_session() {
917929 assert_eq!(page.objects, old.objects);
918930 section.close().unwrap();
919931}
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"
44edition = "2024"
55publish = 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
712[dependencies]
813canvas = { path = "../canvas", features = ["interaction"] }
9draw = { path = "../draw" }
14draw = { path = "../draw", default-features = false, features = ["render"] }
1015ui = { path = "../ui" }
1116png = "0.18"
1217onestore = { path = "../onestore" }
......@@ -14,7 +19,7 @@ notebook = { path = "../notebook", features = ["smb"] }
1419serde = { version = "1", features = ["derive"] }
1520serde_json = "1"
1621pollster = "0.4"
17wgpu.workspace = true
22wgpu = { workspace = true, optional = true }
1823parley.workspace = true
1924winit = "0.30.13"
2025arboard = { version = "3.6.1", default-features = false }
crates/snowbound/src/art.rs-19
......@@ -46,41 +46,22 @@ pub const UNDO: &[&str] = art!("icons/undo");
4646pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");
4747pub 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")]
5249pub const BACK: &[&str] = art!("icons/back");
53#[expect(dead_code, reason = "Its toolbar button has not landed")]
5450pub const COPY: &[&str] = art!("icons/copy");
55#[expect(dead_code, reason = "Its toolbar button has not landed")]
5651pub const CUT: &[&str] = art!("icons/cut");
57#[expect(dead_code, reason = "Its toolbar button has not landed")]
5852pub const DATE_TIME: &[&str] = art!("icons/date-time");
59#[expect(dead_code, reason = "Its toolbar button has not landed")]
6053pub const FIND_TAGS: &[&str] = art!("icons/find-tags");
61#[expect(dead_code, reason = "Its toolbar button has not landed")]
6254pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter");
63#[expect(dead_code, reason = "Its toolbar button has not landed")]
6455pub const FORWARD: &[&str] = art!("icons/forward");
65#[expect(dead_code, reason = "Its toolbar button has not landed")]
6656pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view");
67#[expect(dead_code, reason = "Its toolbar button has not landed")]
6857pub const INSERT_SPACE: &[&str] = art!("icons/insert-space");
69#[expect(dead_code, reason = "Its toolbar button has not landed")]
7058pub const PAGE_COLOR: &[&str] = art!("icons/page-color");
71#[expect(dead_code, reason = "Its toolbar button has not landed")]
7259pub const PASTE: &[&str] = art!("icons/paste");
73#[expect(dead_code, reason = "Its toolbar button has not landed")]
7460pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio");
75#[expect(dead_code, reason = "Its toolbar button has not landed")]
7661pub const RECORD_VIDEO: &[&str] = art!("icons/record-video");
77#[expect(dead_code, reason = "Its toolbar button has not landed")]
7862pub const RULE_LINES: &[&str] = art!("icons/rule-lines");
79#[expect(dead_code, reason = "Its toolbar button has not landed")]
8063pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping");
81#[expect(dead_code, reason = "Its toolbar button has not landed")]
8264pub const SPELLING: &[&str] = art!("icons/spelling");
83#[expect(dead_code, reason = "Its toolbar button has not landed")]
8465pub const SYMBOL: &[&str] = art!("icons/symbol");
8566
8667pub const TAG_REMEMBER: &[&str] = art!("tags/remember");
crates/snowbound/src/commands.rs+139-5
......@@ -25,6 +25,7 @@ pub enum Id {
2525 Cut,
2626 Copy,
2727 Paste,
28 FormatPainter,
2829 SelectAll,
2930 Find,
3031 Search,
......@@ -36,14 +37,21 @@ pub enum Id {
3637 Sidebar,
3738 PageList,
3839 DarkPages,
40 FullPageView,
41 PageColor,
3942 Table,
4043 Picture,
44 ScreenClipping,
4145 Attachment,
4246 Link,
47 InsertSpace,
4348 Equation,
49 Symbol,
4450 Date,
4551 Time,
4652 DateTime,
53 RecordAudio,
54 RecordVideo,
4755 Toggle(Toggle),
4856 Highlight,
4957 FontColor,
......@@ -69,6 +77,10 @@ pub enum Choice {
6977 /// A COLORREF, or `None` for automatic.
7078 Color(Option<u32>),
7179 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>),
7284}
7385
7486#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
......@@ -161,6 +173,9 @@ macro_rules! tag {
161173 ($tag:expr, $digit:literal) => {
162174 row!(Id::Tag($tag), $tag.label(), &[cmd($digit)], &[cmd($digit)])
163175 };
176 ($tag:expr) => {
177 row!(Id::Tag($tag), $tag.label(), NONE, NONE)
178 };
164179}
165180
166181const NONE: &[Chord] = &[];
......@@ -206,6 +221,7 @@ pub const COMMANDS: &[Command] = &[
206221 row!(Id::Cut, "Cut", &[cmd('x')], &[cmd('x')]),
207222 row!(Id::Copy, "Copy", &[cmd('c')], &[cmd('c')]),
208223 row!(Id::Paste, "Paste", &[cmd('v')], &[cmd('v')]),
224 row!(Id::FormatPainter, "Format Painter", NONE, NONE),
209225 row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]),
210226 row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]),
211227 row!(Id::Search, "Search Notebooks", &[cmd('e')], &[cmd('e')]),
......@@ -239,16 +255,27 @@ pub const COMMANDS: &[Command] = &[
239255 row!(Id::Sidebar, "Sidebar", &[cmd('s').control()], NONE),
240256 row!(Id::PageList, "Page List", NONE, NONE),
241257 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),
242266 row!(Id::Table, "Table", NONE, NONE),
243267 row!(Id::Picture, "Picture…", NONE, NONE),
268 row!(Id::ScreenClipping, "Screen Clipping", NONE, NONE),
244269 row!(Id::Attachment, "Attach File…", NONE, NONE),
245270 row!(Id::Link, "Link…", &[cmd('k')], &[cmd('k')]),
271 row!(Id::InsertSpace, "Insert Space", NONE, NONE),
246272 row!(
247273 Id::Equation,
248274 "Equation",
249275 &[key('=').control()],
250276 &[key('=').option()],
251277 ),
278 row!(Id::Symbol, "Symbol", NONE, NONE),
252279 row!(Id::Date, "Date", NONE, &[key('d').option().shift()]),
253280 row!(Id::Time, "Time", NONE, &[key('t').option().shift()]),
254281 row!(
......@@ -257,6 +284,8 @@ pub const COMMANDS: &[Command] = &[
257284 NONE,
258285 &[key('f').option().shift()]
259286 ),
287 row!(Id::RecordAudio, "Record Audio", NONE, NONE),
288 row!(Id::RecordVideo, "Record Video", NONE, NONE),
260289 row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]),
261290 row!(
262291 Id::Toggle(Toggle::Italic),
......@@ -338,6 +367,26 @@ pub const COMMANDS: &[Command] = &[
338367 tag!(NoteTag::Contact, '7'),
339368 tag!(NoteTag::Address, '8'),
340369 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),
341390 row!(
342391 Id::RemoveTags,
343392 "Remove Tag",
......@@ -393,7 +442,9 @@ pub fn title(choice: Choice) -> String {
393442 .iter()
394443 .find(|(listed, _)| *listed == color)
395444 .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 }
397448 }
398449}
399450
......@@ -574,6 +625,8 @@ impl State {
574625 Choice::Font(name) => return checked(format.font.as_ref() == Some(name)),
575626 Choice::Size(size) => return checked(format.font_size == Some(*size)),
576627 Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text),
628 Choice::Table(_) => Id::Table,
629 Choice::PageColor(_) => Id::PageColor,
577630 };
578631 match id {
579632 Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::Help => enabled(!modal),
......@@ -595,6 +648,10 @@ impl State {
595648 checked: session.shown_history == Some(session.space),
596649 })
597650 }
651 Id::FullPageView => Status {
652 enabled: !welcome && !modal,
653 checked: self.full_page,
654 },
598655 Id::Undo => enabled(writable && !field && self.view.editor.can_undo()),
599656 Id::Redo => enabled(writable && !field && self.view.editor.can_redo()),
600657 Id::Cut => enabled(writable && selected),
......@@ -616,8 +673,18 @@ impl State {
616673 enabled: !modal,
617674 checked: !self.light_pages,
618675 },
619 Id::Table | Id::Picture | Id::Attachment => enabled(false),
620 Id::Link
676 Id::FormatPainter => Status {
677 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
621688 | Id::Equation
622689 | Id::Date
623690 | Id::Time
......@@ -638,7 +705,7 @@ impl State {
638705
639706 /// Applies `formatting`, remembering its font, colour or list style as the toolbar's
640707 /// 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>> {
642709 let kept = self.toolbar.clone();
643710 let pens = &mut self.toolbar;
644711 match &formatting {
......@@ -661,6 +728,26 @@ impl State {
661728 Ok(())
662729 }
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
664751 /// The notebook the open section, or the notebook showing none, belongs to.
665752 fn notebook(&self) -> Option<&Arc<crate::Library>> {
666753 self.session
......@@ -682,6 +769,15 @@ impl State {
682769 Choice::Highlight(color) => return format(self, Formatting::Highlight(color)),
683770 Choice::Color(color) => return format(self, Formatting::Color(color)),
684771 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 }
685781 };
686782 let field = self.ui.focused().filter(|focus| *focus != page());
687783 let response = match id {
......@@ -740,6 +836,13 @@ impl State {
740836 return Ok(());
741837 }
742838 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 }
743846 Id::SelectAll => {
744847 match field {
745848 Some(field) => self.ui.focus_all(field),
......@@ -779,13 +882,44 @@ impl State {
779882 self.pages_open = !self.pages_open;
780883 return Ok(());
781884 }
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 }
782914 Id::DarkPages => {
783915 self.light_pages = !self.light_pages;
784916 self.follow_color_scheme();
785917 self.save_settings();
786918 return Ok(());
787919 }
788 Id::Table | Id::Picture | Id::Attachment => return Ok(()),
920 Id::Attachment | Id::RecordAudio | Id::RecordVideo | Id::InsertSpace => {
921 return Ok(());
922 }
789923 Id::Link => {
790924 self.open_link_dialog();
791925 return Ok(());
crates/snowbound/src/history.rs-3
......@@ -165,7 +165,6 @@ impl State {
165165 session
166166 .section
167167 .restore_version(page, version, &self.author)?;
168 session.status = "Saving";
169168 session.refresh_history()?;
170169 self.commands.push(Command::OpenPage(page));
171170 Ok(())
......@@ -178,7 +177,6 @@ impl State {
178177 ) -> Result<(), Box<dyn Error>> {
179178 let session = self.session.as_mut().ok_or("No section is open")?;
180179 session.section.delete_versions(&[(page, vec![version])])?;
181 session.status = "Saving";
182180 session.refresh_history()?;
183181 self.commands.push(Command::OpenPage(page));
184182 Ok(())
......@@ -221,7 +219,6 @@ impl State {
221219 .collect();
222220 if !all.is_empty() {
223221 session.section.delete_versions(&all)?;
224 session.status = "Saving";
225222 }
226223 session.refresh_history()?;
227224 if session.version.is_some() {
crates/snowbound/src/library.rs+32-3
......@@ -7,10 +7,25 @@ use std::{
77 error::Error,
88 io,
99 path::{Path, PathBuf},
10 sync::Arc,
10 sync::{
11 Arc,
12 atomic::{AtomicBool, Ordering},
13 },
1114 time::Duration,
1215};
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
1429/// How long an SMB request may take before the share counts as unreachable.
1530const TIMEOUT: Duration = Duration::from_secs(10);
1631/// The largest section file read whole over SMB.
......@@ -243,7 +258,7 @@ impl Library {
243258 path: &str,
244259 notify: impl Fn() + Send + 'static,
245260 ) -> Result<Section, Box<dyn Error>> {
246 Ok(match (&self.notebook, &self.server) {
261 let section = match (&self.notebook, &self.server) {
247262 (Ok(Some(notebook)), Some(server)) => {
248263 let identity = self
249264 .catalog_section(notebook.catalog(), path)
......@@ -268,7 +283,21 @@ impl Library {
268283 (Ok(Some(notebook)), None) => notebook.section(path, notify)?,
269284 (Ok(None), _) => Section::open(path, &self.cache, notify)?,
270285 (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 }
272301 }
273302
274303 /// 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
4545 QUIT.set(event_loop.create_proxy())
4646 .expect("Only one application event loop is created");
4747 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 {
4949 follow_breeze_radius(event_loop.create_proxy());
5050 }
5151 Ok(event_loop)
......@@ -198,14 +198,10 @@ pub fn install_backdrop(_: &Window) -> bool {
198198/// suit `appearance`: KWin's from the KDE colour scheme, or on GNOME the Adwaita header bar
199199/// that winit's frame and GNOME's both draw.
200200pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {
201 let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
202 let on = |name: &str| desktops.split(':').any(|desktop| desktop == name);
203 let fills = if on("KDE") {
204 kde_titlebar(&kconfig("kdeglobals"))
205 } else if on("GNOME") {
206 adwaita_titlebar(appearance)
207 } else {
208 return None;
201 let fills = match desktop() {
202 Desktop::Kde => kde_titlebar(&kconfig("kdeglobals")),
203 Desktop::Gnome => adwaita_titlebar(appearance),
204 Desktop::Other => return None,
209205 }
210206 .map(|[red, green, blue]| draw::srgb(red, green, blue));
211207 // 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]> {
214210 (dark == (appearance == Theme::Dark)).then_some(fills)
215211}
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
217391/// KConfig file `name` from the system's configuration directories up to the user's, in
218392/// rising precedence.
219393fn kconfig(name: &str) -> Vec<String> {
......@@ -373,8 +547,10 @@ pub fn update_menu(_: impl FnOnce() -> Vec<crate::commands::Status>) {}
373547
374548pub fn clear_marked_text(_: &Window) {}
375549
550#[cfg(feature = "wgpu")]
376551pub fn configure_presentation(_: &wgpu::Surface<'_>) {}
377552
553#[cfg(feature = "wgpu")]
378554pub fn commit_presentation(_: &Window) {}
379555
380556/// GTK's default double-click time.
......@@ -426,6 +602,16 @@ fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) {
426602}
427603
428604fn 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> {
429615 use zbus::zvariant::{OwnedValue, Value};
430616 // A missing portal must not hold up the window for D-Bus's 25 s default.
431617 let connection = zbus::blocking::connection::Builder::session()
......@@ -440,7 +626,7 @@ fn portal_color_scheme() -> Option<bool> {
440626 "org.freedesktop.portal.Settings",
441627 )
442628 .ok()?;
443 let key = ("org.freedesktop.appearance", "color-scheme");
629 let key = (namespace, key);
444630 // Portals before ReadOne answer Read, with the value in a second variant.
445631 let reply: OwnedValue = proxy
446632 .call("ReadOne", &key)
......@@ -450,11 +636,7 @@ fn portal_color_scheme() -> Option<bool> {
450636 while let Value::Value(inner) = value {
451637 value = *inner;
452638 }
453 match u32::try_from(value).ok()? {
454 1 => Some(true),
455 2 => Some(false),
456 _ => None,
457 }
639 value.try_into_owned().ok()
458640}
459641
460642pub fn cache_dir() -> Option<PathBuf> {
......@@ -793,6 +975,41 @@ fn dialog<const Z: usize, const K: usize>(
793975 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))
794976}
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
7961013/// Opens `target`, a folder or a link's URL, with the desktop's handler.
7971014pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
7981015 let target = target.as_ref();
......@@ -963,6 +1180,51 @@ mod tests {
9631180 );
9641181 }
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
9661228 #[test]
9671229 fn kconfig_colours_read_as_channels_or_hex() {
9681230 assert_eq!(kde_color("222,224,226"), Some([222, 224, 226]));
crates/snowbound/src/macos.rs+60-4
......@@ -116,9 +116,15 @@ impl Clipboard {
116116/// AppKit draws the traffic lights.
117117pub fn window_controls(_: &mut ui::Ui, _: &Window) {}
118118
119/// The app draws the title bar around the traffic lights.
120pub fn system_titlebar(_: &Window) -> bool {
121 false
119/// Whether AppKit draws the title bar: before 10.10 content can't extend under it, and
120/// otherwise the app draws it around the traffic lights.
121pub 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 }
122128}
123129
124130unsafe extern "C" fn no_document_drag(
......@@ -163,6 +169,11 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) {
163169 }
164170}
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
166177/// Nothing the backdrop leaves to colour.
167178pub fn titlebar(_: winit::window::Theme) -> Option<[[f32; 4]; 2]> {
168179 None
......@@ -178,6 +189,10 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO
178189/// through it to the view it lies in.
179190pub fn install_backdrop(window: &Window) -> bool {
180191 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 };
181196 let RawWindowHandle::AppKit(handle) =
182197 window.window_handle().expect("Live AppKit window").as_raw()
183198 else {
......@@ -186,7 +201,6 @@ pub fn install_backdrop(window: &Window) -> bool {
186201 unsafe {
187202 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
188203 let class = BACKDROP_CLASS.get_or_init(|| {
189 let effect = AnyClass::get("NSVisualEffectView").expect("AppKit is linked");
190204 let mut class =
191205 ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class");
192206 class.add_method(
......@@ -351,6 +365,46 @@ pub fn pick_new(title: &str, name: &str) -> Option<std::path::PathBuf> {
351365 }
352366}
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
354408/// Tells the user something they asked for could not be done: `message`, then what to do.
355409pub fn alert(message: &str, detail: &str) {
356410 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
......@@ -598,6 +652,7 @@ mod date_tests {
598652 }
599653}
600654
655#[cfg(feature = "wgpu")]
601656pub fn configure_presentation(surface: &wgpu::Surface<'_>) {
602657 MainThreadMarker::new().expect("Layer presentation belongs to the main thread");
603658 // Retain wgpu's surface ownership while synchronizing presentation with AppKit resize transactions.
......@@ -612,6 +667,7 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) {
612667/// Commits a frame presented with the transaction: winit redraws after Core Animation's
613668/// commit observer, so otherwise the frame waits for the next event. A live resize leaves
614669/// the commit to AppKit, which pairs the frame with the window's new size.
670#[cfg(feature = "wgpu")]
615671pub fn commit_presentation(window: &Window) {
616672 let resizing: bool = unsafe { msg_send![&ns_window(window), inLiveResize] };
617673 if !resizing {
crates/snowbound/src/main.rs+429-332
......@@ -20,6 +20,9 @@ mod screenshot;
2020mod search;
2121mod settings;
2222mod sidebar;
23#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")]
24mod surface;
25mod sync;
2326mod templates;
2427
2528use canvas::gpu::page::PageScene;
......@@ -33,6 +36,7 @@ use canvas::{
3336use canvas::{
3437 gpu::{colorref, tag_sources},
3538 outline::TagIcon,
39 template::PAGE_COLORS,
3640};
3741use draw::Renderer;
3842use library::Library;
......@@ -63,6 +67,8 @@ const TAB_ROW: f32 = 28.0;
6367/// Width of the section colour around the page.
6468const FRAME: f32 = 6.0;
6569const 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"];
6672/// Font sizes the size box offers, OneNote's list in points.
6773/// Fonts the font box offers first, OneNote's default leading.
6874const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"];
......@@ -116,6 +122,9 @@ enum UserEvent {
116122 /// Text AppKit inserts outside key events, such as the character palette's.
117123 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
118124 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>),
119128 Accessibility(accesskit_winit::Event),
120129 /// The section's synchronization thread reported an event.
121130 Sync,
......@@ -233,7 +242,8 @@ struct Session {
233242 /// Spaces, titles and outline levels in section order.
234243 pages: Vec<(ExGuid, String, u32)>,
235244 space: ExGuid,
236 status: &'static str,
245 /// The section's sync status as last read.
246 sync: notebook::session::SyncStatus,
237247 /// Each page's conflict pages, the versions a merge could not take.
238248 conflicts: Vec<(ExGuid, Vec<onestore::ConflictPage>)>,
239249 /// The page whose conflict pages the list shows beneath it.
......@@ -268,16 +278,6 @@ enum Bar {
268278}
269279
270280impl 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
281281 fn title(&self) -> &str {
282282 match self.conflict(self.space) {
283283 Some((_, version)) => &version.title,
......@@ -491,9 +491,7 @@ struct State {
491491 trail: navigation::Trail,
492492 /// Asks for a frame when a worker thread finishes something the page shows.
493493 redraw: std::task::Waker,
494 instance: wgpu::Instance,
495 surface: wgpu::Surface<'static>,
496 config: wgpu::SurfaceConfiguration,
494 surface: surface::Surface,
497495 renderer: Renderer,
498496 ui: Ui,
499497 view: PageView,
......@@ -520,8 +518,6 @@ struct State {
520518 text_menu: Option<(canvas::interaction::Context, [f32; 2])>,
521519 color_scheme: settings::ColorScheme,
522520 light_pages: bool,
523 /// Carries frames to the window where the system's backdrop shows through the strip.
524 translucent: Option<draw::Translucent>,
525521 /// The strip's fill with the window focused and not, continuing the system's title bar.
526522 titlebar: [[f32; 4]; 2],
527523 /// A section or group being renamed in the sidebar.
......@@ -541,6 +537,10 @@ struct State {
541537 search: search::Search,
542538 /// Whether the page list is shown beside the page.
543539 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>,
544544 /// Installed font families, for the font box; listing them is slow.
545545 fonts: Vec<String>,
546546 /// Fonts picked from the font box this session, latest first.
......@@ -631,32 +631,8 @@ impl State {
631631 let access_adapter =
632632 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
633633 window.set_visible(visible);
634 let instance = wgpu::Instance::new(
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?;
634 let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?;
646635 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);
660636 let mut engine = TextEngine::default();
661637 for path in substitutes {
662638 let target = engine
......@@ -754,7 +730,7 @@ impl State {
754730 let dpr = window.scale_factor() as f32;
755731 window.set_ime_allowed(true);
756732 window.request_redraw();
757 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());
733 eprintln!("Scale factor {dpr}");
758734 window.set_theme(stored.color_scheme.theme());
759735 let appearance = stored
760736 .color_scheme
......@@ -791,9 +767,7 @@ impl State {
791767 window,
792768 redraw,
793769 proxy,
794 instance,
795770 surface,
796 config,
797771 renderer,
798772 ui,
799773 view: PageView::new(
......@@ -819,7 +793,6 @@ impl State {
819793 options: None,
820794 color_scheme: stored.color_scheme,
821795 light_pages: stored.light_pages,
822 translucent,
823796 titlebar,
824797 renaming: None,
825798 sectionless,
......@@ -828,6 +801,8 @@ impl State {
828801 thumbnails: templates::Thumbnails::default(),
829802 search,
830803 pages_open: true,
804 full_page: false,
805 painter: None,
831806 fonts,
832807 recent_fonts: stored.recent_fonts,
833808 toolbar: stored.toolbar,
......@@ -928,6 +903,7 @@ impl State {
928903 let (section, open_tab, open_page) = self.build()?;
929904 self.options_dialog();
930905 self.link_dialog()?;
906 self.sync_popup()?;
931907 self.text_menu()?;
932908 self.ui.end();
933909 self.edges(section, open_tab, open_page);
......@@ -946,7 +922,7 @@ impl State {
946922
947923 /// Takes `appearance`'s colours.
948924 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());
950926 self.titlebar = platform::titlebar(appearance).unwrap_or([self.ui.theme.strip; 2]);
951927 }
952928
......@@ -1027,10 +1003,15 @@ impl State {
10271003 ..Spec::default()
10281004 },
10291005 );
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 });
10301010 let tab_row = self.ui.open(
10311011 "tabs",
10321012 Spec {
1033 size: [fill(), px(TAB_ROW)],
1013 flags: Flags::CLIP,
1014 size: [fill(), px(height)],
10341015 fill: Some(theme.strip),
10351016 pad: [FRAME, 0.0],
10361017 ..Spec::default()
......@@ -1216,6 +1197,12 @@ impl State {
12161197 self.ui.close();
12171198 self.ui.close();
12181199 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 };
12191206 Ok((section, open_tab, open_page))
12201207 }
12211208
......@@ -1332,7 +1319,7 @@ impl State {
13321319 }
13331320
13341321 /// 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.
13361323 fn title_bar(&mut self, theme: &Theme) {
13371324 self.ui.open_as(
13381325 strip(),
......@@ -1374,23 +1361,14 @@ impl State {
13741361 },
13751362 );
13761363 if let Some(session) = &self.session {
1377 self.ui.leaf(
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 );
1364 sync::control(&mut self.ui, session, theme, TITLE);
13871365 }
13881366 platform::window_controls(&mut self.ui, &self.window);
13891367 self.ui.close();
13901368 }
13911369
1392 /// Two rows of formatting, tag, insert and zoom buttons, grouped as OneNote's Home
1393 /// ribbon groups them.
1370 /// Two rows of navigation, clipboard, formatting, tag, insert and view buttons, grouped
1371 /// as OneNote's ribbon groups them.
13941372 fn toolbar(&mut self, theme: &Theme) -> Result<(), Box<dyn Error>> {
13951373 use canvas::editor::{
13961374 Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
......@@ -1398,13 +1376,28 @@ impl State {
13981376 use commands::{Choice, Id as Cmd, shortcut};
13991377 // A focused picture has no text to show a format for.
14001378 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 };
14011394 let fonts = &self.fonts;
14021395 let recent_fonts = &self.recent_fonts;
14031396 let pens = &self.toolbar;
14041397 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.
14061399 let status = platform::system_titlebar(&self.window)
1407 .then(|| self.session.as_ref().map(Session::status))
1400 .then_some(self.session.as_ref())
14081401 .flatten();
14091402 let ui = &mut self.ui;
14101403 let text = theme.text;
......@@ -1420,16 +1413,80 @@ impl State {
14201413 ..Spec::default()
14211414 },
14221415 );
1423 group(ui, "history", |ui| {
1424 if ui::shell::tool_button(ui, "undo", art::UNDO, text, false).clicked {
1425 choice = Some(Choice::Command(Cmd::Undo));
1426 }
1427 if ui::shell::tool_button(ui, "redo", art::REDO, text, false).clicked {
1428 choice = Some(Choice::Command(Cmd::Redo));
1416 group(ui, "navigate", |ui| {
1417 for (index, buttons) in [
1418 [
1419 ("back", art::BACK, Cmd::Back),
1420 ("forward", art::FORWARD, Cmd::Forward),
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 });
14291435 }
14301436 });
1431 divider(ui, "history", theme);
1432 let popup = |name: &str| Id::ROOT.child(("popup", name));
1437 divider(ui, "navigate", theme);
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);
14331490 let font = state.font.clone().unwrap_or_default();
14341491 let size = state
14351492 .font_size
......@@ -1438,7 +1495,7 @@ impl State {
14381495 row(ui, 0, |ui| {
14391496 let combo = ui.id("font");
14401497 if ui::shell::combo(ui, "font", &font, 120.0).pressed {
1441 ui.open_popup(popup("font"));
1498 ui.open_popup(toolbar_popup("font"));
14421499 }
14431500 // Each family is named with the substitute it shows in, and picked by its
14441501 // own name; a family of None heads a group. Typing searches the full list.
......@@ -1449,7 +1506,7 @@ impl State {
14491506 )
14501507 };
14511508 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) {
14531510 choices.extend(FONTS.map(|name| (label(name), Some(name), false)));
14541511 if !recent_fonts.is_empty() {
14551512 choices.push(("Recent".to_owned(), None, true));
......@@ -1477,18 +1534,18 @@ impl State {
14771534 .collect();
14781535 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());
14791536 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"))
14811538 && let Some(name) = choices[index].1
14821539 {
14831540 choice = Some(Choice::Font(name.to_owned()));
14841541 }
14851542 let combo = ui.id("size");
14861543 if ui::shell::combo(ui, "size", &size, 44.0).pressed {
1487 ui.open_popup(popup("size"));
1544 ui.open_popup(toolbar_popup("size"));
14881545 }
14891546 // A size typed in the field joins the list, in half points as stored.
14901547 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"))
14921549 .and_then(|query| query.trim().parse::<f32>().ok())
14931550 .map(|typed| (typed * 2.0).round() / 2.0)
14941551 .filter(|typed| onestore::FONT_SIZES.contains(typed) && !sizes.contains(typed))
......@@ -1506,7 +1563,8 @@ impl State {
15061563 })
15071564 .collect();
15081565 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))
15101568 {
15111569 choice = Some(Choice::Size(sizes[index]));
15121570 }
......@@ -1518,24 +1576,26 @@ impl State {
15181576 art::BULLETS,
15191577 None,
15201578 state.bullets,
1521 popup("bullets"),
1579 toolbar_popup("bullets"),
15221580 )
15231581 .clicked
15241582 {
15251583 choice = Some(Choice::Command(Cmd::Bullets));
15261584 }
1585 tip(ui, Cmd::Bullets);
15271586 if ui::shell::split_button(
15281587 ui,
15291588 "numbering",
15301589 art::NUMBERING,
15311590 None,
15321591 state.numbering,
1533 popup("numbering"),
1592 toolbar_popup("numbering"),
15341593 )
15351594 .clicked
15361595 {
15371596 choice = Some(Choice::Command(Cmd::Numbering));
15381597 }
1598 tip(ui, Cmd::Numbering);
15391599 let current = |bullet| match state.list {
15401600 Some(ListStyle::Bullet(place)) if bullet => Some(place),
15411601 Some(ListStyle::Number(place)) if !bullet => Some(place),
......@@ -1544,7 +1604,7 @@ impl State {
15441604 let anchor = ui::Anchor::Below(ui.rect(bullets).unwrap_or_default());
15451605 if let Some(place) = list_gallery(
15461606 ui,
1547 popup("bullets"),
1607 toolbar_popup("bullets"),
15481608 anchor,
15491609 ["Recently Used Bullets", "Bullet Library"],
15501610 (&pens.bullets, BULLET_LIBRARY.len()),
......@@ -1570,7 +1630,7 @@ impl State {
15701630 let anchor = ui::Anchor::Below(ui.rect(numbering).unwrap_or_default());
15711631 if let Some(place) = list_gallery(
15721632 ui,
1573 popup("numbering"),
1633 toolbar_popup("numbering"),
15741634 anchor,
15751635 ["Recently Used Number Formats", "Numbering Library"],
15761636 (&pens.numbering, NUMBER_LIBRARY.len()),
......@@ -1613,9 +1673,16 @@ impl State {
16131673 ) {
16141674 choice = Some(Choice::List(place.map(ListStyle::Number)));
16151675 }
1616 if ui::shell::tool_button(ui, "clear", art::CLEAR_FORMATTING, text, false).clicked {
1617 choice = Some(Choice::Command(Cmd::ClearFormatting));
1618 }
1676 let clear = Cmd::ClearFormatting;
1677 choice = tool(
1678 ui,
1679 "clear",
1680 art::CLEAR_FORMATTING,
1681 text,
1682 clear,
1683 status_of(clear),
1684 )
1685 .or(choice.take());
16191686 });
16201687 row(ui, 1, |ui| {
16211688 for (part, icon, toggle) in [
......@@ -1624,12 +1691,16 @@ impl State {
16241691 ("underline", art::UNDERLINE, Toggle::Underline),
16251692 ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough),
16261693 ] {
1627 if ui::shell::tool_button(ui, part, icon, text, on(toggle)).clicked {
1628 choice = Some(Choice::Command(Cmd::Toggle(toggle)));
1629 }
1694 let id = Cmd::Toggle(toggle);
1695 choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take());
16301696 }
16311697 let script = ui.id("script");
16321698 let superscript = on(Toggle::Superscript);
1699 let command = Cmd::Toggle(if superscript {
1700 Toggle::Superscript
1701 } else {
1702 Toggle::Subscript
1703 });
16331704 if ui::shell::split_button(
16341705 ui,
16351706 "script",
......@@ -1640,16 +1711,13 @@ impl State {
16401711 },
16411712 None,
16421713 on(Toggle::Subscript) || superscript,
1643 popup("script"),
1714 toolbar_popup("script"),
16441715 )
16451716 .clicked
16461717 {
1647 choice = Some(Choice::Command(Cmd::Toggle(if superscript {
1648 Toggle::Superscript
1649 } else {
1650 Toggle::Subscript
1651 })));
1718 choice = Some(Choice::Command(command));
16521719 }
1720 tip(ui, command);
16531721 let scripts = [
16541722 (Toggle::Subscript, art::SUBSCRIPT),
16551723 (Toggle::Superscript, art::SUPERSCRIPT),
......@@ -1667,7 +1735,9 @@ impl State {
16671735 })
16681736 .collect();
16691737 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 {
16711741 choice = Some(Choice::Command(Cmd::Toggle(scripts[index].0)));
16721742 }
16731743 // Each button applies its menu's last pick: the highlighter shows it in its
......@@ -1691,19 +1761,22 @@ impl State {
16911761 ),
16921762 ] {
16931763 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));
16951766 let highlight = part == "highlight";
1767 let command = if highlight {
1768 Cmd::Highlight
1769 } else {
1770 Cmd::FontColor
1771 };
16961772 if button.clicked {
1697 choice = Some(Choice::Command(if highlight {
1698 Cmd::Highlight
1699 } else {
1700 Cmd::FontColor
1701 }));
1773 choice = Some(Choice::Command(command));
17021774 }
1775 tip(ui, command);
17031776 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();
17041777 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());
17051778 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)
17071780 {
17081781 let color = chosen.and_then(|chosen| {
17091782 swatches
......@@ -1719,11 +1792,11 @@ impl State {
17191792 });
17201793 }
17211794 }
1722 if ui::shell::tool_button(ui, "outdent", art::OUTDENT, text, false).clicked {
1723 choice = Some(Choice::Command(Cmd::Outdent));
1724 }
1725 if ui::shell::tool_button(ui, "indent", art::INDENT, text, false).clicked {
1726 choice = Some(Choice::Command(Cmd::Indent));
1795 for (part, icon, id) in [
1796 ("outdent", art::OUTDENT, Cmd::Outdent),
1797 ("indent", art::INDENT, Cmd::Indent),
1798 ] {
1799 choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take());
17271800 }
17281801 let alignments = [
17291802 (Alignment::Left, art::ALIGN_LEFT),
......@@ -1735,7 +1808,8 @@ impl State {
17351808 .iter()
17361809 .find(|(alignment, _)| Some(*alignment) == state.alignment)
17371810 .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));
17391813 // The current alignment starts highlighted, keeping its icon under the button's.
17401814 let items: Vec<_> = alignments
17411815 .iter()
......@@ -1748,50 +1822,38 @@ impl State {
17481822 ..Default::default()
17491823 })
17501824 .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 {
17521828 choice = Some(Choice::Command(Cmd::Align(alignments[index].0)));
17531829 }
17541830 });
17551831 });
17561832 divider(ui, "text", theme);
1757 let tags: [(&[&str], NoteTag); 9] = [
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;
1833 let tags = NoteTag::ALL.map(|tag| (tag_icon(tag), tag));
17721834 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() {
17741837 row(ui, index, |ui| {
1775 for (column, (icon, tag)) in tags.iter().enumerate() {
1776 let lit = state.tags.contains(tag);
1777 if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked {
1778 choice = Some(Choice::Command(Cmd::Tag(*tag)));
1779 }
1838 for (column, (icon, tag)) in shown.iter().enumerate() {
1839 let id = Cmd::Tag(*tag);
1840 choice =
1841 tool(ui, column, icon, [1.0; 4], id, status_of(id)).or(choice.take());
17801842 }
17811843 if index == 1 {
17821844 let more = ui.id("more");
1783 let open = ui.popup_open(popup("tags"));
1845 let open = ui.popup_open(toolbar_popup("tags"));
17841846 if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open)
17851847 .pressed
17861848 {
1787 ui.open_popup(popup("tags"));
1849 ui.open_popup(toolbar_popup("tags"));
17881850 }
1789 let shortcuts: Vec<_> = all_tags
1851 let shortcuts: Vec<_> = tags
17901852 .iter()
17911853 .map(|(_, tag)| shortcut(Cmd::Tag(*tag)))
17921854 .collect();
17931855 let remove = shortcut(Cmd::RemoveTags);
1794 let mut items: Vec<_> = all_tags
1856 let mut items: Vec<_> = tags
17951857 .iter()
17961858 .zip(&shortcuts)
17971859 .map(|((icon, tag), shortcut)| ui::popup::Item {
......@@ -1802,19 +1864,33 @@ impl State {
18021864 ..Default::default()
18031865 })
18041866 .collect();
1805 items.push(ui::popup::Item {
1806 text: commands::command(Cmd::RemoveTags).title,
1807 shortcut: &remove,
1808 separated: true,
1809 ..Default::default()
1810 });
1867 // Customize Tags awaits #8 and Find Tags its summary pane, #28.
1868 items.extend([
1869 ui::popup::Item {
1870 text: "Customize Tags…",
1871 separated: true,
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 ]);
18111888 let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default());
18121889 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)
18141891 {
18151892 choice = Some(Choice::Command(
1816 all_tags
1817 .get(chosen)
1893 tags.get(chosen)
18181894 .map_or(Cmd::RemoveTags, |(_, tag)| Cmd::Tag(*tag)),
18191895 ));
18201896 }
......@@ -1824,30 +1900,42 @@ impl State {
18241900 });
18251901 divider(ui, "tags", theme);
18261902 group(ui, "insert", |ui| {
1827 for (index, buttons) in [
1828 &[
1829 ("table", art::TABLE, Cmd::Table),
1903 row(ui, 0, |ui| {
1904 if status_of(Cmd::Table).enabled {
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 [
18301917 ("picture", art::PICTURE, Cmd::Picture),
1918 ("clipping", art::SCREEN_CLIPPING, Cmd::ScreenClipping),
18311919 ("file", art::ATTACHMENT, Cmd::Attachment),
18321920 ("link", art::LINK, Cmd::Link),
1833 ][..],
1834 &[
1921 ("space", art::INSERT_SPACE, Cmd::InsertSpace),
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 [
18351928 ("date", art::CALENDAR, Cmd::Date),
18361929 ("time", art::CLOCK, Cmd::Time),
1930 ("date and time", art::DATE_TIME, Cmd::DateTime),
18371931 ("equation", art::EQUATION, Cmd::Equation),
1838 ],
1839 ]
1840 .into_iter()
1841 .enumerate()
1842 {
1843 row(ui, index, |ui| {
1844 for (part, icon, id) in buttons {
1845 if ui::shell::tool_button(ui, part, icon, text, false).clicked {
1846 choice = Some(Choice::Command(*id));
1847 }
1848 }
1849 });
1850 }
1932 ("symbol", art::SYMBOL, Cmd::Symbol),
1933 ("audio", art::RECORD_AUDIO, Cmd::RecordAudio),
1934 ("video", art::RECORD_VIDEO, Cmd::RecordVideo),
1935 ] {
1936 choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take());
1937 }
1938 });
18511939 });
18521940 ui.leaf(
18531941 "space",
......@@ -1856,25 +1944,93 @@ impl State {
18561944 ..Spec::default()
18571945 },
18581946 );
1859 if let Some(status) = status {
1860 ui.leaf(
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 );
1947 if let Some(session) = status {
1948 sync::control(ui, session, theme, ui::shell::TOOL);
18701949 }
18711950 let zoom = self.view.zoom();
18721951 let label = format!("{:.0}%", zoom * 100.0);
1873 group(ui, "zoom", |ui| {
1952 group(ui, "view", |ui| {
18741953 row(ui, 0, |ui| {
1875 if ui::shell::tool_button(ui, "out", art::ZOOM_OUT, text, false).clicked {
1876 choice = Some(Choice::Command(Cmd::ZoomOut));
1954 if status_of(Cmd::PageColor).enabled {
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]));
18772022 }
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());
18782034 let level = ui.leaf(
18792035 "level",
18802036 Spec {
......@@ -1890,9 +2046,16 @@ impl State {
18902046 if level.clicked {
18912047 choice = Some(Choice::Command(Cmd::ActualSize));
18922048 }
1893 if ui::shell::tool_button(ui, "in", art::ZOOM_IN, text, false).clicked {
1894 choice = Some(Choice::Command(Cmd::ZoomIn));
1895 }
2049 tip(ui, Cmd::ActualSize);
2050 choice = tool(
2051 ui,
2052 "in",
2053 art::ZOOM_IN,
2054 text,
2055 Cmd::ZoomIn,
2056 status_of(Cmd::ZoomIn),
2057 )
2058 .or(choice.take());
18962059 });
18972060 });
18982061 ui.close();
......@@ -1919,6 +2082,7 @@ impl State {
19192082 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked {
19202083 self.commands.push(Command::NewPage { under: None });
19212084 }
2085 tip(&mut self.ui, commands::Id::NewPage);
19222086 let toggle = if self.pages_open {
19232087 art::SIDEBAR_COLLAPSE
19242088 } else {
......@@ -1927,6 +2091,7 @@ impl State {
19272091 if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked {
19282092 self.pages_open = !self.pages_open;
19292093 }
2094 tip(&mut self.ui, commands::Id::PageList);
19302095 self.ui.close();
19312096 }
19322097
......@@ -1935,9 +2100,8 @@ impl State {
19352100 fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option<Id> {
19362101 let session = self.session.as_ref()?;
19372102 let panel = self.ui.id("panel");
1938 let width = self
1939 .ui
1940 .animate(panel, if self.pages_open { PAGE_LIST } else { 0.0 });
2103 let open = self.pages_open && !self.full_page;
2104 let width = self.ui.animate(panel, if open { PAGE_LIST } else { 0.0 });
19412105 self.ui.open(
19422106 "panel",
19432107 Spec {
......@@ -2007,7 +2171,16 @@ impl State {
20072171 if pressed {
20082172 self.view.pointer_pressed(at)?
20092173 } 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
20112184 }
20122185 }
20132186 ui::Event::Button {
......@@ -2097,7 +2270,6 @@ impl State {
20972270 Command::DeleteVersion { page, version } => {
20982271 let session = self.session.as_mut().ok_or("No section is open")?;
20992272 session.section.delete_pages(&[version])?;
2100 session.status = "Saving";
21012273 session.refresh_conflicts()?;
21022274 self.commands.push(Command::OpenPage(page));
21032275 }
......@@ -2550,7 +2722,6 @@ impl State {
25502722 .collect(),
25512723 },
25522724 )?;
2553 session.status = "Saving";
25542725 // The page list shows the title as it is typed.
25552726 if self.view.editor.active_outline().title {
25562727 session.pages = session.section.pages()?;
......@@ -2588,9 +2759,6 @@ impl State {
25882759 let _ = self.view.editor.take_ops();
25892760 self.view.editor.cancel_composition(&mut self.view.engine)?;
25902761 platform::clear_marked_text(&self.window);
2591 if let Some(session) = &mut self.session {
2592 session.status = "Not saving";
2593 }
25942762 self.refresh()?;
25952763 platform::alert(
25962764 "Change not saved",
......@@ -2604,13 +2772,13 @@ impl State {
26042772 let Some(session) = &mut self.session else {
26052773 return Ok(());
26062774 };
2607 let shown = session.status;
2775 let shown = sync::label(&session.sync);
26082776 let mut listed = false;
26092777 let mut changed = false;
26102778 let mut rejected = None;
26112779 for event in session.section.events() {
26122780 use notebook::session::Event;
2613 session.status = match event {
2781 match event {
26142782 Event::Changed(spaces) => {
26152783 listed = true;
26162784 changed |= spaces.contains(&session.space);
......@@ -2619,7 +2787,6 @@ impl State {
26192787 session.section.replica(),
26202788 spaces,
26212789 );
2622 continue;
26232790 }
26242791 Event::Rejected { spaces, error } => {
26252792 if spaces.contains(&session.space) {
......@@ -2627,21 +2794,13 @@ impl State {
26272794 } else {
26282795 eprintln!("Saving failed: {error}");
26292796 }
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"
26412797 }
2642 };
2798 Event::Failed(error) => eprintln!("Synchronization stopped: {error}"),
2799 Event::Attempt { .. } | Event::Unreachable(_) => {}
2800 }
26432801 }
2644 if listed || changed || shown != session.status {
2802 session.sync = session.section.sync_status()?;
2803 if listed || changed || shown != sync::label(&session.sync) {
26452804 self.window.request_redraw();
26462805 }
26472806 if listed {
......@@ -2782,67 +2941,18 @@ impl State {
27822941 if let Some(path) = self.snapshot.take() {
27832942 self.snapshot(&path)?;
27842943 }
2785 if self.occluded || [self.config.width, self.config.height].contains(&0) {
2944 if self.occluded {
27862945 return Ok(());
27872946 }
2788 let mut reconfigure = false;
2789 let frame = match self.surface.get_current_texture() {
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 }
2947 let Some(frame) = self.surface.frame(&self.renderer)? else {
2948 return Ok(());
28182949 };
28192950 lap("acquire", start);
2820 let size = [self.config.width, self.config.height];
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();
2951 self.paint(&frame.target)?;
28382952 let start = Instant::now();
2839 self.renderer.queue.present(frame);
2840 platform::commit_presentation(&self.window);
2953 self.surface.present(&self.renderer, frame);
28412954 lap("present", start);
28422955 trace_input(&"Present submitted");
2843 if reconfigure {
2844 self.surface.configure(&self.renderer.device, &self.config);
2845 }
28462956 Ok(())
28472957 }
28482958
......@@ -2850,7 +2960,7 @@ impl State {
28502960 /// largest texture, so the surface renders scaled rather than failing.
28512961 fn scale(&self) -> f32 {
28522962 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;
28542964 let longest = size.width.max(size.height) as f32;
28552965 self.window.scale_factor() as f32 * (most / longest).min(1.0)
28562966 }
......@@ -2865,7 +2975,7 @@ impl State {
28652975 }
28662976
28672977 /// 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>> {
28692979 let start = Instant::now();
28702980 let paper = self.paper();
28712981 self.view.update_pictures(paper, &self.redraw);
......@@ -2904,12 +3014,7 @@ impl State {
29043014 trace_input(&("Draw", viewport.origin, viewport.scale));
29053015 let start = Instant::now();
29063016 self.renderer
2907 .draw(
2908 target,
2909 [self.config.width, self.config.height],
2910 self.ui.theme.strip,
2911 &layers,
2912 )
3017 .draw(target, self.surface.size, self.ui.theme.strip, &layers)
29133018 .map_err(|error| format!("Canvas drawing failed: {error:?}"))?;
29143019 lap("render", start);
29153020 Ok(())
......@@ -2917,68 +3022,10 @@ impl State {
29173022
29183023 /// Writes the frame to a PNG, so a covered window can be reviewed.
29193024 fn snapshot(&mut self, path: &Path) -> Result<(), Box<dyn Error>> {
2920 let size = [self.config.width, self.config.height];
2921 let device = &self.renderer.device;
2922 let texture = device.create_texture(&wgpu::TextureDescriptor {
2923 label: Some("Snapshot"),
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();
3025 let size = self.surface.size;
3026 let offscreen = self.surface.offscreen(&self.renderer)?;
3027 self.paint(&offscreen.target)?;
3028 let pixels = self.surface.read(&self.renderer, offscreen)?;
29823029 let partial = path.with_extension("partial");
29833030 let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]);
29843031 encoder.set_color(png::ColorType::Rgba);
......@@ -3062,12 +3109,13 @@ impl State {
30623109}
30633110
30643111/// 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.
30663113fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> Theme {
30673114 let mut theme = match appearance {
30683115 winit::window::Theme::Dark => Theme::dark(),
30693116 winit::window::Theme::Light => Theme::light(),
30703117 };
3118 theme.desktop_menu = platform::menu(appearance);
30713119 if light_pages {
30723120 let light = Theme::light();
30733121 [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)) {
36673715 ui.close();
36683716}
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
36703759/// The line between two toolbar groups.
36713760fn divider(ui: &mut Ui, part: &str, theme: &Theme) {
36723761 ui.leaf(
......@@ -3701,6 +3790,7 @@ fn read_session(
37013790 let page = section.page(space)?;
37023791 let conflicts = section.conflicts()?;
37033792 let history = section.versions()?;
3793 let sync = section.sync_status()?;
37043794 Ok((
37053795 Session {
37063796 section,
......@@ -3709,7 +3799,7 @@ fn read_session(
37093799 tab,
37103800 pages,
37113801 space,
3712 status: "",
3802 sync,
37133803 conflicts,
37143804 shown: None,
37153805 change: None,
......@@ -3792,6 +3882,15 @@ impl App {
37923882impl ApplicationHandler<UserEvent> for App {
37933883 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
37943884 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 }
37953894 UserEvent::InsertText(text) => {
37963895 if let Some(state) = &mut self.state {
37973896 if state.ui.focused() == Some(page()) {
......@@ -3943,11 +4042,9 @@ impl ApplicationHandler<UserEvent> for App {
39434042 WindowEvent::Resized(size) => {
39444043 if size.width > 0 && size.height > 0 {
39454044 let ratio = state.scale() / scale;
3946 state.config.width = (size.width as f32 * ratio) as u32;
3947 state.config.height = (size.height as f32 * ratio) as u32;
3948 state
3949 .surface
3950 .configure(&state.renderer.device, &state.config);
4045 state.surface.size =
4046 [size.width, size.height].map(|side| (side as f32 * ratio) as u32);
4047 state.surface.configure(&state.renderer);
39514048 }
39524049 // Present inside AppKit's resize transaction; a redraw on the next turn
39534050 // lets the window show the previous frame at the new size.
crates/snowbound/src/manage.rs+17-4
......@@ -116,7 +116,6 @@ impl State {
116116 ops,
117117 },
118118 )?;
119 session.status = "Saving";
120119 self.title_focus = Some(space);
121120 self.commands.push(Command::OpenPage(space));
122121 Ok(())
......@@ -130,11 +129,26 @@ impl State {
130129 ) -> Result<(), Box<dyn Error>> {
131130 self.persist()?;
132131 let session = self.session.as_ref().ok_or("No section is open")?;
133 let space = session.space;
134 let ops = template_ops(&session.section.page(space)?, choice)?;
132 let ops = template_ops(&session.section.page(session.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>> {
135147 if ops.is_empty() {
136148 return Ok(());
137149 }
150 let session = self.session.as_ref().ok_or("No section is open")?;
151 let space = session.space;
138152 session.section.apply(
139153 &self.author,
140154 Edit {
......@@ -410,7 +424,6 @@ impl State {
410424 ops: vec![Op::Section(SectionOp::Pages(edits))],
411425 },
412426 )?;
413 session.status = "Saving";
414427 session.pages = session.section.pages()?;
415428 Ok(())
416429 }
crates/snowbound/src/menubar.rs+32
......@@ -74,6 +74,7 @@ const MENUS: &[Item] = &[
7474 C(Id::Cut),
7575 C(Id::Copy),
7676 C(Id::Paste),
77 C(Id::FormatPainter),
7778 C(Id::SelectAll),
7879 S,
7980 C(Id::Find),
......@@ -98,8 +99,11 @@ const MENUS: &[Item] = &[
9899 C(Id::ZoomOut),
99100 C(Id::ActualSize),
100101 S,
102 C(Id::FullPageView),
101103 C(Id::Sidebar),
102104 C(Id::PageList),
105 S,
106 C(Id::PageColor),
103107 C(Id::DarkPages),
104108 ],
105109 ),
......@@ -108,8 +112,11 @@ const MENUS: &[Item] = &[
108112 &[
109113 C(Id::Table),
110114 C(Id::Picture),
115 C(Id::ScreenClipping),
111116 C(Id::Link),
117 S,
112118 C(Id::Equation),
119 C(Id::Symbol),
113120 S,
114121 C(Id::Date),
115122 C(Id::Time),
......@@ -158,6 +165,31 @@ const MENUS: &[Item] = &[
158165 C(Id::Tag(NoteTag::Contact)),
159166 C(Id::Tag(NoteTag::Address)),
160167 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 ),
161193 S,
162194 C(Id::RemoveTags),
163195 ],
crates/snowbound/src/rename.rs-1
......@@ -193,7 +193,6 @@ impl State {
193193 }],
194194 },
195195 )?;
196 session.status = "Saving";
197196 session.pages = session.section.pages()?;
198197 self.edited(vec![space]);
199198 self.refresh()
crates/snowbound/src/screenshot.rs+2-2
......@@ -28,9 +28,9 @@ impl State {
2828 .ok_or("SNOWBOUND_SCREENSHOT_SIZE must be WIDTHxHEIGHT in points.")?,
2929 Err(_) => SIZE,
3030 };
31 let most = self.renderer.device.limits().max_texture_dimension_2d as f32;
31 let most = self.renderer.max_texture_dimension() as f32;
3232 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);
3434 self.renderer.clear_glyph_cache();
3535 self.app_icon = platform::app_icon((16.0 * scale) as u32);
3636 let response = self.view.scale_factor_changed(scale)?;
crates/snowbound/src/search.rs+2-4
......@@ -788,9 +788,6 @@ impl State {
788788 let [left, top, right, bottom] = rect;
789789 let anchor = Anchor::Over([left - 4.0, top - 4.0, right + 4.0, bottom + 4.0]);
790790 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;
794791 self.ui.open_as(
795792 results(),
796793 Spec {
......@@ -809,8 +806,9 @@ impl State {
809806 self.ui.open(
810807 "box",
811808 Spec {
809 // Takes the toolbar's box's place at once, widening with the dropdown.
812810 flags: Flags::STILL,
813 size: [px(field_width), px(ui::shell::TOOL)],
811 size: [fill(), px(ui::shell::TOOL)],
814812 fill: Some(theme.base),
815813 border: Some(theme.accent),
816814 radius: 4.0,
crates/snowbound/src/sidebar.rs+6-1
......@@ -616,7 +616,11 @@ impl crate::State {
616616 }
617617 let width = self.ui.animate(
618618 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 },
620624 );
621625 self.ui.open(
622626 "sidebar",
......@@ -735,6 +739,7 @@ impl crate::State {
735739 self.sidebar = !self.sidebar;
736740 self.save_settings();
737741 }
742 crate::tip(&mut self.ui, crate::commands::Id::Sidebar);
738743 self.ui.close();
739744 }
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"
55publish = false
66
77[dependencies]
8draw = { path = "../draw" }
8draw = { path = "../draw", default-features = false, features = ["render"] }
99nucleo-matcher = "0.3.1"
1010parley.workspace = true
1111winit = "0.30.13"
1212
1313[dev-dependencies]
14draw = { path = "../draw", features = ["wgpu"] }
1415pollster = "0.4"
1516png = "0.18"
1617wgpu.workspace = true
crates/ui/src/layout.rs+14-3
......@@ -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};
22use std::collections::HashMap;
33
44/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from
......@@ -19,7 +19,13 @@ pub(crate) fn solve(
1919 }
2020 for node in nodes.iter_mut() {
2121 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 },
2329 Size::Text => {
2430 let content = if axis == 0 {
2531 node.content_width()
......@@ -89,7 +95,12 @@ pub(crate) fn solve(
8995 let mut cursor = nodes[index].pad[axis];
9096 for child in nodes[index].children.clone() {
9197 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)
93104 } else if nodes[child].flags.contains(Flags::FLOAT) {
94105 nodes[child].position[axis]
95106 } else if along(&nodes[index], axis) {
crates/ui/src/lib.rs+152-36
......@@ -12,7 +12,7 @@ mod theme;
1212mod widgets;
1313
1414pub use list::{List, Row, Rows, list};
15pub use theme::{Section, Shades, Theme};
15pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme};
1616pub use widgets::{button, check_box, edit_key, edit_modifiers, scrollbar, text_field};
1717
1818use draw::{
......@@ -46,6 +46,12 @@ const ICON_GAP: f32 = 6.0;
4646/// Seconds a dialog and other popups take to open and to close.
4747const DIALOG: [f32; 2] = [0.24, 0.16];
4848const 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
5056/// A box's identity across frames: its parent's id combined with a builder-chosen part.
5157#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
......@@ -200,8 +206,9 @@ pub enum Anchor {
200206
201207impl Anchor {
202208 /// 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.
204 fn place(self, axis: usize, size: f32, room: f32) -> f32 {
209 /// anchor on the axis it opens along, level with it otherwise. Shown only `shown` long
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 {
205212 let (rect, along) = match self {
206213 Anchor::Below(rect) => (rect, Some(1)),
207214 Anchor::Right(rect) => (rect, Some(0)),
......@@ -218,15 +225,16 @@ impl Anchor {
218225 if first + size <= room {
219226 first
220227 } else if second >= 0.0 {
221 second
228 second + size - shown
222229 } else {
223 first.min(room - size).max(0.0)
230 first.min(room - size).max(0.0) + size - shown
224231 }
225232 }
226233
227234 /// 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.
229 fn motion(self, rect: [f32; 4], open: f32) -> Motion {
235 /// grows out of the anchor as it fades in where it `grows`, and a dialog swings up into
236 /// place.
237 fn motion(self, rect: [f32; 4], open: f32, grows: bool) -> Motion {
230238 let pivot = match self {
231239 Anchor::Below(anchor) => [anchor[0], anchor[3]],
232240 Anchor::Right(anchor) => [anchor[2], anchor[1]],
......@@ -235,8 +243,11 @@ impl Anchor {
235243 Anchor::Dialog => [(rect[0] + rect[2]) / 2.0, rect[1]],
236244 };
237245 let (from, tilt) = match self {
246 _ if !grows => (1.0, 0.0),
238247 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),
240251 _ => (0.94, 0.0),
241252 };
242253 Motion {
......@@ -290,7 +301,8 @@ pub struct Spec<'a> {
290301 pub fill: Option<[f32; 4]>,
291302 /// The fill's colour at the bottom, fading from `fill` at the top.
292303 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.
294306 pub shadow: Option<[f32; 4]>,
295307 /// How far the box draws inside its leading, top, trailing and bottom edges: fill,
296308 /// border, shadow, icon and label; the pointer still finds the whole box.
......@@ -445,6 +457,8 @@ struct Built {
445457 offset: [f32; 2],
446458 cursor: Option<CursorIcon>,
447459 anchor: Option<Anchor>,
460 /// How far open a popup over a box is, as it widens out of the box.
461 open: f32,
448462 motion: Option<Motion>,
449463 /// Rectangles relative to the box, painted over its fill.
450464 marks: Vec<([f32; 4], [f32; 4], f32)>,
......@@ -489,6 +503,15 @@ struct Popup {
489503 opened: Instant,
490504}
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
492515/// A popup's painting as it last showed, fading out since it closed.
493516struct Closing {
494517 id: Id,
......@@ -591,6 +614,9 @@ pub struct Ui {
591614 /// `popups_painted`.
592615 painted: Vec<(Id, Anchor, [f32; 4], std::ops::Range<usize>)>,
593616 closing: Vec<Closing>,
617 tip: Option<Tip>,
618 /// When a tooltip last showed.
619 warm: Option<Instant>,
594620 animating: bool,
595621 /// This frame routed input, whose effects the builder may only have seen after boxes
596622 /// built before it read their state; one more frame shows them.
......@@ -633,6 +659,8 @@ impl Ui {
633659 popups_painted: 0,
634660 painted: Vec::new(),
635661 closing: Vec::new(),
662 tip: None,
663 warm: None,
636664 animating: false,
637665 routed: false,
638666 caret: None,
......@@ -742,6 +770,12 @@ impl Ui {
742770 }
743771
744772 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 }
745779 match event {
746780 Event::PointerMoved(point) => {
747781 self.pointer = Some(point);
......@@ -997,10 +1031,18 @@ impl Ui {
9971031 } else {
9981032 *self.stack.last().unwrap()
9991033 };
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 };
10001040 let index = self.nodes.len();
10011041 self.states.entry(id).or_default().touched = self.frame;
1002 self.nodes
1003 .push(Built::new(id, parent, spec, label, self.theme.text));
1042 self.nodes.push(Built {
1043 open,
1044 ..Built::new(id, parent, spec, label, self.theme.text)
1045 });
10041046 self.nodes[parent].children.push(index);
10051047 self.stack.push(index);
10061048 id
......@@ -1116,6 +1158,13 @@ impl Ui {
11161158 {
11171159 self.close_from(gone);
11181160 }
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"));
11191168 let last = std::mem::take(&mut self.display);
11201169 let last_popups = self.popups_painted;
11211170 self.hits.clear();
......@@ -1129,9 +1178,13 @@ impl Ui {
11291178 continue;
11301179 };
11311180 let (id, rect) = (node.id, node.rect);
1181 if Some(id) == tip {
1182 continue;
1183 }
11321184 if let Some(open) = self.opening(id, anchor) {
11331185 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));
11351188 }
11361189 let from = self.display.len() - self.popups_painted;
11371190 self.paint(index, None, None);
......@@ -1167,23 +1220,47 @@ impl Ui {
11671220 closed: self.now,
11681221 });
11691222 }
1170 let closing = std::mem::take(&mut self.closing);
1171 for closing in &closing {
1172 let shut = self.progress(closing.closed, closing.anchor.durations()[1]);
1173 let open = (1.0 - shut).powi(3);
1223 for closing in std::mem::take(&mut self.closing) {
1224 let motion = self.popup_motion(closing.anchor);
1225 let open = match motion {
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 }
11741237 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);
11761240 self.display.push(Display::Motion(Some(motion)));
11771241 self.display.extend(closing.display.iter().cloned());
11781242 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);
11791263 }
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();
11871264 self.modal = if self.popups.is_empty() { 0 } else { beneath };
11881265 for id in [&mut self.hover, &mut self.active, &mut self.focus] {
11891266 if id.is_some_and(|id| !self.states.contains_key(&id)) {
......@@ -1195,7 +1272,20 @@ impl Ui {
11951272 /// How far open popup `id` beside `anchor` shows this frame, from 0 to 1, while open.
11961273 pub fn opening(&mut self, id: Id, anchor: Anchor) -> Option<f32> {
11971274 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 }
11991289 }
12001290
12011291 /// How far through an animation `duration` seconds long that began at `start` this
......@@ -1255,18 +1345,43 @@ impl Ui {
12551345 rect[3] - bottom,
12561346 ];
12571347 let size = [painted[2] - painted[0], painted[3] - painted[1]];
1258 if let Some(shadow) = node.shadow {
1259 let [spread, drop] = if node.anchor.is_some() {
1260 POPUP_SHADOW
1261 } else {
1262 SHADOW
1348 if let Some(color) = node.shadow {
1349 let (own, menu);
1350 let shadows: &[Shadow] = match node.anchor {
1351 Some(Anchor::Dialog) | None => {
1352 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 }
12631369 };
1264 self.display.push(Display::Path {
1265 data: outline(node.shape, size, node.radius),
1266 origin: [painted[0], painted[1] + drop],
1267 style: PathStyle::Shadow(spread),
1268 colors: [shadow; 2],
1269 });
1370 for &Shadow {
1371 color,
1372 blur,
1373 drop,
1374 spread,
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 }
12701385 }
12711386 if node.shape == Shape::Rounded {
12721387 if fill.is_some() || border.is_some() {
......@@ -1581,6 +1696,7 @@ impl Built {
15811696 offset: spec.offset,
15821697 cursor: spec.cursor,
15831698 anchor: spec.anchor,
1699 open: 1.0,
15841700 motion: None,
15851701 marks: Vec::new(),
15861702 computed: [0.0; 2],
crates/ui/src/popup.rs+290-60
......@@ -3,32 +3,28 @@
33//! frame it is, closing the popup.
44
55use crate::{
6 Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Popup, Row, Rows, Spec, Ui, children,
7 fill, fit, list::GUTTER, mix, px, text_field,
6 Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Menu, Overflow, Popup, Row, Rows, Spec,
7 TIP_DELAY, TIP_FADE, TIP_WARM, Tip, Ui, children, fill, fit, list::GUTTER, mix, px, text_field,
88};
99use nucleo_matcher::{
1010 Config, Matcher, Utf32Str,
1111 pattern::{AtomKind, CaseMatching, Normalization, Pattern},
1212};
13use std::cell::RefCell;
13use std::{cell::RefCell, time::Duration};
1414use winit::keyboard::{Key, NamedKey};
1515
1616pub(crate) const CHECK: &[&str] = &[include_str!("../assets/check.svg")];
1717/// Inset of a popup's contents, and its distance from what it drops down from.
18const PAD: f32 = 4.0;
19/// A palette's rows and filter field, and a menu's more compact rows.
18pub(crate) const PAD: f32 = 4.0;
19/// A palette's rows and filter field, and Snowbound's more compact menu rows.
2020const ROW: f32 = 26.0;
2121pub(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;
2622/// Rows a list shows before it scrolls.
2723const ROWS: f32 = 12.0;
28/// Height of the rule between groups.
29const RULE: f32 = 9.0;
3024const NARROWEST: f32 = 140.0;
3125const PALETTE: f32 = 560.0;
26/// Where a tooltip's description wraps.
27const TIP_WIDTH: f32 = 280.0;
3228/// A colour grid's cell, around its swatch.
3329const CELL: f32 = 22.0;
3430/// Seconds a popup takes to ease to the height of its results.
......@@ -83,10 +79,16 @@ pub fn menu(
8379 if !ui.popup_open(id) {
8480 return None;
8581 }
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 };
8688 let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| {
8789 [
88 text.max(ui.measure(item.text)[0]),
89 shortcut.max(ui.measure(item.shortcut)[0]),
90 text.max(measure(item.text)),
91 shortcut.max(measure(item.shortcut)),
9092 ]
9193 });
9294 let icons = items.iter().any(|item| item.icon.is_some() || item.checked);
......@@ -101,10 +103,10 @@ pub fn menu(
101103 } else {
102104 0.0
103105 }
104 + 2.0 * (PAD + 8.0);
106 + 2.0 * (style.pad + style.row_pad);
105107 let least = match anchor {
106108 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,
108110 _ => 0.0,
109111 };
110112 choose(
......@@ -114,10 +116,98 @@ pub fn menu(
114116 items,
115117 filter,
116118 width.max(least).max(NARROWEST),
117 MENU_ROW,
119 style.row,
118120 )
119121}
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
121211/// Builds popup `id` as a command palette across the top of the window while it is open,
122212/// listing `commands` that match what is typed, most relevant first. Returns the index of
123213/// the command chosen.
......@@ -157,19 +247,11 @@ fn choose(
157247 let mut query = std::mem::take(&mut popup.query);
158248 let mut highlight = popup.highlight;
159249 let theme = ui.theme.clone();
160 // Over a combo box, the field takes the box's place at once and widens as the popup
161 // comes in around it.
162 let (flags, size) = match anchor {
163 Anchor::Over(rect) => {
164 let open = ui.opening(id, anchor).unwrap_or(1.0);
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)]),
250 let style = theme.menu();
251 // Over a combo box, the field takes the box's place at once, widening with the popup.
252 let (flags, height) = match anchor {
253 Anchor::Over(rect) => (Flags::STILL, rect[3] - rect[1]),
254 _ => (Flags::default(), ROW),
173255 };
174256 surface(ui, id, anchor, width);
175257 if let Some(placeholder) = filter {
......@@ -181,7 +263,7 @@ fn choose(
181263 placeholder,
182264 Spec {
183265 flags,
184 size,
266 size: [fill(), px(height)],
185267 fill: Some(theme.base),
186268 border: Some(theme.accent),
187269 radius: 4.0,
......@@ -193,7 +275,7 @@ fn choose(
193275 highlight = None;
194276 }
195277 }
196 let matches = Matches::new(items, &query);
278 let matches = Matches::new(items, &query, style.rule_band);
197279 // Unfiltered, the current or checked item starts highlighted; filtered, the best match.
198280 highlight = highlight.or_else(|| {
199281 let first = if query.is_empty() {
......@@ -207,7 +289,11 @@ fn choose(
207289 first.map(|row| matches.key(row))
208290 });
209291 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 };
211297 let content = matches.count() as f32 * row + matches.space_before(matches.count());
212298 let view = content
213299 .max(row)
......@@ -236,7 +322,8 @@ fn choose(
236322 Spec {
237323 size: [fill(), px(row)],
238324 text: Some("No matches"),
239 color: Some(theme.text_dim),
325 font_size: Some(style.font_size),
326 color: Some(style.dim),
240327 pad: [8.0, 0.0],
241328 ..Spec::default()
242329 },
......@@ -254,12 +341,12 @@ fn choose(
254341 id.child("rows"),
255342 Spec {
256343 size: [fill(), px(view)],
257 fill: Some(theme.popup),
344 fill: Some(style.fill),
258345 ..Spec::default()
259346 },
260347 list,
261348 &mut highlight,
262 |ui, row| menu_row(ui, &theme, &matches, row),
349 |ui, row| menu_row(ui, &style, &matches, row),
263350 );
264351 let entered = keys.contains(&NamedKey::Enter);
265352 clicked.or_else(|| {
......@@ -282,7 +369,7 @@ fn choose(
282369
283370/// Builds a menu's row: the item's icon or check, its text and its shortcut, on the
284371/// 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) {
286373 let item = &matches.items[row.key as usize];
287374 if row.index > 0 && matches.ruled && item.separated {
288375 ui.leaf(
......@@ -290,24 +377,28 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
290377 Spec {
291378 flags: Flags::FLOAT,
292379 size: [fill(), px(1.0)],
293 position: [0.0, -(RULE + 1.0) / 2.0],
294 fill: Some(theme.chip),
380 position: [0.0, -(style.rule_band + 1.0) / 2.0],
381 inset: [style.rule_inset, 0.0, style.rule_inset, 0.0],
382 fill: Some(style.rule),
295383 ..Spec::default()
296384 },
297385 );
298386 }
299 let color = if item.disabled || item.heading {
300 theme.text_dim
387 let color = if item.heading {
388 style.dim
389 } else if item.disabled {
390 style.disabled
301391 } else {
302 theme.text
392 style.text
303393 };
304394 ui.open(
305395 "item",
306396 Spec {
307397 size: [fill(), fill()],
308 fill: row.selected.then(|| theme.hover()),
309 radius: 4.0,
310 pad: [ROW_PAD, 0.0],
398 fill: row.selected.then_some(style.highlight),
399 border: style.highlight_border.filter(|_| row.selected),
400 radius: style.row_radius,
401 pad: [style.row_pad, 0.0],
311402 gap: ICON_GAP,
312403 ..Spec::default()
313404 },
......@@ -324,7 +415,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
324415 size: [px(ICON), fill()],
325416 icon: if item.checked { Some(CHECK) } else { item.icon },
326417 color: Some(if item.disabled {
327 mix(tint, theme.popup, 0.5)
418 mix(tint, style.fill, 0.5)
328419 } else {
329420 tint
330421 }),
......@@ -338,7 +429,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
338429 size: [fill(), fill()],
339430 text: Some(item.text),
340431 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 }),
342433 bold: item.heading,
343434 color: Some(color),
344435 ..Spec::default()
......@@ -350,7 +441,8 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
350441 Spec {
351442 size: [fit(), fill()],
352443 text: Some(item.shortcut),
353 color: Some(theme.text_dim),
444 font_size: Some(style.font_size),
445 color: Some(style.dim),
354446 ..Spec::default()
355447 },
356448 );
......@@ -419,7 +511,12 @@ pub fn colors(
419511
420512 let theme = ui.theme.clone();
421513 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 );
423520 ui.open_as(
424521 cell(0),
425522 Spec {
......@@ -473,6 +570,130 @@ pub fn colors(
473570 None
474571}
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
476697/// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, each
477698/// a heading and a count, in rows of `columns` cells `size` large, with cell `current`
478699/// outlined. `cell` builds a cell's contents from its index, counted through the groups.
......@@ -533,7 +754,12 @@ pub fn gallery(
533754 return Some(index);
534755 }
535756 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 );
537763 let mut index = 0;
538764 for (group, (heading, cells)) in groups.iter().enumerate() {
539765 ui.leaf(
......@@ -584,29 +810,30 @@ pub fn gallery(
584810
585811/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it.
586812fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {
813 let style = ui.theme.menu();
814 let pad = style.pad;
587815 let anchor = match anchor {
588816 Anchor::Below([left, top, right, bottom]) => {
589817 Anchor::Below([left, top - PAD, right, bottom + PAD])
590818 }
591819 // Level with the row it opens from, or with its contents over the box.
592820 Anchor::Right([left, top, right, bottom]) => {
593 Anchor::Right([left, top - PAD, right, bottom + PAD])
821 Anchor::Right([left, top - pad, right, bottom + pad])
594822 }
595823 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])
597825 }
598826 point => point,
599827 };
600 let theme = &ui.theme;
601828 let spec = Spec {
602829 axis: Axis::Y,
603830 size: [px(width), children()],
604 fill: Some(theme.popup),
605 border: Some(theme.chip),
606 shadow: Some(theme.shadow),
607 radius: 6.0,
608 pad: [PAD; 2],
609 gap: PAD,
831 fill: Some(style.fill),
832 border: Some(style.border),
833 shadow: Some(ui.theme.shadow),
834 radius: style.radius,
835 pad: [pad; 2],
836 gap: pad,
610837 anchor: Some(anchor),
611838 ..Spec::default()
612839 };
......@@ -661,11 +888,13 @@ pub(crate) struct Matches<'a> {
661888 /// Rules above each row and the end, counted from the top; only while unfiltered.
662889 rules: Vec<u32>,
663890 ruled: bool,
891 /// Height of the band a rule takes.
892 rule: f32,
664893 icons: bool,
665894}
666895
667896impl<'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 {
669898 let pattern = Pattern::new(
670899 query,
671900 CaseMatching::Ignore,
......@@ -708,6 +937,7 @@ impl<'a> Matches<'a> {
708937 rows,
709938 rules,
710939 ruled,
940 rule,
711941 icons: items.iter().any(|item| item.icon.is_some() || item.checked),
712942 }
713943 }
......@@ -732,6 +962,6 @@ impl Rows for Matches<'_> {
732962 }
733963
734964 fn space_before(&self, index: usize) -> f32 {
735 self.rules[index] as f32 * RULE
965 self.rules[index] as f32 * self.rule
736966 }
737967}
crates/ui/src/shell.rs+46-1
......@@ -222,6 +222,49 @@ pub fn tool_button(
222222 )
223223}
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
225268/// A button showing `icon`, lit while `on`, joined to an arrow that opens popup `menu`,
226269/// with the colour it applies as a bar under the icon when given. Hovering the button fills
227270/// it alone; hovering the arrow, or its menu being open, outlines both as one control.
......@@ -359,7 +402,9 @@ pub fn menu_button(
359402 ui.open_popup(menu);
360403 }
361404 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;
363408 Anchor::Below([left - shift, top, right - shift, bottom])
364409}
365410
crates/ui/src/tests.rs+183-1
......@@ -777,6 +777,74 @@ fn click(ui: &mut Ui, point: [f32; 2]) {
777777 press(ui, at, false);
778778}
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
780848#[test]
781849fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() {
782850 let mut ui = open_menu(None);
......@@ -1062,6 +1130,40 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() {
10621130 assert_eq!(build(&mut ui), Some(None));
10631131}
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
10651167#[test]
10661168fn popups_opened_within_a_popup_stay_above_it() {
10671169 let mut ui = open_menu(None);
......@@ -1369,7 +1471,7 @@ fn fonts() -> Vec<popup::Item<'static>> {
13691471}
13701472
13711473fn 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;
13731475 order.into_iter().map(|index| FONTS[index]).collect()
13741476}
13751477
......@@ -1836,6 +1938,39 @@ fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks()
18361938 assert_eq!(build(&mut ui), Some(3));
18371939}
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
18391974fn built(ui: &Ui, id: Id) -> &Built {
18401975 ui.nodes.iter().find(|node| node.id == id).unwrap()
18411976}
......@@ -2024,3 +2159,50 @@ fn galleries_choose_across_their_groups_by_keys_and_clicks() {
20242159 click(&mut ui, point);
20252160 assert_eq!(build(&mut ui), Some(1));
20262161}
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 {
4747 .context
4848 .ranged_builder(&mut self.fonts, text, 1.0, false);
4949 // Fontconfig configurations without a system-ui alias still have a sans-serif.
50 let system =
51 [GenericFamily::SystemUi, GenericFamily::SansSerif].map(FontFamilyName::Generic);
50 // Before 10.10 macOS's interface font is Lucida Grande, which fontique's system-ui
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 ];
5257 let families: Vec<_> = font
5358 .into_iter()
5459 .map(FontFamilyName::named)
55 .chain(system)
60 .chain(system.into_iter().flatten())
5661 .collect();
5762 builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned(
5863 families,
crates/ui/src/theme.rs+88
......@@ -32,6 +32,62 @@ pub struct Theme {
3232 pub shadow: [f32; 4],
3333 /// Saturation and lightness each of a section's colours takes from its hue.
3434 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]),
3591}
3692
3793/// The platform's caret and selection colours in a light or `dark` appearance.
......@@ -101,6 +157,7 @@ impl Theme {
101157 edge: [0.30, 0.16],
102158 accent: [0.55, 0.55],
103159 },
160 desktop_menu: None,
104161 }
105162 }
106163
......@@ -129,6 +186,7 @@ impl Theme {
129186 edge: [0.30, 0.52],
130187 accent: [0.60, 0.45],
131188 },
189 desktop_menu: None,
132190 }
133191 }
134192
......@@ -145,6 +203,36 @@ impl Theme {
145203 }
146204 }
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
148236 /// Hovered controls: the accent over the base.
149237 pub fn hover(&self) -> [f32; 4] {
150238 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 {
348348 NamedKey::End => Edit::End,
349349 NamedKey::PageUp => Edit::PageUp,
350350 NamedKey::PageDown => Edit::PageDown,
351 NamedKey::F11 => Edit::F11,
351352 NamedKey::Alt
352353 | NamedKey::AltGraph
353354 | NamedKey::Control
platform/snow-leopard/Cargo.lock+1377-75
......@@ -2,18 +2,72 @@
22# It is not intended for manual editing.
33version = 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
521[[package]]
622name = "adler2"
723version = "2.0.1"
824source = "registry+https://github.com/rust-lang/crates.io-index"
925checksum = "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
1140[[package]]
1241name = "allocator-api2"
1342version = "0.2.21"
1443source = "registry+https://github.com/rust-lang/crates.io-index"
1544checksum = "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
1771[[package]]
1872name = "android_system_properties"
1973version = "0.1.6"
......@@ -23,12 +77,24 @@ dependencies = [
2377 "libc",
2478]
2579
80[[package]]
81name = "arrayref"
82version = "0.3.9"
83source = "registry+https://github.com/rust-lang/crates.io-index"
84checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
85
2686[[package]]
2787name = "arrayvec"
2888version = "0.7.8"
2989source = "registry+https://github.com/rust-lang/crates.io-index"
3090checksum = "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
3298[[package]]
3399name = "ash"
34100version = "0.38.0+1.3.281"
......@@ -38,6 +104,12 @@ dependencies = [
38104 "libloading",
39105]
40106
107[[package]]
108name = "atomic-waker"
109version = "1.1.2"
110source = "registry+https://github.com/rust-lang/crates.io-index"
111checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
112
41113[[package]]
42114name = "autocfg"
43115version = "1.5.1"
......@@ -59,19 +131,34 @@ version = "0.9.1"
59131source = "registry+https://github.com/rust-lang/crates.io-index"
60132checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
61133
134[[package]]
135name = "bitflags"
136version = "1.3.2"
137source = "registry+https://github.com/rust-lang/crates.io-index"
138checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
139
62140[[package]]
63141name = "bitflags"
64142version = "2.13.1"
65143source = "registry+https://github.com/rust-lang/crates.io-index"
66144checksum = "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
68155[[package]]
69156name = "block2"
70157version = "0.6.2"
71158source = "registry+https://github.com/rust-lang/crates.io-index"
72159checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
73160dependencies = [
74 "objc2",
161 "objc2 0.6.4",
75162]
76163
77164[[package]]
......@@ -106,6 +193,50 @@ version = "0.1.0"
106193source = "registry+https://github.com/rust-lang/crates.io-index"
107194checksum = "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
109240[[package]]
110241name = "cfg-if"
111242version = "1.0.5"
......@@ -135,6 +266,65 @@ version = "1.1.0"
135266source = "registry+https://github.com/rust-lang/crates.io-index"
136267checksum = "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
138328[[package]]
139329name = "crc32fast"
140330version = "1.5.2"
......@@ -144,20 +334,38 @@ dependencies = [
144334 "cfg-if",
145335]
146336
337[[package]]
338name = "crossbeam-utils"
339version = "0.8.23"
340source = "registry+https://github.com/rust-lang/crates.io-index"
341checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
342
147343[[package]]
148344name = "crunchy"
149345version = "0.2.4"
150346source = "registry+https://github.com/rust-lang/crates.io-index"
151347checksum = "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
153361[[package]]
154362name = "dispatch2"
155363version = "0.3.1"
156364source = "registry+https://github.com/rust-lang/crates.io-index"
157365checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
158366dependencies = [
159 "bitflags",
160 "objc2",
367 "bitflags 2.13.1",
368 "objc2 0.6.4",
161369]
162370
163371[[package]]
......@@ -189,6 +397,18 @@ dependencies = [
189397 "litrs",
190398]
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
192412[[package]]
193413name = "draw"
194414version = "0.1.0"
......@@ -208,6 +428,16 @@ version = "1.0.2"
208428source = "registry+https://github.com/rust-lang/crates.io-index"
209429checksum = "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
211441[[package]]
212442name = "fax"
213443version = "0.2.7"
......@@ -223,6 +453,12 @@ dependencies = [
223453 "simd-adler32",
224454]
225455
456[[package]]
457name = "find-msvc-tools"
458version = "0.1.14"
459source = "registry+https://github.com/rust-lang/crates.io-index"
460checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
461
226462[[package]]
227463name = "flate2"
228464version = "1.1.10"
......@@ -257,10 +493,10 @@ dependencies = [
257493 "hashbrown 0.17.1",
258494 "linebender_resource_handle",
259495 "memmap2",
260 "objc2",
496 "objc2 0.6.4",
261497 "objc2-core-foundation",
262498 "objc2-core-text",
263 "objc2-foundation",
499 "objc2-foundation 0.3.2",
264500 "parlance",
265501 "read-fonts",
266502 "roxmltree",
......@@ -270,6 +506,33 @@ dependencies = [
270506 "yeslogic-fontconfig-sys",
271507]
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
273536[[package]]
274537name = "futures-core"
275538version = "0.3.34"
......@@ -294,6 +557,39 @@ dependencies = [
294557 "slab",
295558]
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
297593[[package]]
298594name = "gif"
299595version = "0.14.2"
......@@ -304,10 +600,6 @@ dependencies = [
304600 "weezl",
305601]
306602
307[[package]]
308name = "gl21"
309version = "0.0.0"
310
311603[[package]]
312604name = "gl_generator"
313605version = "0.14.0"
......@@ -350,7 +642,7 @@ dependencies = [
350642 "hashbrown 0.16.1",
351643 "log",
352644 "presser",
353 "thiserror",
645 "thiserror 2.0.21",
354646 "windows",
355647]
356648
......@@ -372,7 +664,7 @@ version = "0.12.0"
372664source = "registry+https://github.com/rust-lang/crates.io-index"
373665checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
374666dependencies = [
375 "bitflags",
667 "bitflags 2.13.1",
376668 "bytemuck",
377669 "read-fonts",
378670 "smallvec",
......@@ -402,6 +694,12 @@ dependencies = [
402694name = "hello-snow-leopard"
403695version = "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
405703[[package]]
406704name = "icu_collections"
407705version = "2.3.0"
......@@ -558,6 +856,36 @@ dependencies = [
558856 "hashbrown 0.17.1",
559857]
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
561889[[package]]
562890name = "jni-sys"
563891version = "0.3.1"
......@@ -586,6 +914,16 @@ dependencies = [
586914 "syn 2.0.119",
587915]
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
589927[[package]]
590928name = "js-sys"
591929version = "0.3.105"
......@@ -636,12 +974,36 @@ version = "0.2.16"
636974source = "registry+https://github.com/rust-lang/crates.io-index"
637975checksum = "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
639989[[package]]
640990name = "linebender_resource_handle"
641991version = "0.1.1"
642992source = "registry+https://github.com/rust-lang/crates.io-index"
643993checksum = "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
6451007[[package]]
6461008name = "litemap"
6471009version = "0.8.3"
......@@ -722,7 +1084,7 @@ checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
7221084dependencies = [
7231085 "arrayvec",
7241086 "bit-set",
725 "bitflags",
1087 "bitflags 2.13.1",
7261088 "cfg-if",
7271089 "cfg_aliases",
7281090 "codespan-reporting",
......@@ -736,7 +1098,7 @@ dependencies = [
7361098 "once_cell",
7371099 "rustc-hash",
7381100 "spirv",
739 "thiserror",
1101 "thiserror 2.0.21",
7401102 "unicode-ident",
7411103]
7421104
......@@ -749,9 +1111,30 @@ dependencies = [
7491111 "hashbrown 0.17.1",
7501112 "indexmap",
7511113 "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",
7531130]
7541131
1132[[package]]
1133name = "ndk-context"
1134version = "0.1.1"
1135source = "registry+https://github.com/rust-lang/crates.io-index"
1136checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
1137
7551138[[package]]
7561139name = "ndk-sys"
7571140version = "0.6.0+11769913"
......@@ -761,6 +1144,16 @@ dependencies = [
7611144 "jni-sys 0.3.1",
7621145]
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
7641157[[package]]
7651158name = "num-traits"
7661159version = "0.2.19"
......@@ -772,55 +1165,168 @@ dependencies = [
7721165]
7731166
7741167[[package]]
775name = "objc2"
776version = "0.6.4"
1168name = "num_enum"
1169version = "0.7.6"
7771170source = "registry+https://github.com/rust-lang/crates.io-index"
778checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
1171checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
7791172dependencies = [
780 "objc2-encode",
1173 "num_enum_derive",
1174 "rustversion",
7811175]
7821176
7831177[[package]]
784name = "objc2-app-kit"
785version = "0.3.2"
1178name = "num_enum_derive"
1179version = "0.7.6"
7861180source = "registry+https://github.com/rust-lang/crates.io-index"
787checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
1181checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
7881182dependencies = [
789 "bitflags",
790 "objc2",
791 "objc2-foundation",
1183 "proc-macro-crate",
1184 "proc-macro2",
1185 "quote",
1186 "syn 2.0.119",
7921187]
7931188
7941189[[package]]
795name = "objc2-core-foundation"
796version = "0.3.2"
1190name = "objc-sys"
1191version = "0.3.5"
7971192source = "registry+https://github.com/rust-lang/crates.io-index"
798checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
799dependencies = [
800 "bitflags",
801 "dispatch2",
802 "objc2",
803]
1193checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
8041194
8051195[[package]]
806name = "objc2-core-graphics"
807version = "0.3.2"
1196name = "objc2"
1197version = "0.5.2"
8081198source = "registry+https://github.com/rust-lang/crates.io-index"
809checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
1199checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
8101200dependencies = [
811 "dispatch2",
812 "objc2",
813 "objc2-core-foundation",
1201 "objc-sys",
1202 "objc2-encode",
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",
8141296 "objc2-io-surface",
8151297]
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
8171323[[package]]
8181324name = "objc2-core-text"
8191325version = "0.3.2"
8201326source = "registry+https://github.com/rust-lang/crates.io-index"
8211327checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
8221328dependencies = [
823 "bitflags",
1329 "bitflags 2.13.1",
8241330 "objc2-core-foundation",
8251331]
8261332
......@@ -830,14 +1336,27 @@ version = "4.1.0"
8301336source = "registry+https://github.com/rust-lang/crates.io-index"
8311337checksum = "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
8331352[[package]]
8341353name = "objc2-foundation"
8351354version = "0.3.2"
8361355source = "registry+https://github.com/rust-lang/crates.io-index"
8371356checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
8381357dependencies = [
839 "bitflags",
840 "objc2",
1358 "bitflags 2.13.1",
1359 "objc2 0.6.4",
8411360 "objc2-core-foundation",
8421361]
8431362
......@@ -847,21 +1366,58 @@ version = "0.3.2"
8471366source = "registry+https://github.com/rust-lang/crates.io-index"
8481367checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
8491368dependencies = [
850 "bitflags",
851 "objc2",
1369 "bitflags 2.13.1",
1370 "objc2 0.6.4",
8521371 "objc2-core-foundation",
8531372]
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
8551398[[package]]
8561399name = "objc2-metal"
8571400version = "0.3.2"
8581401source = "registry+https://github.com/rust-lang/crates.io-index"
8591402checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
8601403dependencies = [
861 "bitflags",
862 "block2",
863 "objc2",
864 "objc2-foundation",
1404 "bitflags 2.13.1",
1405 "block2 0.6.2",
1406 "objc2 0.6.4",
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",
8651421]
8661422
8671423[[package]]
......@@ -870,12 +1426,67 @@ version = "0.3.2"
8701426source = "registry+https://github.com/rust-lang/crates.io-index"
8711427checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
8721428dependencies = [
873 "bitflags",
874 "objc2",
1429 "bitflags 2.13.1",
1430 "objc2 0.6.4",
8751431 "objc2-core-foundation",
8761432 "objc2-core-graphics",
877 "objc2-foundation",
878 "objc2-metal",
1433 "objc2-foundation 0.3.2",
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",
8791490]
8801491
8811492[[package]]
......@@ -884,6 +1495,16 @@ version = "1.21.4"
8841495source = "registry+https://github.com/rust-lang/crates.io-index"
8851496checksum = "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
8871508[[package]]
8881509name = "ordered-float"
8891510version = "5.5.0"
......@@ -893,6 +1514,15 @@ dependencies = [
8931514 "num-traits",
8941515]
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
8961526[[package]]
8971527name = "parking_lot"
8981528version = "0.12.5"
......@@ -911,7 +1541,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
9111541dependencies = [
9121542 "cfg-if",
9131543 "libc",
914 "redox_syscall",
1544 "redox_syscall 0.5.18",
9151545 "smallvec",
9161546 "windows-link",
9171547]
......@@ -960,6 +1590,32 @@ dependencies = [
9601590 "skrifa",
9611591]
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
9631619[[package]]
9641620name = "pin-project-lite"
9651621version = "0.2.17"
......@@ -972,19 +1628,39 @@ version = "0.3.34"
9721628source = "registry+https://github.com/rust-lang/crates.io-index"
9731629checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
9741630
1631[[package]]
1632name = "plain"
1633version = "0.2.3"
1634source = "registry+https://github.com/rust-lang/crates.io-index"
1635checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
1636
9751637[[package]]
9761638name = "png"
9771639version = "0.18.1"
9781640source = "registry+https://github.com/rust-lang/crates.io-index"
9791641checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
9801642dependencies = [
981 "bitflags",
1643 "bitflags 2.13.1",
9821644 "crc32fast",
9831645 "fdeflate",
9841646 "flate2",
9851647 "miniz_oxide 0.8.9",
9861648]
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
9881664[[package]]
9891665name = "pollster"
9901666version = "0.4.0"
......@@ -1027,11 +1703,21 @@ checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
10271703name = "probe"
10281704version = "0.0.0"
10291705dependencies = [
1030 "gl21",
1031 "objc2",
1032 "objc2-app-kit",
1033 "objc2-foundation",
1706 "draw",
1707 "objc2 0.6.4",
1708 "objc2-app-kit 0.3.2",
1709 "objc2-foundation 0.3.2",
10341710 "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",
10351721]
10361722
10371723[[package]]
......@@ -1061,6 +1747,15 @@ version = "2.0.1"
10611747source = "registry+https://github.com/rust-lang/crates.io-index"
10621748checksum = "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
10641759[[package]]
10651760name = "quote"
10661761version = "1.0.47"
......@@ -1070,6 +1765,18 @@ dependencies = [
10701765 "proc-macro2",
10711766]
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
10731780[[package]]
10741781name = "range-alloc"
10751782version = "0.1.5"
......@@ -1088,10 +1795,10 @@ version = "1.1.0"
10881795source = "registry+https://github.com/rust-lang/crates.io-index"
10891796checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
10901797dependencies = [
1091 "objc2",
1798 "objc2 0.6.4",
10921799 "objc2-core-foundation",
1093 "objc2-foundation",
1094 "objc2-quartz-core",
1800 "objc2-foundation 0.3.2",
1801 "objc2-quartz-core 0.3.2",
10951802]
10961803
10971804[[package]]
......@@ -1105,13 +1812,31 @@ dependencies = [
11051812 "once_cell",
11061813]
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
11081824[[package]]
11091825name = "redox_syscall"
11101826version = "0.5.18"
11111827source = "registry+https://github.com/rust-lang/crates.io-index"
11121828checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
11131829dependencies = [
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",
11151840]
11161841
11171842[[package]]
......@@ -1121,6 +1846,7 @@ dependencies = [
11211846 "draw",
11221847 "png",
11231848 "pollster",
1849 "ui",
11241850 "wgpu",
11251851]
11261852
......@@ -1145,18 +1871,87 @@ version = "1.1.0"
11451871source = "registry+https://github.com/rust-lang/crates.io-index"
11461872checksum = "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
11481909[[package]]
11491910name = "rustversion"
11501911version = "1.0.23"
11511912source = "registry+https://github.com/rust-lang/crates.io-index"
11521913checksum = "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
11541930[[package]]
11551931name = "scopeguard"
11561932version = "1.2.0"
11571933source = "registry+https://github.com/rust-lang/crates.io-index"
11581934checksum = "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
11601955[[package]]
11611956name = "serde"
11621957version = "1.0.229"
......@@ -1187,12 +1982,34 @@ dependencies = [
11871982 "syn 3.0.6",
11881983]
11891984
1985[[package]]
1986name = "shlex"
1987version = "2.0.1"
1988source = "registry+https://github.com/rust-lang/crates.io-index"
1989checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1990
11901991[[package]]
11911992name = "simd-adler32"
11921993version = "0.3.10"
11931994source = "registry+https://github.com/rust-lang/crates.io-index"
11941995checksum = "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
11962013[[package]]
11972014name = "skrifa"
11982015version = "0.44.0"
......@@ -1224,13 +2041,47 @@ version = "1.16.2"
12242041source = "registry+https://github.com/rust-lang/crates.io-index"
12252042checksum = "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
12272078[[package]]
12282079name = "spirv"
12292080version = "0.4.0+sdk-1.4.341.0"
12302081source = "registry+https://github.com/rust-lang/crates.io-index"
12312082checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
12322083dependencies = [
1233 "bitflags",
2084 "bitflags 2.13.1",
12342085]
12352086
12362087[[package]]
......@@ -1245,6 +2096,12 @@ version = "1.1.0"
12452096source = "registry+https://github.com/rust-lang/crates.io-index"
12462097checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
12472098
2099[[package]]
2100name = "strict-num"
2101version = "0.1.1"
2102source = "registry+https://github.com/rust-lang/crates.io-index"
2103checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
2104
12482105[[package]]
12492106name = "swash"
12502107version = "0.2.10"
......@@ -1298,13 +2155,33 @@ dependencies = [
12982155 "winapi-util",
12992156]
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
13012167[[package]]
13022168name = "thiserror"
13032169version = "2.0.21"
13042170source = "registry+https://github.com/rust-lang/crates.io-index"
13052171checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
13062172dependencies = [
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",
13082185]
13092186
13102187[[package]]
......@@ -1332,6 +2209,31 @@ dependencies = [
13322209 "zune-jpeg",
13332210]
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
13352237[[package]]
13362238name = "tinystr"
13372239version = "0.8.4"
......@@ -1343,12 +2245,80 @@ dependencies = [
13432245 "zerovec",
13442246]
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
13462310[[package]]
13472311name = "unicode-ident"
13482312version = "1.0.26"
13492313source = "registry+https://github.com/rust-lang/crates.io-index"
13502314checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
13512315
2316[[package]]
2317name = "unicode-segmentation"
2318version = "1.13.3"
2319source = "registry+https://github.com/rust-lang/crates.io-index"
2320checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
2321
13522322[[package]]
13532323name = "unicode-width"
13542324version = "0.2.2"
......@@ -1367,6 +2337,25 @@ version = "0.9.5"
13672337source = "registry+https://github.com/rust-lang/crates.io-index"
13682338checksum = "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
13702359[[package]]
13712360name = "wasm-bindgen"
13722361version = "0.2.128"
......@@ -1422,6 +2411,103 @@ dependencies = [
14222411 "unicode-ident",
14232412]
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
14252511[[package]]
14262512name = "wayland-sys"
14272513version = "0.31.11"
......@@ -1444,6 +2530,16 @@ dependencies = [
14442530 "wasm-bindgen",
14452531]
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
14472543[[package]]
14482544name = "weezl"
14492545version = "0.1.12"
......@@ -1457,7 +2553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
14572553checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
14582554dependencies = [
14592555 "arrayvec",
1460 "bitflags",
2556 "bitflags 2.13.1",
14612557 "bytemuck",
14622558 "cfg-if",
14632559 "cfg_aliases",
......@@ -1489,7 +2585,7 @@ dependencies = [
14892585 "arrayvec",
14902586 "bit-set",
14912587 "bit-vec",
1492 "bitflags",
2588 "bitflags 2.13.1",
14932589 "bytemuck",
14942590 "cfg_aliases",
14952591 "document-features",
......@@ -1505,7 +2601,7 @@ dependencies = [
15052601 "raw-window-handle",
15062602 "rustc-hash",
15072603 "smallvec",
1508 "thiserror",
2604 "thiserror 2.0.21",
15092605 "wgpu-core-deps-apple",
15102606 "wgpu-core-deps-emscripten",
15112607 "wgpu-core-deps-windows-linux-android",
......@@ -1551,8 +2647,8 @@ dependencies = [
15512647 "arrayvec",
15522648 "ash",
15532649 "bit-set",
1554 "bitflags",
1555 "block2",
2650 "bitflags 2.13.1",
2651 "block2 0.6.2",
15562652 "bytemuck",
15572653 "cfg-if",
15582654 "cfg_aliases",
......@@ -1568,12 +2664,12 @@ dependencies = [
15682664 "naga",
15692665 "naga-types",
15702666 "ndk-sys",
1571 "objc2",
2667 "objc2 0.6.4",
15722668 "objc2-core-foundation",
15732669 "objc2-core-graphics",
1574 "objc2-foundation",
1575 "objc2-metal",
1576 "objc2-quartz-core",
2670 "objc2-foundation 0.3.2",
2671 "objc2-metal 0.3.2",
2672 "objc2-quartz-core 0.3.2",
15772673 "once_cell",
15782674 "ordered-float",
15792675 "parking_lot",
......@@ -1586,7 +2682,7 @@ dependencies = [
15862682 "renderdoc-sys",
15872683 "smallvec",
15882684 "static_assertions",
1589 "thiserror",
2685 "thiserror 2.0.21",
15902686 "wasm-bindgen",
15912687 "wayland-sys",
15922688 "web-sys",
......@@ -1613,7 +2709,7 @@ version = "30.0.1"
16132709source = "registry+https://github.com/rust-lang/crates.io-index"
16142710checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
16152711dependencies = [
1616 "bitflags",
2712 "bitflags 2.13.1",
16172713 "bytemuck",
16182714 "js-sys",
16192715 "log",
......@@ -1629,7 +2725,7 @@ version = "0.1.11"
16292725source = "registry+https://github.com/rust-lang/crates.io-index"
16302726checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
16312727dependencies = [
1632 "windows-sys",
2728 "windows-sys 0.61.2",
16332729]
16342730
16352731[[package]]
......@@ -1733,6 +2829,24 @@ dependencies = [
17332829 "windows-link",
17342830]
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
17362850[[package]]
17372851name = "windows-sys"
17382852version = "0.61.2"
......@@ -1742,6 +2856,22 @@ dependencies = [
17422856 "windows-link",
17432857]
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
17452875[[package]]
17462876name = "windows-threading"
17472877version = "0.2.1"
......@@ -1751,12 +2881,184 @@ dependencies = [
17512881 "windows-link",
17522882]
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
17542999[[package]]
17553000name = "writeable"
17563001version = "0.6.4"
17573002source = "registry+https://github.com/rust-lang/crates.io-index"
17583003checksum = "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
17603062[[package]]
17613063name = "xml-rs"
17623064version = "0.8.29"
platform/snow-leopard/Cargo.toml+1-1
......@@ -1,7 +1,7 @@
11# Mac OS X 10.6 work, built with cargo.sh. Its own workspace so the 10.6 target
22# never enters the main workspace's builds.
33[workspace]
4members = ["gl21", "hello", "probe", "reference"]
4members = ["hello", "probe", "reference"]
55resolver = "3"
66
77[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
1717 if [ "$prev" = -framework ]; then
1818 case "$arg" in
1919 CoreGraphics | CoreText | ImageIO | ColorSync) arg=ApplicationServices ;;
20 # Until draw's wgpu backend is optional: wgpu names Metal, which 10.6 lacks,
21 # and nothing a 10.6 binary reaches calls into it.
20 # Test binaries whose dev-dependencies take draw's wgpu backend (ui, canvas)
21 # name Metal, which 10.6 lacks; nothing they run on 10.6 calls into it.
2222 Metal) arg=Foundation ;;
2323 esac
2424 fi
platform/snow-leopard/probe/Cargo.toml+8-7
......@@ -5,20 +5,21 @@ edition = "2024"
55publish = false
66
77[dependencies]
8gl21 = { path = "../gl21" }
8draw = { path = "../../../crates/draw", default-features = false, features = ["render"] }
99objc2.workspace = true
10objc2-foundation = { workspace = true, features = ["std", "NSString", "NSAutoreleasePool", "NSObject", "NSZone", "NSGeometry", "NSDate", "NSRunLoop"] }
11objc2-app-kit = { workspace = true, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSView", "NSOpenGL", "NSOpenGLView", "NSEvent", "NSRunningApplication", "NSGraphics"] }
10objc2-foundation = { workspace = true, features = ["std", "NSString", "NSObject", "NSZone"] }
11objc2-app-kit = { workspace = true, features = ["std", "NSOpenGL"] }
1212png = "0.18"
13ui = { path = "../../../crates/ui" }
1314
1415[[bin]]
1516name = "probe"
1617path = "src/main.rs"
1718
18[[bin]]
19name = "window-probe"
20path = "src/window.rs"
21
2219[[bin]]
2320name = "render-probe"
2421path = "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 @@
1use objc2::{rc::{Retained, autoreleasepool}, runtime::NSObjectProtocol};
1use objc2::rc::{Retained, autoreleasepool};
22use objc2_foundation::NSCopying;
33use objc2_foundation::{NSObject, NSString};
44
platform/snow-leopard/probe/src/render.rs+10-89
......@@ -1,102 +1,23 @@
1//! Paints `scene` through the GL 2.1 painter into an offscreen target and writes
2//! `render-probe.png`. The vertices follow `draw::Renderer`'s quad and rounded-rectangle
3//! encoding at scale 1.
1//! Paints `scene` through `draw` over OpenGL into an offscreen target and writes
2//! `render-probe.png`.
43#![allow(deprecated)] // OpenGL is the only GPU API 10.6 has.
4mod context;
55mod 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
507fn main() {
518 objc2::rc::autoreleasepool(|_| run());
529}
5310
5411fn run() {
55 let mtm = MainThreadMarker::new().unwrap();
56 let attributes = [NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize, 24, 0];
57 let format = unsafe {
58 NSOpenGLPixelFormat::initWithAttributes(mtm.alloc(), std::ptr::NonNull::new(attributes.as_ptr().cast_mut()).unwrap())
59 }
60 .expect("pixel format");
61 let context = NSOpenGLContext::initWithFormat_shareContext(mtm.alloc(), &format, None).expect("context");
62 context.makeCurrentContext();
63
64 let painter = Painter::new(ATLAS).unwrap_or_else(|error| panic!("{error}"));
65 let target = Target::new(scene::SIZE).unwrap();
66 let (checker_size, checker) = scene::checker();
67 let image = painter.image(checker_size, &checker);
68 let mut vertices = Vec::new();
69 let mut spans = Vec::new();
70 for shape in scene::shapes() {
71 let start = vertices.len() as u32;
72 let is_image = match shape {
73 Shape::Rect { rect, color } => {
74 quad(&mut vertices, rect, WHITE_UV, color);
75 false
76 }
77 Shape::Rounded { rect, radius, stroke, colors } => {
78 rounded(&mut vertices, rect, radius, stroke, colors);
79 false
80 }
81 Shape::Image { rect } => {
82 quad(&mut vertices, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4]);
83 true
84 }
85 };
86 spans.push((start..vertices.len() as u32, is_image));
87 }
88 let batches: Vec<Batch> = spans
89 .into_iter()
90 .map(|(vertices, is_image)| Batch {
91 vertices,
92 image: is_image.then_some(&image),
93 scissor: [0, 0, scene::SIZE[0], scene::SIZE[1]],
94 })
95 .collect();
12 let _context = context::current();
13 let mut renderer = draw::Renderer::new().unwrap_or_else(|error| panic!("{error}"));
14 let target = draw::Target::new(scene::SIZE).unwrap();
15 let checker = scene::checker();
16 let primitives = scene::primitives(&checker);
9617 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();
9819 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());
10021 let file = std::fs::File::create("render-probe.png").unwrap();
10122 let mut encoder = png::Encoder::new(file, scene::SIZE[0], scene::SIZE[1]);
10223 encoder.set_color(png::ColorType::Rgba);
platform/snow-leopard/probe/src/scene.rs+57-23
......@@ -1,40 +1,74 @@
1//! One test scene, painted by `draw` over wgpu (the `reference` crate) and by the GL 2.1
2//! painter on 10.6 (`render-probe`), whose PNGs `compare.py` diffs. Device pixels,
3//! linear colours.
1//! One scene painted by `draw` over wgpu on the dev Mac (`reference`) and over OpenGL on
2//! 10.6 (`render-probe`), whose PNGs `reference --compare` diffs. Device pixels, linear
3//! colours.
4#![allow(dead_code)] // Each binary paints part of it.
5use draw::{Layer, Primitive, RasterImage, Stroke};
46
57pub const SIZE: [u32; 2] = [400, 300];
68pub const CLEAR: [f32; 4] = [0.85, 0.85, 0.85, 1.0];
79
8pub enum Shape {
9 Rect { rect: [f32; 4], color: [f32; 4] },
10 /// `stroke` is a width and whether it is dashed.
11 Rounded { rect: [f32; 4], radius: [f32; 2], stroke: Option<(f32, bool)>, colors: [[f32; 4]; 2] },
12 Image { rect: [f32; 4] },
10/// A 4×4 opaque sRGB checker, stretched and filtered by the image path.
11pub fn checker() -> RasterImage {
12 let pixels = (0..16)
13 .flat_map(|i| if (i % 4 + i / 4) % 2 == 0 { [240, 200, 40, 255] } else { [30, 120, 90, 255] })
14 .collect();
15 RasterImage::new([4, 4], pixels).unwrap()
1316}
1417
15pub fn shapes() -> Vec<Shape> {
18pub fn primitives(checker: &RasterImage) -> Vec<Primitive<'_>> {
1619 let red = [0.6, 0.05, 0.05, 1.0];
1720 let blue = [0.05, 0.1, 0.6, 1.0];
1821 let green = [0.05, 0.4, 0.1, 1.0];
1922 let ink = [0.02, 0.02, 0.02, 1.0];
20 use Shape::*;
23 use Primitive::*;
2124 vec![
2225 Rect { rect: [20.0, 20.0, 120.0, 80.0], color: red },
23 Rounded { rect: [140.0, 20.0, 260.0, 80.0], radius: [12.0, 12.0], stroke: None, colors: [blue; 2] },
24 Rounded { rect: [280.0, 20.5, 380.0, 80.25], radius: [30.0, 12.0], stroke: None, colors: [green; 2] },
25 Rounded { rect: [20.0, 100.0, 120.0, 160.0], radius: [10.0, 10.0], stroke: Some((3.0, false)), colors: [ink; 2] },
26 Rounded { rect: [140.0, 100.0, 260.0, 160.0], radius: [16.0, 16.0], stroke: Some((2.0, true)), colors: [ink; 2] },
27 Rounded { rect: [280.0, 100.0, 380.0, 160.0], radius: [8.0, 8.0], stroke: None, colors: [[0.9, 0.5, 0.1, 1.0], [0.2, 0.05, 0.4, 1.0]] },
28 Image { rect: [20.0, 180.0, 120.0, 280.0] },
26 Gradient { rect: [140.0, 20.0, 260.0, 80.0], radius: [12.0, 12.0], colors: [blue; 2] },
27 Gradient { rect: [280.0, 20.5, 380.0, 80.25], radius: [30.0, 12.0], colors: [green; 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 },
29 RoundedRect { rect: [140.0, 100.0, 260.0, 160.0], radius: [16.0, 16.0], stroke: Some(Stroke::Dashed(2.0)), color: ink },
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]] },
31 Image { image: checker, rect: [20.0, 180.0, 120.0, 280.0] },
2932 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 },
3134 ]
3235}
3336
34/// A 4×4 opaque sRGB checker, stretched and filtered by the image path.
35pub fn checker() -> ([u32; 2], Vec<u8>) {
36 let pixels = (0..16)
37 .flat_map(|i| if (i % 4 + i / 4) % 2 == 0 { [240, 200, 40, 255] } else { [30, 120, 90, 255] })
38 .collect();
39 ([4, 4], pixels)
37pub fn layer<'a>(primitives: &'a [Primitive<'a>]) -> Layer<'a> {
38 Layer { scale: 1.0, origin: [0.0; 2], clip: None, backdrop: None, motion: None, primitives }
39}
40
41/// Lines of interface text through `ui`: black on white like a Cocoa label, the same in
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()
4074}
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" }
1010wgpu = "30.0.1"
1111pollster = "0.4"
1212png = "0.18"
13ui = { path = "../../../crates/ui" }
platform/snow-leopard/reference/src/main.rs+20-31
......@@ -1,18 +1,26 @@
1//! `reference OUT.png` paints the render-probe scene through `draw::Renderer` over wgpu;
2//! `reference --compare A.png B.png DIFF.png` reports how two renders differ and writes
3//! their difference, amplified.
1//! `reference OUT.png` paints the render-probe scene through `draw` over wgpu,
2//! `reference --text SCALE OUT.png` the text-probe scene; `reference --compare A.png B.png
3//! DIFF.png` reports how two renders differ and writes their difference, amplified.
44#[path = "../../probe/src/scene.rs"]
55mod scene;
66
7use draw::{Layer, Primitive, RasterImage, Stroke};
8use scene::Shape;
9
107fn main() {
118 let args: Vec<String> = std::env::args().skip(1).collect();
129 match args.as_slice() {
1310 [flag, a, b, diff] if flag == "--compare" => compare(a, b, diff),
14 [out] => paint(out),
15 _ => panic!("usage: reference OUT.png | reference --compare A.png B.png DIFF.png"),
11 [flag, scale, out] if flag == "--text" => {
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"),
1624 }
1725}
1826
......@@ -47,31 +55,13 @@ fn write(path: &str, [width, height]: [u32; 2], pixels: &[u8]) {
4755 encoder.write_header().unwrap().write_image_data(pixels).unwrap();
4856}
4957
50fn paint(out: &str) {
58fn paint(out: &str, size: [u32; 2], clear: [f32; 4], layers: &[draw::Layer<'_>]) {
5159 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
5260 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
5361 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
5462 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
5563 let mut renderer = draw::Renderer::new(device.clone(), queue.clone(), format);
56 let (checker_size, checker) = scene::checker();
57 let image = RasterImage::new(checker_size, checker).unwrap();
58 let primitives: Vec<Primitive> = scene::shapes()
59 .into_iter()
60 .map(|shape| match shape {
61 Shape::Rect { rect, color } => Primitive::Rect { rect, color },
62 Shape::Rounded { rect, radius, stroke, colors } => match stroke {
63 None => Primitive::Gradient { rect, radius, colors },
64 Some((width, dashed)) => Primitive::RoundedRect {
65 rect,
66 radius,
67 stroke: Some(if dashed { Stroke::Dashed(width) } else { Stroke::Solid(width) }),
68 color: colors[0],
69 },
70 },
71 Shape::Image { rect } => Primitive::Image { image: &image, rect },
72 })
73 .collect();
74 let [width, height] = scene::SIZE;
64 let [width, height] = size;
7565 let texture = device.create_texture(&wgpu::TextureDescriptor {
7666 label: None,
7767 size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
......@@ -82,9 +72,8 @@ fn paint(out: &str) {
8272 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
8373 view_formats: &[],
8474 });
85 let layer = Layer { scale: 1.0, origin: [0.0; 2], clip: None, backdrop: None, primitives: &primitives };
8675 renderer
87 .draw(&texture.create_view(&Default::default()), scene::SIZE, scene::CLEAR, &[layer])
76 .draw(&texture.create_view(&Default::default()), size, clear, layers)
8877 .unwrap();
8978 let row = (width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
9079 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
......@@ -107,5 +96,5 @@ fn paint(out: &str) {
10796 device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }).unwrap();
10897 let mapped = buffer.get_mapped_range(..).unwrap();
10998 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);
111100}
platform/snow-leopard/remote.sh+69-4
......@@ -4,6 +4,14 @@
44# remote.sh run [-C DIR] BINARY [ARGS...]
55# ship to /tmp/snow-leopard, run in DIR (default there),
66# 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
715# remote.sh mirror PATH... copy repo paths to the same absolute paths there, so
816# test binaries find fixtures by CARGO_MANIFEST_DIR
917# 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}
1422here=$(cd "$(dirname "$0")" && pwd)
1523dir=/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
1752case "${1:-}" in
1853run)
1954 shift
......@@ -22,9 +57,8 @@ run)
2257 binary=$1
2358 shift
2459 name=$(basename "$binary")
25 ssh "$host" "mkdir -p $dir && cat > $dir/$name && chmod +x $dir/$name" < "$binary"
26 quoted=
27 for arg; do quoted="$quoted '$(printf %s "$arg" | sed "s/'/'\\\\''/g")'"; done
60 ship "$binary"
61 quote "$@"
2862 # A crash report lands a few seconds after the process dies.
2963 ssh "$host" "touch $dir/.start && cd '$cwd' && status=0 && $dir/$name$quoted || status=\$?
3064 if [ \$status -ge 128 ]; then
......@@ -35,6 +69,37 @@ run)
3569 fi
3670 exit \$status" < /dev/null
3771 ;;
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 ;;
38103mirror)
39104 shift
40105 root=$(cd "$here/../.." && pwd)
......@@ -54,7 +119,7 @@ sh)
54119 ssh "$host" "$@"
55120 ;;
56121*)
57 sed -n '2,12s/^# \{0,1\}//p' "$0" >&2
122 sed -n '2,20s/^# \{0,1\}//p' "$0" >&2
58123 exit 2
59124 ;;
60125esac
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) {
3737}
3838
3939void 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 @@
11#!/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."""
33import argparse
44import json
55from pathlib import Path
......@@ -7,32 +7,53 @@ import platform
77import plistlib
88import shutil
99import subprocess
10import tempfile
1011
1112parser = argparse.ArgumentParser(description=__doc__)
1213parser.add_argument('--release', action='store_true')
1314parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')
1415parser.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')
1518args = parser.parse_args()
1619if bool(args.output) != bool(args.bundle_id):
1720 parser.error('Use --output and --bundle-id together.')
1821if args.output and (args.output.suffix != '.app' or args.output.exists()):
1922 parser.error('Choose a new output path ending in .app.')
2023root = Path(__file__).resolve().parents[2]
21subprocess.run(['cargo', 'build', '-p', 'snowbound'] + (['--release'] if args.release else []), cwd=root, check=True)
22metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root))
23target = Path(metadata['target_directory'])
24profile = ['--release'] if args.release else []
25if args.snow_leopard:
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'])
2434bundle = args.output.resolve() if args.output else target / 'Snowbound.app'
2535if args.output:
2636 bundle.mkdir(parents=True, exist_ok=False)
2737binary = bundle / 'Contents/MacOS/Snowbound'
2838binary.parent.mkdir(parents=True, exist_ok=True)
2939pending = 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)
3141pending.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'
3343resources = bundle / 'Contents/Resources'
3444resources.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')
3657(bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({
3758 'CFBundleExecutable': binary.name,
3859 'CFBundleIdentifier': args.bundle_id or 'dev.snowbound.app',
......@@ -43,7 +64,9 @@ shutil.copy2(root / 'crates/snowbound/assets/icon' / f'{icon}.icns', resources /
4364 'NSHighResolutionCapable': True,
4465 'NSPrincipalClass': 'NSApplication',
4566 'CFBundleIconFile': 'Snowbound',
46}))
47subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True)
48subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True)
67} | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))
68# 10.6 runs the bundle unsigned.
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)
4972print(bundle)