authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 12:38:03-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
log14ffea224c2042a06a245dcbcc1710ce2cfd9df7
treedcfc1b9d9844066184550397301b5f6f608b5b96
parent939081be8a62e17d4e2ec4abb68c052f8e3c506d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: ui kit and the Snowbound shell around the page

The new ui crate is an immediate-mode kit after Ryan Fleury's series: builder code declares boxes every frame from application state, a cache keyed by stable ids keeps hover, press, focus, scroll and animation, and the previous frame's layout routes the frame's input before building. Sizes are solved per axis after building (fixed, label, fraction of an ancestor, sum of children) with overflow shared by strictness. Custom boxes receive their routed events and are painted by the host in the same frame; labels are shaped with parley and painted through draw. Snowbound now builds its window with it: section tabs in the top strip in the notebook's order and colours, the section's pages on the right with subpage indents, Command-F filtering with Enter to open, a conflict bar in place of the Command-Shift-K/T title prompt, and the page as a custom box. The canvas's scrollbars become ui widgets over that box; PageView reports its scroll bounds and scrolls on request. Work a frame asks for runs after it is painted. --notebook opens a notebook folder, and SNOWBOUND_REPLAY scripts input with PNG snapshots so a covered window can be exercised. onestore reads a page's list heading from its metadata, notebook's Section::pages returns outline levels, and the catalog carries section colours. Assisted-by: claude-opus-5.5

19 files changed, 2978 insertions(+), 323 deletions(-)

