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