| author | |
| committer | |
| log | 14ffea224c2042a06a245dcbcc1710ce2cfd9df7 |
| tree | dcfc1b9d9844066184550397301b5f6f608b5b96 |
| parent | 939081be8a62e17d4e2ec4abb68c052f8e3c506d |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.519 files changed, 2978 insertions(+), 323 deletions(-)
Cargo.lock+11| ... | ... | @@ -2650,7 +2650,9 @@ dependencies = [ |
| 2650 | 2650 | "objc2-foundation 0.2.2", |
| 2651 | 2651 | "onestore", |
| 2652 | 2652 | "parley", |
| 2653 | "png", | |
| 2653 | 2654 | "pollster", |
| 2655 | "ui", | |
| 2654 | 2656 | "wgpu", |
| 2655 | 2657 | "winit", |
| 2656 | 2658 | ] |
| ... | ... | @@ -2930,6 +2932,15 @@ version = "1.20.1" |
| 2930 | 2932 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2931 | 2933 | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" |
| 2932 | 2934 | |
| 2935 | [[package]] | |
| 2936 | name = "ui" | |
| 2937 | version = "0.1.0" | |
| 2938 | dependencies = [ | |
| 2939 | "draw", | |
| 2940 | "parley", | |
| 2941 | "winit", | |
| 2942 | ] | |
| 2943 | ||
| 2933 | 2944 | [[package]] |
| 2934 | 2945 | name = "unicode-ident" |
| 2935 | 2946 | version = "1.0.24" |
crates/canvas/src/interaction/mod.rs+27-36| ... | ... | @@ -6,6 +6,8 @@ pub mod accessibility; |
| 6 | 6 | #[cfg(test)] |
| 7 | 7 | mod profile; |
| 8 | 8 | mod scroll; |
| 9 | ||
| 10 | pub use scroll::Scroll; | |
| 9 | 11 | #[cfg(test)] |
| 10 | 12 | mod tests; |
| 11 | 13 | |
| ... | ... | @@ -143,10 +145,6 @@ enum Drag { |
| 143 | 145 | anchor: Selection, |
| 144 | 146 | unit: SelectionUnit, |
| 145 | 147 | }, |
| 146 | Scrollbar { | |
| 147 | axis: usize, | |
| 148 | grab: f32, | |
| 149 | }, | |
| 150 | 148 | Resize { |
| 151 | 149 | outline: Option<Box<TextOutline>>, |
| 152 | 150 | grab: f32, |
| ... | ... | @@ -219,6 +217,11 @@ pub struct PageView { |
| 219 | 217 | blink_at: Instant, |
| 220 | 218 | } |
| 221 | 219 | |
| 220 | /// Where a page's origin sits in a view that has not scrolled, in device pixels. | |
| 221 | fn home(display_scale: f32) -> [f32; 2] { | |
| 222 | [48.0 * display_scale; 2] | |
| 223 | } | |
| 224 | ||
| 222 | 225 | impl PageView { |
| 223 | 226 | /// `size` is in device pixels; `double_click` is the platform's double-click interval. |
| 224 | 227 | pub fn new( |
| ... | ... | @@ -236,7 +239,7 @@ impl PageView { |
| 236 | 239 | viewport: Viewport { |
| 237 | 240 | size, |
| 238 | 241 | scale: display_scale * 96.0 / 72.0, |
| 239 | origin: [48.0 * display_scale; 2], | |
| 242 | origin: home(display_scale), | |
| 240 | 243 | }, |
| 241 | 244 | display_scale, |
| 242 | 245 | double_click, |
| ... | ... | @@ -260,6 +263,13 @@ impl PageView { |
| 260 | 263 | self.object_focus = None; |
| 261 | 264 | } |
| 262 | 265 | |
| 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 | 273 | pub fn modifiers(&self) -> Modifiers { |
| 264 | 274 | self.modifiers |
| 265 | 275 | } |
| ... | ... | @@ -474,7 +484,8 @@ impl PageView { |
| 474 | 484 | ]; |
| 475 | 485 | } |
| 476 | 486 | |
| 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 | 489 | let editable = self |
| 479 | 490 | .editor |
| 480 | 491 | .visible_outlines() |
| ... | ... | @@ -507,7 +518,14 @@ impl PageView { |
| 507 | 518 | scroll::Scroll::new(self.viewport, editable.chain(fixed)) |
| 508 | 519 | } |
| 509 | 520 | |
| 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 | 529 | pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> { |
| 512 | 530 | let preview = match &self.drag { |
| 513 | 531 | Some(Drag::Resize { |
| ... | ... | @@ -538,7 +556,7 @@ impl PageView { |
| 538 | 556 | } |
| 539 | 557 | }), |
| 540 | 558 | }; |
| 541 | let mut primitives = page_primitives( | |
| 559 | page_primitives( | |
| 542 | 560 | &self.editor, |
| 543 | 561 | self.scene.as_ref(), |
| 544 | 562 | preview, |
| ... | ... | @@ -554,22 +572,13 @@ impl PageView { |
| 554 | 572 | pixel: self.pixel(), |
| 555 | 573 | colors, |
| 556 | 574 | }, |
| 557 | )?; | |
| 558 | self.scroll() | |
| 559 | .append(self.viewport, self.display_scale, &mut primitives); | |
| 560 | Ok(primitives) | |
| 575 | ) | |
| 561 | 576 | } |
| 562 | 577 | |
| 563 | 578 | /// The pointer shape at the pointer's position. |
| 564 | 579 | pub fn cursor(&self) -> Cursor { |
| 565 | 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 | 581 | match (&self.drag, hit) { |
| 571 | (Some(Drag::Scrollbar { .. }), _) => Cursor::Default, | |
| 572 | (None, _) if scrollbar => Cursor::Default, | |
| 573 | 582 | (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), |
| 574 | 583 | (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), |
| 575 | 584 | (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize, |
| ... | ... | @@ -638,17 +647,6 @@ impl PageView { |
| 638 | 647 | self.pointer_inside = true; |
| 639 | 648 | self.pointer = position; |
| 640 | 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 | 650 | Some(Drag::Text { anchor, unit }) => { |
| 653 | 651 | let (anchor, unit) = (*anchor, *unit); |
| 654 | 652 | let point = self.viewport.document_point(self.pointer); |
| ... | ... | @@ -700,13 +698,6 @@ impl PageView { |
| 700 | 698 | }) |
| 701 | 699 | .map_or(1, |(_, _, count)| (count % 3) + 1); |
| 702 | 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 | 701 | match self.hit_test(point) { |
| 711 | 702 | Some(Hit::Date(field)) => { |
| 712 | 703 | self.editor.finish_composition(); |
crates/canvas/src/interaction/scroll.rs+3-83| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | use crate::gpu::Viewport; |
| 2 | use draw::Primitive; | |
| 3 | 2 | |
| 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)] | |
| 4 | 6 | pub struct Scroll { |
| 5 | 7 | pub min: [f32; 2], |
| 6 | 8 | pub max: [f32; 2], |
| ... | ... | @@ -30,63 +32,6 @@ impl Scroll { |
| 30 | 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 | } |
| 91 | 36 | |
| 92 | 37 | #[cfg(test)] |
| ... | ... | @@ -107,29 +52,4 @@ mod tests { |
| 107 | 52 | scroll.clamp(&mut viewport); |
| 108 | 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 | 134 | ); |
| 135 | 135 | let pages = section.pages()?; |
| 136 | 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 | 141 | let before = section.page(*space)?; |
| 139 | 142 | let mut after = before.clone(); |
| 140 | 143 | edit(&mut after, label); |
crates/notebook/src/discover.rs+10-5| ... | ... | @@ -28,6 +28,8 @@ pub enum SectionState { |
| 28 | 28 | Readable { |
| 29 | 29 | /// An explicit SectionDisplayName; otherwise use the current filename without its extension. |
| 30 | 30 | name: Option<String>, |
| 31 | /// The tab colour as a COLORREF; OneNote assigns one when absent. | |
| 32 | color: Option<u32>, | |
| 31 | 33 | }, |
| 32 | 34 | Locked, |
| 33 | 35 | Unreadable(onestore::Error), |
| ... | ... | @@ -267,18 +269,21 @@ fn scan( |
| 267 | 269 | } |
| 268 | 270 | index.validate_current()?; |
| 269 | 271 | document.pages()?; |
| 270 | let name = revision | |
| 272 | let (name, color) = revision | |
| 271 | 273 | .roots |
| 272 | 274 | .get(&2) |
| 273 | 275 | .and_then(|id| revision.nodes.get(id)) |
| 274 | .and_then(|node| match &node.kind { | |
| 275 | Kind::SectionMetadata { name, .. } => name.clone(), | |
| 276 | _ => None, | |
| 276 | .map_or((None, None), |node| match &node.kind { | |
| 277 | Kind::SectionMetadata { name, color } => (name.clone(), *color), | |
| 278 | _ => (None, None), | |
| 277 | 279 | }); |
| 278 | 280 | result.sections.push(Section { |
| 279 | 281 | path: child.clone(), |
| 280 | 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 | 288 | } else { |
| 284 | 289 | index.validate_current()?; |
crates/notebook/src/session.rs+17-5| ... | ... | @@ -869,16 +869,28 @@ impl Section { |
| 869 | 869 | Ok(Store::parse(&self.replica.snapshot()?)?.header.file_id) |
| 870 | 870 | } |
| 871 | 871 | |
| 872 | /// Page spaces and titles in section order, from the local working image. | |
| 873 | pub fn pages(&self) -> Result<Vec<(ExGuid, String)>> { | |
| 872 | /// Page spaces, titles and outline levels (1 at the top) in section order, from the | |
| 873 | /// local working image. | |
| 874 | pub fn pages(&self) -> Result<Vec<(ExGuid, String, u32)>> { | |
| 874 | 875 | let snapshot = self.replica.snapshot()?; |
| 875 | 876 | let store = Store::parse(&snapshot)?; |
| 876 | 877 | let index = RevisionIndex::parse(&store)?; |
| 877 | 878 | let document = Document::parse(&index)?; |
| 878 | document | |
| 879 | .pages()? | |
| 879 | let pages = document.pages()?; | |
| 880 | let mut spaces = std::collections::BTreeSet::new(); | |
| 881 | pages | |
| 880 | 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 | 894 | .collect() |
| 883 | 895 | } |
| 884 | 896 |
crates/notebook/tests/links.rs+1-1| ... | ... | @@ -31,7 +31,7 @@ fn internal_links_find_their_page_by_identity() { |
| 31 | 31 | .unwrap(); |
| 32 | 32 | let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap(); |
| 33 | 33 | let section = notebook.section("First.one", || {}).unwrap(); |
| 34 | let (space, _) = section.pages().unwrap()[0]; | |
| 34 | let (space, _, _) = section.pages().unwrap()[0]; | |
| 35 | 35 | let page = section.page(space).unwrap(); |
| 36 | 36 | section.close().unwrap(); |
| 37 | 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 | 259 | assert_eq!(catalog.groups[0].sections.len(), 1); |
| 260 | 260 | let path = catalog.groups[0].sections[0].path.clone(); |
| 261 | 261 | let section = notebook.section(&path, || {}).unwrap(); |
| 262 | let (space, _) = section.pages().unwrap()[0]; | |
| 262 | let (space, _, _) = section.pages().unwrap()[0]; | |
| 263 | 263 | let page = section.page(space).unwrap(); |
| 264 | 264 | let text = first_text(&page); |
| 265 | 265 | assert_eq!( |
crates/onestore/src/page/mod.rs+17| ... | ... | @@ -493,6 +493,23 @@ impl Page { |
| 493 | 493 | }) |
| 494 | 494 | } |
| 495 | 495 | |
| 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 | 513 | /// The single active page declared in one page object space. |
| 497 | 514 | pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result<Self, Error> { |
| 498 | 515 | let pages = document.pages_in(space)?; |
crates/snowbound/Cargo.toml+2| ... | ... | @@ -7,6 +7,8 @@ publish = false |
| 7 | 7 | [dependencies] |
| 8 | 8 | canvas = { path = "../canvas", features = ["interaction"] } |
| 9 | 9 | draw = { path = "../draw" } |
| 10 | ui = { path = "../ui" } | |
| 11 | png = "0.18" | |
| 10 | 12 | onestore = { path = "../onestore" } |
| 11 | 13 | notebook = { path = "../notebook" } |
| 12 | 14 | pollster = "0.4" |
crates/snowbound/src/main.rs+1065-188| ... | ... | @@ -12,19 +12,33 @@ use canvas::{ |
| 12 | 12 | layout::TextEngine, |
| 13 | 13 | }; |
| 14 | 14 | use draw::Renderer; |
| 15 | use onestore::ExGuid; | |
| 15 | 16 | use onestore::document::Format; |
| 16 | 17 | use onestore::page::Page; |
| 17 | 18 | use onestore::page::text::Paragraph; |
| 18 | use std::{error::Error, path::PathBuf, sync::Arc, time::Instant}; | |
| 19 | use std::{ | |
| 20 | error::Error, | |
| 21 | path::{Path, PathBuf}, | |
| 22 | sync::Arc, | |
| 23 | time::Instant, | |
| 24 | }; | |
| 25 | use ui::{Axis, Flags, Id, Spec, Theme, Ui, fill, fit, px}; | |
| 19 | 26 | use winit::{ |
| 20 | 27 | application::ApplicationHandler, |
| 21 | 28 | dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, |
| 22 | 29 | event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, |
| 23 | 30 | event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, |
| 24 | 31 | keyboard::{Key, ModifiersState, NamedKey}, |
| 32 | platform::macos::WindowAttributesExtMacOS, | |
| 25 | 33 | window::{CursorIcon, Window, WindowId}, |
| 26 | 34 | }; |
| 27 | 35 | |
| 36 | /// Height of the window's top strip, which holds the traffic lights and section tabs. | |
| 37 | const STRIP: f32 = 38.0; | |
| 38 | /// Room the traffic lights take at the strip's leading edge. | |
| 39 | const LIGHTS: f32 = 78.0; | |
| 40 | const PAGE_LIST: f32 = 240.0; | |
| 41 | ||
| 28 | 42 | #[derive(Debug)] |
| 29 | 43 | enum UserEvent { |
| 30 | 44 | Quit, |
| ... | ... | @@ -32,6 +46,15 @@ enum UserEvent { |
| 32 | 46 | Accessibility(accesskit_winit::Event), |
| 33 | 47 | /// The section's synchronization thread reported an event. |
| 34 | 48 | Sync, |
| 49 | /// Scripted input from `SNOWBOUND_REPLAY`. | |
| 50 | Replay(Replay), | |
| 51 | } | |
| 52 | ||
| 53 | #[derive(Debug)] | |
| 54 | enum Replay { | |
| 55 | Input(ui::Event), | |
| 56 | /// Paints the next frame into a PNG as well as the window. | |
| 57 | Snapshot(PathBuf), | |
| 35 | 58 | } |
| 36 | 59 | |
| 37 | 60 | impl From<accesskit_winit::Event> for UserEvent { |
| ... | ... | @@ -116,37 +139,109 @@ enum Input { |
| 116 | 139 | title: String, |
| 117 | 140 | cache: PathBuf, |
| 118 | 141 | }, |
| 142 | Notebook { | |
| 143 | root: PathBuf, | |
| 144 | cache: PathBuf, | |
| 145 | }, | |
| 119 | 146 | } |
| 120 | 147 | |
| 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. | |
| 149 | struct 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. | |
| 158 | struct Library { | |
| 159 | notebook: Option<notebook::session::Notebook>, | |
| 160 | tabs: Vec<Tab>, | |
| 161 | cache: PathBuf, | |
| 162 | } | |
| 163 | ||
| 164 | impl 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. | |
| 122 | 182 | struct Session { |
| 123 | 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 | 188 | before: Page, |
| 126 | title: String, | |
| 127 | 189 | status: &'static str, |
| 190 | /// The editor's page has an unreviewed conflict with another machine's change. | |
| 191 | conflict: bool, | |
| 128 | 192 | } |
| 129 | 193 | |
| 130 | 194 | impl Session { |
| 131 | fn window_title(&self) -> String { | |
| 132 | let file = self | |
| 195 | fn title(&self) -> &str { | |
| 196 | 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 | 204 | .section |
| 134 | .file() | |
| 135 | .file_name() | |
| 136 | .map(|name| name.to_string_lossy().into_owned()) | |
| 137 | .unwrap_or_default(); | |
| 138 | format!("{} · {file}{}", self.title, self.status) | |
| 205 | .conflicts()? | |
| 206 | .iter() | |
| 207 | .any(|(edit, _)| edit.space == self.space); | |
| 208 | Ok(()) | |
| 139 | 209 | } |
| 140 | 210 | } |
| 141 | 211 | |
| 212 | /// Work the interface asked for, done after the frame is built. | |
| 213 | enum Command { | |
| 214 | OpenSection(usize), | |
| 215 | OpenPage(ExGuid), | |
| 216 | Resolve { keep_mine: bool }, | |
| 217 | Page(Request), | |
| 218 | } | |
| 219 | ||
| 142 | 220 | struct State { |
| 143 | 221 | window: Arc<Window>, |
| 222 | proxy: EventLoopProxy<UserEvent>, | |
| 144 | 223 | instance: wgpu::Instance, |
| 145 | 224 | surface: wgpu::Surface<'static>, |
| 146 | 225 | config: wgpu::SurfaceConfiguration, |
| 147 | 226 | renderer: Renderer, |
| 227 | ui: Ui, | |
| 148 | 228 | view: PageView, |
| 229 | library: Option<Library>, | |
| 149 | 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 | 245 | initial: Vec<(onestore::ExGuid, TextDocument)>, |
| 151 | 246 | initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, |
| 152 | 247 | initial_date: Option<u64>, |
| ... | ... | @@ -157,6 +252,19 @@ struct State { |
| 157 | 252 | accessibility: accessibility::Accessibility, |
| 158 | 253 | } |
| 159 | 254 | |
| 255 | fn strip() -> Id { | |
| 256 | Id::ROOT.child("strip") | |
| 257 | } | |
| 258 | ||
| 259 | fn page() -> Id { | |
| 260 | Id::ROOT.child("page") | |
| 261 | } | |
| 262 | ||
| 263 | /// The field filtering the page list. | |
| 264 | fn filter() -> Id { | |
| 265 | Id::ROOT.child("filter") | |
| 266 | } | |
| 267 | ||
| 160 | 268 | impl State { |
| 161 | 269 | async fn new( |
| 162 | 270 | event_loop: &ActiveEventLoop, |
| ... | ... | @@ -168,6 +276,9 @@ impl State { |
| 168 | 276 | event_loop.create_window( |
| 169 | 277 | Window::default_attributes() |
| 170 | 278 | .with_visible(false) |
| 279 | .with_titlebar_transparent(true) | |
| 280 | .with_title_hidden(true) | |
| 281 | .with_fullsize_content_view(true) | |
| 171 | 282 | .with_title(match &input { |
| 172 | 283 | Input::Notes { |
| 173 | 284 | reference: Some(page), |
| ... | ... | @@ -178,8 +289,9 @@ impl State { |
| 178 | 289 | reference: None, .. |
| 179 | 290 | } => "Untitled · Temporary page".into(), |
| 180 | 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 | 297 | macos::install_text_input(&window); |
| ... | ... | @@ -210,6 +322,7 @@ impl State { |
| 210 | 322 | .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; |
| 211 | 323 | eprintln!("Using {} for {target}", path.display()); |
| 212 | 324 | } |
| 325 | let mut library = None; | |
| 213 | 326 | let mut session = None; |
| 214 | 327 | let (editor, scene) = match input { |
| 215 | 328 | Input::Notes { |
| ... | ... | @@ -230,29 +343,47 @@ impl State { |
| 230 | 343 | (editor, Some((scene, [0.0; 2]))) |
| 231 | 344 | } |
| 232 | 345 | Input::Section { file, title, cache } => { |
| 233 | let section = notebook::session::Section::open(&file, cache, move || { | |
| 234 | let _ = proxy.send_event(UserEvent::Sync); | |
| 235 | })?; | |
| 236 | let (space, _) = section | |
| 346 | let opened = Library { | |
| 347 | notebook: None, | |
| 348 | tabs: vec![Tab { | |
| 349 | 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 | 360 | .pages()? |
| 238 | 361 | .into_iter() |
| 239 | .find(|(_, candidate)| *candidate == title) | |
| 240 | .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?; | |
| 241 | let before = section.page(space)?; | |
| 242 | let (scene, editor) = PageScene::from_page(before.clone(), &mut engine)?; | |
| 243 | session = Some(Session { | |
| 244 | section, | |
| 245 | space, | |
| 246 | before, | |
| 247 | title, | |
| 248 | status: "", | |
| 249 | }); | |
| 362 | .find(|(_, candidate, _)| *candidate == title) | |
| 363 | .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))? | |
| 364 | .0; | |
| 365 | library = Some(opened); | |
| 366 | let (scene, editor) = | |
| 367 | open_session(section, 0, Some(space), &mut engine, &mut session)?; | |
| 368 | (editor, Some((scene, [0.0; 2]))) | |
| 369 | } | |
| 370 | Input::Notebook { root, cache } => { | |
| 371 | let notebook = notebook::session::Notebook::open(&root, &cache)?; | |
| 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 | 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 | 387 | let initial_date = editor.date().map(|date| date.timestamp()); |
| 257 | 388 | let initial_layouts = editor |
| 258 | 389 | .object_layouts() |
| ... | ... | @@ -265,15 +396,18 @@ impl State { |
| 265 | 396 | .collect(); |
| 266 | 397 | let dpr = window.scale_factor() as f32; |
| 267 | 398 | window.set_ime_allowed(true); |
| 268 | window.set_cursor(CursorIcon::Text); | |
| 269 | 399 | window.request_redraw(); |
| 270 | 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 | 404 | window, |
| 405 | proxy, | |
| 273 | 406 | instance, |
| 274 | 407 | surface, |
| 275 | 408 | config, |
| 276 | 409 | renderer, |
| 410 | ui, | |
| 277 | 411 | view: PageView::new( |
| 278 | 412 | editor, |
| 279 | 413 | engine, |
| ... | ... | @@ -282,7 +416,16 @@ impl State { |
| 282 | 416 | dpr, |
| 283 | 417 | macos::double_click_interval(), |
| 284 | 418 | ), |
| 419 | library, | |
| 285 | 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 | 429 | initial, |
| 287 | 430 | initial_date, |
| 288 | 431 | initial_layouts, |
| ... | ... | @@ -291,66 +434,513 @@ impl State { |
| 291 | 434 | clipboard: arboard::Clipboard::new()?, |
| 292 | 435 | access_adapter, |
| 293 | 436 | accessibility: accessibility::Accessibility::default(), |
| 294 | }) | |
| 437 | }; | |
| 438 | state.title(); | |
| 439 | Ok(state) | |
| 295 | 440 | } |
| 296 | 441 | |
| 297 | /// Carries out what a page event asked of the platform and follows its changes: the | |
| 298 | /// input method's position, accessibility, saving, the window title and a repaint. | |
| 299 | fn respond(&mut self, response: Response) -> Result<(), Box<dyn Error>> { | |
| 300 | match response.request { | |
| 301 | Some(Request::EditDate(field)) => self.edit_date(field)?, | |
| 302 | Some(Request::Copy(text)) => self.clipboard.set_text(text)?, | |
| 303 | Some(Request::Paste) => { | |
| 304 | let text = self.clipboard.get_text()?; | |
| 305 | let response = self.view.commit_text(text)?; | |
| 306 | self.respond(response)?; | |
| 442 | /// Builds, lays out and paints one frame, then does what it asked for and asks for the | |
| 443 | /// frame that shows the result. | |
| 444 | fn frame(&mut self) -> Result<(), Box<dyn Error>> { | |
| 445 | let size = self.window.inner_size(); | |
| 446 | let scale = self.window.scale_factor() as f32; | |
| 447 | self.ui.begin( | |
| 448 | [size.width as f32 / scale, size.height as f32 / scale], | |
| 449 | scale, | |
| 450 | Instant::now(), | |
| 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() { | |
| 312 | self.ime_allowed = self.view.accepts_text(); | |
| 313 | self.window.set_ime_allowed(self.ime_allowed); | |
| 464 | self.window.set_cursor( | |
| 465 | self.ui | |
| 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())); | |
| 316 | if response.changed { | |
| 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(); | |
| 484 | if std::mem::take(&mut self.changed) { | |
| 485 | self.after_edit()?; | |
| 325 | 486 | } |
| 326 | if response.redraw { | |
| 487 | if follow { | |
| 327 | 488 | self.window.request_redraw(); |
| 328 | 489 | } |
| 329 | 490 | Ok(()) |
| 330 | 491 | } |
| 331 | 492 | |
| 332 | /// A temporary page's window takes its title's first line. | |
| 333 | fn title_from_page(&self) { | |
| 334 | let editor = &self.view.editor; | |
| 335 | if self.session.is_some() || !editor.active_outline().title { | |
| 493 | fn build(&mut self) -> Result<(), Box<dyn Error>> { | |
| 494 | let theme = self.ui.theme.clone(); | |
| 495 | self.ui.open_as( | |
| 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 | 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 = editor | |
| 339 | .active_outline() | |
| 340 | .document() | |
| 341 | .paragraphs() | |
| 342 | .next() | |
| 343 | .unwrap() | |
| 344 | .text() | |
| 345 | .split(['\u{000b}', '\n', '\r']) | |
| 346 | .next() | |
| 347 | .unwrap(); | |
| 348 | let title = if title.trim().is_empty() { | |
| 349 | "Untitled" | |
| 350 | } else { | |
| 351 | title | |
| 742 | self.ui.close(); | |
| 743 | let placeholder = format!("Filter {} pages…", session.pages.len()); | |
| 744 | self.ui.open( | |
| 745 | "footer", | |
| 746 | Spec { | |
| 747 | size: [fill(), px(38.0)], | |
| 748 | pad: [6.0, 6.0], | |
| 749 | ..Spec::default() | |
| 750 | }, | |
| 751 | ); | |
| 752 | let before = self.filter.clone(); | |
| 753 | let signal = ui::text_field( | |
| 754 | &mut self.ui, | |
| 755 | 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 | 944 | if self.window.title() != title { |
| 355 | 945 | self.window.set_title(&title); |
| 356 | 946 | } |
| ... | ... | @@ -365,7 +955,8 @@ impl State { |
| 365 | 955 | macos::clear_marked_text(&self.window); |
| 366 | 956 | if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? { |
| 367 | 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 | 961 | Ok(()) |
| 371 | 962 | } |
| ... | ... | @@ -384,8 +975,14 @@ impl State { |
| 384 | 975 | notebook::session::Save::Unchanged => {} |
| 385 | 976 | notebook::session::Save::Queued(_) => { |
| 386 | 977 | session.before = session.section.page(session.space)?; |
| 387 | session.status = " · saving"; | |
| 388 | self.window.set_title(&session.window_title()); | |
| 978 | session.status = "Saving"; | |
| 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 | 987 | notebook::session::Save::Stale => self.reload()?, |
| 391 | 988 | } |
| ... | ... | @@ -401,7 +998,7 @@ impl State { |
| 401 | 998 | let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; |
| 402 | 999 | session.before = page; |
| 403 | 1000 | self.view.replace(editor, Some((scene, [0.0; 2]))); |
| 404 | self.window.set_title(&session.window_title()); | |
| 1001 | self.title(); | |
| 405 | 1002 | self.update_accessibility()?; |
| 406 | 1003 | self.window.request_redraw(); |
| 407 | 1004 | Ok(()) |
| ... | ... | @@ -427,10 +1024,11 @@ impl State { |
| 427 | 1024 | session.section.remote_page(session.space)? |
| 428 | 1025 | }; |
| 429 | 1026 | session.section.review(edit.id, &reviewed)?; |
| 430 | session.status = " · saving"; | |
| 1027 | session.status = "Saving"; | |
| 1028 | session.refresh_conflict()?; | |
| 431 | 1029 | if keep_mine { |
| 432 | 1030 | session.before = session.section.page(session.space)?; |
| 433 | self.window.set_title(&session.window_title()); | |
| 1031 | self.window.request_redraw(); | |
| 434 | 1032 | Ok(()) |
| 435 | 1033 | } else { |
| 436 | 1034 | self.reload() |
| ... | ... | @@ -442,6 +1040,7 @@ impl State { |
| 442 | 1040 | let Some(session) = &mut self.session else { |
| 443 | 1041 | return Ok(()); |
| 444 | 1042 | }; |
| 1043 | let shown = (session.status, session.conflict); | |
| 445 | 1044 | let mut refreshed = false; |
| 446 | 1045 | for event in session.section.events() { |
| 447 | 1046 | use notebook::session::Event; |
| ... | ... | @@ -453,22 +1052,35 @@ impl State { |
| 453 | 1052 | Event::Attempt { |
| 454 | 1053 | status: notebook::EditStatus::Published { .. }, |
| 455 | 1054 | .. |
| 456 | } => " · saved", | |
| 1055 | } => "Saved", | |
| 457 | 1056 | Event::Attempt { |
| 458 | 1057 | status: notebook::EditStatus::Conflict(_), |
| 459 | 1058 | .. |
| 460 | } => " · conflict", | |
| 461 | Event::Attempt { .. } => " · saving", | |
| 462 | Event::Unreachable(_) => " · offline", | |
| 1059 | } => "Conflict", | |
| 1060 | Event::Attempt { .. } => "Saving", | |
| 1061 | Event::Unreachable(_) => "Offline", | |
| 463 | 1062 | Event::Failed(error) => { |
| 464 | 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()); | |
| 470 | if refreshed && session.section.page(session.space)? != session.before { | |
| 471 | self.reload()?; | |
| 1068 | session.refresh_conflict()?; | |
| 1069 | if refreshed || shown != (session.status, session.conflict) { | |
| 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 | 1085 | Ok(()) |
| 474 | 1086 | } |
| ... | ... | @@ -476,10 +1088,14 @@ impl State { |
| 476 | 1088 | fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> { |
| 477 | 1089 | let mut error = None; |
| 478 | 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 | 1095 | self.access_adapter.update_if_active(|| { |
| 480 | 1096 | match self.accessibility.update( |
| 481 | 1097 | &view.editor, |
| 482 | view.viewport, | |
| 1098 | viewport, | |
| 483 | 1099 | &self.window.title(), |
| 484 | 1100 | view.outline_preview(), |
| 485 | 1101 | ) { |
| ... | ... | @@ -488,7 +1104,7 @@ impl State { |
| 488 | 1104 | &mut update, |
| 489 | 1105 | view.scene.as_ref(), |
| 490 | 1106 | &view.editor, |
| 491 | view.viewport, | |
| 1107 | viewport, | |
| 492 | 1108 | view.object_focus().and_then(|focus| focus.read_only()), |
| 493 | 1109 | ); |
| 494 | 1110 | update |
| ... | ... | @@ -528,8 +1144,10 @@ impl State { |
| 528 | 1144 | if let Some(index) = self.accessibility.read_only_for_node(request.target_node) { |
| 529 | 1145 | if request.action == Action::Focus { |
| 530 | 1146 | self.window.focus_window(); |
| 1147 | self.ui.set_focus(Some(page())); | |
| 531 | 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 | 1152 | return Ok(()); |
| 535 | 1153 | } |
| ... | ... | @@ -559,14 +1177,18 @@ impl State { |
| 559 | 1177 | } |
| 560 | 1178 | _ => return Ok(()), |
| 561 | 1179 | } |
| 1180 | self.ui.set_focus(Some(page())); | |
| 562 | 1181 | let response = self.view.focus_text()?; |
| 563 | self.respond(response) | |
| 1182 | self.respond(response); | |
| 1183 | self.window.request_redraw(); | |
| 1184 | Ok(()) | |
| 564 | 1185 | } |
| 565 | 1186 | |
| 566 | 1187 | fn draw(&mut self) -> Result<(), Box<dyn Error>> { |
| 567 | let viewport = self.view.viewport; | |
| 568 | trace_input(&("Draw", viewport.origin, viewport.scale)); | |
| 569 | if self.occluded || viewport.size.contains(&0) { | |
| 1188 | if let Some(path) = self.snapshot.take() { | |
| 1189 | self.snapshot(&path)?; | |
| 1190 | } | |
| 1191 | if self.occluded || [self.config.width, self.config.height].contains(&0) { | |
| 570 | 1192 | return Ok(()); |
| 571 | 1193 | } |
| 572 | 1194 | let mut reconfigure = false; |
| ... | ... | @@ -600,16 +1222,7 @@ impl State { |
| 600 | 1222 | return Err("Canvas surface validation failed".into()); |
| 601 | 1223 | } |
| 602 | 1224 | }; |
| 603 | let [caret, selection] = macos::text_colors(); | |
| 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:?}"))?; | |
| 1225 | self.paint(&frame.texture.create_view(&Default::default()))?; | |
| 613 | 1226 | self.window.pre_present_notify(); |
| 614 | 1227 | self.renderer.queue.present(frame); |
| 615 | 1228 | trace_input(&"Present submitted"); |
| ... | ... | @@ -619,26 +1232,199 @@ impl State { |
| 619 | 1232 | Ok(()) |
| 620 | 1233 | } |
| 621 | 1234 | |
| 622 | fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box<dyn Error>> { | |
| 623 | let modifiers = self.view.modifiers(); | |
| 624 | if modifiers.command | |
| 625 | && modifiers.shift | |
| 626 | && self.session.is_some() | |
| 627 | && self.view.accepts_text() | |
| 628 | && let Key::Character(character) = key | |
| 1235 | /// Paints the interface with the page in its box. | |
| 1236 | fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box<dyn Error>> { | |
| 1237 | let [caret, selection] = macos::text_colors(); | |
| 1238 | let page_primitives = self.view.primitives(TextColors { caret, selection })?; | |
| 1239 | let scale = self.ui.scale(); | |
| 1240 | let corner = self.ui.rect(page()).unwrap_or_default(); | |
| 1241 | 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") { | |
| 631 | return self.resolve_conflict(true); | |
| 632 | } | |
| 633 | if character.eq_ignore_ascii_case("t") { | |
| 634 | return self.resolve_conflict(false); | |
| 1360 | let double = self.strip_press.is_some_and(|last| { | |
| 1361 | at.saturating_duration_since(last) <= macos::double_click_interval() | |
| 1362 | }); | |
| 1363 | self.strip_press = (!double).then_some(at); | |
| 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)?; | |
| 638 | self.respond(response) | |
| 1371 | self.ui.event(event); | |
| 1372 | self.window.request_redraw(); | |
| 639 | 1373 | } |
| 640 | 1374 | } |
| 641 | 1375 | |
| 1376 | /// Section tabs for a notebook's readable top-level sections, in its order. | |
| 1377 | fn 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. | |
| 1400 | fn 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 | ||
| 642 | 1428 | impl App { |
| 643 | 1429 | fn close(&self, event_loop: &ActiveEventLoop) { |
| 644 | 1430 | if self.state.as_ref().is_none_or(|state| { |
| ... | ... | @@ -671,12 +1457,14 @@ impl ApplicationHandler<UserEvent> for App { |
| 671 | 1457 | let event = match event { |
| 672 | 1458 | UserEvent::InsertText(text) => { |
| 673 | 1459 | if let Some(state) = &mut self.state { |
| 674 | let result = state | |
| 675 | .view | |
| 676 | .insert_text(text) | |
| 677 | .and_then(|response| state.respond(response)); | |
| 678 | if let Err(error) = result { | |
| 679 | eprintln!("{error}"); | |
| 1460 | if state.ui.focused() == Some(page()) { | |
| 1461 | match state.view.insert_text(text) { | |
| 1462 | Ok(response) => state.respond(response), | |
| 1463 | Err(error) => eprintln!("{error}"), | |
| 1464 | } | |
| 1465 | state.window.request_redraw(); | |
| 1466 | } else { | |
| 1467 | state.input(ui::Event::Ime(Ime::Commit(text))); | |
| 680 | 1468 | } |
| 681 | 1469 | } |
| 682 | 1470 | return; |
| ... | ... | @@ -693,6 +1481,19 @@ impl ApplicationHandler<UserEvent> for App { |
| 693 | 1481 | } |
| 694 | 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 | 1497 | UserEvent::Accessibility(event) => event, |
| 697 | 1498 | }; |
| 698 | 1499 | let Some(state) = &mut self.state else { |
| ... | ... | @@ -758,11 +1559,10 @@ impl ApplicationHandler<UserEvent> for App { |
| 758 | 1559 | let Some(state) = &mut self.state else { |
| 759 | 1560 | return; |
| 760 | 1561 | }; |
| 761 | let was_marked = | |
| 762 | !matches!(event, WindowEvent::Ime(_)) && state.view.editor.marked_range().is_some(); | |
| 1562 | let scale = state.window.scale_factor() as f32; | |
| 763 | 1563 | let result = (|| -> Result<(), Box<dyn Error>> { |
| 764 | let response = match event { | |
| 765 | WindowEvent::RedrawRequested => return state.draw(), | |
| 1564 | match event { | |
| 1565 | WindowEvent::RedrawRequested => return state.frame(), | |
| 766 | 1566 | WindowEvent::Resized(size) => { |
| 767 | 1567 | if size.width > 0 && size.height > 0 { |
| 768 | 1568 | state.config.width = size.width; |
| ... | ... | @@ -771,72 +1571,66 @@ impl ApplicationHandler<UserEvent> for App { |
| 771 | 1571 | .surface |
| 772 | 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 | 1574 | // Present inside AppKit's resize transaction; a redraw on the next turn |
| 778 | 1575 | // lets the window show the previous frame at the new size. |
| 779 | if changed { | |
| 780 | state.draw()?; | |
| 781 | } | |
| 782 | return Ok(()); | |
| 1576 | return state.frame(); | |
| 783 | 1577 | } |
| 784 | 1578 | WindowEvent::ScaleFactorChanged { scale_factor, .. } => { |
| 785 | 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 | 1588 | WindowEvent::Occluded(occluded) => { |
| 790 | 1589 | trace_input(&("Window occluded", occluded)); |
| 791 | 1590 | state.occluded = occluded; |
| 792 | if occluded { | |
| 793 | return Ok(()); | |
| 794 | } | |
| 795 | Response { | |
| 796 | changed: true, | |
| 797 | redraw: true, | |
| 798 | request: None, | |
| 1591 | if !occluded { | |
| 1592 | state.changed = true; | |
| 1593 | state.window.request_redraw(); | |
| 799 | 1594 | } |
| 800 | 1595 | } |
| 801 | WindowEvent::ModifiersChanged(modifiers) => state | |
| 802 | .view | |
| 803 | .modifiers_changed(page_modifiers(modifiers.state()))?, | |
| 804 | WindowEvent::CursorLeft { .. } => state.view.pointer_left(), | |
| 805 | WindowEvent::CursorMoved { position, .. } => state | |
| 806 | .view | |
| 807 | .pointer_moved([position.x as f32, position.y as f32])?, | |
| 808 | WindowEvent::MouseInput { | |
| 809 | state: ElementState::Pressed, | |
| 810 | button: MouseButton::Left, | |
| 811 | .. | |
| 812 | } => state.view.pointer_pressed(Instant::now())?, | |
| 1596 | WindowEvent::ModifiersChanged(modifiers) => { | |
| 1597 | state.input(ui::Event::Modifiers(modifiers.state())) | |
| 1598 | } | |
| 1599 | WindowEvent::CursorLeft { .. } => state.input(ui::Event::PointerLeft), | |
| 1600 | WindowEvent::CursorMoved { position, .. } => { | |
| 1601 | state.input(ui::Event::PointerMoved([ | |
| 1602 | position.x as f32 / scale, | |
| 1603 | position.y as f32 / scale, | |
| 1604 | ])) | |
| 1605 | } | |
| 813 | 1606 | WindowEvent::MouseInput { |
| 814 | state: ElementState::Released, | |
| 815 | button: MouseButton::Left, | |
| 1607 | state: pressed, | |
| 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 | 1615 | WindowEvent::MouseWheel { delta, .. } => { |
| 819 | let dpr = state.window.scale_factor() as f32; | |
| 820 | state.view.wheel(match delta { | |
| 821 | MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr], | |
| 822 | MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32], | |
| 823 | })? | |
| 1616 | state.input(ui::Event::Wheel(match delta { | |
| 1617 | MouseScrollDelta::LineDelta(x, y) => [x * 32.0, y * 32.0], | |
| 1618 | MouseScrollDelta::PixelDelta(p) => [p.x as f32 / scale, p.y as f32 / scale], | |
| 1619 | })) | |
| 824 | 1620 | } |
| 825 | 1621 | WindowEvent::KeyboardInput { event, .. } |
| 826 | 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)?, | |
| 831 | WindowEvent::Ime(Ime::Commit(text)) => state.view.commit_text(text)?, | |
| 832 | WindowEvent::Ime(Ime::Disabled) => state.view.cancel_composition()?, | |
| 833 | _ => return Ok(()), | |
| 834 | }; | |
| 835 | state.respond(response) | |
| 1629 | WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)), | |
| 1630 | _ => {} | |
| 1631 | } | |
| 1632 | Ok(()) | |
| 836 | 1633 | })(); |
| 837 | if was_marked && state.view.editor.marked_range().is_none() { | |
| 838 | macos::clear_marked_text(&state.window); | |
| 839 | } | |
| 840 | 1634 | if let Err(error) = result { |
| 841 | 1635 | eprintln!("{error}"); |
| 842 | 1636 | } |
| ... | ... | @@ -858,6 +1652,75 @@ impl ApplicationHandler<UserEvent> for App { |
| 858 | 1652 | } |
| 859 | 1653 | } |
| 860 | 1654 | |
| 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`. | |
| 1658 | fn 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 | ||
| 861 | 1724 | fn main() -> Result<(), Box<dyn Error>> { |
| 862 | 1725 | let mut args = std::env::args_os().skip(1); |
| 863 | 1726 | let mut positional = Vec::new(); |
| ... | ... | @@ -865,6 +1728,7 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 865 | 1728 | let mut reference = None; |
| 866 | 1729 | let mut editable = false; |
| 867 | 1730 | let mut section = None; |
| 1731 | let mut notebook = None; | |
| 868 | 1732 | let mut cache = None; |
| 869 | 1733 | while let Some(arg) = args.next() { |
| 870 | 1734 | if arg == "--substitute-font" { |
| ... | ... | @@ -872,6 +1736,11 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 872 | 1736 | args.next() |
| 873 | 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 | 1744 | } else if arg == "--section" { |
| 876 | 1745 | if reference.is_some() || section.is_some() { |
| 877 | 1746 | return Err("Only one page can be opened.".into()); |
| ... | ... | @@ -915,14 +1784,17 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 915 | 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 | 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 | 1795 | if positional.len() > 2 { |
| 924 | 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 | 1798 | .into(), |
| 927 | 1799 | ); |
| 928 | 1800 | } |
| ... | ... | @@ -940,12 +1812,14 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 940 | 1812 | } else { |
| 941 | 1813 | 480.0 |
| 942 | 1814 | }); |
| 943 | let input = if let Some((file, title)) = section { | |
| 944 | let cache = match cache { | |
| 945 | Some(cache) => cache, | |
| 946 | None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?) | |
| 947 | .join("Library/Caches/snowbound"), | |
| 948 | }; | |
| 1815 | let cache = match cache { | |
| 1816 | Some(cache) => cache, | |
| 1817 | None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?) | |
| 1818 | .join("Library/Caches/snowbound"), | |
| 1819 | }; | |
| 1820 | let input = if let Some(root) = notebook { | |
| 1821 | Input::Notebook { root, cache } | |
| 1822 | } else if let Some((file, title)) = section { | |
| 949 | 1823 | Input::Section { file, title, cache } |
| 950 | 1824 | } else if editable { |
| 951 | 1825 | Input::Page(reference.unwrap()) |
| ... | ... | @@ -961,6 +1835,9 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 961 | 1835 | } |
| 962 | 1836 | }; |
| 963 | 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 | 1841 | let mut app = App { |
| 965 | 1842 | proxy: event_loop.create_proxy(), |
| 966 | 1843 | input: Some(input), |
crates/ui/Cargo.toml created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | [package] | |
| 2 | name = "ui" | |
| 3 | version = "0.1.0" | |
| 4 | edition = "2024" | |
| 5 | publish = false | |
| 6 | ||
| 7 | [dependencies] | |
| 8 | draw = { path = "../draw" } | |
| 9 | parley.workspace = true | |
| 10 | winit = "0.30.13" |
crates/ui/src/layout.rs created+136| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | use crate::{Axis, Built, Flags, Id, Size, State}; | |
| 2 | use 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. | |
| 8 | pub(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 | ||
| 111 | fn along(node: &Built, axis: usize) -> bool { | |
| 112 | (node.axis == Axis::Y) == (axis == 1) | |
| 113 | } | |
| 114 | ||
| 115 | fn strictness(nodes: &[Built], index: usize, axis: usize) -> f32 { | |
| 116 | nodes[index].size[axis].strictness.clamp(0.0, 1.0) | |
| 117 | } | |
| 118 | ||
| 119 | fn 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. | |
| 128 | fn 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 | ||
| 6 | mod layout; | |
| 7 | mod text; | |
| 8 | mod theme; | |
| 9 | mod widgets; | |
| 10 | ||
| 11 | pub use theme::Theme; | |
| 12 | pub use widgets::{button, scrollbar, text_field}; | |
| 13 | ||
| 14 | use draw::{Primitive, Stroke}; | |
| 15 | use std::{ | |
| 16 | collections::HashMap, | |
| 17 | hash::{DefaultHasher, Hash, Hasher}, | |
| 18 | ops::BitOr, | |
| 19 | rc::Rc, | |
| 20 | time::Instant, | |
| 21 | }; | |
| 22 | use text::{Label, Painted, Texts}; | |
| 23 | use 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. | |
| 30 | const 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)] | |
| 34 | pub struct Id(u64); | |
| 35 | ||
| 36 | impl 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)] | |
| 48 | pub 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)] | |
| 61 | pub struct Extent { | |
| 62 | pub size: Size, | |
| 63 | pub strictness: f32, | |
| 64 | } | |
| 65 | ||
| 66 | impl Default for Extent { | |
| 67 | fn default() -> Self { | |
| 68 | children() | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | pub fn px(pixels: f32) -> Extent { | |
| 73 | Extent { | |
| 74 | size: Size::Pixels(pixels), | |
| 75 | strictness: 1.0, | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | pub 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. | |
| 87 | pub fn fill() -> Extent { | |
| 88 | Extent { | |
| 89 | size: Size::Fraction(1.0), | |
| 90 | strictness: 0.0, | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | pub fn children() -> Extent { | |
| 95 | Extent { | |
| 96 | size: Size::Children, | |
| 97 | strictness: 1.0, | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 102 | pub enum Axis { | |
| 103 | #[default] | |
| 104 | X, | |
| 105 | Y, | |
| 106 | } | |
| 107 | ||
| 108 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 109 | pub struct Flags(u32); | |
| 110 | ||
| 111 | impl 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 | ||
| 134 | impl 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)] | |
| 144 | pub 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)] | |
| 170 | pub 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)] | |
| 189 | pub 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. | |
| 204 | pub 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 | ||
| 217 | struct 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)] | |
| 245 | struct 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 | ||
| 261 | struct Hit { | |
| 262 | id: Id, | |
| 263 | rect: [f32; 4], | |
| 264 | flags: Flags, | |
| 265 | cursor: Option<CursorIcon>, | |
| 266 | } | |
| 267 | ||
| 268 | enum 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 | ||
| 287 | pub 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 | ||
| 310 | impl 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 | ||
| 841 | impl 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 | ||
| 877 | fn 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 | ||
| 881 | fn 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)] | |
| 892 | mod tests; |
crates/ui/src/tests.rs created+382| ... | ... | @@ -0,0 +1,382 @@ |
| 1 | use super::*; | |
| 2 | use std::time::Duration; | |
| 3 | use winit::keyboard::NamedKey; | |
| 4 | ||
| 5 | thread_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. | |
| 11 | fn 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] | |
| 23 | fn 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] | |
| 57 | fn 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 | ||
| 92 | fn 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] | |
| 109 | fn 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] | |
| 151 | fn 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] | |
| 227 | fn 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] | |
| 275 | fn 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] | |
| 335 | fn 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 @@ |
| 1 | use draw::{GlyphRun, Glyphs, RenderError}; | |
| 2 | use parley::{ | |
| 3 | Affinity, FontContext, FontFamily, GenericFamily, Layout, LayoutContext, PositionedLayoutItem, | |
| 4 | StyleProperty, editing::Cursor, | |
| 5 | }; | |
| 6 | use std::{collections::HashMap, rc::Rc}; | |
| 7 | ||
| 8 | /// A shaped single line of interface text, in logical pixels. | |
| 9 | pub(crate) struct Label { | |
| 10 | layout: Layout<()>, | |
| 11 | pub size: [f32; 2], | |
| 12 | } | |
| 13 | ||
| 14 | impl 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)] | |
| 30 | pub(crate) struct Texts { | |
| 31 | fonts: FontContext, | |
| 32 | context: LayoutContext<()>, | |
| 33 | cache: HashMap<(String, u32), (Rc<Label>, u64)>, | |
| 34 | } | |
| 35 | ||
| 36 | impl 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. | |
| 65 | pub(crate) struct Painted { | |
| 66 | pub label: Rc<Label>, | |
| 67 | pub color: [f32; 4], | |
| 68 | } | |
| 69 | ||
| 70 | impl 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 @@ |
| 1 | use draw::srgb; | |
| 2 | ||
| 3 | /// Colours are linear RGBA; sizes are logical pixels. | |
| 4 | #[derive(Clone, Debug)] | |
| 5 | pub 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 | ||
| 24 | impl 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 @@ |
| 1 | use crate::{Axis, Event, Flags, Id, Signal, Spec, Ui, fit, px}; | |
| 2 | use std::hash::Hash; | |
| 3 | use 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. | |
| 10 | pub 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. | |
| 30 | pub 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. | |
| 88 | pub 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 | ||
| 228 | fn 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 | ||
| 236 | fn previous(text: &str, index: usize) -> usize { | |
| 237 | text[..index] | |
| 238 | .char_indices() | |
| 239 | .next_back() | |
| 240 | .map_or(0, |(index, _)| index) | |
| 241 | } | |
| 242 | ||
| 243 | fn 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 | 24 | |
| 25 | 25 | Control-Command-Space opens the macOS character picker for the active text field. |
| 26 | 26 | |
| 27 | Omit 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. | |
| 27 | Omit 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. | |
| 28 | 28 | |
| 29 | 29 | ```text |
| 30 | 30 | onestore::Format |
| ... | ... | @@ -36,9 +36,26 @@ canvas::gpu page scene · text runs, tag icons, pictures and ink as draw pri |
| 36 | 36 | draw Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches · layers |
| 37 | 37 | ↓ |
| 38 | 38 | canvas::interaction hit layers · drags · placement grid · picture handles · chrome · key routing |
| 39 | · scrolling and zoom · caret blink · AccessKit tree | |
| 39 | · scroll bounds and zoom · caret blink · AccessKit tree | |
| 40 | 40 | ↓ |
| 41 | snowbound Winit window and events · AppKit pickers and quit guard · clipboard · saving | |
| 41 | ui immediate-mode boxes · keyed state · autolayout · event routing · labels · widgets | |
| 42 | ↓ | |
| 43 | snowbound 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 | ||
| 51 | The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. 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 | ||
| 53 | A 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 | |
| 56 | cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one | |
| 57 | SNOWBOUND_REPLAY=SCRIPT "target/Snowbound.app/Contents/MacOS/Snowbound" --notebook /tmp/notebook-copy --cache /tmp/notebook-cache | |
| 58 | cargo test -p ui | |
| 42 | 59 | ``` |
| 43 | 60 | |
| 44 | 61 | The 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. |