From 14ffea224c2042a06a245dcbcc1710ce2cfd9df7 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 25 Sep 2026 12:38:03 -0700 Subject: [PATCH] feat: ui kit and the Snowbound shell around the page The new ui crate is an immediate-mode kit after Ryan Fleury's series: builder code declares boxes every frame from application state, a cache keyed by stable ids keeps hover, press, focus, scroll and animation, and the previous frame's layout routes the frame's input before building. Sizes are solved per axis after building (fixed, label, fraction of an ancestor, sum of children) with overflow shared by strictness. Custom boxes receive their routed events and are painted by the host in the same frame; labels are shaped with parley and painted through draw. Snowbound now builds its window with it: section tabs in the top strip in the notebook's order and colours, the section's pages on the right with subpage indents, Command-F filtering with Enter to open, a conflict bar in place of the Command-Shift-K/T title prompt, and the page as a custom box. The canvas's scrollbars become ui widgets over that box; PageView reports its scroll bounds and scrolls on request. Work a frame asks for runs after it is painted. --notebook opens a notebook folder, and SNOWBOUND_REPLAY scripts input with PNG snapshots so a covered window can be exercised. onestore reads a page's list heading from its metadata, notebook's Section::pages returns outline levels, and the catalog carries section colours. Assisted-by: claude-opus-5.5 --- Cargo.lock | 11 + crates/canvas/src/interaction/mod.rs | 63 +- crates/canvas/src/interaction/scroll.rs | 86 +- crates/notebook/examples/session_client.rs | 5 +- crates/notebook/src/discover.rs | 15 +- crates/notebook/src/session.rs | 22 +- crates/notebook/tests/links.rs | 2 +- crates/notebook/tests/session.rs | 2 +- crates/onestore/src/page/mod.rs | 17 + crates/snowbound/Cargo.toml | 2 + crates/snowbound/src/main.rs | 1261 +++++++++++++++++--- crates/ui/Cargo.toml | 10 + crates/ui/src/layout.rs | 136 +++ crates/ui/src/lib.rs | 892 ++++++++++++++ crates/ui/src/tests.rs | 382 ++++++ crates/ui/src/text.rs | 92 ++ crates/ui/src/theme.rs | 40 + crates/ui/src/widgets.rs | 248 ++++ tools/canvas/README.md | 23 +- 19 files changed, 2982 insertions(+), 327 deletions(-) create mode 100644 crates/ui/Cargo.toml create mode 100644 crates/ui/src/layout.rs create mode 100644 crates/ui/src/lib.rs create mode 100644 crates/ui/src/tests.rs create mode 100644 crates/ui/src/text.rs create mode 100644 crates/ui/src/theme.rs create mode 100644 crates/ui/src/widgets.rs diff --git a/Cargo.lock b/Cargo.lock index 449b36a5e430582beed92793e8823a8c4f848cbe..827b573399519f7b60cc2e55d159408af52ad1c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2650,7 +2650,9 @@ dependencies = [ "objc2-foundation 0.2.2", "onestore", "parley", + "png", "pollster", + "ui", "wgpu", "winit", ] @@ -2930,6 +2932,15 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ui" +version = "0.1.0" +dependencies = [ + "draw", + "parley", + "winit", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index cc6b7bfb3b856c011379d9521fd29535b6b18c91..6919657a660e0c215843d9eb5d9f205d082e9611 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -6,6 +6,8 @@ pub mod accessibility; #[cfg(test)] mod profile; mod scroll; + +pub use scroll::Scroll; #[cfg(test)] mod tests; @@ -143,10 +145,6 @@ enum Drag { anchor: Selection, unit: SelectionUnit, }, - Scrollbar { - axis: usize, - grab: f32, - }, Resize { outline: Option>, grab: f32, @@ -219,6 +217,11 @@ pub struct PageView { blink_at: Instant, } +/// Where a page's origin sits in a view that has not scrolled, in device pixels. +fn home(display_scale: f32) -> [f32; 2] { + [48.0 * display_scale; 2] +} + impl PageView { /// `size` is in device pixels; `double_click` is the platform's double-click interval. pub fn new( @@ -236,7 +239,7 @@ impl PageView { viewport: Viewport { size, scale: display_scale * 96.0 / 72.0, - origin: [48.0 * display_scale; 2], + origin: home(display_scale), }, display_scale, double_click, @@ -260,6 +263,13 @@ impl PageView { self.object_focus = None; } + /// Shows another page from its top-left corner, keeping the zoom. + pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) { + self.replace(editor, scene); + self.viewport.origin = home(self.display_scale); + self.scroll().clamp(&mut self.viewport); + } + pub fn modifiers(&self) -> Modifiers { self.modifiers } @@ -474,7 +484,8 @@ impl PageView { ]; } - fn scroll(&self) -> scroll::Scroll { + /// How far the view may scroll; the current offset is `-viewport.origin`. + pub fn scroll(&self) -> Scroll { let editable = self .editor .visible_outlines() @@ -507,7 +518,14 @@ impl PageView { scroll::Scroll::new(self.viewport, editable.chain(fixed)) } - /// Everything to draw this frame, scrollbars included. + /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`, + /// within the page's bounds. + pub fn scroll_to(&mut self, axis: usize, offset: f32) -> Result { + self.viewport.origin[axis] = -offset; + self.changed() + } + + /// Everything to draw this frame. pub fn primitives(&self, colors: TextColors) -> Result>> { let preview = match &self.drag { Some(Drag::Resize { @@ -538,7 +556,7 @@ impl PageView { } }), }; - let mut primitives = page_primitives( + page_primitives( &self.editor, self.scene.as_ref(), preview, @@ -554,22 +572,13 @@ impl PageView { pixel: self.pixel(), colors, }, - )?; - self.scroll() - .append(self.viewport, self.display_scale, &mut primitives); - Ok(primitives) + ) } /// The pointer shape at the pointer's position. pub fn cursor(&self) -> Cursor { let hit = self.hit_test(self.viewport.document_point(self.pointer)); - let scrollbar = self - .scroll() - .hit_test(self.viewport, self.display_scale, self.pointer) - .is_some(); match (&self.drag, hit) { - (Some(Drag::Scrollbar { .. }), _) => Cursor::Default, - (None, _) if scrollbar => Cursor::Default, (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize, @@ -638,17 +647,6 @@ impl PageView { self.pointer_inside = true; self.pointer = position; match &mut self.drag { - Some(Drag::Scrollbar { axis, grab }) => { - let (axis, grab) = (*axis, *grab); - self.scroll().drag( - &mut self.viewport, - self.display_scale, - axis, - self.pointer[axis], - grab, - ); - self.changed() - } Some(Drag::Text { anchor, unit }) => { let (anchor, unit) = (*anchor, *unit); let point = self.viewport.document_point(self.pointer); @@ -700,13 +698,6 @@ impl PageView { }) .map_or(1, |(_, _, count)| (count % 3) + 1); self.last_click = Some((now, self.pointer, count)); - if let Some((axis, grab)) = - self.scroll() - .hit_test(self.viewport, self.display_scale, self.pointer) - { - self.drag = Some(Drag::Scrollbar { axis, grab }); - return self.changed(); - } match self.hit_test(point) { Some(Hit::Date(field)) => { self.editor.finish_composition(); diff --git a/crates/canvas/src/interaction/scroll.rs b/crates/canvas/src/interaction/scroll.rs index 0b398e8dfe9e877833738fe7daf580c7eaa3978f..4c65e28c60889a1f20668ff7d40c0551f504ec08 100644 --- a/crates/canvas/src/interaction/scroll.rs +++ b/crates/canvas/src/interaction/scroll.rs @@ -1,6 +1,8 @@ use crate::gpu::Viewport; -use draw::Primitive; +/// The least and greatest scroll offsets on each axis, in device pixels: how far the +/// view's corner may sit from the page origin. +#[derive(Clone, Copy, Debug, PartialEq)] pub struct Scroll { pub min: [f32; 2], pub max: [f32; 2], @@ -30,63 +32,6 @@ impl Scroll { viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]); } } - - pub fn thumb(&self, viewport: Viewport, dpr: f32, axis: usize) -> Option<[f32; 4]> { - let range = self.max[axis] - self.min[axis]; - let length = viewport.size[axis] as f32; - let track = length - 20.0 * dpr; - if range <= 0.0 || track <= 0.0 { - return None; - } - let size = (track * length / (length + range)) - .max(28.0 * dpr) - .min(track); - let position = (-viewport.origin[axis] - self.min[axis]) / range; - let start = 4.0 * dpr + position.clamp(0.0, 1.0) * (track - size); - let cross = viewport.size[1 - axis] as f32 - 4.0 * dpr; - Some(if axis == 0 { - [start, cross - 6.0 * dpr, start + size, cross] - } else { - [cross - 6.0 * dpr, start, cross, start + size] - }) - } - - pub fn drag(&self, viewport: &mut Viewport, dpr: f32, axis: usize, pointer: f32, grab: f32) { - let Some(rect) = self.thumb(*viewport, dpr, axis) else { - return; - }; - let travel = viewport.size[axis] as f32 - 20.0 * dpr - (rect[axis + 2] - rect[axis]); - if travel > 0.0 { - let fraction = ((pointer - grab - 4.0 * dpr) / travel).clamp(0.0, 1.0); - viewport.origin[axis] = - -(self.min[axis] + fraction * (self.max[axis] - self.min[axis])); - } - } - - pub fn hit_test(&self, viewport: Viewport, dpr: f32, point: [f32; 2]) -> Option<(usize, f32)> { - (0..2).find_map(|axis| { - let rect = self.thumb(viewport, dpr, axis)?; - let inset = 3.0 * dpr; - ((rect[0] - inset..=rect[2] + inset).contains(&point[0]) - && (rect[1] - inset..=rect[3] + inset).contains(&point[1])) - .then_some((axis, point[axis] - rect[axis])) - }) - } - - pub fn append(&self, viewport: Viewport, dpr: f32, primitives: &mut Vec>) { - for axis in 0..2 { - if let Some(rect) = self.thumb(viewport, dpr, axis) { - let start = viewport.document_point([rect[0], rect[1]]); - let end = viewport.document_point([rect[2], rect[3]]); - primitives.push(Primitive::RoundedRect { - rect: [start[0], start[1], end[0], end[1]], - radius: [3.0 * dpr / viewport.scale; 2], - stroke: None, - color: [0.18, 0.18, 0.18, 0.45], - }); - } - } - } } #[cfg(test)] @@ -107,29 +52,4 @@ mod tests { scroll.clamp(&mut viewport); assert_eq!(viewport.origin, [-1272.0, -1872.0]); } - - #[test] - fn small_page_has_no_scrollbars_and_thumb_drag_reaches_endpoints() { - for dpr in [1.0, 2.0] { - let mut viewport = Viewport { - size: [(800.0 * dpr) as u32, (600.0 * dpr) as u32], - scale: dpr * 4.0 / 3.0, - origin: [48.0 * dpr; 2], - }; - let scroll = Scroll::new(viewport, [[0.0, 0.0, 72.0, 14.0]].into_iter()); - assert!(scroll.thumb(viewport, dpr, 0).is_none()); - assert!(scroll.thumb(viewport, dpr, 1).is_none()); - let scroll = Scroll::new(viewport, [[0.0, 0.0, 2000.0, 2000.0]].into_iter()); - for axis in 0..2 { - scroll.drag(&mut viewport, dpr, axis, -1000.0, 3.0 * dpr); - assert_eq!(-viewport.origin[axis], scroll.min[axis]); - let rect = scroll.thumb(viewport, dpr, axis).unwrap(); - assert!((rect[axis] - 4.0 * dpr).abs() < 0.001); - scroll.drag(&mut viewport, dpr, axis, 10000.0, 3.0 * dpr); - assert_eq!(-viewport.origin[axis], scroll.max[axis]); - let rect = scroll.thumb(viewport, dpr, axis).unwrap(); - assert!((rect[axis + 2] - (viewport.size[axis] as f32 - 16.0 * dpr)).abs() < 0.001); - } - } - } } diff --git a/crates/notebook/examples/session_client.rs b/crates/notebook/examples/session_client.rs index ac36e7f32806a4676341fd9b01ec6c10dc24c68f..2dfc33c0b1269f87b703ceaf6d36e2990ddbb0a8 100644 --- a/crates/notebook/examples/session_client.rs +++ b/crates/notebook/examples/session_client.rs @@ -134,7 +134,10 @@ fn main() -> Result<(), Box> { ); let pages = section.pages()?; let mut queued = Vec::new(); - for (space, title) in pages.iter().filter(|(_, title)| title.starts_with("Move")) { + for (space, title, _) in pages + .iter() + .filter(|(_, title, _)| title.starts_with("Move")) + { let before = section.page(*space)?; let mut after = before.clone(); edit(&mut after, label); diff --git a/crates/notebook/src/discover.rs b/crates/notebook/src/discover.rs index 57f57db439cb559fa66b96fdc1a1428b2cbcc387..7ce65d12fa5f25355f2c6bbacaa6136a35bba402 100644 --- a/crates/notebook/src/discover.rs +++ b/crates/notebook/src/discover.rs @@ -28,6 +28,8 @@ pub enum SectionState { Readable { /// An explicit SectionDisplayName; otherwise use the current filename without its extension. name: Option, + /// The tab colour as a COLORREF; OneNote assigns one when absent. + color: Option, }, Locked, Unreadable(onestore::Error), @@ -267,18 +269,21 @@ fn scan( } index.validate_current()?; document.pages()?; - let name = revision + let (name, color) = revision .roots .get(&2) .and_then(|id| revision.nodes.get(id)) - .and_then(|node| match &node.kind { - Kind::SectionMetadata { name, .. } => name.clone(), - _ => None, + .map_or((None, None), |node| match &node.kind { + Kind::SectionMetadata { name, color } => (name.clone(), *color), + _ => (None, None), }); result.sections.push(Section { path: child.clone(), file_id, - state: SectionState::Readable { name }, + state: SectionState::Readable { + name, + color: color.filter(|color| *color != 0xffff_ffff), + }, }); } else { index.validate_current()?; diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 84842e5dbeda6faa3342228bd00393d1751c13b1..05212683c1a323482664d5d70faa2db564d5101d 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -869,16 +869,28 @@ impl Section { Ok(Store::parse(&self.replica.snapshot()?)?.header.file_id) } - /// Page spaces and titles in section order, from the local working image. - pub fn pages(&self) -> Result> { + /// Page spaces, titles and outline levels (1 at the top) in section order, from the + /// local working image. + pub fn pages(&self) -> Result> { let snapshot = self.replica.snapshot()?; let store = Store::parse(&snapshot)?; let index = RevisionIndex::parse(&store)?; let document = Document::parse(&index)?; - document - .pages()? + let pages = document.pages()?; + let mut spaces = std::collections::BTreeSet::new(); + pages .into_iter() - .map(|(space, _)| Ok((space, Page::from_space(&document, space)?.title))) + .map(|(space, page)| { + if !spaces.insert(space) { + return Err(onestore::Error { + offset: 0, + message: "Choose an object space containing one active page", + } + .into()); + } + let (title, level) = Page::heading(document.active(space)?, page); + Ok((space, title, level)) + }) .collect() } diff --git a/crates/notebook/tests/links.rs b/crates/notebook/tests/links.rs index 39307dc6bc5a259f238786ca8bec97d9dab4946c..429f140f2e564863b03cc397cc83102a2c8a5046 100644 --- a/crates/notebook/tests/links.rs +++ b/crates/notebook/tests/links.rs @@ -31,7 +31,7 @@ fn internal_links_find_their_page_by_identity() { .unwrap(); let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap(); let section = notebook.section("First.one", || {}).unwrap(); - let (space, _) = section.pages().unwrap()[0]; + let (space, _, _) = section.pages().unwrap()[0]; let page = section.page(space).unwrap(); section.close().unwrap(); let target = LinkTarget::Page { diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index 260862ab46f589dc05b28140c92d11a8a8047cad..0905afde9bf075a8ab2957e03599e4c5e090c684 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -259,7 +259,7 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() { assert_eq!(catalog.groups[0].sections.len(), 1); let path = catalog.groups[0].sections[0].path.clone(); let section = notebook.section(&path, || {}).unwrap(); - let (space, _) = section.pages().unwrap()[0]; + let (space, _, _) = section.pages().unwrap()[0]; let page = section.page(space).unwrap(); let text = first_text(&page); assert_eq!( diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index d23fb27b43bddf33145ab0e3ffcfb0c81e26223e..b969bbbbabd7cbf22d80dc2a11019cd66601e0a8 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -493,6 +493,23 @@ impl Page { }) } + /// The title and outline level (1 at the top) a section's page list shows, read from + /// the page's metadata without building its content. + pub fn heading(revision: &Revision<'_>, id: ExGuid) -> (String, u32) { + let level = revision + .roots + .get(&2) + .and_then(|id| revision.nodes.get(id)) + .and_then(|node| match node.kind { + Kind::Metadata { level, .. } => level, + _ => None, + }); + ( + page_title(revision, id).unwrap_or_default().to_owned(), + level.unwrap_or(1), + ) + } + /// The single active page declared in one page object space. pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result { let pages = document.pages_in(space)?; diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 0286e58a4ca6e875265eb89e0b3eb2af8161a4a3..6c68424adecd213826c9cab60bf1743a279a1439 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -7,6 +7,8 @@ publish = false [dependencies] canvas = { path = "../canvas", features = ["interaction"] } draw = { path = "../draw" } +ui = { path = "../ui" } +png = "0.18" onestore = { path = "../onestore" } notebook = { path = "../notebook" } pollster = "0.4" diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 35f617cfb4287891e853b09a3ea360a79f3c43a7..5285a57c6f26b10397b8962d6b23b6e095767e1d 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -12,19 +12,33 @@ use canvas::{ layout::TextEngine, }; use draw::Renderer; +use onestore::ExGuid; use onestore::document::Format; use onestore::page::Page; use onestore::page::text::Paragraph; -use std::{error::Error, path::PathBuf, sync::Arc, time::Instant}; +use std::{ + error::Error, + path::{Path, PathBuf}, + sync::Arc, + time::Instant, +}; +use ui::{Axis, Flags, Id, Spec, Theme, Ui, fill, fit, px}; use winit::{ application::ApplicationHandler, dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, keyboard::{Key, ModifiersState, NamedKey}, + platform::macos::WindowAttributesExtMacOS, window::{CursorIcon, Window, WindowId}, }; +/// Height of the window's top strip, which holds the traffic lights and section tabs. +const STRIP: f32 = 38.0; +/// Room the traffic lights take at the strip's leading edge. +const LIGHTS: f32 = 78.0; +const PAGE_LIST: f32 = 240.0; + #[derive(Debug)] enum UserEvent { Quit, @@ -32,6 +46,15 @@ enum UserEvent { Accessibility(accesskit_winit::Event), /// The section's synchronization thread reported an event. Sync, + /// Scripted input from `SNOWBOUND_REPLAY`. + Replay(Replay), +} + +#[derive(Debug)] +enum Replay { + Input(ui::Event), + /// Paints the next frame into a PNG as well as the window. + Snapshot(PathBuf), } impl From for UserEvent { @@ -116,37 +139,109 @@ enum Input { title: String, cache: PathBuf, }, + Notebook { + root: PathBuf, + cache: PathBuf, + }, } -/// The opened section and the stored model the editor's page was loaded from. +/// A section tab: where the section opens from and how it is labelled. +struct Tab { + /// The notebook catalog path, or the file of a section opened on its own. + path: String, + name: String, + /// COLORREF. + color: Option, +} + +/// The sections the tabs offer. +struct Library { + notebook: Option, + tabs: Vec, + cache: PathBuf, +} + +impl Library { + fn open( + &self, + tab: usize, + proxy: EventLoopProxy, + ) -> Result> { + let notify = move || { + let _ = proxy.send_event(UserEvent::Sync); + }; + let path = &self.tabs[tab].path; + Ok(match &self.notebook { + Some(notebook) => notebook.section(path, notify)?, + None => notebook::session::Section::open(path, &self.cache, notify)?, + }) + } +} + +/// The open section and the stored model the editor's page was loaded from. struct Session { section: notebook::session::Section, - space: onestore::ExGuid, + tab: usize, + /// Spaces, titles and outline levels in section order. + pages: Vec<(ExGuid, String, u32)>, + space: ExGuid, before: Page, - title: String, status: &'static str, + /// The editor's page has an unreviewed conflict with another machine's change. + conflict: bool, } impl Session { - fn window_title(&self) -> String { - let file = self + fn title(&self) -> &str { + self.pages + .iter() + .find(|(space, ..)| *space == self.space) + .map_or("", |(_, title, _)| title) + } + + fn refresh_conflict(&mut self) -> Result<(), Box> { + self.conflict = self .section - .file() - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_default(); - format!("{} · {file}{}", self.title, self.status) + .conflicts()? + .iter() + .any(|(edit, _)| edit.space == self.space); + Ok(()) } } +/// Work the interface asked for, done after the frame is built. +enum Command { + OpenSection(usize), + OpenPage(ExGuid), + Resolve { keep_mine: bool }, + Page(Request), +} + struct State { window: Arc, + proxy: EventLoopProxy, instance: wgpu::Instance, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, renderer: Renderer, + ui: Ui, view: PageView, + library: Option, session: Option, + /// Text filtering the page list. + filter: String, + commands: Vec, + /// The page changed during this frame, to be saved and announced after it. + changed: bool, + /// Whether the page last heard it had keyboard focus. + page_focused: bool, + window_focused: bool, + /// The pointer in logical pixels, for window drags from the strip. + pointer: [f32; 2], + /// When the strip was last pressed, to zoom on a double click. + strip_press: Option, + /// Where a replay asked the next frame to be written. + snapshot: Option, initial: Vec<(onestore::ExGuid, TextDocument)>, initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, initial_date: Option, @@ -157,6 +252,19 @@ struct State { accessibility: accessibility::Accessibility, } +fn strip() -> Id { + Id::ROOT.child("strip") +} + +fn page() -> Id { + Id::ROOT.child("page") +} + +/// The field filtering the page list. +fn filter() -> Id { + Id::ROOT.child("filter") +} + impl State { async fn new( event_loop: &ActiveEventLoop, @@ -168,6 +276,9 @@ impl State { event_loop.create_window( Window::default_attributes() .with_visible(false) + .with_titlebar_transparent(true) + .with_title_hidden(true) + .with_fullsize_content_view(true) .with_title(match &input { Input::Notes { reference: Some(page), @@ -178,8 +289,9 @@ impl State { reference: None, .. } => "Untitled · Temporary page".into(), Input::Section { title, .. } => title.clone(), + Input::Notebook { root, .. } => root.display().to_string(), }) - .with_inner_size(LogicalSize::new(1000.0, 720.0)), + .with_inner_size(LogicalSize::new(1180.0, 760.0)), )?, ); macos::install_text_input(&window); @@ -210,6 +322,7 @@ impl State { .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; eprintln!("Using {} for {target}", path.display()); } + let mut library = None; let mut session = None; let (editor, scene) = match input { Input::Notes { @@ -230,29 +343,47 @@ impl State { (editor, Some((scene, [0.0; 2]))) } Input::Section { file, title, cache } => { - let section = notebook::session::Section::open(&file, cache, move || { - let _ = proxy.send_event(UserEvent::Sync); - })?; - let (space, _) = section + let opened = Library { + notebook: None, + tabs: vec![Tab { + name: file + .file_stem() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(), + path: file.to_string_lossy().into_owned(), + color: None, + }], + cache, + }; + let section = opened.open(0, proxy.clone())?; + let space = section .pages()? .into_iter() - .find(|(_, candidate)| *candidate == title) - .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?; - let before = section.page(space)?; - let (scene, editor) = PageScene::from_page(before.clone(), &mut engine)?; - session = Some(Session { - section, - space, - before, - title, - status: "", - }); + .find(|(_, candidate, _)| *candidate == title) + .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))? + .0; + library = Some(opened); + let (scene, editor) = + open_session(section, 0, Some(space), &mut engine, &mut session)?; + (editor, Some((scene, [0.0; 2]))) + } + Input::Notebook { root, cache } => { + let notebook = notebook::session::Notebook::open(&root, &cache)?; + let tabs = tabs(¬ebook); + if tabs.is_empty() { + return Err(format!("No readable sections in {}", root.display()).into()); + } + let opened = Library { + notebook: Some(notebook), + tabs, + cache, + }; + let section = opened.open(0, proxy.clone())?; + library = Some(opened); + let (scene, editor) = open_session(section, 0, None, &mut engine, &mut session)?; (editor, Some((scene, [0.0; 2]))) } }; - if let Some(session) = &session { - window.set_title(&session.window_title()); - } let initial_date = editor.date().map(|date| date.timestamp()); let initial_layouts = editor .object_layouts() @@ -265,15 +396,18 @@ impl State { .collect(); let dpr = window.scale_factor() as f32; window.set_ime_allowed(true); - window.set_cursor(CursorIcon::Text); window.request_redraw(); eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); - Ok(Self { + let mut ui = Ui::new(Theme::dark()); + ui.set_focus(Some(page())); + let state = Self { window, + proxy, instance, surface, config, renderer, + ui, view: PageView::new( editor, engine, @@ -282,7 +416,16 @@ impl State { dpr, macos::double_click_interval(), ), + library, session, + filter: String::new(), + commands: Vec::new(), + changed: false, + page_focused: true, + window_focused: true, + pointer: [0.0; 2], + strip_press: None, + snapshot: None, initial, initial_date, initial_layouts, @@ -291,66 +434,513 @@ impl State { clipboard: arboard::Clipboard::new()?, access_adapter, accessibility: accessibility::Accessibility::default(), - }) + }; + state.title(); + Ok(state) } - /// Carries out what a page event asked of the platform and follows its changes: the - /// input method's position, accessibility, saving, the window title and a repaint. - fn respond(&mut self, response: Response) -> Result<(), Box> { - match response.request { - Some(Request::EditDate(field)) => self.edit_date(field)?, - Some(Request::Copy(text)) => self.clipboard.set_text(text)?, - Some(Request::Paste) => { + /// Builds, lays out and paints one frame, then does what it asked for and asks for the + /// frame that shows the result. + fn frame(&mut self) -> Result<(), Box> { + let size = self.window.inner_size(); + let scale = self.window.scale_factor() as f32; + self.ui.begin( + [size.width as f32 / scale, size.height as f32 / scale], + scale, + Instant::now(), + ); + self.build()?; + self.ui.end(); + if let Some(rect) = self.ui.rect(page()) { + let size = [ + ((rect[2] - rect[0]) * scale).round() as u32, + ((rect[3] - rect[1]) * scale).round() as u32, + ]; + if size != self.view.viewport.size { + let response = self.view.resized(size)?; + self.respond(response); + } + } + self.window.set_cursor( + self.ui + .cursor() + .unwrap_or_else(|| cursor_icon(self.view.cursor())), + ); + let ime = if self.ui.focused() == Some(page()) { + self.view.accepts_text() + } else { + self.ui.focused() == Some(filter()) + }; + if self.ime_allowed != ime { + self.ime_allowed = ime; + self.window.set_ime_allowed(ime); + } + self.draw()?; + let commands = std::mem::take(&mut self.commands); + let follow = !commands.is_empty() || self.ui.wants_frame(); + for command in commands { + self.apply(command)?; + } + if std::mem::take(&mut self.changed) { + self.after_edit()?; + } + if follow { + self.window.request_redraw(); + } + Ok(()) + } + + fn build(&mut self) -> Result<(), Box> { + let theme = self.ui.theme.clone(); + self.ui.open_as( + strip(), + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(STRIP)], + fill: Some(theme.strip), + pad: [0.0, 8.0], + gap: 2.0, + ..Spec::default() + }, + ); + self.ui.leaf( + "lights", + Spec { + size: [px(LIGHTS), px(1.0)], + ..Spec::default() + }, + ); + match (&self.library, &self.session) { + (Some(library), Some(session)) => { + for (index, tab) in library.tabs.iter().enumerate() { + let active = index == session.tab; + let id = self.ui.open( + ("tab", index), + Spec { + flags: Flags::CLICKABLE, + size: [fit(), px(STRIP - 8.0)], + text: Some(&tab.name), + color: Some(if active { theme.text } else { theme.text_dim }), + fill: Some(if active { theme.base } else { theme.strip }), + hover_fill: (!active).then_some(theme.chip), + radius: 6.0, + pad: [14.0 + 14.0, 0.0], + center: true, + ..Spec::default() + }, + ); + let swatch = tab.color.map_or(theme.text_dim, canvas::gpu::colorref); + let middle = (STRIP - 8.0) / 2.0; + self.ui + .mark([12.0, middle - 4.0, 20.0, middle + 4.0], swatch); + self.ui.close(); + if self.ui.signal(id).clicked && !active { + self.commands.push(Command::OpenSection(index)); + } + } + } + _ => { + self.ui.leaf( + "tab", + Spec { + size: [fit(), px(STRIP - 8.0)], + text: Some("Temporary page"), + fill: Some(theme.base), + radius: 6.0, + pad: [16.0, 0.0], + ..Spec::default() + }, + ); + } + } + self.ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + if let Some(session) = &self.session { + self.ui.leaf( + "status", + Spec { + size: [fit(), px(STRIP - 8.0)], + text: Some(session.status), + color: Some(theme.text_dim), + pad: [14.0, 0.0], + ..Spec::default() + }, + ); + } + self.ui.close(); + + self.ui.open( + "body", + Spec { + size: [fill(), fill()], + ..Spec::default() + }, + ); + self.ui.open( + "column", + Spec { + axis: Axis::Y, + size: [fill(), fill()], + ..Spec::default() + }, + ); + if self + .session + .as_ref() + .is_some_and(|session| session.conflict) + { + self.conflict_bar(&theme); + } + self.ui.open_as( + page(), + Spec { + flags: Flags::CUSTOM | Flags::FOCUSABLE, + size: [fill(), fill()], + fill: Some([1.0; 4]), + ..Spec::default() + }, + ); + let scroll = self.view.scroll(); + for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() { + if let Some(offset) = ui::scrollbar( + &mut self.ui, + index, + axis, + -self.view.viewport.origin[index], + [scroll.min[index], scroll.max[index]], + self.view.viewport.size[index] as f32, + [0.18, 0.18, 0.18, 0.45], + ) { + let response = self.view.scroll_to(index, offset)?; + self.respond(response); + } + } + self.ui.close(); + let signal = self.ui.signal(page()); + let focused = self.window_focused && self.ui.focused() == Some(page()); + if focused != self.page_focused { + self.page_focused = focused; + let response = self.view.focus_changed(focused)?; + self.respond(response); + } + self.page_events(signal.events)?; + self.ui.close(); + if self.session.is_some() { + self.ui.leaf( + "separator", + Spec { + size: [px(1.0), fill()], + fill: Some(theme.separator), + ..Spec::default() + }, + ); + self.page_list(&theme); + } + self.ui.close(); + Ok(()) + } + + fn conflict_bar(&mut self, theme: &Theme) { + self.ui.open( + "conflict", + Spec { + size: [fill(), px(40.0)], + fill: Some(theme.panel), + pad: [12.0, 7.0], + gap: 8.0, + ..Spec::default() + }, + ); + self.ui.leaf( + "message", + Spec { + size: [fit(), px(26.0)], + text: Some("This page also changed on another computer."), + ..Spec::default() + }, + ); + self.ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + if ui::button(&mut self.ui, "mine", "Keep mine").clicked { + self.commands.push(Command::Resolve { keep_mine: true }); + } + if ui::button(&mut self.ui, "theirs", "Keep theirs").clicked { + self.commands.push(Command::Resolve { keep_mine: false }); + } + self.ui.close(); + } + + fn page_list(&mut self, theme: &Theme) { + let Some(session) = &self.session else { + return; + }; + self.ui.open( + "panel", + Spec { + axis: Axis::Y, + size: [px(PAGE_LIST), fill()], + fill: Some(theme.panel), + ..Spec::default() + }, + ); + self.ui.open( + "pages", + Spec { + flags: Flags::SCROLL | Flags::CLIP, + axis: Axis::Y, + size: [fill(), fill()], + pad: [6.0, 6.0], + gap: 1.0, + ..Spec::default() + }, + ); + let query = self.filter.to_lowercase(); + let mut first = None; + for (space, title, level) in &session.pages { + if !title.to_lowercase().contains(&query) { + continue; + } + first.get_or_insert(*space); + let selected = *space == session.space; + let signal = self.ui.leaf( + space, + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(26.0)], + text: Some(if title.is_empty() { + "Untitled page" + } else { + title + }), + color: Some(if selected || !title.is_empty() { + theme.text + } else { + theme.text_dim + }), + fill: selected.then_some(theme.accent), + hover_fill: (!selected).then_some(theme.hover), + hover_border: (!selected).then_some(theme.accent), + radius: 4.0, + pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0], + ..Spec::default() + }, + ); + if signal.clicked && !selected { + self.commands.push(Command::OpenPage(*space)); + } + } + self.ui.close(); + let placeholder = format!("Filter {} pages…", session.pages.len()); + self.ui.open( + "footer", + Spec { + size: [fill(), px(38.0)], + pad: [6.0, 6.0], + ..Spec::default() + }, + ); + let before = self.filter.clone(); + let signal = ui::text_field( + &mut self.ui, + filter(), + &mut self.filter, + &placeholder, + Spec { + size: [fill(), px(26.0)], + fill: Some(theme.base), + border: Some(theme.separator), + radius: 4.0, + pad: [8.0, 0.0], + ..Spec::default() + }, + ); + // The list above was built with the filter as it stood. + if self.filter != before { + self.window.request_redraw(); + } + for event in &signal.events { + if let ui::Event::Key { + key: Key::Named(key @ (NamedKey::Escape | NamedKey::Enter)), + .. + } = event + { + if *key == NamedKey::Enter + && let Some(space) = first.filter(|space| *space != session.space) + { + self.commands.push(Command::OpenPage(space)); + } + self.filter.clear(); + self.ui.set_focus(Some(page())); + } + } + self.ui.close(); + self.ui.close(); + } + + /// Hands the page the events routed to its box, in its device pixels. + fn page_events(&mut self, events: Vec) -> Result<(), Box> { + let scale = self.ui.scale(); + let corner = self.ui.rect(page()).unwrap_or_default(); + let device = |point: [f32; 2]| { + [ + (point[0] - corner[0]) * scale, + (point[1] - corner[1]) * scale, + ] + }; + for event in events { + let response = match event { + ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?, + ui::Event::PointerLeft => self.view.pointer_left(), + ui::Event::Button { + button: MouseButton::Left, + pressed, + at, + } => { + if pressed { + self.view.pointer_pressed(at)? + } else { + self.view.pointer_released()? + } + } + ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) => continue, + ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?, + ui::Event::Key { key, text } => { + let modifiers = self.view.modifiers(); + let find = matches!(&key, Key::Character(character) + if character.eq_ignore_ascii_case("f")); + if modifiers.command && !modifiers.shift && find && self.session.is_some() { + self.ui.set_focus(Some(filter())); + continue; + } + self.view.key(&page_key(&key), text.as_deref())? + } + ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?, + ui::Event::Ime(Ime::Commit(text)) => self.view.commit_text(text)?, + ui::Event::Ime(Ime::Disabled) => self.view.cancel_composition()?, + ui::Event::Modifiers(modifiers) => { + self.view.modifiers_changed(page_modifiers(modifiers))? + } + }; + self.respond(response); + } + if self.view.editor.marked_range().is_none() { + macos::clear_marked_text(&self.window); + } + Ok(()) + } + + /// Notes a page change for after the frame and queues what the page asked of the host. + fn respond(&mut self, response: Response) { + self.changed |= response.changed; + if let Some(request) = response.request { + self.commands.push(Command::Page(request)); + } + } + + fn apply(&mut self, command: Command) -> Result<(), Box> { + match command { + Command::OpenSection(tab) => { + let library = self.library.as_ref().ok_or("No sections to open")?; + let section = library.open(tab, self.proxy.clone())?; + let (scene, editor) = + open_session(section, tab, None, &mut self.view.engine, &mut self.session)?; + self.view.open(editor, Some((scene, [0.0; 2]))); + self.filter.clear(); + self.opened()?; + } + Command::OpenPage(space) => { + let session = self.session.as_mut().ok_or("No section is open")?; + let page = session.section.page(space)?; + let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; + session.space = space; + session.before = page; + session.refresh_conflict()?; + self.view.open(editor, Some((scene, [0.0; 2]))); + self.opened()?; + } + Command::Resolve { keep_mine } => self.resolve_conflict(keep_mine)?, + Command::Page(Request::EditDate(field)) => self.edit_date(field)?, + Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, + Command::Page(Request::Paste) => { let text = self.clipboard.get_text()?; let response = self.view.commit_text(text)?; - self.respond(response)?; + self.respond(response); } - Some(Request::CharacterPalette) => macos::show_character_palette(), - None => {} - } - if self.ime_allowed != self.view.accepts_text() { - self.ime_allowed = self.view.accepts_text(); - self.window.set_ime_allowed(self.ime_allowed); - } - self.window.set_cursor(cursor_icon(self.view.cursor())); - if response.changed { - let [x0, y0, x1, y1] = self.view.caret_area()?; - self.window.set_ime_cursor_area( - PhysicalPosition::new(x0, y0), - PhysicalSize::new(x1 - x0, y1 - y0), - ); - self.update_accessibility()?; - self.persist()?; - self.title_from_page(); - } - if response.redraw { - self.window.request_redraw(); + Command::Page(Request::CharacterPalette) => macos::show_character_palette(), } Ok(()) } - /// A temporary page's window takes its title's first line. - fn title_from_page(&self) { - let editor = &self.view.editor; - if self.session.is_some() || !editor.active_outline().title { - return; - } - let title = editor - .active_outline() - .document() - .paragraphs() - .next() - .unwrap() - .text() - .split(['\u{000b}', '\n', '\r']) - .next() - .unwrap(); - let title = if title.trim().is_empty() { - "Untitled" - } else { - title + /// Follows a page shown in place of another. + fn opened(&mut self) -> Result<(), Box> { + self.ui.set_focus(Some(page())); + self.title(); + self.update_accessibility()?; + self.window.request_redraw(); + Ok(()) + } + + /// Follows an edit: the input method's position, accessibility, saving and the title. + fn after_edit(&mut self) -> Result<(), Box> { + let scale = self.ui.scale(); + let corner = self.ui.rect(page()).unwrap_or_default(); + let [x0, y0, x1, y1] = self.view.caret_area()?; + self.window.set_ime_cursor_area( + PhysicalPosition::new(x0 + corner[0] * scale, y0 + corner[1] * scale), + PhysicalSize::new(x1 - x0, y1 - y0), + ); + self.update_accessibility()?; + self.persist()?; + self.title(); + Ok(()) + } + + /// The window's title names the page: a temporary page takes its title's first line. + fn title(&self) { + let title = match &self.session { + Some(session) => { + let file = session + .section + .file() + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + format!("{} · {file}", session.title()) + } + None => { + let editor = &self.view.editor; + if !editor.active_outline().title { + return; + } + let title = editor + .active_outline() + .document() + .paragraphs() + .next() + .unwrap() + .text() + .split(['\u{000b}', '\n', '\r']) + .next() + .unwrap() + .to_owned(); + let title = if title.trim().is_empty() { + "Untitled".into() + } else { + title + }; + format!("{title} · Temporary page") + } }; - let title = format!("{title} · Temporary page"); if self.window.title() != title { self.window.set_title(&title); } @@ -365,7 +955,8 @@ impl State { macos::clear_marked_text(&self.window); if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? { let response = self.view.change_date(timestamp, text)?; - self.respond(response)?; + self.respond(response); + self.window.request_redraw(); } Ok(()) } @@ -384,8 +975,14 @@ impl State { notebook::session::Save::Unchanged => {} notebook::session::Save::Queued(_) => { session.before = session.section.page(session.space)?; - session.status = " · saving"; - self.window.set_title(&session.window_title()); + session.status = "Saving"; + if let Some(entry) = session + .pages + .iter_mut() + .find(|(space, ..)| *space == session.space) + { + entry.1 = after.title; + } } notebook::session::Save::Stale => self.reload()?, } @@ -401,7 +998,7 @@ impl State { let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; session.before = page; self.view.replace(editor, Some((scene, [0.0; 2]))); - self.window.set_title(&session.window_title()); + self.title(); self.update_accessibility()?; self.window.request_redraw(); Ok(()) @@ -427,10 +1024,11 @@ impl State { session.section.remote_page(session.space)? }; session.section.review(edit.id, &reviewed)?; - session.status = " · saving"; + session.status = "Saving"; + session.refresh_conflict()?; if keep_mine { session.before = session.section.page(session.space)?; - self.window.set_title(&session.window_title()); + self.window.request_redraw(); Ok(()) } else { self.reload() @@ -442,6 +1040,7 @@ impl State { let Some(session) = &mut self.session else { return Ok(()); }; + let shown = (session.status, session.conflict); let mut refreshed = false; for event in session.section.events() { use notebook::session::Event; @@ -453,22 +1052,35 @@ impl State { Event::Attempt { status: notebook::EditStatus::Published { .. }, .. - } => " · saved", + } => "Saved", Event::Attempt { status: notebook::EditStatus::Conflict(_), .. - } => " · conflict", - Event::Attempt { .. } => " · saving", - Event::Unreachable(_) => " · offline", + } => "Conflict", + Event::Attempt { .. } => "Saving", + Event::Unreachable(_) => "Offline", Event::Failed(error) => { eprintln!("Synchronization stopped: {error}"); - " · not saving" + "Not saving" } }; } - self.window.set_title(&session.window_title()); - if refreshed && session.section.page(session.space)? != session.before { - self.reload()?; + session.refresh_conflict()?; + if refreshed || shown != (session.status, session.conflict) { + self.window.request_redraw(); + } + if refreshed { + session.pages = session.section.pages()?; + if !session + .pages + .iter() + .any(|(space, ..)| *space == session.space) + { + let first = session.pages.first().ok_or("The section has no pages")?.0; + self.commands.push(Command::OpenPage(first)); + } else if session.section.page(session.space)? != session.before { + self.reload()?; + } } Ok(()) } @@ -476,10 +1088,14 @@ impl State { fn update_accessibility(&mut self) -> Result<(), Box> { let mut error = None; let view = &self.view; + let corner = self.ui.rect(page()).unwrap_or_default(); + let mut viewport = view.viewport; + viewport.origin[0] += corner[0] * self.ui.scale(); + viewport.origin[1] += corner[1] * self.ui.scale(); self.access_adapter.update_if_active(|| { match self.accessibility.update( &view.editor, - view.viewport, + viewport, &self.window.title(), view.outline_preview(), ) { @@ -488,7 +1104,7 @@ impl State { &mut update, view.scene.as_ref(), &view.editor, - view.viewport, + viewport, view.object_focus().and_then(|focus| focus.read_only()), ); update @@ -528,8 +1144,10 @@ impl State { if let Some(index) = self.accessibility.read_only_for_node(request.target_node) { if request.action == Action::Focus { self.window.focus_window(); + self.ui.set_focus(Some(page())); let response = self.view.focus_read_only(index)?; - self.respond(response)?; + self.respond(response); + self.window.request_redraw(); } return Ok(()); } @@ -559,14 +1177,18 @@ impl State { } _ => return Ok(()), } + self.ui.set_focus(Some(page())); let response = self.view.focus_text()?; - self.respond(response) + self.respond(response); + self.window.request_redraw(); + Ok(()) } fn draw(&mut self) -> Result<(), Box> { - let viewport = self.view.viewport; - trace_input(&("Draw", viewport.origin, viewport.scale)); - if self.occluded || viewport.size.contains(&0) { + if let Some(path) = self.snapshot.take() { + self.snapshot(&path)?; + } + if self.occluded || [self.config.width, self.config.height].contains(&0) { return Ok(()); } let mut reconfigure = false; @@ -600,16 +1222,7 @@ impl State { return Err("Canvas surface validation failed".into()); } }; - let [caret, selection] = macos::text_colors(); - let primitives = self.view.primitives(TextColors { caret, selection })?; - self.renderer - .draw( - &frame.texture.create_view(&Default::default()), - viewport.size, - [1.0; 4], - &[viewport.layer(&primitives)], - ) - .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; + self.paint(&frame.texture.create_view(&Default::default()))?; self.window.pre_present_notify(); self.renderer.queue.present(frame); trace_input(&"Present submitted"); @@ -619,26 +1232,199 @@ impl State { Ok(()) } - fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box> { - let modifiers = self.view.modifiers(); - if modifiers.command - && modifiers.shift - && self.session.is_some() - && self.view.accepts_text() - && let Key::Character(character) = key + /// Paints the interface with the page in its box. + fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box> { + let [caret, selection] = macos::text_colors(); + let page_primitives = self.view.primitives(TextColors { caret, selection })?; + let scale = self.ui.scale(); + let corner = self.ui.rect(page()).unwrap_or_default(); + let viewport = self.view.viewport; + let interface = self.ui.layers(); + let layers: Vec<_> = interface + .iter() + .map(|layer| match layer { + ui::Layer::Primitives { clip, primitives } => draw::Layer { + scale, + origin: [0.0; 2], + clip: clip.map(|clip| clip.map(|value| value * scale)), + primitives, + }, + ui::Layer::Custom { rect, .. } => draw::Layer { + scale: viewport.scale, + origin: [ + viewport.origin[0] + corner[0] * scale, + viewport.origin[1] + corner[1] * scale, + ], + clip: Some(rect.map(|value| value * scale)), + primitives: &page_primitives, + }, + }) + .collect(); + trace_input(&("Draw", viewport.origin, viewport.scale)); + self.renderer + .draw( + target, + [self.config.width, self.config.height], + self.ui.theme.base, + &layers, + ) + .map_err(|error| format!("Canvas drawing failed: {error:?}").into()) + } + + /// Writes the frame to a PNG, so a covered window can be reviewed. + fn snapshot(&mut self, path: &Path) -> Result<(), Box> { + let size = [self.config.width, self.config.height]; + let device = &self.renderer.device; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Snapshot"), + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.config.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Snapshot readback"), + size: u64::from(row) * u64::from(size[1]), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + self.paint(&texture.create_view(&Default::default()))?; + let mut encoder = self + .renderer + .device + .create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row), + rows_per_image: Some(size[1]), + }, + }, + texture.size(), + ); + self.renderer.queue.submit([encoder.finish()]); + buffer.map_async(wgpu::MapMode::Read, .., |_| {}); + self.renderer.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(std::time::Duration::from_secs(5)), + })?; + let bgra = matches!( + self.config.format, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb + ); + let pixels: Vec = buffer + .get_mapped_range(..)? + .chunks_exact(row as usize) + .flat_map(|line| line[..size[0] as usize * 4].chunks_exact(4)) + .flat_map(|pixel| { + if bgra { + [pixel[2], pixel[1], pixel[0], pixel[3]] + } else { + [pixel[0], pixel[1], pixel[2], pixel[3]] + } + }) + .collect(); + let partial = path.with_extension("partial"); + let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header()?.write_image_data(&pixels)?; + std::fs::rename(partial, path)?; + Ok(()) + } + + /// Queues input for the next frame. A press on the strip moves the window at once, + /// while the platform still holds the press; a double press zooms it. + fn input(&mut self, event: ui::Event) { + if let ui::Event::PointerMoved(point) = event { + self.pointer = point; + } + if let ui::Event::Button { + button: MouseButton::Left, + pressed: true, + at, + } = event + && self.ui.box_at(self.pointer) == Some(strip()) { - if character.eq_ignore_ascii_case("k") { - return self.resolve_conflict(true); - } - if character.eq_ignore_ascii_case("t") { - return self.resolve_conflict(false); + let double = self.strip_press.is_some_and(|last| { + at.saturating_duration_since(last) <= macos::double_click_interval() + }); + self.strip_press = (!double).then_some(at); + if double { + self.window.set_maximized(!self.window.is_maximized()); + } else { + let _ = self.window.drag_window(); } + return; } - let response = self.view.key(&page_key(key), text)?; - self.respond(response) + self.ui.event(event); + self.window.request_redraw(); } } +/// Section tabs for a notebook's readable top-level sections, in its order. +fn tabs(notebook: ¬ebook::session::Notebook) -> Vec { + notebook + .catalog() + .sections + .iter() + .filter_map(|section| match §ion.state { + notebook::discover::SectionState::Readable { name, color } => Some(Tab { + name: name.clone().unwrap_or_else(|| { + Path::new(§ion.path) + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_default() + }), + path: section.path.clone(), + color: *color, + }), + _ => None, + }) + .collect() +} + +/// Makes `section` the open one showing `space`, or its first page, and returns the +/// page's scene and editor. +fn open_session( + section: notebook::session::Section, + tab: usize, + space: Option, + engine: &mut TextEngine, + session: &mut Option, +) -> Result<(PageScene, CanvasEditor), Box> { + let pages = section.pages()?; + let space = match space { + Some(space) => space, + None => pages.first().ok_or("The section has no pages")?.0, + }; + let before = section.page(space)?; + let (scene, editor) = PageScene::from_page(before.clone(), engine)?; + let mut opened = Session { + section, + tab, + pages, + space, + before, + status: "", + conflict: false, + }; + opened.refresh_conflict()?; + *session = Some(opened); + Ok((scene, editor)) +} + impl App { fn close(&self, event_loop: &ActiveEventLoop) { if self.state.as_ref().is_none_or(|state| { @@ -671,12 +1457,14 @@ impl ApplicationHandler for App { let event = match event { UserEvent::InsertText(text) => { if let Some(state) = &mut self.state { - let result = state - .view - .insert_text(text) - .and_then(|response| state.respond(response)); - if let Err(error) = result { - eprintln!("{error}"); + if state.ui.focused() == Some(page()) { + match state.view.insert_text(text) { + Ok(response) => state.respond(response), + Err(error) => eprintln!("{error}"), + } + state.window.request_redraw(); + } else { + state.input(ui::Event::Ime(Ime::Commit(text))); } } return; @@ -693,6 +1481,19 @@ impl ApplicationHandler for App { } return; } + UserEvent::Replay(replay) => { + if let Some(state) = &mut self.state { + match replay { + Replay::Input(event) => state.input(event), + Replay::Snapshot(path) => state.snapshot = Some(path), + } + // A covered window gets no redraws, so each step draws its own frame. + if let Err(error) = state.frame() { + eprintln!("{error}"); + } + } + return; + } UserEvent::Accessibility(event) => event, }; let Some(state) = &mut self.state else { @@ -758,11 +1559,10 @@ impl ApplicationHandler for App { let Some(state) = &mut self.state else { return; }; - let was_marked = - !matches!(event, WindowEvent::Ime(_)) && state.view.editor.marked_range().is_some(); + let scale = state.window.scale_factor() as f32; let result = (|| -> Result<(), Box> { - let response = match event { - WindowEvent::RedrawRequested => return state.draw(), + match event { + WindowEvent::RedrawRequested => return state.frame(), WindowEvent::Resized(size) => { if size.width > 0 && size.height > 0 { state.config.width = size.width; @@ -771,72 +1571,66 @@ impl ApplicationHandler for App { .surface .configure(&state.renderer.device, &state.config); } - let response = state.view.resized([size.width, size.height])?; - let changed = response.changed; - state.respond(response)?; // Present inside AppKit's resize transaction; a redraw on the next turn // lets the window show the previous frame at the new size. - if changed { - state.draw()?; - } - return Ok(()); + return state.frame(); } WindowEvent::ScaleFactorChanged { scale_factor, .. } => { state.renderer.clear_glyph_cache(); - state.view.scale_factor_changed(scale_factor as f32)? + let response = state.view.scale_factor_changed(scale_factor as f32)?; + state.respond(response); + state.window.request_redraw(); + } + WindowEvent::Focused(focused) => { + state.window_focused = focused; + state.window.request_redraw(); } - WindowEvent::Focused(focused) => state.view.focus_changed(focused)?, WindowEvent::Occluded(occluded) => { trace_input(&("Window occluded", occluded)); state.occluded = occluded; - if occluded { - return Ok(()); - } - Response { - changed: true, - redraw: true, - request: None, + if !occluded { + state.changed = true; + state.window.request_redraw(); } } - WindowEvent::ModifiersChanged(modifiers) => state - .view - .modifiers_changed(page_modifiers(modifiers.state()))?, - WindowEvent::CursorLeft { .. } => state.view.pointer_left(), - WindowEvent::CursorMoved { position, .. } => state - .view - .pointer_moved([position.x as f32, position.y as f32])?, - WindowEvent::MouseInput { - state: ElementState::Pressed, - button: MouseButton::Left, - .. - } => state.view.pointer_pressed(Instant::now())?, + WindowEvent::ModifiersChanged(modifiers) => { + state.input(ui::Event::Modifiers(modifiers.state())) + } + WindowEvent::CursorLeft { .. } => state.input(ui::Event::PointerLeft), + WindowEvent::CursorMoved { position, .. } => { + state.input(ui::Event::PointerMoved([ + position.x as f32 / scale, + position.y as f32 / scale, + ])) + } WindowEvent::MouseInput { - state: ElementState::Released, - button: MouseButton::Left, + state: pressed, + button, .. - } => state.view.pointer_released()?, + } => state.input(ui::Event::Button { + button, + pressed: pressed == ElementState::Pressed, + at: Instant::now(), + }), WindowEvent::MouseWheel { delta, .. } => { - let dpr = state.window.scale_factor() as f32; - state.view.wheel(match delta { - MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr], - MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32], - })? + state.input(ui::Event::Wheel(match delta { + MouseScrollDelta::LineDelta(x, y) => [x * 32.0, y * 32.0], + MouseScrollDelta::PixelDelta(p) => [p.x as f32 / scale, p.y as f32 / scale], + })) } WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => { - return state.key(&event.logical_key, event.text.as_deref()); + state.input(ui::Event::Key { + key: event.logical_key, + text: event.text.map(|text| text.to_string()), + }) } - WindowEvent::Ime(Ime::Preedit(text, cursor)) => state.view.compose(text, cursor)?, - WindowEvent::Ime(Ime::Commit(text)) => state.view.commit_text(text)?, - WindowEvent::Ime(Ime::Disabled) => state.view.cancel_composition()?, - _ => return Ok(()), - }; - state.respond(response) + WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)), + _ => {} + } + Ok(()) })(); - if was_marked && state.view.editor.marked_range().is_none() { - macos::clear_marked_text(&state.window); - } if let Err(error) = result { eprintln!("{error}"); } @@ -858,6 +1652,75 @@ impl ApplicationHandler for App { } } +/// Feeds a development script to the window from another thread, one command per line +/// in logical pixels: `move X Y`, `press`, `release`, `wheel DX DY`, `key NAME`, `type +/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS` and `snapshot PNG_PATH`. +fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box> { + let mut steps = Vec::new(); + for line in script.lines().filter(|line| !line.trim().is_empty()) { + let (command, rest) = line.split_once(' ').unwrap_or((line, "")); + let numbers = || -> Result, std::num::ParseFloatError> { + rest.split_whitespace().map(str::parse).collect() + }; + let button = |pressed| ui::Event::Button { + button: MouseButton::Left, + pressed, + at: Instant::now(), + }; + steps.push(match command { + "move" => Ok(Replay::Input(ui::Event::PointerMoved( + numbers()?.try_into().map_err(|_| "move takes X Y")?, + ))), + "wheel" => Ok(Replay::Input(ui::Event::Wheel( + numbers()?.try_into().map_err(|_| "wheel takes DX DY")?, + ))), + "press" => Ok(Replay::Input(button(true))), + "release" => Ok(Replay::Input(button(false))), + "key" => Ok(Replay::Input(ui::Event::Key { + key: match rest { + "Escape" => Key::Named(NamedKey::Escape), + "Enter" => Key::Named(NamedKey::Enter), + "Backspace" => Key::Named(NamedKey::Backspace), + "Tab" => Key::Named(NamedKey::Tab), + "Left" => Key::Named(NamedKey::ArrowLeft), + "Right" => Key::Named(NamedKey::ArrowRight), + "Up" => Key::Named(NamedKey::ArrowUp), + "Down" => Key::Named(NamedKey::ArrowDown), + character => Key::Character(character.into()), + }, + text: None, + })), + "type" => Ok(Replay::Input(ui::Event::Ime(Ime::Commit(rest.into())))), + "modifiers" => Ok(Replay::Input(ui::Event::Modifiers( + rest.split_whitespace() + .map(|name| match name { + "shift" => Ok(ModifiersState::SHIFT), + "command" => Ok(ModifiersState::SUPER), + _ => Err(format!("Unknown modifier {name}")), + }) + .try_fold(ModifiersState::empty(), |all, one| one.map(|one| all | one))?, + ))), + "wait" => Err(std::time::Duration::from_millis(rest.parse()?)), + "snapshot" => Ok(Replay::Snapshot(rest.into())), + _ => return Err(format!("Unknown replay command: {line}").into()), + }); + } + std::thread::spawn(move || { + for step in steps { + match step { + Ok(mut replay) => { + if let Replay::Input(ui::Event::Button { at, .. }) = &mut replay { + *at = Instant::now(); + } + let _ = proxy.send_event(UserEvent::Replay(replay)); + } + Err(duration) => std::thread::sleep(duration), + } + } + }); + Ok(()) +} + fn main() -> Result<(), Box> { let mut args = std::env::args_os().skip(1); let mut positional = Vec::new(); @@ -865,6 +1728,7 @@ fn main() -> Result<(), Box> { let mut reference = None; let mut editable = false; let mut section = None; + let mut notebook = None; let mut cache = None; while let Some(arg) = args.next() { if arg == "--substitute-font" { @@ -872,6 +1736,11 @@ fn main() -> Result<(), Box> { args.next() .ok_or("Provide a font file after --substitute-font.")?, )); + } else if arg == "--notebook" { + notebook = Some(PathBuf::from( + args.next() + .ok_or("Provide a notebook folder after --notebook.")?, + )); } else if arg == "--section" { if reference.is_some() || section.is_some() { return Err("Only one page can be opened.".into()); @@ -915,14 +1784,17 @@ fn main() -> Result<(), Box> { positional.push(arg); } } - if (editable || section.is_some()) && !positional.is_empty() { + let stored = notebook.is_some() || section.is_some(); + if (editable || stored) && !positional.is_empty() + || notebook.is_some() && (section.is_some() || reference.is_some()) + { return Err( - "Use --page or --section with a section file and page title, without a text file or width.".into(), + "Use one of --notebook, --page or --section, without a text file or width.".into(), ); } if positional.len() > 2 { return Err( - "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE [--cache DIR]] [--substitute-font FONT_FILE]..." + "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]..." .into(), ); } @@ -940,12 +1812,14 @@ fn main() -> Result<(), Box> { } else { 480.0 }); - let input = if let Some((file, title)) = section { - let cache = match cache { - Some(cache) => cache, - None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?) - .join("Library/Caches/snowbound"), - }; + let cache = match cache { + Some(cache) => cache, + None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?) + .join("Library/Caches/snowbound"), + }; + let input = if let Some(root) = notebook { + Input::Notebook { root, cache } + } else if let Some((file, title)) = section { Input::Section { file, title, cache } } else if editable { Input::Page(reference.unwrap()) @@ -961,6 +1835,9 @@ fn main() -> Result<(), Box> { } }; let event_loop = macos::event_loop()?; + if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") { + replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?; + } let mut app = App { proxy: event_loop.create_proxy(), input: Some(input), diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..5d212cb1cb07cff348a8afdd258ab9978808aba8 --- /dev/null +++ b/crates/ui/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ui" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +draw = { path = "../draw" } +parley.workspace = true +winit = "0.30.13" diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs new file mode 100644 index 0000000000000000000000000000000000000000..106c6810766f2099d55cd24f29c8c70e32480ea5 --- /dev/null +++ b/crates/ui/src/layout.rs @@ -0,0 +1,136 @@ +use crate::{Axis, Built, Flags, Id, Size, State}; +use std::collections::HashMap; + +/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from +/// ancestors (pre-order), sizes summed from children (post-order), overflow shared out +/// by strictness, and positions along each parent's flow. Boxes are in build order, so +/// index order is pre-order. +pub(crate) fn solve(nodes: &mut [Built], states: &HashMap, scale: f32) { + for axis in 0..2 { + for node in nodes.iter_mut() { + node.computed[axis] = match node.size[axis].size { + Size::Pixels(pixels) => pixels, + Size::Text => { + node.label.as_ref().map_or(0.0, |label| label.size[axis]) + 2.0 * node.pad[axis] + } + Size::Fraction(_) | Size::Children => 0.0, + }; + } + for index in 1..nodes.len() { + if let Size::Fraction(fraction) = nodes[index].size[axis].size { + let mut ancestor = nodes[index].parent; + while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children { + ancestor = nodes[ancestor].parent; + } + let room = nodes[ancestor].computed[axis] - 2.0 * nodes[ancestor].pad[axis]; + nodes[index].computed[axis] = room.max(0.0) * fraction; + } + } + for index in (0..nodes.len()).rev() { + if nodes[index].size[axis].size == Size::Children { + let content = flow(nodes, index, axis); + nodes[index].computed[axis] = content + 2.0 * nodes[index].pad[axis]; + } + } + for index in 0..nodes.len() { + let node = &nodes[index]; + if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) { + continue; + } + let room = node.computed[axis] - 2.0 * node.pad[axis]; + let children: Vec<_> = in_flow(nodes, index).collect(); + if along(node, axis) { + let excess = flow(nodes, index, axis) - room; + let give: f32 = children + .iter() + .map(|child| { + nodes[*child].computed[axis] * (1.0 - strictness(nodes, *child, axis)) + }) + .sum(); + if excess > 0.0 && give > 0.0 { + for child in children { + let share = + nodes[child].computed[axis] * (1.0 - strictness(nodes, child, axis)); + nodes[child].computed[axis] -= excess.min(give) * share / give; + } + } + } else { + for child in children { + let over = nodes[child].computed[axis] - room; + if over > 0.0 { + nodes[child].computed[axis] -= + over * (1.0 - strictness(nodes, child, axis)); + } + } + } + } + for index in 0..nodes.len() { + let mut cursor = nodes[index].pad[axis]; + for child in nodes[index].children.clone() { + nodes[child].relative[axis] = if nodes[child].flags.contains(Flags::FLOAT) { + nodes[child].position[axis] + } else if along(&nodes[index], axis) { + let at = cursor; + cursor += nodes[child].computed[axis] + nodes[index].gap; + at + } else { + nodes[index].pad[axis] + }; + } + if axis == 1 { + nodes[index].content = flow(nodes, index, axis) + 2.0 * nodes[index].pad[axis]; + } + } + } + let snap = |value: f32| (value * scale).round() / scale; + nodes[0].rect = [ + 0.0, + 0.0, + snap(nodes[0].computed[0]), + snap(nodes[0].computed[1]), + ]; + for index in 1..nodes.len() { + let parent = &nodes[nodes[index].parent]; + let scroll = + if parent.flags.contains(Flags::SCROLL) && !nodes[index].flags.contains(Flags::FLOAT) { + states.get(&parent.id).map_or(0.0, |state| state.scroll) + } else { + 0.0 + }; + let x = parent.rect[0] + nodes[index].relative[0]; + let y = parent.rect[1] + nodes[index].relative[1] - scroll; + nodes[index].rect = [ + snap(x), + snap(y), + snap(x + nodes[index].computed[0]), + snap(y + nodes[index].computed[1]), + ]; + } +} + +fn along(node: &Built, axis: usize) -> bool { + (node.axis == Axis::Y) == (axis == 1) +} + +fn strictness(nodes: &[Built], index: usize, axis: usize) -> f32 { + nodes[index].size[axis].strictness.clamp(0.0, 1.0) +} + +fn in_flow(nodes: &[Built], index: usize) -> impl Iterator + '_ { + nodes[index] + .children + .iter() + .copied() + .filter(|child| !nodes[*child].flags.contains(Flags::FLOAT)) +} + +/// The children's extent on `axis`: summed with gaps along the flow, otherwise the largest. +fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 { + let sizes = in_flow(nodes, index).map(|child| nodes[child].computed[axis]); + if along(&nodes[index], axis) { + let (sum, count) = sizes.fold((0.0, 0), |(sum, count), size| (sum + size, count + 1)); + sum + nodes[index].gap * (count.max(1) - 1) as f32 + } else { + sizes.fold(0.0, f32::max) + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..0cee2fbbf18cc6e94b62c7674eea08a794ed87ca --- /dev/null +++ b/crates/ui/src/lib.rs @@ -0,0 +1,892 @@ +//! An immediate-mode interface kit. Builder code declares boxes every frame from +//! application state; a cache keyed by stable ids keeps hover, press, focus, scroll and +//! animation state. Input is answered with the previous frame's layout, so a frame's +//! events are routed before building and its layout is solved after. + +mod layout; +mod text; +mod theme; +mod widgets; + +pub use theme::Theme; +pub use widgets::{button, scrollbar, text_field}; + +use draw::{Primitive, Stroke}; +use std::{ + collections::HashMap, + hash::{DefaultHasher, Hash, Hasher}, + ops::BitOr, + rc::Rc, + time::Instant, +}; +use text::{Label, Painted, Texts}; +use winit::{ + event::{Ime, MouseButton}, + keyboard::{Key, ModifiersState}, + window::CursorIcon, +}; + +/// Seconds for an animated value to close half of its remaining distance. +const HALF_LIFE: f32 = 0.03; + +/// A box's identity across frames: its parent's id combined with a builder-chosen part. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Id(u64); + +impl Id { + pub const ROOT: Id = Id(0); + + pub fn child(self, part: impl Hash) -> Id { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + part.hash(&mut hasher); + Id(hasher.finish()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Size { + /// Logical pixels. + Pixels(f32), + /// The label and padding. + Text, + /// A fraction of the nearest ancestor not sized by its children. + Fraction(f32), + /// The children laid out along this axis, and padding. + Children, +} + +/// A size on one axis and the share of it a box keeps when its siblings overflow. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Extent { + pub size: Size, + pub strictness: f32, +} + +impl Default for Extent { + fn default() -> Self { + children() + } +} + +pub fn px(pixels: f32) -> Extent { + Extent { + size: Size::Pixels(pixels), + strictness: 1.0, + } +} + +pub fn fit() -> Extent { + Extent { + size: Size::Text, + strictness: 1.0, + } +} + +/// All the room the parent has, yielding to stricter siblings. +pub fn fill() -> Extent { + Extent { + size: Size::Fraction(1.0), + strictness: 0.0, + } +} + +pub fn children() -> Extent { + Extent { + size: Size::Children, + strictness: 1.0, + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Axis { + #[default] + X, + Y, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Flags(u32); + +impl Flags { + /// Takes pointer presses, clicks and drags, and blocks boxes beneath it. + pub const CLICKABLE: Flags = Flags(1); + /// A press gives it keyboard focus. + pub const FOCUSABLE: Flags = Flags(2); + /// The wheel scrolls its children vertically; they may overflow it. + pub const SCROLL: Flags = Flags(4); + /// Children paint only within it. + pub const CLIP: Flags = Flags(8); + /// Placed at its `position` in the parent instead of in the parent's flow. + pub const FLOAT: Flags = Flags(16); + /// The host paints it and receives the events routed to it. + pub const CUSTOM: Flags = Flags(32); + + pub(crate) fn contains(self, other: Flags) -> bool { + self.0 & other.0 == other.0 + } + + fn intersects(self, other: Flags) -> bool { + self.0 & other.0 != 0 + } +} + +impl BitOr for Flags { + type Output = Flags; + + fn bitor(self, other: Flags) -> Flags { + Flags(self.0 | other.0) + } +} + +/// What a box is this frame. Colours are linear RGBA. +#[derive(Clone, Default)] +pub struct Spec<'a> { + pub flags: Flags, + pub size: [Extent; 2], + /// The axis its children flow along. + pub axis: Axis, + pub text: Option<&'a str>, + /// The label's colour; the theme's text colour otherwise. + pub color: Option<[f32; 4]>, + pub fill: Option<[f32; 4]>, + /// The fill under the pointer, blended in as hover animates. + pub hover_fill: Option<[f32; 4]>, + pub border: Option<[f32; 4]>, + pub hover_border: Option<[f32; 4]>, + pub radius: f32, + /// Inset of the label and children on each axis. + pub pad: [f32; 2], + /// Space between children along the flow. + pub gap: f32, + pub center: bool, + /// For floating boxes, the offset from the parent's corner. + pub position: [f32; 2], + pub cursor: Option, +} + +/// Input the host forwards; positions and wheel distances are logical pixels. +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + PointerMoved([f32; 2]), + PointerLeft, + Button { + button: MouseButton, + pressed: bool, + at: Instant, + }, + Wheel([f32; 2]), + Key { + key: Key, + text: Option, + }, + Ime(Ime), + Modifiers(ModifiersState), +} + +/// How the user acted on a box this frame. +#[derive(Clone, Debug, Default)] +pub struct Signal { + pub hovered: bool, + /// The primary button went down on the box. + pub pressed: bool, + /// The primary button came up over the box it went down on. + pub clicked: bool, + /// The primary button is held after pressing the box. + pub dragging: bool, + pub focused: bool, + /// For custom boxes: every event routed to the box, in order. For focused boxes: + /// keys, text and composition. + pub events: Vec, +} + +/// A frame's painting, in order: interface primitives under a clip, or a host-painted box. +pub enum Layer<'a> { + Primitives { + /// Logical pixels. + clip: Option<[f32; 4]>, + primitives: Vec>, + }, + Custom { + id: Id, + /// The box's visible logical rectangle. + rect: [f32; 4], + }, +} + +struct Built { + id: Id, + parent: usize, + children: Vec, + flags: Flags, + size: [Extent; 2], + axis: Axis, + label: Option>, + color: [f32; 4], + fill: Option<[f32; 4]>, + hover_fill: Option<[f32; 4]>, + border: Option<[f32; 4]>, + hover_border: Option<[f32; 4]>, + radius: f32, + pad: [f32; 2], + gap: f32, + center: bool, + position: [f32; 2], + cursor: Option, + /// Rectangles relative to the box, painted over its fill. + marks: Vec<([f32; 4], [f32; 4])>, + computed: [f32; 2], + relative: [f32; 2], + rect: [f32; 4], + content: f32, +} + +#[derive(Default)] +struct State { + touched: u64, + hot: f32, + active: f32, + scroll: f32, + scroll_target: f32, + /// Height of a scrolling box's children last frame. + content: f32, + rect: [f32; 4], + /// A text field's caret and selection anchor, in bytes. + caret: usize, + mark: usize, + /// Where a dragged scrollbar thumb was taken, from its start. + grab: f32, +} + +struct Hit { + id: Id, + rect: [f32; 4], + flags: Flags, + cursor: Option, +} + +enum Display { + Clip(Option<[f32; 4]>), + Rect { + rect: [f32; 4], + fill: [f32; 4], + border: Option<[f32; 4]>, + radius: f32, + }, + Text { + painted: usize, + origin: [f32; 2], + clip: [f32; 4], + }, + Custom { + id: Id, + rect: [f32; 4], + }, +} + +pub struct Ui { + pub theme: Theme, + frame: u64, + now: Instant, + scale: f32, + nodes: Vec, + stack: Vec, + states: HashMap, + texts: Texts, + queue: Vec, + signals: HashMap, + /// The previous frame's interactive boxes in paint order. + hits: Vec, + pointer: Option<[f32; 2]>, + hover: Option, + active: Option, + focus: Option, + modifiers: ModifiersState, + display: Vec, + painted: Vec, + animating: bool, +} + +impl Ui { + pub fn new(theme: Theme) -> Self { + Self { + theme, + frame: 0, + now: Instant::now(), + scale: 1.0, + nodes: Vec::new(), + stack: Vec::new(), + states: HashMap::new(), + texts: Texts::default(), + queue: Vec::new(), + signals: HashMap::new(), + hits: Vec::new(), + pointer: None, + hover: None, + active: None, + focus: None, + modifiers: ModifiersState::empty(), + display: Vec::new(), + painted: Vec::new(), + animating: false, + } + } + + /// Queues input for the next frame. + pub fn event(&mut self, event: Event) { + self.queue.push(event); + } + + /// Whether queued input or animation needs another frame. + pub fn wants_frame(&self) -> bool { + self.animating || !self.queue.is_empty() + } + + pub fn scale(&self) -> f32 { + self.scale + } + + pub(crate) fn modifiers(&self) -> ModifiersState { + self.modifiers + } + + pub(crate) fn pointer(&self) -> Option<[f32; 2]> { + self.pointer + } + + pub fn focused(&self) -> Option { + self.focus + } + + pub fn set_focus(&mut self, id: Option) { + self.focus = id; + } + + /// The clickable or custom box at `point` in the latest layout. + pub fn box_at(&self, point: [f32; 2]) -> Option { + self.hit(point, Flags::CLICKABLE | Flags::CUSTOM) + } + + /// The box's rectangle from the latest layout. + pub fn rect(&self, id: Id) -> Option<[f32; 4]> { + self.states.get(&id).map(|state| state.rect) + } + + /// The pointer's cursor, or None over a custom box, whose host chooses. + pub fn cursor(&self) -> Option { + let hit = self + .hover + .and_then(|id| self.hits.iter().find(|hit| hit.id == id)); + match hit { + Some(hit) if hit.flags.contains(Flags::CUSTOM) => None, + Some(hit) => Some(hit.cursor.unwrap_or_default()), + None => Some(CursorIcon::Default), + } + } + + /// Starts a frame of `size` logical pixels drawn at `scale` device pixels each, and + /// routes the queued input through the previous frame's layout. + pub fn begin(&mut self, size: [f32; 2], scale: f32, now: Instant) { + self.frame += 1; + let dt = now + .saturating_duration_since(self.now) + .as_secs_f32() + .min(0.1); + self.now = now; + self.scale = scale; + self.nodes.clear(); + self.nodes.push(Built::new( + Id::ROOT, + 0, + Spec { + size: [px(size[0]), px(size[1])], + axis: Axis::Y, + ..Spec::default() + }, + None, + self.theme.text, + )); + self.stack.clear(); + self.stack.push(0); + self.signals.clear(); + for event in std::mem::take(&mut self.queue) { + self.route(event); + } + self.animate(dt); + } + + fn route(&mut self, event: Event) { + match event { + Event::PointerMoved(point) => { + self.pointer = Some(point); + let hover = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM); + if hover != self.hover + && self.active.is_none_or(|active| Some(active) != self.hover) + { + self.custom_event(self.hover, Event::PointerLeft); + } + self.hover = hover; + self.custom_event(self.active.or(hover), event); + } + Event::PointerLeft => { + self.pointer = None; + if self.active.is_none() { + self.custom_event(self.hover, Event::PointerLeft); + self.hover = None; + } + } + Event::Button { + button, + pressed: true, + .. + } => { + let Some(point) = self.pointer else { + return; + }; + let target = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM); + if let Some(id) = target + && button == MouseButton::Left + { + self.active = Some(id); + self.signals.entry(id).or_default().pressed = true; + if self.hit_flags(id).contains(Flags::FOCUSABLE) { + self.focus = Some(id); + } + } + self.custom_event(target, event); + } + Event::Button { pressed: false, .. } => { + let Some(active) = self.active else { + self.custom_event(self.hover, event); + return; + }; + if self + .pointer + .and_then(|point| self.hit(point, Flags::CLICKABLE | Flags::CUSTOM)) + == Some(active) + { + self.signals.entry(active).or_default().clicked = true; + } + self.custom_event(Some(active), event); + self.active = None; + self.hover = self + .pointer + .and_then(|point| self.hit(point, Flags::CLICKABLE | Flags::CUSTOM)); + if self.hover != Some(active) { + self.custom_event(Some(active), Event::PointerLeft); + } + } + Event::Wheel(delta) => { + let Some(point) = self.pointer else { + return; + }; + let target = self.hit(point, Flags::SCROLL | Flags::CUSTOM); + match target { + Some(id) if self.hit_flags(id).contains(Flags::CUSTOM) => { + self.custom_event(Some(id), event) + } + Some(id) => { + let state = self.states.entry(id).or_default(); + let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0); + state.scroll_target = (state.scroll_target - delta[1]).clamp(0.0, most); + } + None => {} + } + } + Event::Key { .. } | Event::Ime(_) => { + if let Some(focus) = self.focus { + self.signals.entry(focus).or_default().events.push(event); + } + } + Event::Modifiers(modifiers) => { + self.modifiers = modifiers; + let custom: Vec<_> = self + .hits + .iter() + .filter(|hit| hit.flags.contains(Flags::CUSTOM)) + .map(|hit| hit.id) + .collect(); + for id in custom { + self.signals + .entry(id) + .or_default() + .events + .push(event.clone()); + } + } + } + } + + fn custom_event(&mut self, target: Option, event: Event) { + if let Some(id) = target + && self.hit_flags(id).contains(Flags::CUSTOM) + { + self.signals.entry(id).or_default().events.push(event); + } + } + + fn hit_flags(&self, id: Id) -> Flags { + self.hits + .iter() + .find(|hit| hit.id == id) + .map_or(Flags::default(), |hit| hit.flags) + } + + /// The topmost box under `point` with any of `flags`. + fn hit(&self, point: [f32; 2], flags: Flags) -> Option { + self.hits + .iter() + .rev() + .find(|hit| { + hit.flags.intersects(flags) + && point[0] >= hit.rect[0] + && point[0] < hit.rect[2] + && point[1] >= hit.rect[1] + && point[1] < hit.rect[3] + }) + .map(|hit| hit.id) + } + + fn animate(&mut self, dt: f32) { + let rate = 1.0 - 0.5_f32.powf(dt / HALF_LIFE); + let mut animating = false; + for (id, state) in &mut self.states { + let hot = self.hover == Some(*id) && self.active.is_none_or(|active| active == *id); + let targets = [ + (&mut state.hot, f32::from(u8::from(hot)), 0.002), + ( + &mut state.active, + f32::from(u8::from(self.active == Some(*id))), + 0.002, + ), + (&mut state.scroll, state.scroll_target, 0.25), + ]; + for (value, target, close) in targets { + let delta = target - *value; + if delta.abs() < close { + *value = target; + } else { + *value += delta * rate; + animating = true; + } + } + } + self.animating = animating; + } + + /// The box being built, which new boxes become children of. + pub(crate) fn current(&self) -> Id { + self.nodes[*self.stack.last().unwrap()].id + } + + /// The id a box built next under the current parent would take. + pub fn id(&self, part: impl Hash) -> Id { + self.nodes[*self.stack.last().unwrap()].id.child(part) + } + + /// Adds a box and makes it the parent of the boxes built until `close`. + pub fn open(&mut self, part: impl Hash, spec: Spec<'_>) -> Id { + self.open_as(self.id(part), spec) + } + + /// Opens a box under an id chosen outside the hierarchy, so the host can name it + /// wherever it is built. + pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id { + let label = spec + .text + .map(|text| self.texts.label(text, self.theme.font_size, self.frame)); + let parent = *self.stack.last().unwrap(); + let index = self.nodes.len(); + self.nodes + .push(Built::new(id, parent, spec, label, self.theme.text)); + self.nodes[parent].children.push(index); + self.stack.push(index); + self.states.entry(id).or_default().touched = self.frame; + id + } + + pub fn close(&mut self) { + debug_assert!(self.stack.len() > 1, "close without open"); + self.stack.pop(); + } + + /// Adds a box without children and reports how it was used. + pub fn leaf(&mut self, part: impl Hash, spec: Spec<'_>) -> Signal { + let id = self.open(part, spec); + self.close(); + self.signal(id) + } + + /// Paints `color` over the current box at `rect`, relative to its corner. + pub fn mark(&mut self, rect: [f32; 4], color: [f32; 4]) { + let index = *self.stack.last().unwrap(); + self.nodes[index].marks.push((rect, color)); + } + + /// How the user acted on the box this frame; a box's routed events are taken once. + pub fn signal(&mut self, id: Id) -> Signal { + let mut signal = self.signals.remove(&id).unwrap_or_default(); + signal.hovered = self.hover == Some(id); + signal.dragging = self.active == Some(id); + signal.focused = self.focus == Some(id); + signal + } + + /// Solves this frame's layout and prepares its painting. + pub fn end(&mut self) { + debug_assert_eq!(self.stack.len(), 1, "open without close"); + layout::solve(&mut self.nodes, &self.states, self.scale); + for node in &self.nodes { + let state = self.states.entry(node.id).or_default(); + state.rect = node.rect; + if node.flags.contains(Flags::SCROLL) { + state.content = node.content; + let most = (node.content - (node.rect[3] - node.rect[1])).max(0.0); + state.scroll_target = state.scroll_target.clamp(0.0, most); + state.scroll = state.scroll.clamp(0.0, most); + } + } + self.states + .retain(|id, state| state.touched == self.frame || *id == Id::ROOT); + self.texts.prune(self.frame); + self.display.clear(); + self.painted.clear(); + self.hits.clear(); + self.paint(0, None); + for id in [&mut self.hover, &mut self.active, &mut self.focus] { + if id.is_some_and(|id| !self.states.contains_key(&id)) { + *id = None; + } + } + } + + fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) { + let node = &self.nodes[index]; + let state = &self.states[&node.id]; + let rect = node.rect; + let visible = clip.map_or(Some(rect), |clip| intersect(rect, clip)); + let blend = |base: Option<[f32; 4]>, hover: Option<[f32; 4]>| match (base, hover) { + (base, Some(hover)) => Some(mix(base.unwrap_or([0.0; 4]), hover, state.hot)), + (base, None) => base, + }; + let fill = blend(node.fill, node.hover_fill); + let border = blend(node.border, node.hover_border); + if fill.is_some() || border.is_some() { + self.display.push(Display::Rect { + rect, + fill: fill.unwrap_or([0.0; 4]), + border, + radius: node.radius, + }); + } + for (mark, color) in &node.marks { + self.display.push(Display::Rect { + rect: [ + rect[0] + mark[0], + rect[1] + mark[1], + rect[0] + mark[2], + rect[1] + mark[3], + ], + fill: *color, + border: None, + radius: 0.0, + }); + } + if let Some(label) = &node.label { + let inner = [ + rect[0] + node.pad[0], + rect[1], + rect[2] - node.pad[0], + rect[3], + ]; + let x = if node.center { + (inner[0] + inner[2] - label.size[0]) / 2.0 + } else { + inner[0] + }; + let y = (rect[1] + rect[3] - label.size[1]) / 2.0; + self.display.push(Display::Text { + painted: self.painted.len(), + origin: [x, y], + clip: inner, + }); + self.painted.push(Painted { + label: label.clone(), + color: node.color, + }); + } + if let Some(visible) = visible + && node + .flags + .intersects(Flags::CLICKABLE | Flags::CUSTOM | Flags::SCROLL) + { + self.hits.push(Hit { + id: node.id, + rect: visible, + flags: node.flags, + cursor: node.cursor, + }); + if node.flags.contains(Flags::CUSTOM) { + self.display.push(Display::Custom { + id: node.id, + rect: visible, + }); + } + } + let inner_clip = if node.flags.contains(Flags::CLIP) { + Some(visible.unwrap_or([rect[0], rect[1], rect[0], rect[1]])) + } else { + clip + }; + if inner_clip != clip { + self.display.push(Display::Clip(inner_clip)); + } + for child in node.children.clone() { + self.paint(child, inner_clip); + } + if inner_clip != clip { + self.display.push(Display::Clip(clip)); + } + } + + /// This frame's painting in order, in logical pixels. + pub fn layers(&self) -> Vec> { + let mut layers = Vec::new(); + let mut clip = None; + let mut primitives = Vec::new(); + for item in &self.display { + match item { + Display::Clip(next) => { + if !primitives.is_empty() { + layers.push(Layer::Primitives { + clip, + primitives: std::mem::take(&mut primitives), + }); + } + clip = *next; + } + Display::Rect { + rect, + fill, + border, + radius, + } => { + if fill[3] > 0.0 { + primitives.push(if *radius > 0.0 { + Primitive::RoundedRect { + rect: *rect, + radius: [*radius; 2], + stroke: None, + color: *fill, + } + } else { + Primitive::Rect { + rect: *rect, + color: *fill, + } + }); + } + if let Some(border) = border.filter(|border| border[3] > 0.0) { + primitives.push(Primitive::RoundedRect { + rect: *rect, + radius: [*radius; 2], + stroke: Some(Stroke::Solid(1.0 / self.scale)), + color: border, + }); + } + } + Display::Text { + painted, + origin, + clip, + } => primitives.push(Primitive::Text { + text: &self.painted[*painted], + origin: *origin, + clip: Some(*clip), + }), + Display::Custom { id, rect } => { + if !primitives.is_empty() { + layers.push(Layer::Primitives { + clip, + primitives: std::mem::take(&mut primitives), + }); + } + layers.push(Layer::Custom { + id: *id, + rect: *rect, + }); + } + } + } + if !primitives.is_empty() { + layers.push(Layer::Primitives { clip, primitives }); + } + layers + } + + pub(crate) fn texts(&mut self) -> (&mut Texts, u64) { + (&mut self.texts, self.frame) + } + + pub(crate) fn caret(&mut self, id: Id) -> (&mut usize, &mut usize) { + let state = self.states.entry(id).or_default(); + (&mut state.caret, &mut state.mark) + } + + pub(crate) fn grab(&mut self, id: Id) -> &mut f32 { + &mut self.states.entry(id).or_default().grab + } +} + +impl Built { + fn new( + id: Id, + parent: usize, + spec: Spec<'_>, + label: Option>, + text: [f32; 4], + ) -> Self { + Self { + id, + parent, + children: Vec::new(), + flags: spec.flags, + size: spec.size, + axis: spec.axis, + label, + color: spec.color.unwrap_or(text), + fill: spec.fill, + hover_fill: spec.hover_fill, + border: spec.border, + hover_border: spec.hover_border, + radius: spec.radius, + pad: spec.pad, + gap: spec.gap, + center: spec.center, + position: spec.position, + cursor: spec.cursor, + marks: Vec::new(), + computed: [0.0; 2], + relative: [0.0; 2], + rect: [0.0; 4], + content: 0.0, + } + } +} + +fn mix(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] { + std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t) +} + +fn intersect(a: [f32; 4], b: [f32; 4]) -> Option<[f32; 4]> { + let rect = [ + a[0].max(b[0]), + a[1].max(b[1]), + a[2].min(b[2]), + a[3].min(b[3]), + ]; + (rect[0] < rect[2] && rect[1] < rect[3]).then_some(rect) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..1cb976b22126ca3eb7a99e1b1b26c588f4cad1d3 --- /dev/null +++ b/crates/ui/src/tests.rs @@ -0,0 +1,382 @@ +use super::*; +use std::time::Duration; +use winit::keyboard::NamedKey; + +thread_local! { + static START: Instant = Instant::now(); + static FRAMES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Builds a frame 16 ms after this thread's previous one. +fn frame(ui: &mut Ui, build: impl FnOnce(&mut Ui)) { + let frames = FRAMES.with(|frames| { + frames.set(frames.get() + 1); + frames.get() + }); + let now = START.with(|start| *start) + Duration::from_millis(16) * frames; + ui.begin([400.0, 300.0], 2.0, now); + build(ui); + ui.end(); +} + +#[test] +fn fill_yields_to_strict_siblings_along_the_flow() { + let mut ui = Ui::new(Theme::dark()); + let mut ids = Vec::new(); + frame(&mut ui, |ui| { + ui.open( + "row", + Spec { + size: [fill(), px(100.0)], + ..Spec::default() + }, + ); + ids.push(ui.open( + "page", + Spec { + size: [fill(), fill()], + ..Spec::default() + }, + )); + ui.close(); + ids.push(ui.open( + "list", + Spec { + size: [px(120.0), fill()], + ..Spec::default() + }, + )); + ui.close(); + ui.close(); + }); + assert_eq!(ui.rect(ids[0]), Some([0.0, 0.0, 280.0, 100.0])); + assert_eq!(ui.rect(ids[1]), Some([280.0, 0.0, 400.0, 100.0])); +} + +#[test] +fn children_sum_with_gaps_and_padding() { + let mut ui = Ui::new(Theme::dark()); + let mut column = None; + let mut second = None; + frame(&mut ui, |ui| { + column = Some(ui.open( + "column", + Spec { + axis: Axis::Y, + pad: [4.0, 6.0], + gap: 2.0, + ..Spec::default() + }, + )); + ui.leaf( + 1, + Spec { + size: [px(50.0), px(10.0)], + ..Spec::default() + }, + ); + second = Some(ui.open( + 2, + Spec { + size: [px(30.0), px(20.0)], + ..Spec::default() + }, + )); + ui.close(); + ui.close(); + }); + assert_eq!(ui.rect(column.unwrap()), Some([0.0, 0.0, 58.0, 44.0])); + assert_eq!(ui.rect(second.unwrap()), Some([4.0, 18.0, 34.0, 38.0])); +} + +fn button_frame(ui: &mut Ui) -> Signal { + let mut signal = Signal::default(); + frame(ui, |ui| { + signal = ui.leaf( + "button", + Spec { + flags: Flags::CLICKABLE, + size: [px(100.0), px(40.0)], + hover_fill: Some([1.0; 4]), + ..Spec::default() + }, + ); + }); + signal +} + +#[test] +fn clicks_use_the_previous_layout_and_hover_animates() { + let mut ui = Ui::new(Theme::dark()); + button_frame(&mut ui); + let at = Instant::now(); + ui.event(Event::PointerMoved([10.0, 10.0])); + ui.event(Event::Button { + button: MouseButton::Left, + pressed: true, + at, + }); + let signal = button_frame(&mut ui); + assert!(signal.hovered && signal.pressed && signal.dragging && !signal.clicked); + assert!(ui.wants_frame(), "hover fades in"); + ui.event(Event::Button { + button: MouseButton::Left, + pressed: false, + at, + }); + ui.event(Event::Button { + button: MouseButton::Left, + pressed: true, + at: at + Duration::from_millis(100), + }); + let signal = button_frame(&mut ui); + assert!(signal.clicked && signal.pressed); + button_frame(&mut ui); + ui.event(Event::PointerMoved([200.0, 10.0])); + ui.event(Event::Button { + button: MouseButton::Left, + pressed: false, + at, + }); + let signal = button_frame(&mut ui); + assert!(!signal.clicked && !signal.hovered && !signal.dragging); + assert!(ui.wants_frame(), "hover fades out"); + for _ in 0..40 { + button_frame(&mut ui); + } + assert!(!ui.wants_frame()); +} + +#[test] +fn custom_boxes_receive_their_events_and_a_leave_when_the_pointer_moves_off() { + let mut ui = Ui::new(Theme::dark()); + let build = |ui: &mut Ui| { + let mut signals = Vec::new(); + frame(ui, |ui| { + ui.open( + "row", + Spec { + size: [fill(), fill()], + ..Spec::default() + }, + ); + signals.push(ui.leaf( + "page", + Spec { + flags: Flags::CUSTOM | Flags::FOCUSABLE, + size: [fill(), fill()], + ..Spec::default() + }, + )); + signals.push(ui.leaf( + "list", + Spec { + flags: Flags::CLICKABLE, + size: [px(100.0), fill()], + ..Spec::default() + }, + )); + ui.close(); + }); + signals + }; + build(&mut ui); + let at = Instant::now(); + ui.event(Event::PointerMoved([20.0, 20.0])); + ui.event(Event::Button { + button: MouseButton::Left, + pressed: true, + at, + }); + ui.event(Event::Key { + key: Key::Character("a".into()), + text: Some("a".into()), + }); + let signals = build(&mut ui); + assert!(signals[0].focused); + assert_eq!(signals[0].events.len(), 3); + assert!(matches!(signals[0].events[2], Event::Key { .. })); + assert!( + ui.cursor().is_none(), + "the host picks the cursor over its box" + ); + ui.event(Event::PointerMoved([350.0, 20.0])); + let signals = build(&mut ui); + assert_eq!( + signals[0].events, + [Event::PointerMoved([350.0, 20.0])], + "a drag keeps its box" + ); + ui.event(Event::Button { + button: MouseButton::Left, + pressed: false, + at, + }); + let signals = build(&mut ui); + assert!(matches!( + signals[0].events[..], + [Event::Button { .. }, Event::PointerLeft] + )); + assert!(signals[1].hovered); + ui.event(Event::Modifiers(ModifiersState::SHIFT)); + let signals = build(&mut ui); + assert_eq!(signals[0].events, [Event::Modifiers(ModifiersState::SHIFT)]); +} + +#[test] +fn wheel_scrolls_within_the_content_and_clips_children() { + let mut ui = Ui::new(Theme::dark()); + let mut list = None; + let mut last = None; + let mut build = |ui: &mut Ui| { + frame(ui, |ui| { + list = Some(ui.open( + "list", + Spec { + flags: Flags::SCROLL | Flags::CLIP, + axis: Axis::Y, + size: [px(100.0), px(100.0)], + ..Spec::default() + }, + )); + for row in 0..10 { + last = Some(ui.open( + row, + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(30.0)], + ..Spec::default() + }, + )); + ui.close(); + } + ui.close(); + }); + }; + build(&mut ui); + ui.event(Event::PointerMoved([50.0, 50.0])); + ui.event(Event::Wheel([0.0, -1000.0])); + for _ in 0..60 { + build(&mut ui); + } + assert_eq!(ui.states[&list.unwrap()].scroll, 200.0); + assert_eq!(ui.rect(last.unwrap()), Some([0.0, 70.0, 100.0, 100.0])); + let layers = ui.layers(); + assert!(matches!( + layers[..], + [Layer::Primitives { + clip: Some([0.0, 0.0, 100.0, 100.0]), + .. + }] | [] + )); +} + +#[test] +fn text_fields_edit_with_keys_and_selection() { + let mut ui = Ui::new(Theme::dark()); + let mut text = String::from("café"); + let build = |ui: &mut Ui, text: &mut String| { + let mut signal = Signal::default(); + frame(ui, |ui| { + signal = text_field( + ui, + Id::ROOT.child("filter"), + text, + "Filter", + Spec { + size: [px(200.0), px(26.0)], + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + }); + signal + }; + build(&mut ui, &mut text); + let id = Id::ROOT.child("filter"); + ui.set_focus(Some(id)); + let key = |named| Event::Key { + key: Key::Named(named), + text: None, + }; + ui.event(Event::Key { + key: Key::Named(NamedKey::End), + text: None, + }); + ui.event(key(NamedKey::Backspace)); + ui.event(Event::Key { + key: Key::Character("e".into()), + text: Some("e".into()), + }); + build(&mut ui, &mut text); + assert_eq!(text, "cafe"); + ui.event(Event::Modifiers(ModifiersState::SHIFT)); + ui.event(key(NamedKey::ArrowLeft)); + ui.event(key(NamedKey::ArrowLeft)); + build(&mut ui, &mut text); + ui.event(Event::Modifiers(ModifiersState::empty())); + ui.event(Event::Ime(Ime::Commit("é".into()))); + build(&mut ui, &mut text); + assert_eq!(text, "caé"); + ui.event(Event::Modifiers(ModifiersState::SUPER)); + ui.event(Event::Key { + key: Key::Character("a".into()), + text: Some("a".into()), + }); + build(&mut ui, &mut text); + ui.event(Event::Modifiers(ModifiersState::empty())); + ui.event(key(NamedKey::Delete)); + let signal = build(&mut ui, &mut text); + assert!(signal.focused); + assert_eq!(text, ""); +} + +#[test] +fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() { + let mut ui = Ui::new(Theme::dark()); + let mut offset = 0.0; + let build = |ui: &mut Ui, offset: &mut f32| { + frame(ui, |ui| { + ui.open( + "view", + Spec { + flags: Flags::CUSTOM, + size: [px(200.0), px(220.0)], + ..Spec::default() + }, + ); + if let Some(chosen) = + scrollbar(ui, "bar", Axis::Y, *offset, [0.0, 1000.0], 400.0, [0.5; 4]) + { + *offset = chosen; + } + ui.close(); + }); + }; + build(&mut ui, &mut offset); + build(&mut ui, &mut offset); + let thumb = Id::ROOT.child("view").child("bar"); + let rect = ui.rect(thumb).unwrap(); + assert_eq!( + rect, + [190.0, 4.0, 196.0, 61.0], + "a 400 of 1400 share of a 200 track" + ); + let at = Instant::now(); + ui.event(Event::PointerMoved([193.0, 10.0])); + ui.event(Event::Button { + button: MouseButton::Left, + pressed: true, + at, + }); + build(&mut ui, &mut offset); + assert_eq!(offset, 0.0, "a press alone keeps the offset"); + ui.event(Event::PointerMoved([193.0, 500.0])); + build(&mut ui, &mut offset); + assert_eq!(offset, 1000.0); + build(&mut ui, &mut offset); + assert_eq!(ui.rect(thumb).unwrap(), [190.0, 147.0, 196.0, 204.0]); + ui.event(Event::PointerMoved([193.0, -500.0])); + build(&mut ui, &mut offset); + assert_eq!(offset, 0.0); +} diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs new file mode 100644 index 0000000000000000000000000000000000000000..0cbe7c0cc938cc7025bf9c31f4ce1aa010565282 --- /dev/null +++ b/crates/ui/src/text.rs @@ -0,0 +1,92 @@ +use draw::{GlyphRun, Glyphs, RenderError}; +use parley::{ + Affinity, FontContext, FontFamily, GenericFamily, Layout, LayoutContext, PositionedLayoutItem, + StyleProperty, editing::Cursor, +}; +use std::{collections::HashMap, rc::Rc}; + +/// A shaped single line of interface text, in logical pixels. +pub(crate) struct Label { + layout: Layout<()>, + pub size: [f32; 2], +} + +impl Label { + /// The caret's left edge before byte `index`. + pub fn caret_x(&self, index: usize) -> f32 { + Cursor::from_byte_index(&self.layout, index, Affinity::Downstream) + .geometry(&self.layout, 1.0) + .x0 as f32 + } + + /// The byte index whose caret position is nearest `x`. + pub fn index_at(&self, x: f32) -> usize { + Cursor::from_point(&self.layout, x, self.size[1] / 2.0).index() + } +} + +/// Labels shaped this frame or the previous one, keyed by their text and size. +#[derive(Default)] +pub(crate) struct Texts { + fonts: FontContext, + context: LayoutContext<()>, + cache: HashMap<(String, u32), (Rc