Cargo.lock+11
...@@ -2650,7 +2650,9 @@ dependencies = [...@@ -2650,7 +2650,9 @@ dependencies = [
2650 "objc2-foundation 0.2.2",2650 "objc2-foundation 0.2.2",
2651 "onestore",2651 "onestore",
2652 "parley",2652 "parley",
2653 "png",
2653 "pollster",2654 "pollster",
2655 "ui",
2654 "wgpu",2656 "wgpu",
2655 "winit",2657 "winit",
2656]2658]
...@@ -2930,6 +2932,15 @@ version = "1.20.1"...@@ -2930,6 +2932,15 @@ version = "1.20.1"
2930source = "registry+https://github.com/rust-lang/crates.io-index"2932source = "registry+https://github.com/rust-lang/crates.io-index"
2931checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"2933checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
29322934
2935[[package]]
2936name = "ui"
2937version = "0.1.0"
2938dependencies = [
2939 "draw",
2940 "parley",
2941 "winit",
2942]
2943
2933[[package]]2944[[package]]
2934name = "unicode-ident"2945name = "unicode-ident"
2935version = "1.0.24"2946version = "1.0.24"
crates/canvas/src/interaction/mod.rs+27-36
...@@ -6,6 +6,8 @@ pub mod accessibility;...@@ -6,6 +6,8 @@ pub mod accessibility;
6#[cfg(test)]6#[cfg(test)]
7mod profile;7mod profile;
8mod scroll;8mod scroll;
9
10pub use scroll::Scroll;
9#[cfg(test)]11#[cfg(test)]
10mod tests;12mod tests;
1113
...@@ -143,10 +145,6 @@ enum Drag {...@@ -143,10 +145,6 @@ enum Drag {
143 anchor: Selection,145 anchor: Selection,
144 unit: SelectionUnit,146 unit: SelectionUnit,
145 },147 },
146 Scrollbar {
147 axis: usize,
148 grab: f32,
149 },
150 Resize {148 Resize {
151 outline: Option<Box<TextOutline>>,149 outline: Option<Box<TextOutline>>,
152 grab: f32,150 grab: f32,
...@@ -219,6 +217,11 @@ pub struct PageView {...@@ -219,6 +217,11 @@ pub struct PageView {
219 blink_at: Instant,217 blink_at: Instant,
220}218}
221219
220/// Where a page's origin sits in a view that has not scrolled, in device pixels.
221fn home(display_scale: f32) -> [f32; 2] {
222 [48.0 * display_scale; 2]
223}
224
222impl PageView {225impl PageView {
223 /// `size` is in device pixels; `double_click` is the platform's double-click interval.226 /// `size` is in device pixels; `double_click` is the platform's double-click interval.
224 pub fn new(227 pub fn new(
...@@ -236,7 +239,7 @@ impl PageView {...@@ -236,7 +239,7 @@ impl PageView {
236 viewport: Viewport {239 viewport: Viewport {
237 size,240 size,
238 scale: display_scale * 96.0 / 72.0,241 scale: display_scale * 96.0 / 72.0,
239 origin: [48.0 * display_scale; 2],242 origin: home(display_scale),
240 },243 },
241 display_scale,244 display_scale,
242 double_click,245 double_click,
...@@ -260,6 +263,13 @@ impl PageView {...@@ -260,6 +263,13 @@ impl PageView {
260 self.object_focus = None;263 self.object_focus = None;
261 }264 }
262265
266 /// Shows another page from its top-left corner, keeping the zoom.
267 pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) {
268 self.replace(editor, scene);
269 self.viewport.origin = home(self.display_scale);
270 self.scroll().clamp(&mut self.viewport);
271 }
272
263 pub fn modifiers(&self) -> Modifiers {273 pub fn modifiers(&self) -> Modifiers {
264 self.modifiers274 self.modifiers
265 }275 }
...@@ -474,7 +484,8 @@ impl PageView {...@@ -474,7 +484,8 @@ impl PageView {
474 ];484 ];
475 }485 }
476486
477 fn scroll(&self) -> scroll::Scroll {487 /// How far the view may scroll; the current offset is `-viewport.origin`.
488 pub fn scroll(&self) -> Scroll {
478 let editable = self489 let editable = self
479 .editor490 .editor
480 .visible_outlines()491 .visible_outlines()
...@@ -507,7 +518,14 @@ impl PageView {...@@ -507,7 +518,14 @@ impl PageView {
507 scroll::Scroll::new(self.viewport, editable.chain(fixed))518 scroll::Scroll::new(self.viewport, editable.chain(fixed))
508 }519 }
509520
510 /// Everything to draw this frame, scrollbars included.521 /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`,
522 /// within the page's bounds.
523 pub fn scroll_to(&mut self, axis: usize, offset: f32) -> Result<Response> {
524 self.viewport.origin[axis] = -offset;
525 self.changed()
526 }
527
528 /// Everything to draw this frame.
511 pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> {529 pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> {
512 let preview = match &self.drag {530 let preview = match &self.drag {
513 Some(Drag::Resize {531 Some(Drag::Resize {
...@@ -538,7 +556,7 @@ impl PageView {...@@ -538,7 +556,7 @@ impl PageView {
538 }556 }
539 }),557 }),
540 };558 };
541 let mut primitives = page_primitives(559 page_primitives(
542 &self.editor,560 &self.editor,
543 self.scene.as_ref(),561 self.scene.as_ref(),
544 preview,562 preview,
...@@ -554,22 +572,13 @@ impl PageView {...@@ -554,22 +572,13 @@ impl PageView {
554 pixel: self.pixel(),572 pixel: self.pixel(),
555 colors,573 colors,
556 },574 },
557 )?;575 )
558 self.scroll()
559 .append(self.viewport, self.display_scale, &mut primitives);
560 Ok(primitives)
561 }576 }
562577
563 /// The pointer shape at the pointer's position.578 /// The pointer shape at the pointer's position.
564 pub fn cursor(&self) -> Cursor {579 pub fn cursor(&self) -> Cursor {
565 let hit = self.hit_test(self.viewport.document_point(self.pointer));580 let hit = self.hit_test(self.viewport.document_point(self.pointer));
566 let scrollbar = self
567 .scroll()
568 .hit_test(self.viewport, self.display_scale, self.pointer)
569 .is_some();
570 match (&self.drag, hit) {581 match (&self.drag, hit) {
571 (Some(Drag::Scrollbar { .. }), _) => Cursor::Default,
572 (None, _) if scrollbar => Cursor::Default,
573 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),582 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),
574 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),583 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),
575 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize,584 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize,
...@@ -638,17 +647,6 @@ impl PageView {...@@ -638,17 +647,6 @@ impl PageView {
638 self.pointer_inside = true;647 self.pointer_inside = true;
639 self.pointer = position;648 self.pointer = position;
640 match &mut self.drag {649 match &mut self.drag {
641 Some(Drag::Scrollbar { axis, grab }) => {
642 let (axis, grab) = (*axis, *grab);
643 self.scroll().drag(
644 &mut self.viewport,
645 self.display_scale,
646 axis,
647 self.pointer[axis],
648 grab,
649 );
650 self.changed()
651 }
652 Some(Drag::Text { anchor, unit }) => {650 Some(Drag::Text { anchor, unit }) => {
653 let (anchor, unit) = (*anchor, *unit);651 let (anchor, unit) = (*anchor, *unit);
654 let point = self.viewport.document_point(self.pointer);652 let point = self.viewport.document_point(self.pointer);
...@@ -700,13 +698,6 @@ impl PageView {...@@ -700,13 +698,6 @@ impl PageView {
700 })698 })
701 .map_or(1, |(_, _, count)| (count % 3) + 1);699 .map_or(1, |(_, _, count)| (count % 3) + 1);
702 self.last_click = Some((now, self.pointer, count));700 self.last_click = Some((now, self.pointer, count));
703 if let Some((axis, grab)) =
704 self.scroll()
705 .hit_test(self.viewport, self.display_scale, self.pointer)
706 {
707 self.drag = Some(Drag::Scrollbar { axis, grab });
708 return self.changed();
709 }
710 match self.hit_test(point) {701 match self.hit_test(point) {
711 Some(Hit::Date(field)) => {702 Some(Hit::Date(field)) => {
712 self.editor.finish_composition();703 self.editor.finish_composition();
crates/canvas/src/interaction/scroll.rs+3-83
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1use crate::gpu::Viewport;1use crate::gpu::Viewport;
2use draw::Primitive;
32
3/// The least and greatest scroll offsets on each axis, in device pixels: how far the
4/// view's corner may sit from the page origin.
5#[derive(Clone, Copy, Debug, PartialEq)]
4pub struct Scroll {6pub struct Scroll {
5 pub min: [f32; 2],7 pub min: [f32; 2],
6 pub max: [f32; 2],8 pub max: [f32; 2],
...@@ -30,63 +32,6 @@ impl Scroll {...@@ -30,63 +32,6 @@ impl Scroll {
30 viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]);32 viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]);
31 }33 }
32 }34 }
33
34 pub fn thumb(&self, viewport: Viewport, dpr: f32, axis: usize) -> Option<[f32; 4]> {
35 let range = self.max[axis] - self.min[axis];
36 let length = viewport.size[axis] as f32;
37 let track = length - 20.0 * dpr;
38 if range <= 0.0 || track <= 0.0 {
39 return None;
40 }
41 let size = (track * length / (length + range))
42 .max(28.0 * dpr)
43 .min(track);
44 let position = (-viewport.origin[axis] - self.min[axis]) / range;
45 let start = 4.0 * dpr + position.clamp(0.0, 1.0) * (track - size);
46 let cross = viewport.size[1 - axis] as f32 - 4.0 * dpr;
47 Some(if axis == 0 {
48 [start, cross - 6.0 * dpr, start + size, cross]
49 } else {
50 [cross - 6.0 * dpr, start, cross, start + size]
51 })
52 }
53
54 pub fn drag(&self, viewport: &mut Viewport, dpr: f32, axis: usize, pointer: f32, grab: f32) {
55 let Some(rect) = self.thumb(*viewport, dpr, axis) else {
56 return;
57 };
58 let travel = viewport.size[axis] as f32 - 20.0 * dpr - (rect[axis + 2] - rect[axis]);
59 if travel > 0.0 {
60 let fraction = ((pointer - grab - 4.0 * dpr) / travel).clamp(0.0, 1.0);
61 viewport.origin[axis] =
62 -(self.min[axis] + fraction * (self.max[axis] - self.min[axis]));
63 }
64 }
65
66 pub fn hit_test(&self, viewport: Viewport, dpr: f32, point: [f32; 2]) -> Option<(usize, f32)> {
67 (0..2).find_map(|axis| {
68 let rect = self.thumb(viewport, dpr, axis)?;
69 let inset = 3.0 * dpr;
70 ((rect[0] - inset..=rect[2] + inset).contains(&point[0])
71 && (rect[1] - inset..=rect[3] + inset).contains(&point[1]))
72 .then_some((axis, point[axis] - rect[axis]))
73 })
74 }
75
76 pub fn append(&self, viewport: Viewport, dpr: f32, primitives: &mut Vec<Primitive<'_>>) {
77 for axis in 0..2 {
78 if let Some(rect) = self.thumb(viewport, dpr, axis) {
79 let start = viewport.document_point([rect[0], rect[1]]);
80 let end = viewport.document_point([rect[2], rect[3]]);
81 primitives.push(Primitive::RoundedRect {
82 rect: [start[0], start[1], end[0], end[1]],
83 radius: [3.0 * dpr / viewport.scale; 2],
84 stroke: None,
85 color: [0.18, 0.18, 0.18, 0.45],
86 });
87 }
88 }
89 }
90}35}
9136
92#[cfg(test)]37#[cfg(test)]
...@@ -107,29 +52,4 @@ mod tests {...@@ -107,29 +52,4 @@ mod tests {
107 scroll.clamp(&mut viewport);52 scroll.clamp(&mut viewport);
108 assert_eq!(viewport.origin, [-1272.0, -1872.0]);53 assert_eq!(viewport.origin, [-1272.0, -1872.0]);
109 }54 }
110
111 #[test]
112 fn small_page_has_no_scrollbars_and_thumb_drag_reaches_endpoints() {
113 for dpr in [1.0, 2.0] {
114 let mut viewport = Viewport {
115 size: [(800.0 * dpr) as u32, (600.0 * dpr) as u32],
116 scale: dpr * 4.0 / 3.0,
117 origin: [48.0 * dpr; 2],
118 };
119 let scroll = Scroll::new(viewport, [[0.0, 0.0, 72.0, 14.0]].into_iter());
120 assert!(scroll.thumb(viewport, dpr, 0).is_none());
121 assert!(scroll.thumb(viewport, dpr, 1).is_none());
122 let scroll = Scroll::new(viewport, [[0.0, 0.0, 2000.0, 2000.0]].into_iter());
123 for axis in 0..2 {
124 scroll.drag(&mut viewport, dpr, axis, -1000.0, 3.0 * dpr);
125 assert_eq!(-viewport.origin[axis], scroll.min[axis]);
126 let rect = scroll.thumb(viewport, dpr, axis).unwrap();
127 assert!((rect[axis] - 4.0 * dpr).abs() < 0.001);
128 scroll.drag(&mut viewport, dpr, axis, 10000.0, 3.0 * dpr);
129 assert_eq!(-viewport.origin[axis], scroll.max[axis]);
130 let rect = scroll.thumb(viewport, dpr, axis).unwrap();
131 assert!((rect[axis + 2] - (viewport.size[axis] as f32 - 16.0 * dpr)).abs() < 0.001);
132 }
133 }
134 }
135}55}
crates/notebook/examples/session_client.rs+4-1
...@@ -134,7 +134,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -134,7 +134,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
134 );134 );
135 let pages = section.pages()?;135 let pages = section.pages()?;
136 let mut queued = Vec::new();136 let mut queued = Vec::new();
137 for (space, title) in pages.iter().filter(|(_, title)| title.starts_with("Move")) {137 for (space, title, _) in pages
138 .iter()
139 .filter(|(_, title, _)| title.starts_with("Move"))
140 {
138 let before = section.page(*space)?;141 let before = section.page(*space)?;
139 let mut after = before.clone();142 let mut after = before.clone();
140 edit(&mut after, label);143 edit(&mut after, label);
crates/notebook/src/discover.rs+10-5
...@@ -28,6 +28,8 @@ pub enum SectionState {...@@ -28,6 +28,8 @@ pub enum SectionState {
28 Readable {28 Readable {
29 /// An explicit SectionDisplayName; otherwise use the current filename without its extension.29 /// An explicit SectionDisplayName; otherwise use the current filename without its extension.
30 name: Option<String>,30 name: Option<String>,
31 /// The tab colour as a COLORREF; OneNote assigns one when absent.
32 color: Option<u32>,
31 },33 },
32 Locked,34 Locked,
33 Unreadable(onestore::Error),35 Unreadable(onestore::Error),
...@@ -267,18 +269,21 @@ fn scan(...@@ -267,18 +269,21 @@ fn scan(
267 }269 }
268 index.validate_current()?;270 index.validate_current()?;
269 document.pages()?;271 document.pages()?;
270 let name = revision272 let (name, color) = revision
271 .roots273 .roots
272 .get(&2)274 .get(&2)
273 .and_then(|id| revision.nodes.get(id))275 .and_then(|id| revision.nodes.get(id))
274 .and_then(|node| match &node.kind {276 .map_or((None, None), |node| match &node.kind {
275 Kind::SectionMetadata { name, .. } => name.clone(),277 Kind::SectionMetadata { name, color } => (name.clone(), *color),
276 _ => None,278 _ => (None, None),
277 });279 });
278 result.sections.push(Section {280 result.sections.push(Section {
279 path: child.clone(),281 path: child.clone(),
280 file_id,282 file_id,
281 state: SectionState::Readable { name },283 state: SectionState::Readable {
284 name,
285 color: color.filter(|color| *color != 0xffff_ffff),
286 },
282 });287 });
283 } else {288 } else {
284 index.validate_current()?;289 index.validate_current()?;
crates/notebook/src/session.rs+17-5
...@@ -869,16 +869,28 @@ impl Section {...@@ -869,16 +869,28 @@ impl Section {
869 Ok(Store::parse(&self.replica.snapshot()?)?.header.file_id)869 Ok(Store::parse(&self.replica.snapshot()?)?.header.file_id)
870 }870 }
871871
872 /// Page spaces and titles in section order, from the local working image.872 /// Page spaces, titles and outline levels (1 at the top) in section order, from the
873 pub fn pages(&self) -> Result<Vec<(ExGuid, String)>> {873 /// local working image.
874 pub fn pages(&self) -> Result<Vec<(ExGuid, String, u32)>> {
874 let snapshot = self.replica.snapshot()?;875 let snapshot = self.replica.snapshot()?;
875 let store = Store::parse(&snapshot)?;876 let store = Store::parse(&snapshot)?;
876 let index = RevisionIndex::parse(&store)?;877 let index = RevisionIndex::parse(&store)?;
877 let document = Document::parse(&index)?;878 let document = Document::parse(&index)?;
878 document879 let pages = document.pages()?;
879 .pages()?880 let mut spaces = std::collections::BTreeSet::new();
881 pages
880 .into_iter()882 .into_iter()
881 .map(|(space, _)| Ok((space, Page::from_space(&document, space)?.title)))883 .map(|(space, page)| {
884 if !spaces.insert(space) {
885 return Err(onestore::Error {
886 offset: 0,
887 message: "Choose an object space containing one active page",
888 }
889 .into());
890 }
891 let (title, level) = Page::heading(document.active(space)?, page);
892 Ok((space, title, level))
893 })
882 .collect()894 .collect()
883 }895 }
884896
crates/notebook/tests/links.rs+1-1
...@@ -31,7 +31,7 @@ fn internal_links_find_their_page_by_identity() {...@@ -31,7 +31,7 @@ fn internal_links_find_their_page_by_identity() {
31 .unwrap();31 .unwrap();
32 let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap();32 let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap();
33 let section = notebook.section("First.one", || {}).unwrap();33 let section = notebook.section("First.one", || {}).unwrap();
34 let (space, _) = section.pages().unwrap()[0];34 let (space, _, _) = section.pages().unwrap()[0];
35 let page = section.page(space).unwrap();35 let page = section.page(space).unwrap();
36 section.close().unwrap();36 section.close().unwrap();
37 let target = LinkTarget::Page {37 let target = LinkTarget::Page {
crates/notebook/tests/session.rs+1-1
...@@ -259,7 +259,7 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() {...@@ -259,7 +259,7 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() {
259 assert_eq!(catalog.groups[0].sections.len(), 1);259 assert_eq!(catalog.groups[0].sections.len(), 1);
260 let path = catalog.groups[0].sections[0].path.clone();260 let path = catalog.groups[0].sections[0].path.clone();
261 let section = notebook.section(&path, || {}).unwrap();261 let section = notebook.section(&path, || {}).unwrap();
262 let (space, _) = section.pages().unwrap()[0];262 let (space, _, _) = section.pages().unwrap()[0];
263 let page = section.page(space).unwrap();263 let page = section.page(space).unwrap();
264 let text = first_text(&page);264 let text = first_text(&page);
265 assert_eq!(265 assert_eq!(
crates/onestore/src/page/mod.rs+17
...@@ -493,6 +493,23 @@ impl Page {...@@ -493,6 +493,23 @@ impl Page {
493 })493 })
494 }494 }
495495
496 /// The title and outline level (1 at the top) a section's page list shows, read from
497 /// the page's metadata without building its content.
498 pub fn heading(revision: &Revision<'_>, id: ExGuid) -> (String, u32) {
499 let level = revision
500 .roots
501 .get(&2)
502 .and_then(|id| revision.nodes.get(id))
503 .and_then(|node| match node.kind {
504 Kind::Metadata { level, .. } => level,
505 _ => None,
506 });
507 (
508 page_title(revision, id).unwrap_or_default().to_owned(),
509 level.unwrap_or(1),
510 )
511 }
512
496 /// The single active page declared in one page object space.513 /// The single active page declared in one page object space.
497 pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result<Self, Error> {514 pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result<Self, Error> {
498 let pages = document.pages_in(space)?;515 let pages = document.pages_in(space)?;
crates/snowbound/Cargo.toml+2
...@@ -7,6 +7,8 @@ publish = false...@@ -7,6 +7,8 @@ publish = false
7[dependencies]7[dependencies]
8canvas = { path = "../canvas", features = ["interaction"] }8canvas = { path = "../canvas", features = ["interaction"] }
9draw = { path = "../draw" }9draw = { path = "../draw" }
10ui = { path = "../ui" }
11png = "0.18"
10onestore = { path = "../onestore" }12onestore = { path = "../onestore" }
11notebook = { path = "../notebook" }13notebook = { path = "../notebook" }
12pollster = "0.4"14pollster = "0.4"
crates/snowbound/src/main.rs+1065-188
...@@ -12,19 +12,33 @@ use canvas::{...@@ -12,19 +12,33 @@ use canvas::{
12 layout::TextEngine,12 layout::TextEngine,
13};13};
14use draw::Renderer;14use draw::Renderer;
15use onestore::ExGuid;
15use onestore::document::Format;16use onestore::document::Format;
16use onestore::page::Page;17use onestore::page::Page;
17use onestore::page::text::Paragraph;18use onestore::page::text::Paragraph;
18use std::{error::Error, path::PathBuf, sync::Arc, time::Instant};19use std::{
20 error::Error,
21 path::{Path, PathBuf},
22 sync::Arc,
23 time::Instant,
24};
25use ui::{Axis, Flags, Id, Spec, Theme, Ui, fill, fit, px};
19use winit::{26use winit::{
20 application::ApplicationHandler,27 application::ApplicationHandler,
21 dpi::{LogicalSize, PhysicalPosition, PhysicalSize},28 dpi::{LogicalSize, PhysicalPosition, PhysicalSize},
22 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},29 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
23 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},30 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},
24 keyboard::{Key, ModifiersState, NamedKey},31 keyboard::{Key, ModifiersState, NamedKey},
32 platform::macos::WindowAttributesExtMacOS,
25 window::{CursorIcon, Window, WindowId},33 window::{CursorIcon, Window, WindowId},
26};34};
2735
36/// Height of the window's top strip, which holds the traffic lights and section tabs.
37const STRIP: f32 = 38.0;
38/// Room the traffic lights take at the strip's leading edge.
39const LIGHTS: f32 = 78.0;
40const PAGE_LIST: f32 = 240.0;
41
28#[derive(Debug)]42#[derive(Debug)]
29enum UserEvent {43enum UserEvent {
30 Quit,44 Quit,
...@@ -32,6 +46,15 @@ enum UserEvent {...@@ -32,6 +46,15 @@ enum UserEvent {
32 Accessibility(accesskit_winit::Event),46 Accessibility(accesskit_winit::Event),
33 /// The section's synchronization thread reported an event.47 /// The section's synchronization thread reported an event.
34 Sync,48 Sync,
49 /// Scripted input from `SNOWBOUND_REPLAY`.
50 Replay(Replay),
51}
52
53#[derive(Debug)]
54enum Replay {
55 Input(ui::Event),
56 /// Paints the next frame into a PNG as well as the window.
57 Snapshot(PathBuf),
35}58}
3659
37impl From<accesskit_winit::Event> for UserEvent {60impl From<accesskit_winit::Event> for UserEvent {
...@@ -116,37 +139,109 @@ enum Input {...@@ -116,37 +139,109 @@ enum Input {
116 title: String,139 title: String,
117 cache: PathBuf,140 cache: PathBuf,
118 },141 },
142 Notebook {
143 root: PathBuf,
144 cache: PathBuf,
145 },
119}146}
120147
121/// The opened section and the stored model the editor's page was loaded from.148/// A section tab: where the section opens from and how it is labelled.
149struct Tab {
150 /// The notebook catalog path, or the file of a section opened on its own.
151 path: String,
152 name: String,
153 /// COLORREF.
154 color: Option<u32>,
155}
156
157/// The sections the tabs offer.
158struct Library {
159 notebook: Option<notebook::session::Notebook>,
160 tabs: Vec<Tab>,
161 cache: PathBuf,
162}
163
164impl Library {
165 fn open(
166 &self,
167 tab: usize,
168 proxy: EventLoopProxy<UserEvent>,
169 ) -> Result<notebook::session::Section, Box<dyn Error>> {
170 let notify = move || {
171 let _ = proxy.send_event(UserEvent::Sync);
172 };
173 let path = &self.tabs[tab].path;
174 Ok(match &self.notebook {
175 Some(notebook) => notebook.section(path, notify)?,
176 None => notebook::session::Section::open(path, &self.cache, notify)?,
177 })
178 }
179}
180
181/// The open section and the stored model the editor's page was loaded from.
122struct Session {182struct Session {
123 section: notebook::session::Section,183 section: notebook::session::Section,
124 space: onestore::ExGuid,184 tab: usize,
185 /// Spaces, titles and outline levels in section order.
186 pages: Vec<(ExGuid, String, u32)>,
187 space: ExGuid,
125 before: Page,188 before: Page,
126 title: String,
127 status: &'static str,189 status: &'static str,
190 /// The editor's page has an unreviewed conflict with another machine's change.
191 conflict: bool,
128}192}
129193
130impl Session {194impl Session {
131 fn window_title(&self) -> String {195 fn title(&self) -> &str {
132 let file = self196 self.pages
197 .iter()
198 .find(|(space, ..)| *space == self.space)
199 .map_or("", |(_, title, _)| title)
200 }
201
202 fn refresh_conflict(&mut self) -> Result<(), Box<dyn Error>> {
203 self.conflict = self
133 .section204 .section
134 .file()205 .conflicts()?
135 .file_name()206 .iter()
136 .map(|name| name.to_string_lossy().into_owned())207 .any(|(edit, _)| edit.space == self.space);
137 .unwrap_or_default();208 Ok(())
138 format!("{} · {file}{}", self.title, self.status)
139 }209 }
140}210}
141211
212/// Work the interface asked for, done after the frame is built.
213enum Command {
214 OpenSection(usize),
215 OpenPage(ExGuid),
216 Resolve { keep_mine: bool },
217 Page(Request),
218}
219
142struct State {220struct State {
143 window: Arc<Window>,221 window: Arc<Window>,
222 proxy: EventLoopProxy<UserEvent>,
144 instance: wgpu::Instance,223 instance: wgpu::Instance,
145 surface: wgpu::Surface<'static>,224 surface: wgpu::Surface<'static>,
146 config: wgpu::SurfaceConfiguration,225 config: wgpu::SurfaceConfiguration,
147 renderer: Renderer,226 renderer: Renderer,
227 ui: Ui,
148 view: PageView,228 view: PageView,
229 library: Option<Library>,
149 session: Option<Session>,230 session: Option<Session>,
231 /// Text filtering the page list.
232 filter: String,
233 commands: Vec<Command>,
234 /// The page changed during this frame, to be saved and announced after it.
235 changed: bool,
236 /// Whether the page last heard it had keyboard focus.
237 page_focused: bool,
238 window_focused: bool,
239 /// The pointer in logical pixels, for window drags from the strip.
240 pointer: [f32; 2],
241 /// When the strip was last pressed, to zoom on a double click.
242 strip_press: Option<Instant>,
243 /// Where a replay asked the next frame to be written.
244 snapshot: Option<PathBuf>,
150 initial: Vec<(onestore::ExGuid, TextDocument)>,245 initial: Vec<(onestore::ExGuid, TextDocument)>,
151 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,246 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,
152 initial_date: Option<u64>,247 initial_date: Option<u64>,
...@@ -157,6 +252,19 @@ struct State {...@@ -157,6 +252,19 @@ struct State {
157 accessibility: accessibility::Accessibility,252 accessibility: accessibility::Accessibility,
158}253}
159254
255fn strip() -> Id {
256 Id::ROOT.child("strip")
257}
258
259fn page() -> Id {
260 Id::ROOT.child("page")
261}
262
263/// The field filtering the page list.
264fn filter() -> Id {
265 Id::ROOT.child("filter")
266}
267
160impl State {268impl State {
161 async fn new(269 async fn new(
162 event_loop: &ActiveEventLoop,270 event_loop: &ActiveEventLoop,
...@@ -168,6 +276,9 @@ impl State {...@@ -168,6 +276,9 @@ impl State {
168 event_loop.create_window(276 event_loop.create_window(
169 Window::default_attributes()277 Window::default_attributes()
170 .with_visible(false)278 .with_visible(false)
279 .with_titlebar_transparent(true)
280 .with_title_hidden(true)
281 .with_fullsize_content_view(true)
171 .with_title(match &input {282 .with_title(match &input {
172 Input::Notes {283 Input::Notes {
173 reference: Some(page),284 reference: Some(page),
...@@ -178,8 +289,9 @@ impl State {...@@ -178,8 +289,9 @@ impl State {
178 reference: None, ..289 reference: None, ..
179 } => "Untitled · Temporary page".into(),290 } => "Untitled · Temporary page".into(),
180 Input::Section { title, .. } => title.clone(),291 Input::Section { title, .. } => title.clone(),
292 Input::Notebook { root, .. } => root.display().to_string(),
181 })293 })
182 .with_inner_size(LogicalSize::new(1000.0, 720.0)),294 .with_inner_size(LogicalSize::new(1180.0, 760.0)),
183 )?,295 )?,
184 );296 );
185 macos::install_text_input(&window);297 macos::install_text_input(&window);
...@@ -210,6 +322,7 @@ impl State {...@@ -210,6 +322,7 @@ impl State {
210 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;322 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;
211 eprintln!("Using {} for {target}", path.display());323 eprintln!("Using {} for {target}", path.display());
212 }324 }
325 let mut library = None;
213 let mut session = None;326 let mut session = None;
214 let (editor, scene) = match input {327 let (editor, scene) = match input {
215 Input::Notes {328 Input::Notes {
...@@ -230,29 +343,47 @@ impl State {...@@ -230,29 +343,47 @@ impl State {
230 (editor, Some((scene, [0.0; 2])))343 (editor, Some((scene, [0.0; 2])))
231 }344 }
232 Input::Section { file, title, cache } => {345 Input::Section { file, title, cache } => {
233 let section = notebook::session::Section::open(&file, cache, move || {346 let opened = Library {
234 let _ = proxy.send_event(UserEvent::Sync);347 notebook: None,
235 })?;348 tabs: vec![Tab {
236 let (space, _) = section349 name: file
350 .file_stem()
351 .map(|name| name.to_string_lossy().into_owned())
352 .unwrap_or_default(),
353 path: file.to_string_lossy().into_owned(),
354 color: None,
355 }],
356 cache,
357 };
358 let section = opened.open(0, proxy.clone())?;
359 let space = section
237 .pages()?360 .pages()?
238 .into_iter()361 .into_iter()
239 .find(|(_, candidate)| *candidate == title)362 .find(|(_, candidate, _)| *candidate == title)
240 .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?;363 .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?
241 let before = section.page(space)?;364 .0;
242 let (scene, editor) = PageScene::from_page(before.clone(), &mut engine)?;365 library = Some(opened);
243 session = Some(Session {366 let (scene, editor) =
244 section,367 open_session(section, 0, Some(space), &mut engine, &mut session)?;
245 space,368 (editor, Some((scene, [0.0; 2])))
246 before,369 }
247 title,370 Input::Notebook { root, cache } => {
248 status: "",371 let notebook = notebook::session::Notebook::open(&root, &cache)?;
249 });372 let tabs = tabs(&notebook);
373 if tabs.is_empty() {
374 return Err(format!("No readable sections in {}", root.display()).into());
375 }
376 let opened = Library {
377 notebook: Some(notebook),
378 tabs,
379 cache,
380 };
381 let section = opened.open(0, proxy.clone())?;
382 library = Some(opened);
383 let (scene, editor) = open_session(section, 0, None, &mut engine, &mut session)?;
250 (editor, Some((scene, [0.0; 2])))384 (editor, Some((scene, [0.0; 2])))
251 }385 }
252 };386 };
253 if let Some(session) = &session {
254 window.set_title(&session.window_title());
255 }
256 let initial_date = editor.date().map(|date| date.timestamp());387 let initial_date = editor.date().map(|date| date.timestamp());
257 let initial_layouts = editor388 let initial_layouts = editor
258 .object_layouts()389 .object_layouts()
...@@ -265,15 +396,18 @@ impl State {...@@ -265,15 +396,18 @@ impl State {
265 .collect();396 .collect();
266 let dpr = window.scale_factor() as f32;397 let dpr = window.scale_factor() as f32;
267 window.set_ime_allowed(true);398 window.set_ime_allowed(true);
268 window.set_cursor(CursorIcon::Text);
269 window.request_redraw();399 window.request_redraw();
270 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());400 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());
271 Ok(Self {401 let mut ui = Ui::new(Theme::dark());
402 ui.set_focus(Some(page()));
403 let state = Self {
272 window,404 window,
405 proxy,
273 instance,406 instance,
274 surface,407 surface,
275 config,408 config,
276 renderer,409 renderer,
410 ui,
277 view: PageView::new(411 view: PageView::new(
278 editor,412 editor,
279 engine,413 engine,
...@@ -282,7 +416,16 @@ impl State {...@@ -282,7 +416,16 @@ impl State {
282 dpr,416 dpr,
283 macos::double_click_interval(),417 macos::double_click_interval(),
284 ),418 ),
419 library,
285 session,420 session,
421 filter: String::new(),
422 commands: Vec::new(),
423 changed: false,
424 page_focused: true,
425 window_focused: true,
426 pointer: [0.0; 2],
427 strip_press: None,
428 snapshot: None,
286 initial,429 initial,
287 initial_date,430 initial_date,
288 initial_layouts,431 initial_layouts,
...@@ -291,66 +434,513 @@ impl State {...@@ -291,66 +434,513 @@ impl State {
291 clipboard: arboard::Clipboard::new()?,434 clipboard: arboard::Clipboard::new()?,
292 access_adapter,435 access_adapter,
293 accessibility: accessibility::Accessibility::default(),436 accessibility: accessibility::Accessibility::default(),
294 })437 };
438 state.title();
439 Ok(state)
295 }440 }
296441
297 /// Carries out what a page event asked of the platform and follows its changes: the442 /// Builds, lays out and paints one frame, then does what it asked for and asks for the
298 /// input method's position, accessibility, saving, the window title and a repaint.443 /// frame that shows the result.
299 fn respond(&mut self, response: Response) -> Result<(), Box<dyn Error>> {444 fn frame(&mut self) -> Result<(), Box<dyn Error>> {
300 match response.request {445 let size = self.window.inner_size();
301 Some(Request::EditDate(field)) => self.edit_date(field)?,446 let scale = self.window.scale_factor() as f32;
302 Some(Request::Copy(text)) => self.clipboard.set_text(text)?,447 self.ui.begin(
303 Some(Request::Paste) => {448 [size.width as f32 / scale, size.height as f32 / scale],
304 let text = self.clipboard.get_text()?;449 scale,
305 let response = self.view.commit_text(text)?;450 Instant::now(),
306 self.respond(response)?;451 );
452 self.build()?;
453 self.ui.end();
454 if let Some(rect) = self.ui.rect(page()) {
455 let size = [
456 ((rect[2] - rect[0]) * scale).round() as u32,
457 ((rect[3] - rect[1]) * scale).round() as u32,
458 ];
459 if size != self.view.viewport.size {
460 let response = self.view.resized(size)?;
461 self.respond(response);
307 }462 }
308 Some(Request::CharacterPalette) => macos::show_character_palette(),
309 None => {}
310 }463 }
311 if self.ime_allowed != self.view.accepts_text() {464 self.window.set_cursor(
312 self.ime_allowed = self.view.accepts_text();465 self.ui
313 self.window.set_ime_allowed(self.ime_allowed);466 .cursor()
467 .unwrap_or_else(|| cursor_icon(self.view.cursor())),
468 );
469 let ime = if self.ui.focused() == Some(page()) {
470 self.view.accepts_text()
471 } else {
472 self.ui.focused() == Some(filter())
473 };
474 if self.ime_allowed != ime {
475 self.ime_allowed = ime;
476 self.window.set_ime_allowed(ime);
477 }
478 self.draw()?;
479 let commands = std::mem::take(&mut self.commands);
480 let follow = !commands.is_empty() || self.ui.wants_frame();
481 for command in commands {
482 self.apply(command)?;
314 }483 }
315 self.window.set_cursor(cursor_icon(self.view.cursor()));484 if std::mem::take(&mut self.changed) {
316 if response.changed {485 self.after_edit()?;
317 let [x0, y0, x1, y1] = self.view.caret_area()?;
318 self.window.set_ime_cursor_area(
319 PhysicalPosition::new(x0, y0),
320 PhysicalSize::new(x1 - x0, y1 - y0),
321 );
322 self.update_accessibility()?;
323 self.persist()?;
324 self.title_from_page();
325 }486 }
326 if response.redraw {487 if follow {
327 self.window.request_redraw();488 self.window.request_redraw();
328 }489 }
329 Ok(())490 Ok(())
330 }491 }
331492
332 /// A temporary page's window takes its title's first line.493 fn build(&mut self) -> Result<(), Box<dyn Error>> {
333 fn title_from_page(&self) {494 let theme = self.ui.theme.clone();
334 let editor = &self.view.editor;495 self.ui.open_as(
335 if self.session.is_some() || !editor.active_outline().title {496 strip(),
497 Spec {
498 flags: Flags::CLICKABLE,
499 size: [fill(), px(STRIP)],
500 fill: Some(theme.strip),
501 pad: [0.0, 8.0],
502 gap: 2.0,
503 ..Spec::default()
504 },
505 );
506 self.ui.leaf(
507 "lights",
508 Spec {
509 size: [px(LIGHTS), px(1.0)],
510 ..Spec::default()
511 },
512 );
513 match (&self.library, &self.session) {
514 (Some(library), Some(session)) => {
515 for (index, tab) in library.tabs.iter().enumerate() {
516 let active = index == session.tab;
517 let id = self.ui.open(
518 ("tab", index),
519 Spec {
520 flags: Flags::CLICKABLE,
521 size: [fit(), px(STRIP - 8.0)],
522 text: Some(&tab.name),
523 color: Some(if active { theme.text } else { theme.text_dim }),
524 fill: Some(if active { theme.base } else { theme.strip }),
525 hover_fill: (!active).then_some(theme.chip),
526 radius: 6.0,
527 pad: [14.0 + 14.0, 0.0],
528 center: true,
529 ..Spec::default()
530 },
531 );
532 let swatch = tab.color.map_or(theme.text_dim, canvas::gpu::colorref);
533 let middle = (STRIP - 8.0) / 2.0;
534 self.ui
535 .mark([12.0, middle - 4.0, 20.0, middle + 4.0], swatch);
536 self.ui.close();
537 if self.ui.signal(id).clicked && !active {
538 self.commands.push(Command::OpenSection(index));
539 }
540 }
541 }
542 _ => {
543 self.ui.leaf(
544 "tab",
545 Spec {
546 size: [fit(), px(STRIP - 8.0)],
547 text: Some("Temporary page"),
548 fill: Some(theme.base),
549 radius: 6.0,
550 pad: [16.0, 0.0],
551 ..Spec::default()
552 },
553 );
554 }
555 }
556 self.ui.leaf(
557 "space",
558 Spec {
559 size: [fill(), px(1.0)],
560 ..Spec::default()
561 },
562 );
563 if let Some(session) = &self.session {
564 self.ui.leaf(
565 "status",
566 Spec {
567 size: [fit(), px(STRIP - 8.0)],
568 text: Some(session.status),
569 color: Some(theme.text_dim),
570 pad: [14.0, 0.0],
571 ..Spec::default()
572 },
573 );
574 }
575 self.ui.close();
576
577 self.ui.open(
578 "body",
579 Spec {
580 size: [fill(), fill()],
581 ..Spec::default()
582 },
583 );
584 self.ui.open(
585 "column",
586 Spec {
587 axis: Axis::Y,
588 size: [fill(), fill()],
589 ..Spec::default()
590 },
591 );
592 if self
593 .session
594 .as_ref()
595 .is_some_and(|session| session.conflict)
596 {
597 self.conflict_bar(&theme);
598 }
599 self.ui.open_as(
600 page(),
601 Spec {
602 flags: Flags::CUSTOM | Flags::FOCUSABLE,
603 size: [fill(), fill()],
604 fill: Some([1.0; 4]),
605 ..Spec::default()
606 },
607 );
608 let scroll = self.view.scroll();
609 for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() {
610 if let Some(offset) = ui::scrollbar(
611 &mut self.ui,
612 index,
613 axis,
614 -self.view.viewport.origin[index],
615 [scroll.min[index], scroll.max[index]],
616 self.view.viewport.size[index] as f32,
617 [0.18, 0.18, 0.18, 0.45],
618 ) {
619 let response = self.view.scroll_to(index, offset)?;
620 self.respond(response);
621 }
622 }
623 self.ui.close();
624 let signal = self.ui.signal(page());
625 let focused = self.window_focused && self.ui.focused() == Some(page());
626 if focused != self.page_focused {
627 self.page_focused = focused;
628 let response = self.view.focus_changed(focused)?;
629 self.respond(response);
630 }
631 self.page_events(signal.events)?;
632 self.ui.close();
633 if self.session.is_some() {
634 self.ui.leaf(
635 "separator",
636 Spec {
637 size: [px(1.0), fill()],
638 fill: Some(theme.separator),
639 ..Spec::default()
640 },
641 );
642 self.page_list(&theme);
643 }
644 self.ui.close();
645 Ok(())
646 }
647
648 fn conflict_bar(&mut self, theme: &Theme) {
649 self.ui.open(
650 "conflict",
651 Spec {
652 size: [fill(), px(40.0)],
653 fill: Some(theme.panel),
654 pad: [12.0, 7.0],
655 gap: 8.0,
656 ..Spec::default()
657 },
658 );
659 self.ui.leaf(
660 "message",
661 Spec {
662 size: [fit(), px(26.0)],
663 text: Some("This page also changed on another computer."),
664 ..Spec::default()
665 },
666 );
667 self.ui.leaf(
668 "space",
669 Spec {
670 size: [fill(), px(1.0)],
671 ..Spec::default()
672 },
673 );
674 if ui::button(&mut self.ui, "mine", "Keep mine").clicked {
675 self.commands.push(Command::Resolve { keep_mine: true });
676 }
677 if ui::button(&mut self.ui, "theirs", "Keep theirs").clicked {
678 self.commands.push(Command::Resolve { keep_mine: false });
679 }
680 self.ui.close();
681 }
682
683 fn page_list(&mut self, theme: &Theme) {
684 let Some(session) = &self.session else {
336 return;685 return;
686 };
687 self.ui.open(
688 "panel",
689 Spec {
690 axis: Axis::Y,
691 size: [px(PAGE_LIST), fill()],
692 fill: Some(theme.panel),
693 ..Spec::default()
694 },
695 );
696 self.ui.open(
697 "pages",
698 Spec {
699 flags: Flags::SCROLL | Flags::CLIP,
700 axis: Axis::Y,
701 size: [fill(), fill()],
702 pad: [6.0, 6.0],
703 gap: 1.0,
704 ..Spec::default()
705 },
706 );
707 let query = self.filter.to_lowercase();
708 let mut first = None;
709 for (space, title, level) in &session.pages {
710 if !title.to_lowercase().contains(&query) {
711 continue;
712 }
713 first.get_or_insert(*space);
714 let selected = *space == session.space;
715 let signal = self.ui.leaf(
716 space,
717 Spec {
718 flags: Flags::CLICKABLE,
719 size: [fill(), px(26.0)],
720 text: Some(if title.is_empty() {
721 "Untitled page"
722 } else {
723 title
724 }),
725 color: Some(if selected || !title.is_empty() {
726 theme.text
727 } else {
728 theme.text_dim
729 }),
730 fill: selected.then_some(theme.accent),
731 hover_fill: (!selected).then_some(theme.hover),
732 hover_border: (!selected).then_some(theme.accent),
733 radius: 4.0,
734 pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0],
735 ..Spec::default()
736 },
737 );
738 if signal.clicked && !selected {
739 self.commands.push(Command::OpenPage(*space));
740 }
337 }741 }
338 let title = editor742 self.ui.close();
339 .active_outline()743 let placeholder = format!("Filter {} pages…", session.pages.len());
340 .document()744 self.ui.open(
341 .paragraphs()745 "footer",
342 .next()746 Spec {
343 .unwrap()747 size: [fill(), px(38.0)],
344 .text()748 pad: [6.0, 6.0],
345 .split(['\u{000b}', '\n', '\r'])749 ..Spec::default()
346 .next()750 },
347 .unwrap();751 );
348 let title = if title.trim().is_empty() {752 let before = self.filter.clone();
349 "Untitled"753 let signal = ui::text_field(
350 } else {754 &mut self.ui,
351 title755 filter(),
756 &mut self.filter,
757 &placeholder,
758 Spec {
759 size: [fill(), px(26.0)],
760 fill: Some(theme.base),
761 border: Some(theme.separator),
762 radius: 4.0,
763 pad: [8.0, 0.0],
764 ..Spec::default()
765 },
766 );
767 // The list above was built with the filter as it stood.
768 if self.filter != before {
769 self.window.request_redraw();
770 }
771 for event in &signal.events {
772 if let ui::Event::Key {
773 key: Key::Named(key @ (NamedKey::Escape | NamedKey::Enter)),
774 ..
775 } = event
776 {
777 if *key == NamedKey::Enter
778 && let Some(space) = first.filter(|space| *space != session.space)
779 {
780 self.commands.push(Command::OpenPage(space));
781 }
782 self.filter.clear();
783 self.ui.set_focus(Some(page()));
784 }
785 }
786 self.ui.close();
787 self.ui.close();
788 }
789
790 /// Hands the page the events routed to its box, in its device pixels.
791 fn page_events(&mut self, events: Vec<ui::Event>) -> Result<(), Box<dyn Error>> {
792 let scale = self.ui.scale();
793 let corner = self.ui.rect(page()).unwrap_or_default();
794 let device = |point: [f32; 2]| {
795 [
796 (point[0] - corner[0]) * scale,
797 (point[1] - corner[1]) * scale,
798 ]
799 };
800 for event in events {
801 let response = match event {
802 ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?,
803 ui::Event::PointerLeft => self.view.pointer_left(),
804 ui::Event::Button {
805 button: MouseButton::Left,
806 pressed,
807 at,
808 } => {
809 if pressed {
810 self.view.pointer_pressed(at)?
811 } else {
812 self.view.pointer_released()?
813 }
814 }
815 ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) => continue,
816 ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?,
817 ui::Event::Key { key, text } => {
818 let modifiers = self.view.modifiers();
819 let find = matches!(&key, Key::Character(character)
820 if character.eq_ignore_ascii_case("f"));
821 if modifiers.command && !modifiers.shift && find && self.session.is_some() {
822 self.ui.set_focus(Some(filter()));
823 continue;
824 }
825 self.view.key(&page_key(&key), text.as_deref())?
826 }
827 ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?,
828 ui::Event::Ime(Ime::Commit(text)) => self.view.commit_text(text)?,
829 ui::Event::Ime(Ime::Disabled) => self.view.cancel_composition()?,
830 ui::Event::Modifiers(modifiers) => {
831 self.view.modifiers_changed(page_modifiers(modifiers))?
832 }
833 };
834 self.respond(response);
835 }
836 if self.view.editor.marked_range().is_none() {
837 macos::clear_marked_text(&self.window);
838 }
839 Ok(())
840 }
841
842 /// Notes a page change for after the frame and queues what the page asked of the host.
843 fn respond(&mut self, response: Response) {
844 self.changed |= response.changed;
845 if let Some(request) = response.request {
846 self.commands.push(Command::Page(request));
847 }
848 }
849
850 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {
851 match command {
852 Command::OpenSection(tab) => {
853 let library = self.library.as_ref().ok_or("No sections to open")?;
854 let section = library.open(tab, self.proxy.clone())?;
855 let (scene, editor) =
856 open_session(section, tab, None, &mut self.view.engine, &mut self.session)?;
857 self.view.open(editor, Some((scene, [0.0; 2])));
858 self.filter.clear();
859 self.opened()?;
860 }
861 Command::OpenPage(space) => {
862 let session = self.session.as_mut().ok_or("No section is open")?;
863 let page = session.section.page(space)?;
864 let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?;
865 session.space = space;
866 session.before = page;
867 session.refresh_conflict()?;
868 self.view.open(editor, Some((scene, [0.0; 2])));
869 self.opened()?;
870 }
871 Command::Resolve { keep_mine } => self.resolve_conflict(keep_mine)?,
872 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
873 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
874 Command::Page(Request::Paste) => {
875 let text = self.clipboard.get_text()?;
876 let response = self.view.commit_text(text)?;
877 self.respond(response);
878 }
879 Command::Page(Request::CharacterPalette) => macos::show_character_palette(),
880 }
881 Ok(())
882 }
883
884 /// Follows a page shown in place of another.
885 fn opened(&mut self) -> Result<(), Box<dyn Error>> {
886 self.ui.set_focus(Some(page()));
887 self.title();
888 self.update_accessibility()?;
889 self.window.request_redraw();
890 Ok(())
891 }
892
893 /// Follows an edit: the input method's position, accessibility, saving and the title.
894 fn after_edit(&mut self) -> Result<(), Box<dyn Error>> {
895 let scale = self.ui.scale();
896 let corner = self.ui.rect(page()).unwrap_or_default();
897 let [x0, y0, x1, y1] = self.view.caret_area()?;
898 self.window.set_ime_cursor_area(
899 PhysicalPosition::new(x0 + corner[0] * scale, y0 + corner[1] * scale),
900 PhysicalSize::new(x1 - x0, y1 - y0),
901 );
902 self.update_accessibility()?;
903 self.persist()?;
904 self.title();
905 Ok(())
906 }
907
908 /// The window's title names the page: a temporary page takes its title's first line.
909 fn title(&self) {
910 let title = match &self.session {
911 Some(session) => {
912 let file = session
913 .section
914 .file()
915 .file_name()
916 .map(|name| name.to_string_lossy().into_owned())
917 .unwrap_or_default();
918 format!("{} · {file}", session.title())
919 }
920 None => {
921 let editor = &self.view.editor;
922 if !editor.active_outline().title {
923 return;
924 }
925 let title = editor
926 .active_outline()
927 .document()
928 .paragraphs()
929 .next()
930 .unwrap()
931 .text()
932 .split(['\u{000b}', '\n', '\r'])
933 .next()
934 .unwrap()
935 .to_owned();
936 let title = if title.trim().is_empty() {
937 "Untitled".into()
938 } else {
939 title
940 };
941 format!("{title} · Temporary page")
942 }
352 };943 };
353 let title = format!("{title} · Temporary page");
354 if self.window.title() != title {944 if self.window.title() != title {
355 self.window.set_title(&title);945 self.window.set_title(&title);
356 }946 }
...@@ -365,7 +955,8 @@ impl State {...@@ -365,7 +955,8 @@ impl State {
365 macos::clear_marked_text(&self.window);955 macos::clear_marked_text(&self.window);
366 if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? {956 if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? {
367 let response = self.view.change_date(timestamp, text)?;957 let response = self.view.change_date(timestamp, text)?;
368 self.respond(response)?;958 self.respond(response);
959 self.window.request_redraw();
369 }960 }
370 Ok(())961 Ok(())
371 }962 }
...@@ -384,8 +975,14 @@ impl State {...@@ -384,8 +975,14 @@ impl State {
384 notebook::session::Save::Unchanged => {}975 notebook::session::Save::Unchanged => {}
385 notebook::session::Save::Queued(_) => {976 notebook::session::Save::Queued(_) => {
386 session.before = session.section.page(session.space)?;977 session.before = session.section.page(session.space)?;
387 session.status = " · saving";978 session.status = "Saving";
388 self.window.set_title(&session.window_title());979 if let Some(entry) = session
980 .pages
981 .iter_mut()
982 .find(|(space, ..)| *space == session.space)
983 {
984 entry.1 = after.title;
985 }
389 }986 }
390 notebook::session::Save::Stale => self.reload()?,987 notebook::session::Save::Stale => self.reload()?,
391 }988 }
...@@ -401,7 +998,7 @@ impl State {...@@ -401,7 +998,7 @@ impl State {
401 let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?;998 let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?;
402 session.before = page;999 session.before = page;
403 self.view.replace(editor, Some((scene, [0.0; 2])));1000 self.view.replace(editor, Some((scene, [0.0; 2])));
404 self.window.set_title(&session.window_title());1001 self.title();
405 self.update_accessibility()?;1002 self.update_accessibility()?;
406 self.window.request_redraw();1003 self.window.request_redraw();
407 Ok(())1004 Ok(())
...@@ -427,10 +1024,11 @@ impl State {...@@ -427,10 +1024,11 @@ impl State {
427 session.section.remote_page(session.space)?1024 session.section.remote_page(session.space)?
428 };1025 };
429 session.section.review(edit.id, &reviewed)?;1026 session.section.review(edit.id, &reviewed)?;
430 session.status = " · saving";1027 session.status = "Saving";
1028 session.refresh_conflict()?;
431 if keep_mine {1029 if keep_mine {
432 session.before = session.section.page(session.space)?;1030 session.before = session.section.page(session.space)?;
433 self.window.set_title(&session.window_title());1031 self.window.request_redraw();
434 Ok(())1032 Ok(())
435 } else {1033 } else {
436 self.reload()1034 self.reload()
...@@ -442,6 +1040,7 @@ impl State {...@@ -442,6 +1040,7 @@ impl State {
442 let Some(session) = &mut self.session else {1040 let Some(session) = &mut self.session else {
443 return Ok(());1041 return Ok(());
444 };1042 };
1043 let shown = (session.status, session.conflict);
445 let mut refreshed = false;1044 let mut refreshed = false;
446 for event in session.section.events() {1045 for event in session.section.events() {
447 use notebook::session::Event;1046 use notebook::session::Event;
...@@ -453,22 +1052,35 @@ impl State {...@@ -453,22 +1052,35 @@ impl State {
453 Event::Attempt {1052 Event::Attempt {
454 status: notebook::EditStatus::Published { .. },1053 status: notebook::EditStatus::Published { .. },
455 ..1054 ..
456 } => " · saved",1055 } => "Saved",
457 Event::Attempt {1056 Event::Attempt {
458 status: notebook::EditStatus::Conflict(_),1057 status: notebook::EditStatus::Conflict(_),
459 ..1058 ..
460 } => " · conflict",1059 } => "Conflict",
461 Event::Attempt { .. } => " · saving",1060 Event::Attempt { .. } => "Saving",
462 Event::Unreachable(_) => " · offline",1061 Event::Unreachable(_) => "Offline",
463 Event::Failed(error) => {1062 Event::Failed(error) => {
464 eprintln!("Synchronization stopped: {error}");1063 eprintln!("Synchronization stopped: {error}");
465 " · not saving"1064 "Not saving"
466 }1065 }
467 };1066 };
468 }1067 }
469 self.window.set_title(&session.window_title());1068 session.refresh_conflict()?;
470 if refreshed && session.section.page(session.space)? != session.before {1069 if refreshed || shown != (session.status, session.conflict) {
471 self.reload()?;1070 self.window.request_redraw();
1071 }
1072 if refreshed {
1073 session.pages = session.section.pages()?;
1074 if !session
1075 .pages
1076 .iter()
1077 .any(|(space, ..)| *space == session.space)
1078 {
1079 let first = session.pages.first().ok_or("The section has no pages")?.0;
1080 self.commands.push(Command::OpenPage(first));
1081 } else if session.section.page(session.space)? != session.before {
1082 self.reload()?;
1083 }
472 }1084 }
473 Ok(())1085 Ok(())
474 }1086 }
...@@ -476,10 +1088,14 @@ impl State {...@@ -476,10 +1088,14 @@ impl State {
476 fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> {1088 fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> {
477 let mut error = None;1089 let mut error = None;
478 let view = &self.view;1090 let view = &self.view;
1091 let corner = self.ui.rect(page()).unwrap_or_default();
1092 let mut viewport = view.viewport;
1093 viewport.origin[0] += corner[0] * self.ui.scale();
1094 viewport.origin[1] += corner[1] * self.ui.scale();
479 self.access_adapter.update_if_active(|| {1095 self.access_adapter.update_if_active(|| {
480 match self.accessibility.update(1096 match self.accessibility.update(
481 &view.editor,1097 &view.editor,
482 view.viewport,1098 viewport,
483 &self.window.title(),1099 &self.window.title(),
484 view.outline_preview(),1100 view.outline_preview(),
485 ) {1101 ) {
...@@ -488,7 +1104,7 @@ impl State {...@@ -488,7 +1104,7 @@ impl State {
488 &mut update,1104 &mut update,
489 view.scene.as_ref(),1105 view.scene.as_ref(),
490 &view.editor,1106 &view.editor,
491 view.viewport,1107 viewport,
492 view.object_focus().and_then(|focus| focus.read_only()),1108 view.object_focus().and_then(|focus| focus.read_only()),
493 );1109 );
494 update1110 update
...@@ -528,8 +1144,10 @@ impl State {...@@ -528,8 +1144,10 @@ impl State {
528 if let Some(index) = self.accessibility.read_only_for_node(request.target_node) {1144 if let Some(index) = self.accessibility.read_only_for_node(request.target_node) {
529 if request.action == Action::Focus {1145 if request.action == Action::Focus {
530 self.window.focus_window();1146 self.window.focus_window();
1147 self.ui.set_focus(Some(page()));
531 let response = self.view.focus_read_only(index)?;1148 let response = self.view.focus_read_only(index)?;
532 self.respond(response)?;1149 self.respond(response);
1150 self.window.request_redraw();
533 }1151 }
534 return Ok(());1152 return Ok(());
535 }1153 }
...@@ -559,14 +1177,18 @@ impl State {...@@ -559,14 +1177,18 @@ impl State {
559 }1177 }
560 _ => return Ok(()),1178 _ => return Ok(()),
561 }1179 }
1180 self.ui.set_focus(Some(page()));
562 let response = self.view.focus_text()?;1181 let response = self.view.focus_text()?;
563 self.respond(response)1182 self.respond(response);
1183 self.window.request_redraw();
1184 Ok(())
564 }1185 }
5651186
566 fn draw(&mut self) -> Result<(), Box<dyn Error>> {1187 fn draw(&mut self) -> Result<(), Box<dyn Error>> {
567 let viewport = self.view.viewport;1188 if let Some(path) = self.snapshot.take() {
568 trace_input(&("Draw", viewport.origin, viewport.scale));1189 self.snapshot(&path)?;
569 if self.occluded || viewport.size.contains(&0) {1190 }
1191 if self.occluded || [self.config.width, self.config.height].contains(&0) {
570 return Ok(());1192 return Ok(());
571 }1193 }
572 let mut reconfigure = false;1194 let mut reconfigure = false;
...@@ -600,16 +1222,7 @@ impl State {...@@ -600,16 +1222,7 @@ impl State {
600 return Err("Canvas surface validation failed".into());1222 return Err("Canvas surface validation failed".into());
601 }1223 }
602 };1224 };
603 let [caret, selection] = macos::text_colors();1225 self.paint(&frame.texture.create_view(&Default::default()))?;
604 let primitives = self.view.primitives(TextColors { caret, selection })?;
605 self.renderer
606 .draw(
607 &frame.texture.create_view(&Default::default()),
608 viewport.size,
609 [1.0; 4],
610 &[viewport.layer(&primitives)],
611 )
612 .map_err(|error| format!("Canvas drawing failed: {error:?}"))?;
613 self.window.pre_present_notify();1226 self.window.pre_present_notify();
614 self.renderer.queue.present(frame);1227 self.renderer.queue.present(frame);
615 trace_input(&"Present submitted");1228 trace_input(&"Present submitted");
...@@ -619,26 +1232,199 @@ impl State {...@@ -619,26 +1232,199 @@ impl State {
619 Ok(())1232 Ok(())
620 }1233 }
6211234
622 fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box<dyn Error>> {1235 /// Paints the interface with the page in its box.
623 let modifiers = self.view.modifiers();1236 fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box<dyn Error>> {
624 if modifiers.command1237 let [caret, selection] = macos::text_colors();
625 && modifiers.shift1238 let page_primitives = self.view.primitives(TextColors { caret, selection })?;
626 && self.session.is_some()1239 let scale = self.ui.scale();
627 && self.view.accepts_text()1240 let corner = self.ui.rect(page()).unwrap_or_default();
628 && let Key::Character(character) = key1241 let viewport = self.view.viewport;
1242 let interface = self.ui.layers();
1243 let layers: Vec<_> = interface
1244 .iter()
1245 .map(|layer| match layer {
1246 ui::Layer::Primitives { clip, primitives } => draw::Layer {
1247 scale,
1248 origin: [0.0; 2],
1249 clip: clip.map(|clip| clip.map(|value| value * scale)),
1250 primitives,
1251 },
1252 ui::Layer::Custom { rect, .. } => draw::Layer {
1253 scale: viewport.scale,
1254 origin: [
1255 viewport.origin[0] + corner[0] * scale,
1256 viewport.origin[1] + corner[1] * scale,
1257 ],
1258 clip: Some(rect.map(|value| value * scale)),
1259 primitives: &page_primitives,
1260 },
1261 })
1262 .collect();
1263 trace_input(&("Draw", viewport.origin, viewport.scale));
1264 self.renderer
1265 .draw(
1266 target,
1267 [self.config.width, self.config.height],
1268 self.ui.theme.base,
1269 &layers,
1270 )
1271 .map_err(|error| format!("Canvas drawing failed: {error:?}").into())
1272 }
1273
1274 /// Writes the frame to a PNG, so a covered window can be reviewed.
1275 fn snapshot(&mut self, path: &Path) -> Result<(), Box<dyn Error>> {
1276 let size = [self.config.width, self.config.height];
1277 let device = &self.renderer.device;
1278 let texture = device.create_texture(&wgpu::TextureDescriptor {
1279 label: Some("Snapshot"),
1280 size: wgpu::Extent3d {
1281 width: size[0],
1282 height: size[1],
1283 depth_or_array_layers: 1,
1284 },
1285 mip_level_count: 1,
1286 sample_count: 1,
1287 dimension: wgpu::TextureDimension::D2,
1288 format: self.config.format,
1289 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1290 view_formats: &[],
1291 });
1292 let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
1293 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
1294 label: Some("Snapshot readback"),
1295 size: u64::from(row) * u64::from(size[1]),
1296 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1297 mapped_at_creation: false,
1298 });
1299 self.paint(&texture.create_view(&Default::default()))?;
1300 let mut encoder = self
1301 .renderer
1302 .device
1303 .create_command_encoder(&Default::default());
1304 encoder.copy_texture_to_buffer(
1305 texture.as_image_copy(),
1306 wgpu::TexelCopyBufferInfo {
1307 buffer: &buffer,
1308 layout: wgpu::TexelCopyBufferLayout {
1309 offset: 0,
1310 bytes_per_row: Some(row),
1311 rows_per_image: Some(size[1]),
1312 },
1313 },
1314 texture.size(),
1315 );
1316 self.renderer.queue.submit([encoder.finish()]);
1317 buffer.map_async(wgpu::MapMode::Read, .., |_| {});
1318 self.renderer.device.poll(wgpu::PollType::Wait {
1319 submission_index: None,
1320 timeout: Some(std::time::Duration::from_secs(5)),
1321 })?;
1322 let bgra = matches!(
1323 self.config.format,
1324 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
1325 );
1326 let pixels: Vec<u8> = buffer
1327 .get_mapped_range(..)?
1328 .chunks_exact(row as usize)
1329 .flat_map(|line| line[..size[0] as usize * 4].chunks_exact(4))
1330 .flat_map(|pixel| {
1331 if bgra {
1332 [pixel[2], pixel[1], pixel[0], pixel[3]]
1333 } else {
1334 [pixel[0], pixel[1], pixel[2], pixel[3]]
1335 }
1336 })
1337 .collect();
1338 let partial = path.with_extension("partial");
1339 let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]);
1340 encoder.set_color(png::ColorType::Rgba);
1341 encoder.set_depth(png::BitDepth::Eight);
1342 encoder.write_header()?.write_image_data(&pixels)?;
1343 std::fs::rename(partial, path)?;
1344 Ok(())
1345 }
1346
1347 /// Queues input for the next frame. A press on the strip moves the window at once,
1348 /// while the platform still holds the press; a double press zooms it.
1349 fn input(&mut self, event: ui::Event) {
1350 if let ui::Event::PointerMoved(point) = event {
1351 self.pointer = point;
1352 }
1353 if let ui::Event::Button {
1354 button: MouseButton::Left,
1355 pressed: true,
1356 at,
1357 } = event
1358 && self.ui.box_at(self.pointer) == Some(strip())
629 {1359 {
630 if character.eq_ignore_ascii_case("k") {1360 let double = self.strip_press.is_some_and(|last| {
631 return self.resolve_conflict(true);1361 at.saturating_duration_since(last) <= macos::double_click_interval()
632 }1362 });
633 if character.eq_ignore_ascii_case("t") {1363 self.strip_press = (!double).then_some(at);
634 return self.resolve_conflict(false);1364 if double {
1365 self.window.set_maximized(!self.window.is_maximized());
1366 } else {
1367 let _ = self.window.drag_window();
635 }1368 }
1369 return;
636 }1370 }
637 let response = self.view.key(&page_key(key), text)?;1371 self.ui.event(event);
638 self.respond(response)1372 self.window.request_redraw();
639 }1373 }
640}1374}
6411375
1376/// Section tabs for a notebook's readable top-level sections, in its order.
1377fn tabs(notebook: &notebook::session::Notebook) -> Vec<Tab> {
1378 notebook
1379 .catalog()
1380 .sections
1381 .iter()
1382 .filter_map(|section| match &section.state {
1383 notebook::discover::SectionState::Readable { name, color } => Some(Tab {
1384 name: name.clone().unwrap_or_else(|| {
1385 Path::new(&section.path)
1386 .file_stem()
1387 .map(|stem| stem.to_string_lossy().into_owned())
1388 .unwrap_or_default()
1389 }),
1390 path: section.path.clone(),
1391 color: *color,
1392 }),
1393 _ => None,
1394 })
1395 .collect()
1396}
1397
1398/// Makes `section` the open one showing `space`, or its first page, and returns the
1399/// page's scene and editor.
1400fn open_session(
1401 section: notebook::session::Section,
1402 tab: usize,
1403 space: Option<ExGuid>,
1404 engine: &mut TextEngine,
1405 session: &mut Option<Session>,
1406) -> Result<(PageScene, CanvasEditor), Box<dyn Error>> {
1407 let pages = section.pages()?;
1408 let space = match space {
1409 Some(space) => space,
1410 None => pages.first().ok_or("The section has no pages")?.0,
1411 };
1412 let before = section.page(space)?;
1413 let (scene, editor) = PageScene::from_page(before.clone(), engine)?;
1414 let mut opened = Session {
1415 section,
1416 tab,
1417 pages,
1418 space,
1419 before,
1420 status: "",
1421 conflict: false,
1422 };
1423 opened.refresh_conflict()?;
1424 *session = Some(opened);
1425 Ok((scene, editor))
1426}
1427
642impl App {1428impl App {
643 fn close(&self, event_loop: &ActiveEventLoop) {1429 fn close(&self, event_loop: &ActiveEventLoop) {
644 if self.state.as_ref().is_none_or(|state| {1430 if self.state.as_ref().is_none_or(|state| {
...@@ -671,12 +1457,14 @@ impl ApplicationHandler<UserEvent> for App {...@@ -671,12 +1457,14 @@ impl ApplicationHandler<UserEvent> for App {
671 let event = match event {1457 let event = match event {
672 UserEvent::InsertText(text) => {1458 UserEvent::InsertText(text) => {
673 if let Some(state) = &mut self.state {1459 if let Some(state) = &mut self.state {
674 let result = state1460 if state.ui.focused() == Some(page()) {
675 .view1461 match state.view.insert_text(text) {
676 .insert_text(text)1462 Ok(response) => state.respond(response),
677 .and_then(|response| state.respond(response));1463 Err(error) => eprintln!("{error}"),
678 if let Err(error) = result {1464 }
679 eprintln!("{error}");1465 state.window.request_redraw();
1466 } else {
1467 state.input(ui::Event::Ime(Ime::Commit(text)));
680 }1468 }
681 }1469 }
682 return;1470 return;
...@@ -693,6 +1481,19 @@ impl ApplicationHandler<UserEvent> for App {...@@ -693,6 +1481,19 @@ impl ApplicationHandler<UserEvent> for App {
693 }1481 }
694 return;1482 return;
695 }1483 }
1484 UserEvent::Replay(replay) => {
1485 if let Some(state) = &mut self.state {
1486 match replay {
1487 Replay::Input(event) => state.input(event),
1488 Replay::Snapshot(path) => state.snapshot = Some(path),
1489 }
1490 // A covered window gets no redraws, so each step draws its own frame.
1491 if let Err(error) = state.frame() {
1492 eprintln!("{error}");
1493 }
1494 }
1495 return;
1496 }
696 UserEvent::Accessibility(event) => event,1497 UserEvent::Accessibility(event) => event,
697 };1498 };
698 let Some(state) = &mut self.state else {1499 let Some(state) = &mut self.state else {
...@@ -758,11 +1559,10 @@ impl ApplicationHandler<UserEvent> for App {...@@ -758,11 +1559,10 @@ impl ApplicationHandler<UserEvent> for App {
758 let Some(state) = &mut self.state else {1559 let Some(state) = &mut self.state else {
759 return;1560 return;
760 };1561 };
761 let was_marked =1562 let scale = state.window.scale_factor() as f32;
762 !matches!(event, WindowEvent::Ime(_)) && state.view.editor.marked_range().is_some();
763 let result = (|| -> Result<(), Box<dyn Error>> {1563 let result = (|| -> Result<(), Box<dyn Error>> {
764 let response = match event {1564 match event {
765 WindowEvent::RedrawRequested => return state.draw(),1565 WindowEvent::RedrawRequested => return state.frame(),
766 WindowEvent::Resized(size) => {1566 WindowEvent::Resized(size) => {
767 if size.width > 0 && size.height > 0 {1567 if size.width > 0 && size.height > 0 {
768 state.config.width = size.width;1568 state.config.width = size.width;
...@@ -771,72 +1571,66 @@ impl ApplicationHandler<UserEvent> for App {...@@ -771,72 +1571,66 @@ impl ApplicationHandler<UserEvent> for App {
771 .surface1571 .surface
772 .configure(&state.renderer.device, &state.config);1572 .configure(&state.renderer.device, &state.config);
773 }1573 }
774 let response = state.view.resized([size.width, size.height])?;
775 let changed = response.changed;
776 state.respond(response)?;
777 // Present inside AppKit's resize transaction; a redraw on the next turn1574 // Present inside AppKit's resize transaction; a redraw on the next turn
778 // lets the window show the previous frame at the new size.1575 // lets the window show the previous frame at the new size.
779 if changed {1576 return state.frame();
780 state.draw()?;
781 }
782 return Ok(());
783 }1577 }
784 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {1578 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
785 state.renderer.clear_glyph_cache();1579 state.renderer.clear_glyph_cache();
786 state.view.scale_factor_changed(scale_factor as f32)?1580 let response = state.view.scale_factor_changed(scale_factor as f32)?;
1581 state.respond(response);
1582 state.window.request_redraw();
1583 }
1584 WindowEvent::Focused(focused) => {
1585 state.window_focused = focused;
1586 state.window.request_redraw();
787 }1587 }
788 WindowEvent::Focused(focused) => state.view.focus_changed(focused)?,
789 WindowEvent::Occluded(occluded) => {1588 WindowEvent::Occluded(occluded) => {
790 trace_input(&("Window occluded", occluded));1589 trace_input(&("Window occluded", occluded));
791 state.occluded = occluded;1590 state.occluded = occluded;
792 if occluded {1591 if !occluded {
793 return Ok(());1592 state.changed = true;
794 }1593 state.window.request_redraw();
795 Response {
796 changed: true,
797 redraw: true,
798 request: None,
799 }1594 }
800 }1595 }
801 WindowEvent::ModifiersChanged(modifiers) => state1596 WindowEvent::ModifiersChanged(modifiers) => {
802 .view1597 state.input(ui::Event::Modifiers(modifiers.state()))
803 .modifiers_changed(page_modifiers(modifiers.state()))?,1598 }
804 WindowEvent::CursorLeft { .. } => state.view.pointer_left(),1599 WindowEvent::CursorLeft { .. } => state.input(ui::Event::PointerLeft),
805 WindowEvent::CursorMoved { position, .. } => state1600 WindowEvent::CursorMoved { position, .. } => {
806 .view1601 state.input(ui::Event::PointerMoved([
807 .pointer_moved([position.x as f32, position.y as f32])?,1602 position.x as f32 / scale,
808 WindowEvent::MouseInput {1603 position.y as f32 / scale,
809 state: ElementState::Pressed,1604 ]))
810 button: MouseButton::Left,1605 }
811 ..
812 } => state.view.pointer_pressed(Instant::now())?,
813 WindowEvent::MouseInput {1606 WindowEvent::MouseInput {
814 state: ElementState::Released,1607 state: pressed,
815 button: MouseButton::Left,1608 button,
816 ..1609 ..
817 } => state.view.pointer_released()?,1610 } => state.input(ui::Event::Button {
1611 button,
1612 pressed: pressed == ElementState::Pressed,
1613 at: Instant::now(),
1614 }),
818 WindowEvent::MouseWheel { delta, .. } => {1615 WindowEvent::MouseWheel { delta, .. } => {
819 let dpr = state.window.scale_factor() as f32;1616 state.input(ui::Event::Wheel(match delta {
820 state.view.wheel(match delta {1617 MouseScrollDelta::LineDelta(x, y) => [x * 32.0, y * 32.0],
821 MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr],1618 MouseScrollDelta::PixelDelta(p) => [p.x as f32 / scale, p.y as f32 / scale],
822 MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32],1619 }))
823 })?
824 }1620 }
825 WindowEvent::KeyboardInput { event, .. }1621 WindowEvent::KeyboardInput { event, .. }
826 if event.state == ElementState::Pressed =>1622 if event.state == ElementState::Pressed =>
827 {1623 {
828 return state.key(&event.logical_key, event.text.as_deref());1624 state.input(ui::Event::Key {
1625 key: event.logical_key,
1626 text: event.text.map(|text| text.to_string()),
1627 })
829 }1628 }
830 WindowEvent::Ime(Ime::Preedit(text, cursor)) => state.view.compose(text, cursor)?,1629 WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)),
831 WindowEvent::Ime(Ime::Commit(text)) => state.view.commit_text(text)?,1630 _ => {}
832 WindowEvent::Ime(Ime::Disabled) => state.view.cancel_composition()?,1631 }
833 _ => return Ok(()),1632 Ok(())
834 };
835 state.respond(response)
836 })();1633 })();
837 if was_marked && state.view.editor.marked_range().is_none() {
838 macos::clear_marked_text(&state.window);
839 }
840 if let Err(error) = result {1634 if let Err(error) = result {
841 eprintln!("{error}");1635 eprintln!("{error}");
842 }1636 }
...@@ -858,6 +1652,75 @@ impl ApplicationHandler<UserEvent> for App {...@@ -858,6 +1652,75 @@ impl ApplicationHandler<UserEvent> for App {
858 }1652 }
859}1653}
8601654
1655/// Feeds a development script to the window from another thread, one command per line
1656/// in logical pixels: `move X Y`, `press`, `release`, `wheel DX DY`, `key NAME`, `type
1657/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS` and `snapshot PNG_PATH`.
1658fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
1659 let mut steps = Vec::new();
1660 for line in script.lines().filter(|line| !line.trim().is_empty()) {
1661 let (command, rest) = line.split_once(' ').unwrap_or((line, ""));
1662 let numbers = || -> Result<Vec<f32>, std::num::ParseFloatError> {
1663 rest.split_whitespace().map(str::parse).collect()
1664 };
1665 let button = |pressed| ui::Event::Button {
1666 button: MouseButton::Left,
1667 pressed,
1668 at: Instant::now(),
1669 };
1670 steps.push(match command {
1671 "move" => Ok(Replay::Input(ui::Event::PointerMoved(
1672 numbers()?.try_into().map_err(|_| "move takes X Y")?,
1673 ))),
1674 "wheel" => Ok(Replay::Input(ui::Event::Wheel(
1675 numbers()?.try_into().map_err(|_| "wheel takes DX DY")?,
1676 ))),
1677 "press" => Ok(Replay::Input(button(true))),
1678 "release" => Ok(Replay::Input(button(false))),
1679 "key" => Ok(Replay::Input(ui::Event::Key {
1680 key: match rest {
1681 "Escape" => Key::Named(NamedKey::Escape),
1682 "Enter" => Key::Named(NamedKey::Enter),
1683 "Backspace" => Key::Named(NamedKey::Backspace),
1684 "Tab" => Key::Named(NamedKey::Tab),
1685 "Left" => Key::Named(NamedKey::ArrowLeft),
1686 "Right" => Key::Named(NamedKey::ArrowRight),
1687 "Up" => Key::Named(NamedKey::ArrowUp),
1688 "Down" => Key::Named(NamedKey::ArrowDown),
1689 character => Key::Character(character.into()),
1690 },
1691 text: None,
1692 })),
1693 "type" => Ok(Replay::Input(ui::Event::Ime(Ime::Commit(rest.into())))),
1694 "modifiers" => Ok(Replay::Input(ui::Event::Modifiers(
1695 rest.split_whitespace()
1696 .map(|name| match name {
1697 "shift" => Ok(ModifiersState::SHIFT),
1698 "command" => Ok(ModifiersState::SUPER),
1699 _ => Err(format!("Unknown modifier {name}")),
1700 })
1701 .try_fold(ModifiersState::empty(), |all, one| one.map(|one| all | one))?,
1702 ))),
1703 "wait" => Err(std::time::Duration::from_millis(rest.parse()?)),
1704 "snapshot" => Ok(Replay::Snapshot(rest.into())),
1705 _ => return Err(format!("Unknown replay command: {line}").into()),
1706 });
1707 }
1708 std::thread::spawn(move || {
1709 for step in steps {
1710 match step {
1711 Ok(mut replay) => {
1712 if let Replay::Input(ui::Event::Button { at, .. }) = &mut replay {
1713 *at = Instant::now();
1714 }
1715 let _ = proxy.send_event(UserEvent::Replay(replay));
1716 }
1717 Err(duration) => std::thread::sleep(duration),
1718 }
1719 }
1720 });
1721 Ok(())
1722}
1723
861fn main() -> Result<(), Box<dyn Error>> {1724fn main() -> Result<(), Box<dyn Error>> {
862 let mut args = std::env::args_os().skip(1);1725 let mut args = std::env::args_os().skip(1);
863 let mut positional = Vec::new();1726 let mut positional = Vec::new();
...@@ -865,6 +1728,7 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -865,6 +1728,7 @@ fn main() -> Result<(), Box<dyn Error>> {
865 let mut reference = None;1728 let mut reference = None;
866 let mut editable = false;1729 let mut editable = false;
867 let mut section = None;1730 let mut section = None;
1731 let mut notebook = None;
868 let mut cache = None;1732 let mut cache = None;
869 while let Some(arg) = args.next() {1733 while let Some(arg) = args.next() {
870 if arg == "--substitute-font" {1734 if arg == "--substitute-font" {
...@@ -872,6 +1736,11 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -872,6 +1736,11 @@ fn main() -> Result<(), Box<dyn Error>> {
872 args.next()1736 args.next()
873 .ok_or("Provide a font file after --substitute-font.")?,1737 .ok_or("Provide a font file after --substitute-font.")?,
874 ));1738 ));
1739 } else if arg == "--notebook" {
1740 notebook = Some(PathBuf::from(
1741 args.next()
1742 .ok_or("Provide a notebook folder after --notebook.")?,
1743 ));
875 } else if arg == "--section" {1744 } else if arg == "--section" {
876 if reference.is_some() || section.is_some() {1745 if reference.is_some() || section.is_some() {
877 return Err("Only one page can be opened.".into());1746 return Err("Only one page can be opened.".into());
...@@ -915,14 +1784,17 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -915,14 +1784,17 @@ fn main() -> Result<(), Box<dyn Error>> {
915 positional.push(arg);1784 positional.push(arg);
916 }1785 }
917 }1786 }
918 if (editable || section.is_some()) && !positional.is_empty() {1787 let stored = notebook.is_some() || section.is_some();
1788 if (editable || stored) && !positional.is_empty()
1789 || notebook.is_some() && (section.is_some() || reference.is_some())
1790 {
919 return Err(1791 return Err(
920 "Use --page or --section with a section file and page title, without a text file or width.".into(),1792 "Use one of --notebook, --page or --section, without a text file or width.".into(),
921 );1793 );
922 }1794 }
923 if positional.len() > 2 {1795 if positional.len() > 2 {
924 return Err(1796 return Err(
925 "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE [--cache DIR]] [--substitute-font FONT_FILE]..."1797 "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE | --notebook FOLDER] [--cache DIR] [--substitute-font FONT_FILE]..."
926 .into(),1798 .into(),
927 );1799 );
928 }1800 }
...@@ -940,12 +1812,14 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -940,12 +1812,14 @@ fn main() -> Result<(), Box<dyn Error>> {
940 } else {1812 } else {
941 480.01813 480.0
942 });1814 });
943 let input = if let Some((file, title)) = section {1815 let cache = match cache {
944 let cache = match cache {1816 Some(cache) => cache,
945 Some(cache) => cache,1817 None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?)
946 None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?)1818 .join("Library/Caches/snowbound"),
947 .join("Library/Caches/snowbound"),1819 };
948 };1820 let input = if let Some(root) = notebook {
1821 Input::Notebook { root, cache }
1822 } else if let Some((file, title)) = section {
949 Input::Section { file, title, cache }1823 Input::Section { file, title, cache }
950 } else if editable {1824 } else if editable {
951 Input::Page(reference.unwrap())1825 Input::Page(reference.unwrap())
...@@ -961,6 +1835,9 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -961,6 +1835,9 @@ fn main() -> Result<(), Box<dyn Error>> {
961 }1835 }
962 };1836 };
963 let event_loop = macos::event_loop()?;1837 let event_loop = macos::event_loop()?;
1838 if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") {
1839 replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?;
1840 }
964 let mut app = App {1841 let mut app = App {
965 proxy: event_loop.create_proxy(),1842 proxy: event_loop.create_proxy(),
966 input: Some(input),1843 input: Some(input),
crates/ui/Cargo.toml created+10
...@@ -0,0 +1,10 @@
1[package]
2name = "ui"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8draw = { path = "../draw" }
9parley.workspace = true
10winit = "0.30.13"
crates/ui/src/layout.rs created+136
...@@ -0,0 +1,136 @@
1use crate::{Axis, Built, Flags, Id, Size, State};
2use std::collections::HashMap;
3
4/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from
5/// ancestors (pre-order), sizes summed from children (post-order), overflow shared out
6/// by strictness, and positions along each parent's flow. Boxes are in build order, so
7/// index order is pre-order.
8pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32) {
9 for axis in 0..2 {
10 for node in nodes.iter_mut() {
11 node.computed[axis] = match node.size[axis].size {
12 Size::Pixels(pixels) => pixels,
13 Size::Text => {
14 node.label.as_ref().map_or(0.0, |label| label.size[axis]) + 2.0 * node.pad[axis]
15 }
16 Size::Fraction(_) | Size::Children => 0.0,
17 };
18 }
19 for index in 1..nodes.len() {
20 if let Size::Fraction(fraction) = nodes[index].size[axis].size {
21 let mut ancestor = nodes[index].parent;
22 while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children {
23 ancestor = nodes[ancestor].parent;
24 }
25 let room = nodes[ancestor].computed[axis] - 2.0 * nodes[ancestor].pad[axis];
26 nodes[index].computed[axis] = room.max(0.0) * fraction;
27 }
28 }
29 for index in (0..nodes.len()).rev() {
30 if nodes[index].size[axis].size == Size::Children {
31 let content = flow(nodes, index, axis);
32 nodes[index].computed[axis] = content + 2.0 * nodes[index].pad[axis];
33 }
34 }
35 for index in 0..nodes.len() {
36 let node = &nodes[index];
37 if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) {
38 continue;
39 }
40 let room = node.computed[axis] - 2.0 * node.pad[axis];
41 let children: Vec<_> = in_flow(nodes, index).collect();
42 if along(node, axis) {
43 let excess = flow(nodes, index, axis) - room;
44 let give: f32 = children
45 .iter()
46 .map(|child| {
47 nodes[*child].computed[axis] * (1.0 - strictness(nodes, *child, axis))
48 })
49 .sum();
50 if excess > 0.0 && give > 0.0 {
51 for child in children {
52 let share =
53 nodes[child].computed[axis] * (1.0 - strictness(nodes, child, axis));
54 nodes[child].computed[axis] -= excess.min(give) * share / give;
55 }
56 }
57 } else {
58 for child in children {
59 let over = nodes[child].computed[axis] - room;
60 if over > 0.0 {
61 nodes[child].computed[axis] -=
62 over * (1.0 - strictness(nodes, child, axis));
63 }
64 }
65 }
66 }
67 for index in 0..nodes.len() {
68 let mut cursor = nodes[index].pad[axis];
69 for child in nodes[index].children.clone() {
70 nodes[child].relative[axis] = if nodes[child].flags.contains(Flags::FLOAT) {
71 nodes[child].position[axis]
72 } else if along(&nodes[index], axis) {
73 let at = cursor;
74 cursor += nodes[child].computed[axis] + nodes[index].gap;
75 at
76 } else {
77 nodes[index].pad[axis]
78 };
79 }
80 if axis == 1 {
81 nodes[index].content = flow(nodes, index, axis) + 2.0 * nodes[index].pad[axis];
82 }
83 }
84 }
85 let snap = |value: f32| (value * scale).round() / scale;
86 nodes[0].rect = [
87 0.0,
88 0.0,
89 snap(nodes[0].computed[0]),
90 snap(nodes[0].computed[1]),
91 ];
92 for index in 1..nodes.len() {
93 let parent = &nodes[nodes[index].parent];
94 let scroll =
95 if parent.flags.contains(Flags::SCROLL) && !nodes[index].flags.contains(Flags::FLOAT) {
96 states.get(&parent.id).map_or(0.0, |state| state.scroll)
97 } else {
98 0.0
99 };
100 let x = parent.rect[0] + nodes[index].relative[0];
101 let y = parent.rect[1] + nodes[index].relative[1] - scroll;
102 nodes[index].rect = [
103 snap(x),
104 snap(y),
105 snap(x + nodes[index].computed[0]),
106 snap(y + nodes[index].computed[1]),
107 ];
108 }
109}
110
111fn along(node: &Built, axis: usize) -> bool {
112 (node.axis == Axis::Y) == (axis == 1)
113}
114
115fn strictness(nodes: &[Built], index: usize, axis: usize) -> f32 {
116 nodes[index].size[axis].strictness.clamp(0.0, 1.0)
117}
118
119fn in_flow(nodes: &[Built], index: usize) -> impl Iterator<Item = usize> + '_ {
120 nodes[index]
121 .children
122 .iter()
123 .copied()
124 .filter(|child| !nodes[*child].flags.contains(Flags::FLOAT))
125}
126
127/// The children's extent on `axis`: summed with gaps along the flow, otherwise the largest.
128fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 {
129 let sizes = in_flow(nodes, index).map(|child| nodes[child].computed[axis]);
130 if along(&nodes[index], axis) {
131 let (sum, count) = sizes.fold((0.0, 0), |(sum, count), size| (sum + size, count + 1));
132 sum + nodes[index].gap * (count.max(1) - 1) as f32
133 } else {
134 sizes.fold(0.0, f32::max)
135 }
136}
crates/ui/src/lib.rs created+892
...@@ -0,0 +1,892 @@
1//! An immediate-mode interface kit. Builder code declares boxes every frame from
2//! application state; a cache keyed by stable ids keeps hover, press, focus, scroll and
3//! animation state. Input is answered with the previous frame's layout, so a frame's
4//! events are routed before building and its layout is solved after.
5
6mod layout;
7mod text;
8mod theme;
9mod widgets;
10
11pub use theme::Theme;
12pub use widgets::{button, scrollbar, text_field};
13
14use draw::{Primitive, Stroke};
15use std::{
16 collections::HashMap,
17 hash::{DefaultHasher, Hash, Hasher},
18 ops::BitOr,
19 rc::Rc,
20 time::Instant,
21};
22use text::{Label, Painted, Texts};
23use winit::{
24 event::{Ime, MouseButton},
25 keyboard::{Key, ModifiersState},
26 window::CursorIcon,
27};
28
29/// Seconds for an animated value to close half of its remaining distance.
30const HALF_LIFE: f32 = 0.03;
31
32/// A box's identity across frames: its parent's id combined with a builder-chosen part.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34pub struct Id(u64);
35
36impl Id {
37 pub const ROOT: Id = Id(0);
38
39 pub fn child(self, part: impl Hash) -> Id {
40 let mut hasher = DefaultHasher::new();
41 self.0.hash(&mut hasher);
42 part.hash(&mut hasher);
43 Id(hasher.finish())
44 }
45}
46
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub enum Size {
49 /// Logical pixels.
50 Pixels(f32),
51 /// The label and padding.
52 Text,
53 /// A fraction of the nearest ancestor not sized by its children.
54 Fraction(f32),
55 /// The children laid out along this axis, and padding.
56 Children,
57}
58
59/// A size on one axis and the share of it a box keeps when its siblings overflow.
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct Extent {
62 pub size: Size,
63 pub strictness: f32,
64}
65
66impl Default for Extent {
67 fn default() -> Self {
68 children()
69 }
70}
71
72pub fn px(pixels: f32) -> Extent {
73 Extent {
74 size: Size::Pixels(pixels),
75 strictness: 1.0,
76 }
77}
78
79pub fn fit() -> Extent {
80 Extent {
81 size: Size::Text,
82 strictness: 1.0,
83 }
84}
85
86/// All the room the parent has, yielding to stricter siblings.
87pub fn fill() -> Extent {
88 Extent {
89 size: Size::Fraction(1.0),
90 strictness: 0.0,
91 }
92}
93
94pub fn children() -> Extent {
95 Extent {
96 size: Size::Children,
97 strictness: 1.0,
98 }
99}
100
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub enum Axis {
103 #[default]
104 X,
105 Y,
106}
107
108#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109pub struct Flags(u32);
110
111impl Flags {
112 /// Takes pointer presses, clicks and drags, and blocks boxes beneath it.
113 pub const CLICKABLE: Flags = Flags(1);
114 /// A press gives it keyboard focus.
115 pub const FOCUSABLE: Flags = Flags(2);
116 /// The wheel scrolls its children vertically; they may overflow it.
117 pub const SCROLL: Flags = Flags(4);
118 /// Children paint only within it.
119 pub const CLIP: Flags = Flags(8);
120 /// Placed at its `position` in the parent instead of in the parent's flow.
121 pub const FLOAT: Flags = Flags(16);
122 /// The host paints it and receives the events routed to it.
123 pub const CUSTOM: Flags = Flags(32);
124
125 pub(crate) fn contains(self, other: Flags) -> bool {
126 self.0 & other.0 == other.0
127 }
128
129 fn intersects(self, other: Flags) -> bool {
130 self.0 & other.0 != 0
131 }
132}
133
134impl BitOr for Flags {
135 type Output = Flags;
136
137 fn bitor(self, other: Flags) -> Flags {
138 Flags(self.0 | other.0)
139 }
140}
141
142/// What a box is this frame. Colours are linear RGBA.
143#[derive(Clone, Default)]
144pub struct Spec<'a> {
145 pub flags: Flags,
146 pub size: [Extent; 2],
147 /// The axis its children flow along.
148 pub axis: Axis,
149 pub text: Option<&'a str>,
150 /// The label's colour; the theme's text colour otherwise.
151 pub color: Option<[f32; 4]>,
152 pub fill: Option<[f32; 4]>,
153 /// The fill under the pointer, blended in as hover animates.
154 pub hover_fill: Option<[f32; 4]>,
155 pub border: Option<[f32; 4]>,
156 pub hover_border: Option<[f32; 4]>,
157 pub radius: f32,
158 /// Inset of the label and children on each axis.
159 pub pad: [f32; 2],
160 /// Space between children along the flow.
161 pub gap: f32,
162 pub center: bool,
163 /// For floating boxes, the offset from the parent's corner.
164 pub position: [f32; 2],
165 pub cursor: Option<CursorIcon>,
166}
167
168/// Input the host forwards; positions and wheel distances are logical pixels.
169#[derive(Clone, Debug, PartialEq)]
170pub enum Event {
171 PointerMoved([f32; 2]),
172 PointerLeft,
173 Button {
174 button: MouseButton,
175 pressed: bool,
176 at: Instant,
177 },
178 Wheel([f32; 2]),
179 Key {
180 key: Key,
181 text: Option<String>,
182 },
183 Ime(Ime),
184 Modifiers(ModifiersState),
185}
186
187/// How the user acted on a box this frame.
188#[derive(Clone, Debug, Default)]
189pub struct Signal {
190 pub hovered: bool,
191 /// The primary button went down on the box.
192 pub pressed: bool,
193 /// The primary button came up over the box it went down on.
194 pub clicked: bool,
195 /// The primary button is held after pressing the box.
196 pub dragging: bool,
197 pub focused: bool,
198 /// For custom boxes: every event routed to the box, in order. For focused boxes:
199 /// keys, text and composition.
200 pub events: Vec<Event>,
201}
202
203/// A frame's painting, in order: interface primitives under a clip, or a host-painted box.
204pub enum Layer<'a> {
205 Primitives {
206 /// Logical pixels.
207 clip: Option<[f32; 4]>,
208 primitives: Vec<Primitive<'a>>,
209 },
210 Custom {
211 id: Id,
212 /// The box's visible logical rectangle.
213 rect: [f32; 4],
214 },
215}
216
217struct Built {
218 id: Id,
219 parent: usize,
220 children: Vec<usize>,
221 flags: Flags,
222 size: [Extent; 2],
223 axis: Axis,
224 label: Option<Rc<Label>>,
225 color: [f32; 4],
226 fill: Option<[f32; 4]>,
227 hover_fill: Option<[f32; 4]>,
228 border: Option<[f32; 4]>,
229 hover_border: Option<[f32; 4]>,
230 radius: f32,
231 pad: [f32; 2],
232 gap: f32,
233 center: bool,
234 position: [f32; 2],
235 cursor: Option<CursorIcon>,
236 /// Rectangles relative to the box, painted over its fill.
237 marks: Vec<([f32; 4], [f32; 4])>,
238 computed: [f32; 2],
239 relative: [f32; 2],
240 rect: [f32; 4],
241 content: f32,
242}
243
244#[derive(Default)]
245struct State {
246 touched: u64,
247 hot: f32,
248 active: f32,
249 scroll: f32,
250 scroll_target: f32,
251 /// Height of a scrolling box's children last frame.
252 content: f32,
253 rect: [f32; 4],
254 /// A text field's caret and selection anchor, in bytes.
255 caret: usize,
256 mark: usize,
257 /// Where a dragged scrollbar thumb was taken, from its start.
258 grab: f32,
259}
260
261struct Hit {
262 id: Id,
263 rect: [f32; 4],
264 flags: Flags,
265 cursor: Option<CursorIcon>,
266}
267
268enum Display {
269 Clip(Option<[f32; 4]>),
270 Rect {
271 rect: [f32; 4],
272 fill: [f32; 4],
273 border: Option<[f32; 4]>,
274 radius: f32,
275 },
276 Text {
277 painted: usize,
278 origin: [f32; 2],
279 clip: [f32; 4],
280 },
281 Custom {
282 id: Id,
283 rect: [f32; 4],
284 },
285}
286
287pub struct Ui {
288 pub theme: Theme,
289 frame: u64,
290 now: Instant,
291 scale: f32,
292 nodes: Vec<Built>,
293 stack: Vec<usize>,
294 states: HashMap<Id, State>,
295 texts: Texts,
296 queue: Vec<Event>,
297 signals: HashMap<Id, Signal>,
298 /// The previous frame's interactive boxes in paint order.
299 hits: Vec<Hit>,
300 pointer: Option<[f32; 2]>,
301 hover: Option<Id>,
302 active: Option<Id>,
303 focus: Option<Id>,
304 modifiers: ModifiersState,
305 display: Vec<Display>,
306 painted: Vec<Painted>,
307 animating: bool,
308}
309
310impl Ui {
311 pub fn new(theme: Theme) -> Self {
312 Self {
313 theme,
314 frame: 0,
315 now: Instant::now(),
316 scale: 1.0,
317 nodes: Vec::new(),
318 stack: Vec::new(),
319 states: HashMap::new(),
320 texts: Texts::default(),
321 queue: Vec::new(),
322 signals: HashMap::new(),
323 hits: Vec::new(),
324 pointer: None,
325 hover: None,
326 active: None,
327 focus: None,
328 modifiers: ModifiersState::empty(),
329 display: Vec::new(),
330 painted: Vec::new(),
331 animating: false,
332 }
333 }
334
335 /// Queues input for the next frame.
336 pub fn event(&mut self, event: Event) {
337 self.queue.push(event);
338 }
339
340 /// Whether queued input or animation needs another frame.
341 pub fn wants_frame(&self) -> bool {
342 self.animating || !self.queue.is_empty()
343 }
344
345 pub fn scale(&self) -> f32 {
346 self.scale
347 }
348
349 pub(crate) fn modifiers(&self) -> ModifiersState {
350 self.modifiers
351 }
352
353 pub(crate) fn pointer(&self) -> Option<[f32; 2]> {
354 self.pointer
355 }
356
357 pub fn focused(&self) -> Option<Id> {
358 self.focus
359 }
360
361 pub fn set_focus(&mut self, id: Option<Id>) {
362 self.focus = id;
363 }
364
365 /// The clickable or custom box at `point` in the latest layout.
366 pub fn box_at(&self, point: [f32; 2]) -> Option<Id> {
367 self.hit(point, Flags::CLICKABLE | Flags::CUSTOM)
368 }
369
370 /// The box's rectangle from the latest layout.
371 pub fn rect(&self, id: Id) -> Option<[f32; 4]> {
372 self.states.get(&id).map(|state| state.rect)
373 }
374
375 /// The pointer's cursor, or None over a custom box, whose host chooses.
376 pub fn cursor(&self) -> Option<CursorIcon> {
377 let hit = self
378 .hover
379 .and_then(|id| self.hits.iter().find(|hit| hit.id == id));
380 match hit {
381 Some(hit) if hit.flags.contains(Flags::CUSTOM) => None,
382 Some(hit) => Some(hit.cursor.unwrap_or_default()),
383 None => Some(CursorIcon::Default),
384 }
385 }
386
387 /// Starts a frame of `size` logical pixels drawn at `scale` device pixels each, and
388 /// routes the queued input through the previous frame's layout.
389 pub fn begin(&mut self, size: [f32; 2], scale: f32, now: Instant) {
390 self.frame += 1;
391 let dt = now
392 .saturating_duration_since(self.now)
393 .as_secs_f32()
394 .min(0.1);
395 self.now = now;
396 self.scale = scale;
397 self.nodes.clear();
398 self.nodes.push(Built::new(
399 Id::ROOT,
400 0,
401 Spec {
402 size: [px(size[0]), px(size[1])],
403 axis: Axis::Y,
404 ..Spec::default()
405 },
406 None,
407 self.theme.text,
408 ));
409 self.stack.clear();
410 self.stack.push(0);
411 self.signals.clear();
412 for event in std::mem::take(&mut self.queue) {
413 self.route(event);
414 }
415 self.animate(dt);
416 }
417
418 fn route(&mut self, event: Event) {
419 match event {
420 Event::PointerMoved(point) => {
421 self.pointer = Some(point);
422 let hover = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM);
423 if hover != self.hover
424 && self.active.is_none_or(|active| Some(active) != self.hover)
425 {
426 self.custom_event(self.hover, Event::PointerLeft);
427 }
428 self.hover = hover;
429 self.custom_event(self.active.or(hover), event);
430 }
431 Event::PointerLeft => {
432 self.pointer = None;
433 if self.active.is_none() {
434 self.custom_event(self.hover, Event::PointerLeft);
435 self.hover = None;
436 }
437 }
438 Event::Button {
439 button,
440 pressed: true,
441 ..
442 } => {
443 let Some(point) = self.pointer else {
444 return;
445 };
446 let target = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM);
447 if let Some(id) = target
448 && button == MouseButton::Left
449 {
450 self.active = Some(id);
451 self.signals.entry(id).or_default().pressed = true;
452 if self.hit_flags(id).contains(Flags::FOCUSABLE) {
453 self.focus = Some(id);
454 }
455 }
456 self.custom_event(target, event);
457 }
458 Event::Button { pressed: false, .. } => {
459 let Some(active) = self.active else {
460 self.custom_event(self.hover, event);
461 return;
462 };
463 if self
464 .pointer
465 .and_then(|point| self.hit(point, Flags::CLICKABLE | Flags::CUSTOM))
466 == Some(active)
467 {
468 self.signals.entry(active).or_default().clicked = true;
469 }
470 self.custom_event(Some(active), event);
471 self.active = None;
472 self.hover = self
473 .pointer
474 .and_then(|point| self.hit(point, Flags::CLICKABLE | Flags::CUSTOM));
475 if self.hover != Some(active) {
476 self.custom_event(Some(active), Event::PointerLeft);
477 }
478 }
479 Event::Wheel(delta) => {
480 let Some(point) = self.pointer else {
481 return;
482 };
483 let target = self.hit(point, Flags::SCROLL | Flags::CUSTOM);
484 match target {
485 Some(id) if self.hit_flags(id).contains(Flags::CUSTOM) => {
486 self.custom_event(Some(id), event)
487 }
488 Some(id) => {
489 let state = self.states.entry(id).or_default();
490 let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0);
491 state.scroll_target = (state.scroll_target - delta[1]).clamp(0.0, most);
492 }
493 None => {}
494 }
495 }
496 Event::Key { .. } | Event::Ime(_) => {
497 if let Some(focus) = self.focus {
498 self.signals.entry(focus).or_default().events.push(event);
499 }
500 }
501 Event::Modifiers(modifiers) => {
502 self.modifiers = modifiers;
503 let custom: Vec<_> = self
504 .hits
505 .iter()
506 .filter(|hit| hit.flags.contains(Flags::CUSTOM))
507 .map(|hit| hit.id)
508 .collect();
509 for id in custom {
510 self.signals
511 .entry(id)
512 .or_default()
513 .events
514 .push(event.clone());
515 }
516 }
517 }
518 }
519
520 fn custom_event(&mut self, target: Option<Id>, event: Event) {
521 if let Some(id) = target
522 && self.hit_flags(id).contains(Flags::CUSTOM)
523 {
524 self.signals.entry(id).or_default().events.push(event);
525 }
526 }
527
528 fn hit_flags(&self, id: Id) -> Flags {
529 self.hits
530 .iter()
531 .find(|hit| hit.id == id)
532 .map_or(Flags::default(), |hit| hit.flags)
533 }
534
535 /// The topmost box under `point` with any of `flags`.
536 fn hit(&self, point: [f32; 2], flags: Flags) -> Option<Id> {
537 self.hits
538 .iter()
539 .rev()
540 .find(|hit| {
541 hit.flags.intersects(flags)
542 && point[0] >= hit.rect[0]
543 && point[0] < hit.rect[2]
544 && point[1] >= hit.rect[1]
545 && point[1] < hit.rect[3]
546 })
547 .map(|hit| hit.id)
548 }
549
550 fn animate(&mut self, dt: f32) {
551 let rate = 1.0 - 0.5_f32.powf(dt / HALF_LIFE);
552 let mut animating = false;
553 for (id, state) in &mut self.states {
554 let hot = self.hover == Some(*id) && self.active.is_none_or(|active| active == *id);
555 let targets = [
556 (&mut state.hot, f32::from(u8::from(hot)), 0.002),
557 (
558 &mut state.active,
559 f32::from(u8::from(self.active == Some(*id))),
560 0.002,
561 ),
562 (&mut state.scroll, state.scroll_target, 0.25),
563 ];
564 for (value, target, close) in targets {
565 let delta = target - *value;
566 if delta.abs() < close {
567 *value = target;
568 } else {
569 *value += delta * rate;
570 animating = true;
571 }
572 }
573 }
574 self.animating = animating;
575 }
576
577 /// The box being built, which new boxes become children of.
578 pub(crate) fn current(&self) -> Id {
579 self.nodes[*self.stack.last().unwrap()].id
580 }
581
582 /// The id a box built next under the current parent would take.
583 pub fn id(&self, part: impl Hash) -> Id {
584 self.nodes[*self.stack.last().unwrap()].id.child(part)
585 }
586
587 /// Adds a box and makes it the parent of the boxes built until `close`.
588 pub fn open(&mut self, part: impl Hash, spec: Spec<'_>) -> Id {
589 self.open_as(self.id(part), spec)
590 }
591
592 /// Opens a box under an id chosen outside the hierarchy, so the host can name it
593 /// wherever it is built.
594 pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id {
595 let label = spec
596 .text
597 .map(|text| self.texts.label(text, self.theme.font_size, self.frame));
598 let parent = *self.stack.last().unwrap();
599 let index = self.nodes.len();
600 self.nodes
601 .push(Built::new(id, parent, spec, label, self.theme.text));
602 self.nodes[parent].children.push(index);
603 self.stack.push(index);
604 self.states.entry(id).or_default().touched = self.frame;
605 id
606 }
607
608 pub fn close(&mut self) {
609 debug_assert!(self.stack.len() > 1, "close without open");
610 self.stack.pop();
611 }
612
613 /// Adds a box without children and reports how it was used.
614 pub fn leaf(&mut self, part: impl Hash, spec: Spec<'_>) -> Signal {
615 let id = self.open(part, spec);
616 self.close();
617 self.signal(id)
618 }
619
620 /// Paints `color` over the current box at `rect`, relative to its corner.
621 pub fn mark(&mut self, rect: [f32; 4], color: [f32; 4]) {
622 let index = *self.stack.last().unwrap();
623 self.nodes[index].marks.push((rect, color));
624 }
625
626 /// How the user acted on the box this frame; a box's routed events are taken once.
627 pub fn signal(&mut self, id: Id) -> Signal {
628 let mut signal = self.signals.remove(&id).unwrap_or_default();
629 signal.hovered = self.hover == Some(id);
630 signal.dragging = self.active == Some(id);
631 signal.focused = self.focus == Some(id);
632 signal
633 }
634
635 /// Solves this frame's layout and prepares its painting.
636 pub fn end(&mut self) {
637 debug_assert_eq!(self.stack.len(), 1, "open without close");
638 layout::solve(&mut self.nodes, &self.states, self.scale);
639 for node in &self.nodes {
640 let state = self.states.entry(node.id).or_default();
641 state.rect = node.rect;
642 if node.flags.contains(Flags::SCROLL) {
643 state.content = node.content;
644 let most = (node.content - (node.rect[3] - node.rect[1])).max(0.0);
645 state.scroll_target = state.scroll_target.clamp(0.0, most);
646 state.scroll = state.scroll.clamp(0.0, most);
647 }
648 }
649 self.states
650 .retain(|id, state| state.touched == self.frame || *id == Id::ROOT);
651 self.texts.prune(self.frame);
652 self.display.clear();
653 self.painted.clear();
654 self.hits.clear();
655 self.paint(0, None);
656 for id in [&mut self.hover, &mut self.active, &mut self.focus] {
657 if id.is_some_and(|id| !self.states.contains_key(&id)) {
658 *id = None;
659 }
660 }
661 }
662
663 fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) {
664 let node = &self.nodes[index];
665 let state = &self.states[&node.id];
666 let rect = node.rect;
667 let visible = clip.map_or(Some(rect), |clip| intersect(rect, clip));
668 let blend = |base: Option<[f32; 4]>, hover: Option<[f32; 4]>| match (base, hover) {
669 (base, Some(hover)) => Some(mix(base.unwrap_or([0.0; 4]), hover, state.hot)),
670 (base, None) => base,
671 };
672 let fill = blend(node.fill, node.hover_fill);
673 let border = blend(node.border, node.hover_border);
674 if fill.is_some() || border.is_some() {
675 self.display.push(Display::Rect {
676 rect,
677 fill: fill.unwrap_or([0.0; 4]),
678 border,
679 radius: node.radius,
680 });
681 }
682 for (mark, color) in &node.marks {
683 self.display.push(Display::Rect {
684 rect: [
685 rect[0] + mark[0],
686 rect[1] + mark[1],
687 rect[0] + mark[2],
688 rect[1] + mark[3],
689 ],
690 fill: *color,
691 border: None,
692 radius: 0.0,
693 });
694 }
695 if let Some(label) = &node.label {
696 let inner = [
697 rect[0] + node.pad[0],
698 rect[1],
699 rect[2] - node.pad[0],
700 rect[3],
701 ];
702 let x = if node.center {
703 (inner[0] + inner[2] - label.size[0]) / 2.0
704 } else {
705 inner[0]
706 };
707 let y = (rect[1] + rect[3] - label.size[1]) / 2.0;
708 self.display.push(Display::Text {
709 painted: self.painted.len(),
710 origin: [x, y],
711 clip: inner,
712 });
713 self.painted.push(Painted {
714 label: label.clone(),
715 color: node.color,
716 });
717 }
718 if let Some(visible) = visible
719 && node
720 .flags
721 .intersects(Flags::CLICKABLE | Flags::CUSTOM | Flags::SCROLL)
722 {
723 self.hits.push(Hit {
724 id: node.id,
725 rect: visible,
726 flags: node.flags,
727 cursor: node.cursor,
728 });
729 if node.flags.contains(Flags::CUSTOM) {
730 self.display.push(Display::Custom {
731 id: node.id,
732 rect: visible,
733 });
734 }
735 }
736 let inner_clip = if node.flags.contains(Flags::CLIP) {
737 Some(visible.unwrap_or([rect[0], rect[1], rect[0], rect[1]]))
738 } else {
739 clip
740 };
741 if inner_clip != clip {
742 self.display.push(Display::Clip(inner_clip));
743 }
744 for child in node.children.clone() {
745 self.paint(child, inner_clip);
746 }
747 if inner_clip != clip {
748 self.display.push(Display::Clip(clip));
749 }
750 }
751
752 /// This frame's painting in order, in logical pixels.
753 pub fn layers(&self) -> Vec<Layer<'_>> {
754 let mut layers = Vec::new();
755 let mut clip = None;
756 let mut primitives = Vec::new();
757 for item in &self.display {
758 match item {
759 Display::Clip(next) => {
760 if !primitives.is_empty() {
761 layers.push(Layer::Primitives {
762 clip,
763 primitives: std::mem::take(&mut primitives),
764 });
765 }
766 clip = *next;
767 }
768 Display::Rect {
769 rect,
770 fill,
771 border,
772 radius,
773 } => {
774 if fill[3] > 0.0 {
775 primitives.push(if *radius > 0.0 {
776 Primitive::RoundedRect {
777 rect: *rect,
778 radius: [*radius; 2],
779 stroke: None,
780 color: *fill,
781 }
782 } else {
783 Primitive::Rect {
784 rect: *rect,
785 color: *fill,
786 }
787 });
788 }
789 if let Some(border) = border.filter(|border| border[3] > 0.0) {
790 primitives.push(Primitive::RoundedRect {
791 rect: *rect,
792 radius: [*radius; 2],
793 stroke: Some(Stroke::Solid(1.0 / self.scale)),
794 color: border,
795 });
796 }
797 }
798 Display::Text {
799 painted,
800 origin,
801 clip,
802 } => primitives.push(Primitive::Text {
803 text: &self.painted[*painted],
804 origin: *origin,
805 clip: Some(*clip),
806 }),
807 Display::Custom { id, rect } => {
808 if !primitives.is_empty() {
809 layers.push(Layer::Primitives {
810 clip,
811 primitives: std::mem::take(&mut primitives),
812 });
813 }
814 layers.push(Layer::Custom {
815 id: *id,
816 rect: *rect,
817 });
818 }
819 }
820 }
821 if !primitives.is_empty() {
822 layers.push(Layer::Primitives { clip, primitives });
823 }
824 layers
825 }
826
827 pub(crate) fn texts(&mut self) -> (&mut Texts, u64) {
828 (&mut self.texts, self.frame)
829 }
830
831 pub(crate) fn caret(&mut self, id: Id) -> (&mut usize, &mut usize) {
832 let state = self.states.entry(id).or_default();
833 (&mut state.caret, &mut state.mark)
834 }
835
836 pub(crate) fn grab(&mut self, id: Id) -> &mut f32 {
837 &mut self.states.entry(id).or_default().grab
838 }
839}
840
841impl Built {
842 fn new(
843 id: Id,
844 parent: usize,
845 spec: Spec<'_>,
846 label: Option<Rc<Label>>,
847 text: [f32; 4],
848 ) -> Self {
849 Self {
850 id,
851 parent,
852 children: Vec::new(),
853 flags: spec.flags,
854 size: spec.size,
855 axis: spec.axis,
856 label,
857 color: spec.color.unwrap_or(text),
858 fill: spec.fill,
859 hover_fill: spec.hover_fill,
860 border: spec.border,
861 hover_border: spec.hover_border,
862 radius: spec.radius,
863 pad: spec.pad,
864 gap: spec.gap,
865 center: spec.center,
866 position: spec.position,
867 cursor: spec.cursor,
868 marks: Vec::new(),
869 computed: [0.0; 2],
870 relative: [0.0; 2],
871 rect: [0.0; 4],
872 content: 0.0,
873 }
874 }
875}
876
877fn mix(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
878 std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t)
879}
880
881fn intersect(a: [f32; 4], b: [f32; 4]) -> Option<[f32; 4]> {
882 let rect = [
883 a[0].max(b[0]),
884 a[1].max(b[1]),
885 a[2].min(b[2]),
886 a[3].min(b[3]),
887 ];
888 (rect[0] < rect[2] && rect[1] < rect[3]).then_some(rect)
889}
890
891#[cfg(test)]
892mod tests;
crates/ui/src/tests.rs created+382
...@@ -0,0 +1,382 @@
1use super::*;
2use std::time::Duration;
3use winit::keyboard::NamedKey;
4
5thread_local! {
6 static START: Instant = Instant::now();
7 static FRAMES: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
8}
9
10/// Builds a frame 16 ms after this thread's previous one.
11fn frame(ui: &mut Ui, build: impl FnOnce(&mut Ui)) {
12 let frames = FRAMES.with(|frames| {
13 frames.set(frames.get() + 1);
14 frames.get()
15 });
16 let now = START.with(|start| *start) + Duration::from_millis(16) * frames;
17 ui.begin([400.0, 300.0], 2.0, now);
18 build(ui);
19 ui.end();
20}
21
22#[test]
23fn fill_yields_to_strict_siblings_along_the_flow() {
24 let mut ui = Ui::new(Theme::dark());
25 let mut ids = Vec::new();
26 frame(&mut ui, |ui| {
27 ui.open(
28 "row",
29 Spec {
30 size: [fill(), px(100.0)],
31 ..Spec::default()
32 },
33 );
34 ids.push(ui.open(
35 "page",
36 Spec {
37 size: [fill(), fill()],
38 ..Spec::default()
39 },
40 ));
41 ui.close();
42 ids.push(ui.open(
43 "list",
44 Spec {
45 size: [px(120.0), fill()],
46 ..Spec::default()
47 },
48 ));
49 ui.close();
50 ui.close();
51 });
52 assert_eq!(ui.rect(ids[0]), Some([0.0, 0.0, 280.0, 100.0]));
53 assert_eq!(ui.rect(ids[1]), Some([280.0, 0.0, 400.0, 100.0]));
54}
55
56#[test]
57fn children_sum_with_gaps_and_padding() {
58 let mut ui = Ui::new(Theme::dark());
59 let mut column = None;
60 let mut second = None;
61 frame(&mut ui, |ui| {
62 column = Some(ui.open(
63 "column",
64 Spec {
65 axis: Axis::Y,
66 pad: [4.0, 6.0],
67 gap: 2.0,
68 ..Spec::default()
69 },
70 ));
71 ui.leaf(
72 1,
73 Spec {
74 size: [px(50.0), px(10.0)],
75 ..Spec::default()
76 },
77 );
78 second = Some(ui.open(
79 2,
80 Spec {
81 size: [px(30.0), px(20.0)],
82 ..Spec::default()
83 },
84 ));
85 ui.close();
86 ui.close();
87 });
88 assert_eq!(ui.rect(column.unwrap()), Some([0.0, 0.0, 58.0, 44.0]));
89 assert_eq!(ui.rect(second.unwrap()), Some([4.0, 18.0, 34.0, 38.0]));
90}
91
92fn button_frame(ui: &mut Ui) -> Signal {
93 let mut signal = Signal::default();
94 frame(ui, |ui| {
95 signal = ui.leaf(
96 "button",
97 Spec {
98 flags: Flags::CLICKABLE,
99 size: [px(100.0), px(40.0)],
100 hover_fill: Some([1.0; 4]),
101 ..Spec::default()
102 },
103 );
104 });
105 signal
106}
107
108#[test]
109fn clicks_use_the_previous_layout_and_hover_animates() {
110 let mut ui = Ui::new(Theme::dark());
111 button_frame(&mut ui);
112 let at = Instant::now();
113 ui.event(Event::PointerMoved([10.0, 10.0]));
114 ui.event(Event::Button {
115 button: MouseButton::Left,
116 pressed: true,
117 at,
118 });
119 let signal = button_frame(&mut ui);
120 assert!(signal.hovered && signal.pressed && signal.dragging && !signal.clicked);
121 assert!(ui.wants_frame(), "hover fades in");
122 ui.event(Event::Button {
123 button: MouseButton::Left,
124 pressed: false,
125 at,
126 });
127 ui.event(Event::Button {
128 button: MouseButton::Left,
129 pressed: true,
130 at: at + Duration::from_millis(100),
131 });
132 let signal = button_frame(&mut ui);
133 assert!(signal.clicked && signal.pressed);
134 button_frame(&mut ui);
135 ui.event(Event::PointerMoved([200.0, 10.0]));
136 ui.event(Event::Button {
137 button: MouseButton::Left,
138 pressed: false,
139 at,
140 });
141 let signal = button_frame(&mut ui);
142 assert!(!signal.clicked && !signal.hovered && !signal.dragging);
143 assert!(ui.wants_frame(), "hover fades out");
144 for _ in 0..40 {
145 button_frame(&mut ui);
146 }
147 assert!(!ui.wants_frame());
148}
149
150#[test]
151fn custom_boxes_receive_their_events_and_a_leave_when_the_pointer_moves_off() {
152 let mut ui = Ui::new(Theme::dark());
153 let build = |ui: &mut Ui| {
154 let mut signals = Vec::new();
155 frame(ui, |ui| {
156 ui.open(
157 "row",
158 Spec {
159 size: [fill(), fill()],
160 ..Spec::default()
161 },
162 );
163 signals.push(ui.leaf(
164 "page",
165 Spec {
166 flags: Flags::CUSTOM | Flags::FOCUSABLE,
167 size: [fill(), fill()],
168 ..Spec::default()
169 },
170 ));
171 signals.push(ui.leaf(
172 "list",
173 Spec {
174 flags: Flags::CLICKABLE,
175 size: [px(100.0), fill()],
176 ..Spec::default()
177 },
178 ));
179 ui.close();
180 });
181 signals
182 };
183 build(&mut ui);
184 let at = Instant::now();
185 ui.event(Event::PointerMoved([20.0, 20.0]));
186 ui.event(Event::Button {
187 button: MouseButton::Left,
188 pressed: true,
189 at,
190 });
191 ui.event(Event::Key {
192 key: Key::Character("a".into()),
193 text: Some("a".into()),
194 });
195 let signals = build(&mut ui);
196 assert!(signals[0].focused);
197 assert_eq!(signals[0].events.len(), 3);
198 assert!(matches!(signals[0].events[2], Event::Key { .. }));
199 assert!(
200 ui.cursor().is_none(),
201 "the host picks the cursor over its box"
202 );
203 ui.event(Event::PointerMoved([350.0, 20.0]));
204 let signals = build(&mut ui);
205 assert_eq!(
206 signals[0].events,
207 [Event::PointerMoved([350.0, 20.0])],
208 "a drag keeps its box"
209 );
210 ui.event(Event::Button {
211 button: MouseButton::Left,
212 pressed: false,
213 at,
214 });
215 let signals = build(&mut ui);
216 assert!(matches!(
217 signals[0].events[..],
218 [Event::Button { .. }, Event::PointerLeft]
219 ));
220 assert!(signals[1].hovered);
221 ui.event(Event::Modifiers(ModifiersState::SHIFT));
222 let signals = build(&mut ui);
223 assert_eq!(signals[0].events, [Event::Modifiers(ModifiersState::SHIFT)]);
224}
225
226#[test]
227fn wheel_scrolls_within_the_content_and_clips_children() {
228 let mut ui = Ui::new(Theme::dark());
229 let mut list = None;
230 let mut last = None;
231 let mut build = |ui: &mut Ui| {
232 frame(ui, |ui| {
233 list = Some(ui.open(
234 "list",
235 Spec {
236 flags: Flags::SCROLL | Flags::CLIP,
237 axis: Axis::Y,
238 size: [px(100.0), px(100.0)],
239 ..Spec::default()
240 },
241 ));
242 for row in 0..10 {
243 last = Some(ui.open(
244 row,
245 Spec {
246 flags: Flags::CLICKABLE,
247 size: [fill(), px(30.0)],
248 ..Spec::default()
249 },
250 ));
251 ui.close();
252 }
253 ui.close();
254 });
255 };
256 build(&mut ui);
257 ui.event(Event::PointerMoved([50.0, 50.0]));
258 ui.event(Event::Wheel([0.0, -1000.0]));
259 for _ in 0..60 {
260 build(&mut ui);
261 }
262 assert_eq!(ui.states[&list.unwrap()].scroll, 200.0);
263 assert_eq!(ui.rect(last.unwrap()), Some([0.0, 70.0, 100.0, 100.0]));
264 let layers = ui.layers();
265 assert!(matches!(
266 layers[..],
267 [Layer::Primitives {
268 clip: Some([0.0, 0.0, 100.0, 100.0]),
269 ..
270 }] | []
271 ));
272}
273
274#[test]
275fn text_fields_edit_with_keys_and_selection() {
276 let mut ui = Ui::new(Theme::dark());
277 let mut text = String::from("café");
278 let build = |ui: &mut Ui, text: &mut String| {
279 let mut signal = Signal::default();
280 frame(ui, |ui| {
281 signal = text_field(
282 ui,
283 Id::ROOT.child("filter"),
284 text,
285 "Filter",
286 Spec {
287 size: [px(200.0), px(26.0)],
288 pad: [6.0, 0.0],
289 ..Spec::default()
290 },
291 );
292 });
293 signal
294 };
295 build(&mut ui, &mut text);
296 let id = Id::ROOT.child("filter");
297 ui.set_focus(Some(id));
298 let key = |named| Event::Key {
299 key: Key::Named(named),
300 text: None,
301 };
302 ui.event(Event::Key {
303 key: Key::Named(NamedKey::End),
304 text: None,
305 });
306 ui.event(key(NamedKey::Backspace));
307 ui.event(Event::Key {
308 key: Key::Character("e".into()),
309 text: Some("e".into()),
310 });
311 build(&mut ui, &mut text);
312 assert_eq!(text, "cafe");
313 ui.event(Event::Modifiers(ModifiersState::SHIFT));
314 ui.event(key(NamedKey::ArrowLeft));
315 ui.event(key(NamedKey::ArrowLeft));
316 build(&mut ui, &mut text);
317 ui.event(Event::Modifiers(ModifiersState::empty()));
318 ui.event(Event::Ime(Ime::Commit("é".into())));
319 build(&mut ui, &mut text);
320 assert_eq!(text, "caé");
321 ui.event(Event::Modifiers(ModifiersState::SUPER));
322 ui.event(Event::Key {
323 key: Key::Character("a".into()),
324 text: Some("a".into()),
325 });
326 build(&mut ui, &mut text);
327 ui.event(Event::Modifiers(ModifiersState::empty()));
328 ui.event(key(NamedKey::Delete));
329 let signal = build(&mut ui, &mut text);
330 assert!(signal.focused);
331 assert_eq!(text, "");
332}
333
334#[test]
335fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() {
336 let mut ui = Ui::new(Theme::dark());
337 let mut offset = 0.0;
338 let build = |ui: &mut Ui, offset: &mut f32| {
339 frame(ui, |ui| {
340 ui.open(
341 "view",
342 Spec {
343 flags: Flags::CUSTOM,
344 size: [px(200.0), px(220.0)],
345 ..Spec::default()
346 },
347 );
348 if let Some(chosen) =
349 scrollbar(ui, "bar", Axis::Y, *offset, [0.0, 1000.0], 400.0, [0.5; 4])
350 {
351 *offset = chosen;
352 }
353 ui.close();
354 });
355 };
356 build(&mut ui, &mut offset);
357 build(&mut ui, &mut offset);
358 let thumb = Id::ROOT.child("view").child("bar");
359 let rect = ui.rect(thumb).unwrap();
360 assert_eq!(
361 rect,
362 [190.0, 4.0, 196.0, 61.0],
363 "a 400 of 1400 share of a 200 track"
364 );
365 let at = Instant::now();
366 ui.event(Event::PointerMoved([193.0, 10.0]));
367 ui.event(Event::Button {
368 button: MouseButton::Left,
369 pressed: true,
370 at,
371 });
372 build(&mut ui, &mut offset);
373 assert_eq!(offset, 0.0, "a press alone keeps the offset");
374 ui.event(Event::PointerMoved([193.0, 500.0]));
375 build(&mut ui, &mut offset);
376 assert_eq!(offset, 1000.0);
377 build(&mut ui, &mut offset);
378 assert_eq!(ui.rect(thumb).unwrap(), [190.0, 147.0, 196.0, 204.0]);
379 ui.event(Event::PointerMoved([193.0, -500.0]));
380 build(&mut ui, &mut offset);
381 assert_eq!(offset, 0.0);
382}
crates/ui/src/text.rs created+92
...@@ -0,0 +1,92 @@
1use draw::{GlyphRun, Glyphs, RenderError};
2use parley::{
3 Affinity, FontContext, FontFamily, GenericFamily, Layout, LayoutContext, PositionedLayoutItem,
4 StyleProperty, editing::Cursor,
5};
6use std::{collections::HashMap, rc::Rc};
7
8/// A shaped single line of interface text, in logical pixels.
9pub(crate) struct Label {
10 layout: Layout<()>,
11 pub size: [f32; 2],
12}
13
14impl Label {
15 /// The caret's left edge before byte `index`.
16 pub fn caret_x(&self, index: usize) -> f32 {
17 Cursor::from_byte_index(&self.layout, index, Affinity::Downstream)
18 .geometry(&self.layout, 1.0)
19 .x0 as f32
20 }
21
22 /// The byte index whose caret position is nearest `x`.
23 pub fn index_at(&self, x: f32) -> usize {
24 Cursor::from_point(&self.layout, x, self.size[1] / 2.0).index()
25 }
26}
27
28/// Labels shaped this frame or the previous one, keyed by their text and size.
29#[derive(Default)]
30pub(crate) struct Texts {
31 fonts: FontContext,
32 context: LayoutContext<()>,
33 cache: HashMap<(String, u32), (Rc<Label>, u64)>,
34}
35
36impl Texts {
37 /// `size` is in logical pixels.
38 pub fn label(&mut self, text: &str, size: f32, frame: u64) -> Rc<Label> {
39 let key = (text.to_owned(), size.to_bits());
40 if let Some((label, touched)) = self.cache.get_mut(&key) {
41 *touched = frame;
42 return label.clone();
43 }
44 let mut builder = self
45 .context
46 .ranged_builder(&mut self.fonts, text, 1.0, false);
47 builder.push_default(StyleProperty::FontFamily(FontFamily::from(
48 GenericFamily::SystemUi,
49 )));
50 builder.push_default(StyleProperty::FontSize(size));
51 let mut layout = builder.build(text);
52 layout.break_all_lines(None);
53 let size = [layout.full_width(), layout.height()];
54 let label = Rc::new(Label { layout, size });
55 self.cache.insert(key, (label.clone(), frame));
56 label
57 }
58
59 pub fn prune(&mut self, frame: u64) {
60 self.cache.retain(|_, (_, touched)| *touched + 1 >= frame);
61 }
62}
63
64/// A label painted in one colour this frame.
65pub(crate) struct Painted {
66 pub label: Rc<Label>,
67 pub color: [f32; 4],
68}
69
70impl Glyphs for Painted {
71 fn runs(
72 &self,
73 paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>,
74 ) -> Result<(), RenderError> {
75 for line in self.label.layout.lines() {
76 let metrics = line.metrics();
77 for item in line.items() {
78 if let PositionedLayoutItem::GlyphRun(run) = item {
79 draw::paint_parley_run(
80 &run,
81 metrics.baseline,
82 0.0,
83 [metrics.block_min_coord, metrics.line_height],
84 |_| self.color,
85 paint,
86 )?;
87 }
88 }
89 }
90 Ok(())
91 }
92}
crates/ui/src/theme.rs created+40
...@@ -0,0 +1,40 @@
1use draw::srgb;
2
3/// Colours are linear RGBA; sizes are logical pixels.
4#[derive(Clone, Debug)]
5pub struct Theme {
6 pub font_size: f32,
7 /// Content surfaces, such as the active tab and lists.
8 pub base: [f32; 4],
9 /// Side panels.
10 pub panel: [f32; 4],
11 /// The window's top strip and inactive tabs.
12 pub strip: [f32; 4],
13 /// Hairlines between rows and panels.
14 pub separator: [f32; 4],
15 pub accent: [f32; 4],
16 /// A hovered row under the pointer, outlined in the accent.
17 pub hover: [f32; 4],
18 pub text: [f32; 4],
19 pub text_dim: [f32; 4],
20 /// Key caps and small controls.
21 pub chip: [f32; 4],
22}
23
24impl Theme {
25 /// After File Pilot's default dark scheme.
26 pub fn dark() -> Self {
27 Self {
28 font_size: 13.0,
29 base: srgb(0x19, 0x1b, 0x1c),
30 panel: srgb(0x1f, 0x22, 0x23),
31 strip: srgb(0x27, 0x2a, 0x2b),
32 separator: srgb(0x20, 0x23, 0x24),
33 accent: srgb(0x00, 0x79, 0xa6),
34 hover: srgb(0x10, 0x3c, 0x4c),
35 text: srgb(0xdd, 0xde, 0xe0),
36 text_dim: srgb(0x6b, 0x70, 0x78),
37 chip: srgb(0x38, 0x3c, 0x3d),
38 }
39 }
40}
crates/ui/src/widgets.rs created+248
...@@ -0,0 +1,248 @@
1use crate::{Axis, Event, Flags, Id, Signal, Spec, Ui, fit, px};
2use std::hash::Hash;
3use winit::{
4 event::Ime,
5 keyboard::{Key, NamedKey},
6 window::CursorIcon,
7};
8
9/// A labelled push button that fills with the accent tint under the pointer.
10pub fn button(ui: &mut Ui, part: impl Hash, text: &str) -> Signal {
11 let theme = &ui.theme;
12 let spec = Spec {
13 flags: Flags::CLICKABLE,
14 size: [fit(), px(theme.font_size * 2.0)],
15 text: Some(text),
16 fill: Some(theme.chip),
17 hover_fill: Some(theme.hover),
18 hover_border: Some(theme.accent),
19 radius: 4.0,
20 pad: [theme.font_size * 0.75, 0.0],
21 center: true,
22 ..Spec::default()
23 };
24 ui.leaf(part, spec)
25}
26
27/// An overlay scrollbar along the far edge of the current box, for content whose scroll
28/// offset ranges over `range` while `view` of it shows, all in one unit. Returns the offset
29/// a drag of its thumb chose.
30pub fn scrollbar(
31 ui: &mut Ui,
32 part: impl Hash,
33 axis: Axis,
34 offset: f32,
35 range: [f32; 2],
36 view: f32,
37 color: [f32; 4],
38) -> Option<f32> {
39 let along = usize::from(axis == Axis::Y);
40 let rect = ui.rect(ui.current())?;
41 let length = rect[along + 2] - rect[along];
42 let cross = rect[3 - along] - rect[1 - along];
43 let span = range[1] - range[0];
44 // The far end leaves room for the other axis's bar.
45 let track = length - 20.0;
46 if span <= 0.0 || track <= 0.0 {
47 return None;
48 }
49 let size = (track * view / (view + span)).max(28.0).min(track);
50 let start = 4.0 + ((offset - range[0]) / span).clamp(0.0, 1.0) * (track - size);
51 let id = ui.id(&part);
52 let signal = ui.signal(id);
53 let pointer = ui.pointer().map(|pointer| pointer[along] - rect[along]);
54 if signal.pressed
55 && let Some(pointer) = pointer
56 {
57 *ui.grab(id) = pointer - start;
58 }
59 let chosen = signal.dragging.then_some(pointer).flatten().map(|pointer| {
60 let travel = (track - size).max(f32::EPSILON);
61 let fraction = ((pointer - *ui.grab(id) - 4.0) / travel).clamp(0.0, 1.0);
62 range[0] + fraction * span
63 });
64 let mut position = [0.0; 2];
65 position[along] = start;
66 position[1 - along] = cross - 10.0;
67 let mut extent = [px(6.0); 2];
68 extent[along] = px(size);
69 ui.leaf(
70 &part,
71 Spec {
72 flags: Flags::CLICKABLE | Flags::FLOAT,
73 size: extent,
74 position,
75 fill: Some(color),
76 hover_fill: Some([color[0], color[1], color[2], (color[3] * 1.6).min(1.0)]),
77 radius: 3.0,
78 cursor: Some(CursorIcon::Default),
79 ..Spec::default()
80 },
81 );
82 chosen
83}
84
85/// A single-line field editing `text`, showing `placeholder` while empty. Typing,
86/// Backspace/Delete, Left/Right with Shift to select, Home/End, Command-A and input
87/// method commits edit it; a press places the caret.
88pub fn text_field(
89 ui: &mut Ui,
90 id: Id,
91 text: &mut String,
92 placeholder: &str,
93 spec: Spec<'_>,
94) -> Signal {
95 let signal = ui.signal(id);
96 let pad = spec.pad[0];
97 let size = ui.theme.font_size;
98 let clicked_at = signal
99 .pressed
100 .then(|| ui.pointer().zip(ui.rect(id)))
101 .flatten()
102 .map(|(pointer, rect)| pointer[0] - rect[0] - pad);
103 let command = ui.modifiers().super_key() || ui.modifiers().control_key();
104 let shift = ui.modifiers().shift_key();
105 let (mut caret, mut mark) = {
106 let (caret, mark) = ui.caret(id);
107 (*caret, *mark)
108 };
109 caret = floor_boundary(text, caret);
110 mark = floor_boundary(text, mark);
111 if let Some(x) = clicked_at {
112 let (texts, frame) = ui.texts();
113 caret = texts.label(text, size, frame).index_at(x);
114 mark = caret;
115 }
116 for event in &signal.events {
117 let selection = caret.min(mark)..caret.max(mark);
118 let insert = |text: &mut String, inserted: &str, caret: &mut usize| {
119 text.replace_range(selection.clone(), inserted);
120 *caret = selection.start + inserted.len();
121 };
122 match event {
123 Event::Ime(Ime::Commit(committed)) => {
124 insert(text, committed, &mut caret);
125 mark = caret;
126 }
127 Event::Key { key, text: typed } => match key {
128 Key::Named(NamedKey::Backspace | NamedKey::Delete) => {
129 if selection.is_empty() {
130 let edge = if matches!(key, Key::Named(NamedKey::Backspace)) {
131 previous(text, caret)..caret
132 } else {
133 caret..next(text, caret)
134 };
135 text.replace_range(edge.clone(), "");
136 caret = edge.start;
137 } else {
138 insert(text, "", &mut caret);
139 }
140 mark = caret;
141 }
142 Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowRight) => {
143 let left = matches!(key, Key::Named(NamedKey::ArrowLeft));
144 caret = match (shift, selection.is_empty(), left) {
145 (false, false, true) => selection.start,
146 (false, false, false) => selection.end,
147 (_, _, true) => previous(text, caret),
148 (_, _, false) => next(text, caret),
149 };
150 if !shift {
151 mark = caret;
152 }
153 }
154 Key::Named(NamedKey::Home | NamedKey::End) => {
155 caret = if matches!(key, Key::Named(NamedKey::Home)) {
156 0
157 } else {
158 text.len()
159 };
160 if !shift {
161 mark = caret;
162 }
163 }
164 Key::Character(character) if command => {
165 if character.eq_ignore_ascii_case("a") {
166 mark = 0;
167 caret = text.len();
168 }
169 }
170 _ => {
171 if let Some(typed) = typed.as_deref().filter(|typed| {
172 !command && !typed.is_empty() && !typed.chars().any(char::is_control)
173 }) {
174 insert(text, typed, &mut caret);
175 mark = caret;
176 }
177 }
178 },
179 _ => {}
180 }
181 }
182 {
183 let (stored_caret, stored_mark) = ui.caret(id);
184 *stored_caret = caret;
185 *stored_mark = mark;
186 }
187 let theme = ui.theme.clone();
188 let shown = if text.is_empty() {
189 placeholder
190 } else {
191 text.as_str()
192 };
193 let height = spec.size[1];
194 ui.open_as(
195 id,
196 Spec {
197 flags: spec.flags | Flags::CLICKABLE | Flags::FOCUSABLE,
198 text: Some(shown),
199 color: Some(if text.is_empty() {
200 theme.text_dim
201 } else {
202 theme.text
203 }),
204 cursor: Some(CursorIcon::Text),
205 ..spec
206 },
207 );
208 if signal.focused {
209 let (texts, frame) = ui.texts();
210 let label = texts.label(text, size, frame);
211 let line = label.size[1];
212 let [start, end] =
213 [caret.min(mark), caret.max(mark)].map(|index| pad + label.caret_x(index));
214 let top = match height.size {
215 crate::Size::Pixels(height) => (height - line) / 2.0,
216 _ => 0.0,
217 };
218 if end > start {
219 ui.mark([start, top, end, top + line], theme.hover);
220 }
221 let x = pad + label.caret_x(caret);
222 ui.mark([x, top, x + 1.0, top + line], theme.text);
223 }
224 ui.close();
225 signal
226}
227
228fn floor_boundary(text: &str, index: usize) -> usize {
229 let mut index = index.min(text.len());
230 while !text.is_char_boundary(index) {
231 index -= 1;
232 }
233 index
234}
235
236fn previous(text: &str, index: usize) -> usize {
237 text[..index]
238 .char_indices()
239 .next_back()
240 .map_or(0, |(index, _)| index)
241}
242
243fn next(text: &str, index: usize) -> usize {
244 text[index..]
245 .chars()
246 .next()
247 .map_or(index, |character| index + character.len_utf8())
248}
tools/canvas/README.md+20-3
...@@ -24,7 +24,7 @@ The explicit output path must not exist. Its filename supplies the bundle's disp...@@ -24,7 +24,7 @@ The explicit output path must not exist. Its filename supplies the bundle's disp
2424
25Control-Command-Space opens the macOS character picker for the active text field.25Control-Command-Space opens the macOS character picker for the active text field.
2626
27Omit both arguments for a blank page with provisional caret placement, or provide a UTF-8 file and optional document width (default 480 pt). Typing, single/double/triple-click selection, drag selection, arrow/word/line/paragraph/document navigation, Backspace/Delete, word/line deletion, indentation, clipboard shortcuts, Cmd-Z/Shift-Cmd-Z and IME use the shared editor. Tab/Shift-Tab indent plain paragraphs; Enter retains their indentation. Cmd-Up/Down reach document boundaries, Option-Up/Down reach paragraph boundaries, and Home/End scroll without moving the caret. Mid-text Tab currently indents; OneNote’s table creation is not implemented. Scrolling is bounded by page objects, including negative coordinates, with draggable overlay indicators; Cmd-wheel or Cmd-plus/minus zooms without changing document width, and Cmd-0 returns to 100%. The GPU test requires an available native adapter and compares readback pixels before/after repaint and forced glyph/image-cache eviction.27Omit both arguments for a blank page with provisional caret placement, or provide a UTF-8 file and optional document width (default 480 pt). Typing, single/double/triple-click selection, drag selection, arrow/word/line/paragraph/document navigation, Backspace/Delete, word/line deletion, indentation, clipboard shortcuts, Cmd-Z/Shift-Cmd-Z and IME use the shared editor. Tab/Shift-Tab indent plain paragraphs; Enter retains their indentation. Cmd-Up/Down reach document boundaries, Option-Up/Down reach paragraph boundaries, and Home/End scroll without moving the caret. Mid-text Tab currently indents; OneNote’s table creation is not implemented. Scrolling is bounded by page objects, including negative coordinates; Cmd-wheel or Cmd-plus/minus zooms without changing document width, and Cmd-0 returns to 100%. The GPU test requires an available native adapter and compares readback pixels before/after repaint and forced glyph/image-cache eviction.
2828
29```text29```text
30onestore::Format30onestore::Format
...@@ -36,9 +36,26 @@ canvas::gpu page scene · text runs, tag icons, pictures and ink as draw pri...@@ -36,9 +36,26 @@ canvas::gpu page scene · text runs, tag icons, pictures and ink as draw pri
36draw Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches · layers36draw Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches · layers
37 ↓37 ↓
38canvas::interaction hit layers · drags · placement grid · picture handles · chrome · key routing38canvas::interaction hit layers · drags · placement grid · picture handles · chrome · key routing
39 · scrolling and zoom · caret blink · AccessKit tree39 · scroll bounds and zoom · caret blink · AccessKit tree
40 ↓40 ↓
41snowbound Winit window and events · AppKit pickers and quit guard · clipboard · saving41ui immediate-mode boxes · keyed state · autolayout · event routing · labels · widgets
42 ↓
43snowbound section tabs · page list · the page as a custom box · Winit window and events
44 · AppKit pickers and quit guard · clipboard · saving
45```
46
47## Desktop shell
48
49`--notebook FOLDER` opens a notebook with its readable top-level sections as tabs in the window's top strip, in the notebook's order and colours, and the open section's pages listed on the right, subpages indented. Command-F filters the list; Enter opens the first match and Escape clears it. A page that also changed on another computer shows Keep mine / Keep theirs above it. `--section FILE TITLE` opens one section the same way at that page. Section files must be regular files; discovery rejects symbolic links.
50
51The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Work the frame asks for (opening pages or sections, page requests, saving) runs after the frame is painted and requests the frame that shows it.
52
53A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press`, `release`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS` and `snapshot PNG_PATH` lines in logical pixels, and each step draws its own frame. Replays edit and save like a user, so point them at a copy of a notebook.
54
55```sh
56cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one
57SNOWBOUND_REPLAY=SCRIPT "target/Snowbound.app/Contents/MacOS/Snowbound" --notebook /tmp/notebook-copy --cache /tmp/notebook-cache
58cargo test -p ui
42```59```
4360
44The renderer allocates a 2048² RGBA glyph atlas and a 3.5 MiB vertex buffer, limits combined text-glyph and tag-icon entries to 8192, and rebuilds a frame after atlas eviction. Font scaling uses 32 cache entries. These limits bound those caches; they are not a process-memory or worst-case rasterization budget. Drawing and hit-testing share Windows-metric line boxes. First-baseline fidelity and substitute-font metric selection remain experimental; the `windows_*` probe values expose that policy independently from Parley's original metrics.61The renderer allocates a 2048² RGBA glyph atlas and a 3.5 MiB vertex buffer, limits combined text-glyph and tag-icon entries to 8192, and rebuilds a frame after atlas eviction. Font scaling uses 32 cache entries. These limits bound those caches; they are not a process-memory or worst-case rasterization budget. Drawing and hit-testing share Windows-metric line boxes. First-baseline fidelity and substitute-font metric selection remain experimental; the `windows_*` probe values expose that policy independently from Parley's original metrics.