From 3198d9eaee695639cb482b2fb1f2a47b21cb25dd Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Sat, 26 Sep 2026 15:31:51 -0400 Subject: [PATCH] shikra: Add input handling --- crates/shikra/README.md | 3 + crates/shikra/examples/input.rs | 199 +++++++ crates/shikra/src/app.rs | 34 +- crates/shikra/src/context.rs | 22 +- crates/shikra/src/div.rs | 233 +++++++- crates/shikra/src/element.rs | 67 ++- crates/shikra/src/focus.rs | 76 +++ crates/shikra/src/geometry.rs | 9 + crates/shikra/src/input.rs | 191 ++++++ crates/shikra/src/lib.rs | 6 + crates/shikra/src/platform.rs | 151 ++++- crates/shikra/src/style.rs | 39 +- crates/shikra/src/test.rs | 30 + crates/shikra/src/view.rs | 14 +- crates/shikra/src/window.rs | 7 + crates/shikra/src/window/dispatch.rs | 856 +++++++++++++++++++++++++++ 16 files changed, 1884 insertions(+), 53 deletions(-) create mode 100644 crates/shikra/examples/input.rs create mode 100644 crates/shikra/src/focus.rs create mode 100644 crates/shikra/src/input.rs create mode 100644 crates/shikra/src/test.rs create mode 100644 crates/shikra/src/window/dispatch.rs diff --git a/crates/shikra/README.md b/crates/shikra/README.md index d8d4576..a4fa95f 100644 --- a/crates/shikra/README.md +++ b/crates/shikra/README.md @@ -7,6 +7,9 @@ A native UI library for Rust, built on winit, wgpu, and tokio. ```sh cargo run -p shikra --example hello cargo run -p shikra --example layout +cargo run -p shikra --example clock +cargo run -p shikra --example text +cargo run -p shikra --example input ``` On NixOS, winit and wgpu load X11/Wayland and Vulkan at runtime, so they need to be on the library path. The dev shell in the repo's flake sets that up: diff --git a/crates/shikra/examples/input.rs b/crates/shikra/examples/input.rs new file mode 100644 index 0000000..9f23548 --- /dev/null +++ b/crates/shikra/examples/input.rs @@ -0,0 +1,199 @@ +//! Mouse and keyboard input: clicks, hover and pressed styles, cursors, focus, +//! and keyboard navigation. +//! +//! Click the buttons, or Tab to them and press Enter or Space. Type anywhere +//! to see the keys you press. + +use shikra::{ + Application, ClickEvent, Context, FocusHandle, IntoElement, Key, KeyDownEvent, Keystroke, + ParentElement, Render, Rgba, ScrollDelta, ScrollWheelEvent, SharedString, Window, + WindowOptions, div, rgb, size, +}; + +struct Counter { + count: i32, + buttons: [FocusHandle; 3], + status: SharedString, + hovering: bool, +} + +impl Counter { + fn new(cx: &mut Context) -> Self { + Self { + count: 0, + buttons: [cx.focus_handle(), cx.focus_handle(), cx.focus_handle()], + status: "Click a button, or press Tab".into(), + hovering: false, + } + } + + fn apply(&mut self, action: Action, how: String, cx: &mut Context) { + match action { + Action::Decrement => self.count -= 1, + Action::Increment => self.count += 1, + Action::Reset => self.count = 0, + } + self.status = format!("{} ({how})", action.label()).into(); + cx.notify(); + } +} + +#[derive(Clone, Copy)] +enum Action { + Decrement, + Increment, + Reset, +} + +impl Action { + fn label(self) -> &'static str { + match self { + Action::Decrement => "Decrement", + Action::Increment => "Increment", + Action::Reset => "Reset", + } + } +} + +impl Render for Counter { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let button = |label: &'static str, action: Action, handle: &FocusHandle| { + div() + .style(|s| { + s.flex() + .justify_center() + .items_center() + .w_24() + .h_12() + .rounded_lg() + .border_2() + .border_color(Rgba::TRANSPARENT) + .bg(rgb(0x4f46e5)) + .text_color(Rgba::WHITE) + .font_semibold() + .cursor_pointer() + }) + .hover(|s| s.bg(rgb(0x6366f1))) + .active(|s| s.bg(rgb(0x3730a3))) + .focus_visible(|s| s.border_color(rgb(0xf59e0b))) + .track_focus(handle) + .on_click(cx.listener(move |this, event: &ClickEvent, _, cx| { + let how = match event.click_count { + 1 => "clicked".into(), + count => format!("clicked {count} times"), + }; + this.apply(action, how, cx); + })) + .on_key_down(cx.listener(move |this, event: &KeyDownEvent, window, cx| { + if matches!(event.keystroke.key, Key::Enter | Key::Space) { + this.apply(action, format!("{:?} pressed", event.keystroke.key), cx); + // Don't let the root log this key. + window.stop_propagation(); + } + })) + .child(label) + }; + + div() + .style(|s| { + s.size_full() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_8() + .bg(rgb(0xf8fafc)) + .text_color(rgb(0x0f172a)) + }) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + let mut status = format!("Pressed {}", describe(&event.keystroke)); + if let Some(text) = &event.text { + status.push_str(&format!(", typing {text:?}")); + } + this.status = status.into(); + cx.notify(); + })) + .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| { + this.status = match event.delta { + ScrollDelta::Lines(delta) => { + format!("Scrolled {:.1} lines", delta.y) + } + ScrollDelta::Pixels(delta) => format!("Scrolled {:.0}px", delta.y), + } + .into(); + cx.notify(); + })) + .child( + div() + .style(|s| s.text_7xl().font_bold()) + .child(self.count.to_string()), + ) + .child( + div() + .style(|s| s.flex().gap_4()) + .child(button("−", Action::Decrement, &self.buttons[0])) + .child(button("+", Action::Increment, &self.buttons[1])) + .child(button("Reset", Action::Reset, &self.buttons[2])), + ) + .child( + div() + .style(|s| { + s.w_96() + .p_6() + .rounded_xl() + .border_1() + .border_color(rgb(0xcbd5e1)) + .bg(Rgba::WHITE) + .text_center() + .cursor_crosshair() + }) + .hover(|s| s.border_color(rgb(0x6366f1)).bg(rgb(0xeef2ff))) + .on_hover(cx.listener(|this, hovering: &bool, _, cx| { + this.hovering = *hovering; + cx.notify(); + })) + .child(if self.hovering { + "The mouse is over this card" + } else { + "Hover over this card" + }), + ) + .child( + div() + .style(|s| s.text_sm().text_color(rgb(0x64748b))) + .child(self.status.clone()), + ) + } +} + +fn describe(keystroke: &Keystroke) -> String { + let modifiers = keystroke.modifiers; + let mut parts = Vec::new(); + for (held, name) in [ + (modifiers.control, "Ctrl"), + (modifiers.alt, "Alt"), + (modifiers.shift, "Shift"), + (modifiers.platform, "Super"), + ] { + if held { + parts.push(name.to_string()); + } + } + parts.push(match &keystroke.key { + Key::Character(character) => character.to_uppercase(), + key => format!("{key:?}"), + }); + parts.join("+") +} + +fn main() { + Application::new().run(|cx| { + cx.open_window( + WindowOptions { + title: "Input".into(), + size: size(720., 560.), + }, + |_window, cx| cx.new(Counter::new), + ); + }); +} diff --git a/crates/shikra/src/app.rs b/crates/shikra/src/app.rs index b14cf35..f4a3177 100644 --- a/crates/shikra/src/app.rs +++ b/crates/shikra/src/app.rs @@ -9,8 +9,8 @@ use tokio::runtime::{Handle, RuntimeFlavor}; use crate::entity::EntityMap; use crate::subscription::SubscriberSet; use crate::{ - BackgroundExecutor, Context, Entity, EntityId, EventEmitter, ForegroundExecutor, Render, - Subscription, Task, TextSystem, Window, WindowHandle, WindowId, WindowOptions, + BackgroundExecutor, Context, Entity, EntityId, EventEmitter, FocusHandle, ForegroundExecutor, + Render, Subscription, Task, TextSystem, Window, WindowHandle, WindowId, WindowOptions, }; /// The entry point for a Shikra application. @@ -168,6 +168,12 @@ impl App { &mut self.text_system } + /// Creates a handle for something that can be focused. See + /// [`FocusHandle`]. + pub fn focus_handle(&self) -> FocusHandle { + FocusHandle::new() + } + pub fn quit(&mut self) { self.quit_requested = true; } @@ -428,31 +434,9 @@ mod tests { use std::cell::Cell; use super::*; - use crate::executor::ForegroundQueue; + use crate::test::test_app; use crate::{IntoElement, div}; - struct TestApp { - app: Rc>, - _foreground_queue: ForegroundQueue, - _runtime: tokio::runtime::Runtime, - } - - fn test_app() -> TestApp { - let runtime = tokio::runtime::Builder::new_current_thread() - .build() - .unwrap(); - let foreground_queue = ForegroundQueue::new(|| {}); - let app = App::create( - foreground_queue.executor(), - BackgroundExecutor::new(runtime.handle().clone()), - ); - TestApp { - app, - _foreground_queue: foreground_queue, - _runtime: runtime, - } - } - struct Counter { count: usize, } diff --git a/crates/shikra/src/context.rs b/crates/shikra/src/context.rs index 08ef0db..333cfeb 100644 --- a/crates/shikra/src/context.rs +++ b/crates/shikra/src/context.rs @@ -2,7 +2,7 @@ use std::ops::{Deref, DerefMut}; use anyhow::Result; -use crate::{App, AppContext, AsyncApp, Entity, EntityId, Subscription, Task, WeakEntity}; +use crate::{App, AppContext, AsyncApp, Entity, EntityId, Subscription, Task, WeakEntity, Window}; /// Marks an entity as emitting events of type `E`, which other entities can /// [subscribe](Context::subscribe) to. @@ -86,6 +86,26 @@ impl<'a, T: 'static> Context<'a, T> { }) } + /// Wraps `f` as an event listener that updates this entity, for use with + /// methods like [`Div::on_click`](crate::Div::on_click). + /// + /// ```ignore + /// div().on_click(cx.listener(|this, _, _, cx| { + /// this.count += 1; + /// cx.notify(); + /// })) + /// ``` + pub fn listener( + &self, + f: impl Fn(&mut T, &E, &mut Window, &mut Context) + 'static, + ) -> impl Fn(&E, &mut Window, &mut App) + 'static { + let this = self.weak_entity(); + move |event, window, cx| { + // The entity may have been released since the frame was drawn. + let _ = this.update(cx, |this, cx| f(this, event, window, cx)); + } + } + /// Spawns a task on the main thread with a weak handle to this entity. /// /// Store the returned task in the entity to cancel it when the entity is diff --git a/crates/shikra/src/div.rs b/crates/shikra/src/div.rs index 177d599..6e161e9 100644 --- a/crates/shikra/src/div.rs +++ b/crates/shikra/src/div.rs @@ -1,21 +1,50 @@ +use std::mem; +use std::rc::Rc; + +use crate::element::GlobalElementId; +use crate::window::{Listeners, Node}; use crate::{ - AnyElement, App, Bounds, Element, LayoutId, PaintQuad, ParentElement, Rgba, Style, Window, px, + AnyElement, App, Bounds, ClickEvent, Element, ElementId, FocusHandle, KeyDownEvent, KeyUpEvent, + LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, + Rgba, ScrollWheelEvent, Style, Window, px, }; /// A box with a style and children. The basic building block of a UI. pub fn div() -> Div { Div { + id: None, style: Style::default(), + hover_style: None, + active_style: None, + focus_style: None, + focus_visible_style: None, + focus_handle: None, + tab_stop: true, + listeners: Listeners::default(), children: Vec::new(), } } pub struct Div { + id: Option, style: Style, + hover_style: Option>, + active_style: Option>, + focus_style: Option>, + focus_visible_style: Option>, + focus_handle: Option, + tab_stop: bool, + listeners: Listeners, children: Vec, } impl Div { + /// Identifies the element among its siblings. See [`ElementId`]. + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + /// Refines the element's style. /// /// ```ignore @@ -25,6 +54,158 @@ impl Div { self.style = f(self.style); self } + + /// Refines the element's style while the mouse is over it or one of its + /// descendants. Like Tailwind's `hover:`. + pub fn hover(mut self, f: impl FnOnce(Style) -> Style) -> Self { + self.hover_style = Some(Box::new(f(Style::default()))); + self + } + + /// Refines the element's style while the left mouse button is held after + /// being pressed on it. Like Tailwind's `active:`. + pub fn active(mut self, f: impl FnOnce(Style) -> Style) -> Self { + self.active_style = Some(Box::new(f(Style::default()))); + self + } + + /// Refines the element's style while it's focused. Requires + /// [`track_focus`](Self::track_focus). Like Tailwind's `focus:`. + pub fn focus(mut self, f: impl FnOnce(Style) -> Style) -> Self { + self.focus_style = Some(Box::new(f(Style::default()))); + self + } + + /// Refines the element's style while it's focused and the user is + /// navigating with the keyboard. Requires + /// [`track_focus`](Self::track_focus). Like Tailwind's `focus-visible:`. + pub fn focus_visible(mut self, f: impl FnOnce(Style) -> Style) -> Self { + self.focus_visible_style = Some(Box::new(f(Style::default()))); + self + } + + /// Makes the element focusable, with `handle` tracking its focus. + /// + /// The element takes focus when clicked and when tabbed to, and receives + /// keyboard events while it or one of its descendants is focused. + pub fn track_focus(mut self, handle: &FocusHandle) -> Self { + self.focus_handle = Some(handle.clone()); + self + } + + /// Whether Tab can move focus to the element. Defaults to `true`; set it + /// to `false` for elements that should only take focus when clicked or + /// focused programmatically. + pub fn tab_stop(mut self, tab_stop: bool) -> Self { + self.tab_stop = tab_stop; + self + } + + pub fn on_mouse_down( + mut self, + button: MouseButton, + listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.listeners + .mouse_down + .push(Rc::new(move |event, window, cx| { + if event.button == button { + listener(event, window, cx); + } + })); + self + } + + pub fn on_mouse_up( + mut self, + button: MouseButton, + listener: impl Fn(&MouseUpEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.listeners + .mouse_up + .push(Rc::new(move |event, window, cx| { + if event.button == button { + listener(event, window, cx); + } + })); + self + } + + pub fn on_mouse_move( + mut self, + listener: impl Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.listeners.mouse_move.push(Rc::new(listener)); + self + } + + pub fn on_scroll_wheel( + mut self, + listener: impl Fn(&ScrollWheelEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.listeners.scroll_wheel.push(Rc::new(listener)); + self + } + + /// Listens for the left mouse button being pressed and released on the + /// element. + pub fn on_click( + mut self, + listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.listeners.click.push(Rc::new(listener)); + self + } + + /// Listens for the mouse entering (`true`) or leaving (`false`) the + /// element, including its descendants. + pub fn on_hover(mut self, listener: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { + self.listeners.hover.push(Rc::new(listener)); + self + } + + /// Listens for keys pressed while the element or one of its descendants + /// is focused. + pub fn on_key_down( + mut self, + listener: impl Fn(&KeyDownEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.listeners.key_down.push(Rc::new(listener)); + self + } + + pub fn on_key_up( + mut self, + listener: impl Fn(&KeyUpEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.listeners.key_up.push(Rc::new(listener)); + self + } + + /// Applies the styles for the element's current state. + fn resolve_style(&mut self, id: GlobalElementId, window: &Window) { + let focus_handle = self.focus_handle.as_ref(); + // In the same order as Tailwind, so later states win. + let states = [ + (&self.hover_style, window.is_element_hovered(id)), + ( + &self.focus_style, + focus_handle.is_some_and(|handle| handle.is_focused(window)), + ), + ( + &self.focus_visible_style, + focus_handle.is_some_and(|handle| handle.is_focus_visible(window)), + ), + (&self.active_style, window.is_element_active(id)), + ]; + for (style, active) in states { + if let Some(style) = style + && active + { + self.style = mem::take(&mut self.style).refine(style); + } + } + } } impl ParentElement for Div { @@ -34,19 +215,29 @@ impl ParentElement for Div { } impl Element for Div { - type LayoutState = (); - - fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, ()) { - let children = window.with_text_style(&self.style.text, |window| { - self.children - .iter_mut() - .map(|child| child.request_layout(window, cx)) - .collect::>() - }); - (window.request_layout(&self.style, &children), ()) + type LayoutState = GlobalElementId; + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, GlobalElementId) { + let element_id = self.id.take(); + window.with_element_id(element_id.as_ref(), |window, id| { + self.resolve_style(id, window); + let children = window.with_text_style(&self.style.text, |window| { + self.children + .iter_mut() + .map(|child| child.request_layout(window, cx)) + .collect::>() + }); + (window.request_layout(&self.style, &children), id) + }) } - fn paint(&mut self, bounds: Bounds, _: &mut (), window: &mut Window, cx: &mut App) { + fn paint( + &mut self, + bounds: Bounds, + id: &mut GlobalElementId, + window: &mut Window, + cx: &mut App, + ) { let rem_size = window.rem_size(); let background = self.style.background; let border_widths = self @@ -75,10 +266,20 @@ impl Element for Div { }); } - window.with_text_style(&self.style.text, |window| { - for child in &mut self.children { - child.paint(window, cx); - } + let mut node = Node::new(*id, bounds); + node.cursor = self.style.cursor; + node.focus = self.focus_handle.as_ref().map(FocusHandle::downgrade); + node.tab_stop = self.tab_stop; + node.hover_styled = self.hover_style.is_some(); + node.active_styled = self.active_style.is_some(); + node.listeners = mem::take(&mut self.listeners); + + window.with_node(node, |window| { + window.with_text_style(&self.style.text, |window| { + for child in &mut self.children { + child.paint(window, cx); + } + }); }); } } diff --git a/crates/shikra/src/element.rs b/crates/shikra/src/element.rs index 21070a3..a8f3f94 100644 --- a/crates/shikra/src/element.rs +++ b/crates/shikra/src/element.rs @@ -1,6 +1,7 @@ use std::any::type_name; +use std::hash::{DefaultHasher, Hash, Hasher}; -use crate::{App, Bounds, LayoutId, Window}; +use crate::{App, Bounds, EntityId, LayoutId, SharedString, Window}; /// Something that can be laid out and painted. /// @@ -130,3 +131,67 @@ impl ElementObject for Drawable { self.phase = Phase::Painted; } } + +/// Identifies an element among its siblings. +/// +/// Elements are identified by their position among their siblings by default, +/// so that state like whether they're hovered or pressed carries over from +/// one frame to the next. Give an element an ID when its position isn't +/// stable, like in a list whose items can be reordered. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ElementId { + Name(SharedString), + Integer(u64), +} + +impl From<&'static str> for ElementId { + fn from(name: &'static str) -> Self { + Self::Name(name.into()) + } +} + +impl From for ElementId { + fn from(name: String) -> Self { + Self::Name(name.into()) + } +} + +impl From for ElementId { + fn from(name: SharedString) -> Self { + Self::Name(name) + } +} + +impl From for ElementId { + fn from(id: u64) -> Self { + Self::Integer(id) + } +} + +impl From for ElementId { + fn from(id: usize) -> Self { + Self::Integer(id as u64) + } +} + +/// Identifies an element within its window: a hash of its [`ElementId`] (or +/// position) and those of its ancestors. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct GlobalElementId(u64); + +/// Identifies an element among its siblings. +#[derive(Hash)] +pub(crate) enum LocalElementId<'a> { + Index(usize), + Id(&'a ElementId), + View(EntityId), +} + +impl GlobalElementId { + pub(crate) fn new(parent: GlobalElementId, local: LocalElementId) -> Self { + let mut hasher = DefaultHasher::new(); + parent.hash(&mut hasher); + local.hash(&mut hasher); + Self(hasher.finish()) + } +} diff --git a/crates/shikra/src/focus.rs b/crates/shikra/src/focus.rs new file mode 100644 index 0000000..d17c213 --- /dev/null +++ b/crates/shikra/src/focus.rs @@ -0,0 +1,76 @@ +use std::rc::{Rc, Weak}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::Window; + +/// Identifies something that can be focused. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FocusId(u64); + +/// A handle to something that can receive keyboard focus. +/// +/// Attach it to an element with [`Div::track_focus`](crate::Div::track_focus) +/// to make that element focusable. Focus is released once every handle to it +/// is dropped. +#[derive(Debug, Clone)] +pub struct FocusHandle(Rc); + +impl FocusHandle { + pub(crate) fn new() -> Self { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + Self(Rc::new(FocusId(NEXT_ID.fetch_add(1, Ordering::Relaxed)))) + } + + pub fn id(&self) -> FocusId { + *self.0 + } + + pub(crate) fn downgrade(&self) -> WeakFocusHandle { + WeakFocusHandle { + id: self.id(), + handle: Rc::downgrade(&self.0), + } + } + + /// Moves keyboard focus in `window` here. + pub fn focus(&self, window: &mut Window) { + window.focus(self); + } + + pub fn is_focused(&self, window: &Window) -> bool { + window.focused() == Some(self.id()) + } + + /// Whether this is focused and the focus should be visibly indicated, + /// because the user is navigating with the keyboard. Like CSS's + /// `:focus-visible`. + pub fn is_focus_visible(&self, window: &Window) -> bool { + self.is_focused(window) && window.is_focus_visible() + } + + /// Whether this or an element inside the element tracking it is focused. + /// Like CSS's `:focus-within`. + pub fn contains_focused(&self, window: &Window) -> bool { + self.is_focused(window) || window.focus_path().contains(&self.id()) + } +} + +impl PartialEq for FocusHandle { + fn eq(&self, other: &Self) -> bool { + self.id() == other.id() + } +} + +impl Eq for FocusHandle {} + +#[derive(Debug, Clone)] +pub(crate) struct WeakFocusHandle { + pub(crate) id: FocusId, + handle: Weak, +} + +impl WeakFocusHandle { + pub(crate) fn is_alive(&self) -> bool { + self.handle.strong_count() > 0 + } +} diff --git a/crates/shikra/src/geometry.rs b/crates/shikra/src/geometry.rs index f3daa4a..c7dcfc0 100644 --- a/crates/shikra/src/geometry.rs +++ b/crates/shikra/src/geometry.rs @@ -40,6 +40,15 @@ impl Bounds { pub fn bottom(&self) -> f32 { self.origin.y + self.size.height } + + /// Whether `point` is inside the bounds, including the top and left edges + /// but not the bottom and right ones. + pub fn contains(&self, point: Point) -> bool { + point.x >= self.origin.x + && point.y >= self.origin.y + && point.x < self.right() + && point.y < self.bottom() + } } #[derive(Debug, Default, Clone, Copy, PartialEq)] diff --git a/crates/shikra/src/input.rs b/crates/shikra/src/input.rs new file mode 100644 index 0000000..0e7431d --- /dev/null +++ b/crates/shikra/src/input.rs @@ -0,0 +1,191 @@ +//! Mouse and keyboard events. +//! +//! Positions are in logical pixels, relative to the window's content area. + +use crate::{Point, SharedString}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MouseButton { + Left, + Right, + Middle, + Back, + Forward, + Other(u16), +} + +/// The modifier keys held during an event. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Modifiers { + pub shift: bool, + pub control: bool, + pub alt: bool, + /// Command on macOS, and the Super (Windows) key elsewhere. + pub platform: bool, +} + +impl Modifiers { + /// Whether the platform's primary shortcut modifier is held: Command on + /// macOS, and Control elsewhere. + pub fn secondary(&self) -> bool { + if cfg!(target_os = "macos") { + self.platform + } else { + self.control + } + } + + /// Whether no modifiers are held. + pub fn is_empty(&self) -> bool { + *self == Self::default() + } +} + +#[derive(Debug, Clone)] +pub struct MouseDownEvent { + pub button: MouseButton, + pub position: Point, + pub modifiers: Modifiers, + /// 1 for a single click, 2 for a double click, and so on. + pub click_count: usize, +} + +#[derive(Debug, Clone)] +pub struct MouseUpEvent { + pub button: MouseButton, + pub position: Point, + pub modifiers: Modifiers, + pub click_count: usize, +} + +#[derive(Debug, Clone)] +pub struct MouseMoveEvent { + pub position: Point, + /// The button held down while moving, if any, for dragging. + pub pressed_button: Option, + pub modifiers: Modifiers, +} + +/// A press and release of the left mouse button on the same element. +#[derive(Debug, Clone)] +pub struct ClickEvent { + /// Where the button was released. + pub position: Point, + pub modifiers: Modifiers, + pub click_count: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ScrollDelta { + /// A precise amount, from a touchpad, in logical pixels. + Pixels(Point), + /// A number of lines, from a mouse wheel. + Lines(Point), +} + +#[derive(Debug, Clone)] +pub struct ScrollWheelEvent { + pub position: Point, + /// Positive values mean the content should move right or down, as when + /// scrolling towards the start of a document. + pub delta: ScrollDelta, + pub modifiers: Modifiers, +} + +/// A key, independent of the modifiers held with it. +/// +/// Character keys are reported as the character they'd type without any +/// modifiers, according to the current keyboard layout, so Shift+A is +/// `Character("a")` with `shift` set. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Key { + Character(SharedString), + Enter, + Tab, + Space, + Backspace, + Delete, + Escape, + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + Home, + End, + PageUp, + PageDown, + Insert, + /// A function key, like F1. + F(u8), + /// A key without a representation here, like a modifier or media key. + Unidentified, +} + +impl Key { + /// Whether this is the character key for `character`. + pub fn is_character(&self, character: &str) -> bool { + matches!(self, Key::Character(c) if &**c == character) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Keystroke { + pub key: Key, + pub modifiers: Modifiers, +} + +#[derive(Debug, Clone)] +pub struct KeyDownEvent { + pub keystroke: Keystroke, + /// The text the key typed, if any. Doesn't include control characters. + /// + /// This is enough for simple text fields, but not for input methods, which + /// compose text over several keystrokes. + pub text: Option, + /// Whether the key is being held down and this is a repeat. + pub is_repeat: bool, +} + +#[derive(Debug, Clone)] +pub struct KeyUpEvent { + pub keystroke: Keystroke, +} + +/// The mouse cursor to show over an element. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CursorStyle { + #[default] + Default, + Pointer, + Text, + Crosshair, + Move, + NotAllowed, + Grab, + Grabbing, + EwResize, + NsResize, + Wait, + Help, +} + +/// Input from the platform, before the window adds its own state (like the +/// modifiers and click count) to make events. +#[derive(Debug, Clone)] +pub(crate) enum PlatformInput { + MouseMove(Point), + MouseExited, + /// At the last position the mouse moved to. + MouseDown(MouseButton), + MouseUp(MouseButton), + ScrollWheel(ScrollDelta), + KeyDown { + key: Key, + text: Option, + is_repeat: bool, + }, + KeyUp(Key), + ModifiersChanged(Modifiers), + /// The window lost focus to another window or app. + WindowDeactivated, +} diff --git a/crates/shikra/src/lib.rs b/crates/shikra/src/lib.rs index 56c09fa..0900558 100644 --- a/crates/shikra/src/lib.rs +++ b/crates/shikra/src/lib.rs @@ -6,13 +6,17 @@ mod div; mod element; mod entity; mod executor; +mod focus; mod geometry; +mod input; mod layout; mod platform; mod renderer; mod scene; mod style; mod subscription; +#[cfg(test)] +mod test; mod text; mod view; mod window; @@ -24,7 +28,9 @@ pub use div::*; pub use element::*; pub use entity::*; pub use executor::*; +pub use focus::{FocusHandle, FocusId}; pub use geometry::*; +pub use input::*; pub use layout::{AvailableSpace, LayoutId}; pub use style::*; pub use subscription::Subscription; diff --git a/crates/shikra/src/platform.rs b/crates/shikra/src/platform.rs index 56f4fb6..c0fc1b4 100644 --- a/crates/shikra/src/platform.rs +++ b/crates/shikra/src/platform.rs @@ -11,12 +11,18 @@ use std::sync::Arc; use winit::application::ApplicationHandler; use winit::dpi::LogicalSize; -use winit::event::WindowEvent; +use winit::event::{ElementState, MouseScrollDelta, WindowEvent}; use winit::event_loop::{ActiveEventLoop, EventLoop}; +use winit::platform::modifier_supplement::KeyEventExtModifierSupplement; +use winit::window::CursorIcon; use crate::executor::ForegroundQueue; +use crate::input::PlatformInput; use crate::renderer::{Renderer, WindowSurface}; -use crate::{App, BackgroundExecutor, WindowId}; +use crate::{ + App, BackgroundExecutor, CursorStyle, Key, Modifiers, MouseButton, ScrollDelta, SharedString, + WindowId, point, +}; pub(crate) type OnLaunch = Box; @@ -109,6 +115,70 @@ impl ApplicationHandler for Platform { } window.window.request_redraw(); } + WindowEvent::CursorMoved { position, .. } => { + let position = position.to_logical::(window.window.scale_factor()); + let position = point(position.x, position.y); + dispatch_input(&self.app, window.id, PlatformInput::MouseMove(position)); + } + WindowEvent::CursorLeft { .. } => { + dispatch_input(&self.app, window.id, PlatformInput::MouseExited); + } + WindowEvent::MouseInput { state, button, .. } => { + let button = mouse_button(button); + let input = match state { + ElementState::Pressed => PlatformInput::MouseDown(button), + ElementState::Released => PlatformInput::MouseUp(button), + }; + dispatch_input(&self.app, window.id, input); + } + WindowEvent::MouseWheel { delta, .. } => { + let delta = match delta { + MouseScrollDelta::LineDelta(x, y) => ScrollDelta::Lines(point(x, y)), + MouseScrollDelta::PixelDelta(delta) => { + let delta = delta.to_logical::(window.window.scale_factor()); + ScrollDelta::Pixels(point(delta.x, delta.y)) + } + }; + dispatch_input(&self.app, window.id, PlatformInput::ScrollWheel(delta)); + } + WindowEvent::ModifiersChanged(modifiers) => { + let state = modifiers.state(); + let modifiers = Modifiers { + shift: state.shift_key(), + control: state.control_key(), + alt: state.alt_key(), + platform: state.super_key(), + }; + dispatch_input( + &self.app, + window.id, + PlatformInput::ModifiersChanged(modifiers), + ); + } + // Synthetic events report keys held when the window gains or + // loses focus, which weren't pressed in it. + WindowEvent::KeyboardInput { + event, + is_synthetic: false, + .. + } => { + let key = key(&event.key_without_modifiers()); + let input = match event.state { + ElementState::Pressed => PlatformInput::KeyDown { + key, + text: event + .text + .filter(|text| !text.chars().any(char::is_control)) + .map(|text| SharedString::from(text.to_string())), + is_repeat: event.repeat, + }, + ElementState::Released => PlatformInput::KeyUp(key), + }; + dispatch_input(&self.app, window.id, input); + } + WindowEvent::Focused(false) => { + dispatch_input(&self.app, window.id, PlatformInput::WindowDeactivated); + } WindowEvent::RedrawRequested => { let renderer = &mut self.renderer; self.app @@ -190,6 +260,11 @@ impl Platform { if std::mem::take(&mut window.title_changed) { platform_window.window.set_title(&window.title); } + if std::mem::take(&mut window.input.cursor_changed) { + platform_window + .window + .set_cursor(cursor_icon(window.input.cursor)); + } if std::mem::take(&mut window.needs_redraw) { platform_window.window.request_redraw(); } @@ -204,3 +279,75 @@ fn sync_window_metrics(app_window: &mut crate::Window, window: &winit::window::W app_window.viewport_size = crate::size(size.width, size.height); app_window.scale_factor = scale_factor as f32; } + +fn dispatch_input(app: &RefCell, window_id: WindowId, input: PlatformInput) { + app.borrow_mut() + .update_window(window_id, |window, cx| window.dispatch_input(input, cx)); +} + +fn mouse_button(button: winit::event::MouseButton) -> MouseButton { + match button { + winit::event::MouseButton::Left => MouseButton::Left, + winit::event::MouseButton::Right => MouseButton::Right, + winit::event::MouseButton::Middle => MouseButton::Middle, + winit::event::MouseButton::Back => MouseButton::Back, + winit::event::MouseButton::Forward => MouseButton::Forward, + winit::event::MouseButton::Other(button) => MouseButton::Other(button), + } +} + +fn key(key: &winit::keyboard::Key) -> Key { + use winit::keyboard::{Key as WinitKey, NamedKey}; + + match key { + WinitKey::Character(character) => Key::Character(character.to_string().into()), + WinitKey::Named(named) => match named { + NamedKey::Enter => Key::Enter, + NamedKey::Tab => Key::Tab, + NamedKey::Space => Key::Space, + NamedKey::Backspace => Key::Backspace, + NamedKey::Delete => Key::Delete, + NamedKey::Escape => Key::Escape, + NamedKey::ArrowUp => Key::ArrowUp, + NamedKey::ArrowDown => Key::ArrowDown, + NamedKey::ArrowLeft => Key::ArrowLeft, + NamedKey::ArrowRight => Key::ArrowRight, + NamedKey::Home => Key::Home, + NamedKey::End => Key::End, + NamedKey::PageUp => Key::PageUp, + NamedKey::PageDown => Key::PageDown, + NamedKey::Insert => Key::Insert, + NamedKey::F1 => Key::F(1), + NamedKey::F2 => Key::F(2), + NamedKey::F3 => Key::F(3), + NamedKey::F4 => Key::F(4), + NamedKey::F5 => Key::F(5), + NamedKey::F6 => Key::F(6), + NamedKey::F7 => Key::F(7), + NamedKey::F8 => Key::F(8), + NamedKey::F9 => Key::F(9), + NamedKey::F10 => Key::F(10), + NamedKey::F11 => Key::F(11), + NamedKey::F12 => Key::F(12), + _ => Key::Unidentified, + }, + _ => Key::Unidentified, + } +} + +fn cursor_icon(cursor: CursorStyle) -> CursorIcon { + match cursor { + CursorStyle::Default => CursorIcon::Default, + CursorStyle::Pointer => CursorIcon::Pointer, + CursorStyle::Text => CursorIcon::Text, + CursorStyle::Crosshair => CursorIcon::Crosshair, + CursorStyle::Move => CursorIcon::Move, + CursorStyle::NotAllowed => CursorIcon::NotAllowed, + CursorStyle::Grab => CursorIcon::Grab, + CursorStyle::Grabbing => CursorIcon::Grabbing, + CursorStyle::EwResize => CursorIcon::EwResize, + CursorStyle::NsResize => CursorIcon::NsResize, + CursorStyle::Wait => CursorIcon::Wait, + CursorStyle::Help => CursorIcon::Help, + } +} diff --git a/crates/shikra/src/style.rs b/crates/shikra/src/style.rs index fda514b..d2798e4 100644 --- a/crates/shikra/src/style.rs +++ b/crates/shikra/src/style.rs @@ -15,8 +15,8 @@ use pastey::paste; use crate::{ - AbsoluteLength, Corners, DefiniteLength, Edges, FontWeight, Length, LineHeight, Rgba, - SharedString, Size, TextAlign, TextStyleRefinement, px, relative, rems, + AbsoluteLength, Corners, CursorStyle, DefiniteLength, Edges, FontWeight, Length, LineHeight, + Rgba, SharedString, Size, TextAlign, TextStyleRefinement, px, relative, rems, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -100,6 +100,8 @@ pub struct Style { pub background: Option, pub border_color: Option, pub corner_radii: Corners>, + /// The mouse cursor over the element. Inherited by descendants. + pub cursor: Option, // Text, inherited by descendants pub text: TextStyleRefinement, @@ -131,7 +133,7 @@ macro_rules! for_each_field { flex_basis ); $f!(align_items, align_self, align_content, justify_content); - $f!(background, border_color); + $f!(background, border_color, cursor); $f!( corner_radii.top_left, corner_radii.top_right, @@ -418,6 +420,37 @@ impl Style { self.border_color = Some(color); self } + + // Interactivity + + pub fn cursor(mut self, cursor: CursorStyle) -> Self { + self.cursor = Some(cursor); + self + } +} + +/// Defines methods that set the cursor. +macro_rules! cursor_methods { + ($($name:ident => $cursor:ident),+ $(,)?) => { + impl Style { + $(pub fn $name(self) -> Self { self.cursor(CursorStyle::$cursor) })+ + } + }; +} + +cursor_methods! { + cursor_default => Default, + cursor_pointer => Pointer, + cursor_text => Text, + cursor_crosshair => Crosshair, + cursor_move => Move, + cursor_not_allowed => NotAllowed, + cursor_grab => Grab, + cursor_grabbing => Grabbing, + cursor_ew_resize => EwResize, + cursor_ns_resize => NsResize, + cursor_wait => Wait, + cursor_help => Help, } /// Defines a method that sets one or more fields to a length. diff --git a/crates/shikra/src/test.rs b/crates/shikra/src/test.rs new file mode 100644 index 0000000..c5eefc5 --- /dev/null +++ b/crates/shikra/src/test.rs @@ -0,0 +1,30 @@ +//! Helpers for tests. + +use std::cell::RefCell; +use std::rc::Rc; + +use crate::executor::ForegroundQueue; +use crate::{App, BackgroundExecutor}; + +/// An app without an event loop or platform windows. +pub(crate) struct TestApp { + pub(crate) app: Rc>, + _foreground_queue: ForegroundQueue, + _runtime: tokio::runtime::Runtime, +} + +pub(crate) fn test_app() -> TestApp { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let foreground_queue = ForegroundQueue::new(|| {}); + let app = App::create( + foreground_queue.executor(), + BackgroundExecutor::new(runtime.handle().clone()), + ); + TestApp { + app, + _foreground_queue: foreground_queue, + _runtime: runtime, + } +} diff --git a/crates/shikra/src/view.rs b/crates/shikra/src/view.rs index 79cecfd..6a24522 100644 --- a/crates/shikra/src/view.rs +++ b/crates/shikra/src/view.rs @@ -14,15 +14,17 @@ impl Element for Entity { type LayoutState = AnyElement; fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, AnyElement) { - let mut element = self.update(cx, |view, cx| view.render(window, cx).into_any_element()); - (element.request_layout(window, cx), element) + window.with_view_id(self.entity_id(), |window| { + let mut element = + self.update(cx, |view, cx| view.render(window, cx).into_any_element()); + (element.request_layout(window, cx), element) + }) } fn paint(&mut self, _: Bounds, element: &mut AnyElement, window: &mut Window, cx: &mut App) { element.paint(window, cx); } } - /// A view of any type. #[derive(Clone)] pub struct AnyView { @@ -61,8 +63,10 @@ impl Element for AnyView { type LayoutState = AnyElement; fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, AnyElement) { - let mut element = (self.render)(&self.entity, window, cx); - (element.request_layout(window, cx), element) + window.with_view_id(self.entity.entity_id(), |window| { + let mut element = (self.render)(&self.entity, window, cx); + (element.request_layout(window, cx), element) + }) } fn paint(&mut self, _: Bounds, element: &mut AnyElement, window: &mut Window, cx: &mut App) { diff --git a/crates/shikra/src/window.rs b/crates/shikra/src/window.rs index 292e22c..a59d91e 100644 --- a/crates/shikra/src/window.rs +++ b/crates/shikra/src/window.rs @@ -1,3 +1,5 @@ +mod dispatch; + use std::cell::RefCell; use std::collections::HashSet; use std::marker::PhantomData; @@ -14,6 +16,7 @@ use crate::{ IntoElement, LayoutId, Point, Rgba, SharedString, Size, Style, TextStyle, TextStyleRefinement, TextSystem, px, size, }; +pub(crate) use dispatch::{InputState, Listeners, Node}; /// Identifies a window within an [`App`](crate::App). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -100,6 +103,7 @@ pub struct Window { text_style_stack: Vec, text_layout_cache: TextLayoutCache, pub(crate) scene: Scene, + pub(crate) input: InputState, } impl Window { @@ -117,6 +121,7 @@ impl Window { text_style_stack: Vec::new(), text_layout_cache: TextLayoutCache::default(), scene: Scene::default(), + input: InputState::default(), } } @@ -279,6 +284,7 @@ impl Window { self.layout.clear(); self.text_layout_cache.start_frame(); self.scene.clear(); + self.start_input_frame(); let mut root = self .root_view @@ -303,6 +309,7 @@ impl Window { root.paint(self, cx); self.accessed_entities = cx.stop_tracking_accesses(); + self.finish_input_frame(cx); } } diff --git a/crates/shikra/src/window/dispatch.rs b/crates/shikra/src/window/dispatch.rs new file mode 100644 index 0000000..9a4325e --- /dev/null +++ b/crates/shikra/src/window/dispatch.rs @@ -0,0 +1,856 @@ +//! Hit testing, event dispatch, hover and pressed state, and focus. +//! +//! While painting, every element that takes part in input (for now, every +//! div) adds a [`Node`] to the frame, recording its bounds, its parent, and +//! its listeners. This mirrors the DOM: the element under the mouse is the +//! last-painted node containing it, it and its ancestors are hovered, and +//! events bubble from it up through its ancestors. Keyboard events do the +//! same from the focused element. + +use std::iter; +use std::mem; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use crate::element::{GlobalElementId, LocalElementId}; +use crate::focus::WeakFocusHandle; +use crate::input::PlatformInput; +use crate::{ + App, Bounds, ClickEvent, CursorStyle, ElementId, EntityId, FocusHandle, FocusId, Key, + KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, Point, ScrollWheelEvent, Window, +}; + +/// How long after one click another counts towards a double click. +const MULTI_CLICK_INTERVAL: Duration = Duration::from_millis(500); +/// How far the mouse can move between clicks of a double click. +const MULTI_CLICK_DISTANCE: f32 = 4.; + +pub(crate) type Listener = Rc; + +/// An element's event listeners. +#[derive(Default)] +pub(crate) struct Listeners { + pub(crate) mouse_down: Vec>, + pub(crate) mouse_up: Vec>, + pub(crate) mouse_move: Vec>, + pub(crate) scroll_wheel: Vec>, + pub(crate) click: Vec>, + pub(crate) hover: Vec>, + pub(crate) key_down: Vec>, + pub(crate) key_up: Vec>, +} + +/// An element that takes part in input, recorded while painting. +pub(crate) struct Node { + pub(crate) id: GlobalElementId, + pub(crate) bounds: Bounds, + pub(crate) cursor: Option, + pub(crate) focus: Option, + pub(crate) tab_stop: bool, + /// Whether the element looks different when hovered, so a change needs + /// a redraw. + pub(crate) hover_styled: bool, + /// Whether the element looks different when pressed. + pub(crate) active_styled: bool, + pub(crate) listeners: Listeners, + /// Set when the node is inserted. + parent: Option, +} + +impl Node { + pub(crate) fn new(id: GlobalElementId, bounds: Bounds) -> Self { + Self { + id, + bounds, + cursor: None, + focus: None, + tab_stop: true, + hover_styled: false, + active_styled: false, + listeners: Listeners::default(), + parent: None, + } + } +} + +/// The nodes painted in a frame, in paint order. +#[derive(Default)] +pub(crate) struct Frame { + nodes: Vec, + /// The nodes whose children are being painted, innermost last. + stack: Vec, +} + +impl Frame { + /// The topmost node containing `position` and its ancestors, innermost + /// first. + fn hit_test(&self, position: Point) -> Vec { + let target = self + .nodes + .iter() + .rposition(|node| node.bounds.contains(position)); + self.path(target) + } + + /// `node` and its ancestors, innermost first. + fn path(&self, node: Option) -> Vec { + iter::successors(node, |&ix| self.nodes[ix].parent).collect() + } + + fn focused_node(&self, focus: FocusId) -> Option { + self.nodes + .iter() + .position(|node| node.focus.as_ref().is_some_and(|f| f.id == focus)) + } + + fn ids(&self, path: &[usize]) -> Vec { + path.iter().map(|&ix| self.nodes[ix].id).collect() + } +} + +/// The state of the window's input, carried between frames. +#[derive(Default)] +pub(crate) struct InputState { + /// The frame that was last drawn, which events are dispatched against. + rendered_frame: Frame, + /// The frame being drawn. + next_frame: Frame, + /// The elements being laid out, innermost last, with the index their next + /// child will get. + element_id_stack: Vec<(GlobalElementId, usize)>, + mouse_position: Option, + modifiers: Modifiers, + /// The element under the mouse and its ancestors, innermost first. + hovered: Vec, + /// The element the left mouse button was pressed on and its ancestors. + pressed: Vec, + pressed_button: Option, + last_mouse_down: Option<(Instant, Point, MouseButton, usize)>, + focused: Option, + /// Whether the user is navigating with the keyboard, so focus should be + /// visible. + focus_visible: bool, + pub(crate) cursor: CursorStyle, + pub(crate) cursor_changed: bool, + propagation_stopped: bool, + default_prevented: bool, +} + +impl Window { + /// The position of the mouse, if it's over the window. + pub fn mouse_position(&self) -> Option { + self.input.mouse_position + } + + /// The modifier keys currently held. + pub fn modifiers(&self) -> Modifiers { + self.input.modifiers + } + + /// Stops the event being handled from bubbling up to the element's + /// ancestors. Other listeners on the same element still run. + pub fn stop_propagation(&mut self) { + self.input.propagation_stopped = true; + } + + /// Prevents the window's default response to the event being handled: + /// moving focus on mouse down, and to the next element on Tab. + pub fn prevent_default(&mut self) { + self.input.default_prevented = true; + } + + /// The focused element's focus ID, if any. + pub fn focused(&self) -> Option { + self.input + .focused + .as_ref() + .filter(|focused| focused.is_alive()) + .map(|focused| focused.id) + } + + pub(crate) fn is_focus_visible(&self) -> bool { + self.input.focus_visible + } + + /// Moves keyboard focus to `handle`. + pub fn focus(&mut self, handle: &FocusHandle) { + self.set_focus(Some(handle.downgrade())); + } + + /// Removes keyboard focus from whatever has it. + pub fn blur(&mut self) { + self.set_focus(None); + } + + fn set_focus(&mut self, focus: Option) { + let id = focus.as_ref().map(|focus| focus.id); + if id != self.focused() { + self.input.focused = focus; + self.refresh(); + } + } + + /// The focus IDs of the focused element and its ancestors. + pub(crate) fn focus_path(&self) -> Vec { + let frame = &self.input.rendered_frame; + let focused = self.focused().and_then(|focus| frame.focused_node(focus)); + frame + .path(focused) + .into_iter() + .filter_map(|ix| frame.nodes[ix].focus.as_ref().map(|f| f.id)) + .collect() + } + + /// Moves focus to the next (or previous) focusable element in the order + /// they were painted, wrapping around. + pub fn focus_next(&mut self, backwards: bool) { + let stops: Vec = self + .input + .rendered_frame + .nodes + .iter() + .filter(|node| node.tab_stop) + .filter_map(|node| node.focus.clone()) + .collect(); + if stops.is_empty() { + return; + } + + let current = self + .focused() + .and_then(|focused| stops.iter().position(|stop| stop.id == focused)); + let next = match (current, backwards) { + (Some(ix), false) => (ix + 1) % stops.len(), + (Some(ix), true) => (ix + stops.len() - 1) % stops.len(), + (None, false) => 0, + (None, true) => stops.len() - 1, + }; + self.set_focus(Some(stops[next].clone())); + } + + // Element identity + + /// Runs `f` as an element with the given ID, or the next position among + /// its siblings if it doesn't have one, passing it its global ID. + pub(crate) fn with_element_id( + &mut self, + id: Option<&ElementId>, + f: impl FnOnce(&mut Self, GlobalElementId) -> R, + ) -> R { + let stack = &mut self.input.element_id_stack; + let (parent, next_index) = match stack.last_mut() { + Some((parent, next_index)) => (*parent, Some(next_index)), + None => (GlobalElementId::default(), None), + }; + let local = match id { + Some(id) => LocalElementId::Id(id), + None => { + let next_index = next_index.map_or(0, |next_index| { + *next_index += 1; + *next_index - 1 + }); + LocalElementId::Index(next_index) + } + }; + self.push_element_id(GlobalElementId::new(parent, local), f) + } + + /// Runs `f` as a view, whose elements are identified relative to the view + /// rather than their position, so they keep their identity when the view + /// moves among its siblings. + pub(crate) fn with_view_id( + &mut self, + entity_id: EntityId, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + let parent = self + .input + .element_id_stack + .last() + .map_or_else(GlobalElementId::default, |(parent, _)| *parent); + let id = GlobalElementId::new(parent, LocalElementId::View(entity_id)); + self.push_element_id(id, |window, _| f(window)) + } + + fn push_element_id( + &mut self, + id: GlobalElementId, + f: impl FnOnce(&mut Self, GlobalElementId) -> R, + ) -> R { + self.input.element_id_stack.push((id, 0)); + let result = f(self, id); + self.input.element_id_stack.pop(); + result + } + + pub(crate) fn is_element_hovered(&self, id: GlobalElementId) -> bool { + self.input.hovered.contains(&id) + } + + pub(crate) fn is_element_active(&self, id: GlobalElementId) -> bool { + self.input.pressed.contains(&id) + } + + // Painting + + /// Adds a node for an element being painted, and runs `f` to paint its + /// children. + pub(crate) fn with_node(&mut self, mut node: Node, f: impl FnOnce(&mut Self) -> R) -> R { + let frame = &mut self.input.next_frame; + node.parent = frame.stack.last().copied(); + frame.stack.push(frame.nodes.len()); + frame.nodes.push(node); + let result = f(self); + self.input.next_frame.stack.pop(); + result + } + + pub(super) fn start_input_frame(&mut self) { + self.input.next_frame = Frame::default(); + self.input.element_id_stack.clear(); + } + + /// Makes the frame just painted the one events are dispatched against. + pub(super) fn finish_input_frame(&mut self, cx: &mut App) { + self.input.rendered_frame = mem::take(&mut self.input.next_frame); + // The layout may have moved things under the mouse. + self.update_hover(cx); + } + + // Dispatch + + pub(crate) fn dispatch_input(&mut self, input: PlatformInput, cx: &mut App) { + match input { + PlatformInput::MouseMove(position) => { + self.input.mouse_position = Some(position); + self.update_hover(cx); + let event = MouseMoveEvent { + position, + pressed_button: self.input.pressed_button, + modifiers: self.input.modifiers, + }; + let path = self.input.rendered_frame.hit_test(position); + self.dispatch_on_path(&path, &event, |l| &l.mouse_move, cx); + } + PlatformInput::MouseExited => { + self.input.mouse_position = None; + self.update_hover(cx); + } + PlatformInput::MouseDown(button) => { + let Some(position) = self.input.mouse_position else { + return; + }; + self.mouse_down(button, position, cx); + } + PlatformInput::MouseUp(button) => { + let Some(position) = self.input.mouse_position else { + return; + }; + self.mouse_up(button, position, cx); + } + PlatformInput::ScrollWheel(delta) => { + let Some(position) = self.input.mouse_position else { + return; + }; + let event = ScrollWheelEvent { + position, + delta, + modifiers: self.input.modifiers, + }; + let path = self.input.rendered_frame.hit_test(position); + self.dispatch_on_path(&path, &event, |l| &l.scroll_wheel, cx); + } + PlatformInput::KeyDown { + key, + text, + is_repeat, + } => self.key_down(key, text, is_repeat, cx), + PlatformInput::KeyUp(key) => { + let event = KeyUpEvent { + keystroke: Keystroke { + key, + modifiers: self.input.modifiers, + }, + }; + let path = self.key_path(); + self.dispatch_on_path(&path, &event, |l| &l.key_up, cx); + } + PlatformInput::ModifiersChanged(modifiers) => { + self.input.modifiers = modifiers; + } + PlatformInput::WindowDeactivated => { + // We won't hear about keys released while another window has + // focus. + self.input.modifiers = Modifiers::default(); + } + } + } + + fn mouse_down(&mut self, button: MouseButton, position: Point, cx: &mut App) { + self.set_focus_visible(false); + + let now = Instant::now(); + let click_count = match self.input.last_mouse_down { + Some((time, last_position, last_button, count)) + if last_button == button + && now - time <= MULTI_CLICK_INTERVAL + && distance(last_position, position) <= MULTI_CLICK_DISTANCE => + { + count + 1 + } + _ => 1, + }; + self.input.last_mouse_down = Some((now, position, button, click_count)); + self.input.pressed_button = Some(button); + + let path = self.input.rendered_frame.hit_test(position); + let event = MouseDownEvent { + button, + position, + modifiers: self.input.modifiers, + click_count, + }; + self.input.default_prevented = false; + self.dispatch_on_path(&path, &event, |l| &l.mouse_down, cx); + + if button == MouseButton::Left { + let pressed = self.input.rendered_frame.ids(&path); + self.set_pressed(pressed); + } + + // Like the web, focus the innermost focusable element under the + // mouse, or nothing if there isn't one. + if !self.input.default_prevented { + let frame = &self.input.rendered_frame; + let focus = path.iter().find_map(|&ix| frame.nodes[ix].focus.clone()); + self.set_focus(focus); + } + } + + fn mouse_up(&mut self, button: MouseButton, position: Point, cx: &mut App) { + let (_, _, _, click_count) = + self.input + .last_mouse_down + .unwrap_or((Instant::now(), position, button, 1)); + if self.input.pressed_button == Some(button) { + self.input.pressed_button = None; + } + + let path = self.input.rendered_frame.hit_test(position); + let event = MouseUpEvent { + button, + position, + modifiers: self.input.modifiers, + click_count, + }; + self.dispatch_on_path(&path, &event, |l| &l.mouse_up, cx); + + if button == MouseButton::Left { + let pressed = self.set_pressed(Vec::new()); + // Like the web, the click goes to the innermost element that + // both the press and release were in, and bubbles from there. + let frame = &self.input.rendered_frame; + let targets: Vec = path + .into_iter() + .filter(|&ix| pressed.contains(&frame.nodes[ix].id)) + .collect(); + let event = ClickEvent { + position, + modifiers: self.input.modifiers, + click_count, + }; + self.dispatch_on_path(&targets, &event, |l| &l.click, cx); + } + } + + fn key_down( + &mut self, + key: Key, + text: Option, + is_repeat: bool, + cx: &mut App, + ) { + let modifiers = self.input.modifiers; + // Shortcuts aren't navigation, so they don't make focus visible. + if !(modifiers.control || modifiers.alt || modifiers.platform) { + self.set_focus_visible(true); + } + + let event = KeyDownEvent { + keystroke: Keystroke { + key: key.clone(), + modifiers, + }, + text, + is_repeat, + }; + let path = self.key_path(); + self.input.default_prevented = false; + self.dispatch_on_path(&path, &event, |l| &l.key_down, cx); + + let only_shift = Modifiers { + shift: false, + ..modifiers + } + .is_empty(); + if key == Key::Tab && only_shift && !self.input.default_prevented { + self.focus_next(modifiers.shift); + } + } + + /// The focused element and its ancestors, or the root element if nothing + /// that was drawn is focused. + fn key_path(&self) -> Vec { + let frame = &self.input.rendered_frame; + let focused = self.focused().and_then(|focus| frame.focused_node(focus)); + let root = (!frame.nodes.is_empty()).then_some(0); + frame.path(focused.or(root)) + } + + /// Calls each node's listeners in turn, until one stops propagation. + fn dispatch_on_path( + &mut self, + path: &[usize], + event: &E, + listeners: impl Fn(&Listeners) -> &Vec>, + cx: &mut App, + ) { + self.input.propagation_stopped = false; + for &ix in path { + // Clone the listeners, since they can mutate the window. + let node_listeners = listeners(&self.input.rendered_frame.nodes[ix].listeners).clone(); + for listener in node_listeners { + listener(event, self, cx); + } + if self.input.propagation_stopped { + break; + } + } + } + + /// Updates which elements are hovered, and the cursor, from the mouse + /// position. + fn update_hover(&mut self, cx: &mut App) { + let frame = &self.input.rendered_frame; + let path = self + .input + .mouse_position + .map(|position| frame.hit_test(position)) + .unwrap_or_default(); + + let cursor = path + .iter() + .find_map(|&ix| frame.nodes[ix].cursor) + .unwrap_or_default(); + if cursor != self.input.cursor { + self.input.cursor = cursor; + self.input.cursor_changed = true; + } + + let hovered = frame.ids(&path); + if hovered == self.input.hovered { + return; + } + let previously_hovered = mem::replace(&mut self.input.hovered, hovered); + + let frame = &self.input.rendered_frame; + let changed: Vec<(usize, bool)> = frame + .nodes + .iter() + .enumerate() + .filter_map(|(ix, node)| { + let was_hovered = previously_hovered.contains(&node.id); + let is_hovered = self.input.hovered.contains(&node.id); + (was_hovered != is_hovered).then_some((ix, is_hovered)) + }) + .collect(); + if changed.iter().any(|&(ix, _)| frame.nodes[ix].hover_styled) { + self.refresh(); + } + for (ix, hovered) in changed { + for listener in self.input.rendered_frame.nodes[ix].listeners.hover.clone() { + listener(&hovered, self, cx); + } + } + } + + /// Sets the pressed elements, returning the previous ones. + fn set_pressed(&mut self, pressed: Vec) -> Vec { + let previous = mem::replace(&mut self.input.pressed, pressed); + let needs_refresh = self.input.rendered_frame.nodes.iter().any(|node| { + node.active_styled + && (previous.contains(&node.id) != self.input.pressed.contains(&node.id)) + }); + if needs_refresh { + self.refresh(); + } + previous + } + + fn set_focus_visible(&mut self, focus_visible: bool) { + if self.input.focus_visible != focus_visible { + self.input.focus_visible = focus_visible; + if self.focused().is_some() { + self.refresh(); + } + } + } +} + +fn distance(a: Point, b: Point) -> f32 { + (a.x - b.x).hypot(a.y - b.y) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use crate::input::PlatformInput; + use crate::test::test_app; + use crate::{ + App, Context, FocusHandle, IntoElement, Key, Modifiers, MouseButton, ParentElement, Render, + Window, WindowId, WindowOptions, div, point, px, rgb, + }; + + type Log = Rc>>; + + fn draw(cx: &mut App, window: WindowId) { + cx.update_window(window, |window, cx| window.draw(cx)); + } + + fn dispatch(cx: &mut App, window: WindowId, input: PlatformInput) { + cx.update_window(window, |window, cx| window.dispatch_input(input, cx)); + } + + fn click(cx: &mut App, window: WindowId, x: f32, y: f32) { + dispatch(cx, window, PlatformInput::MouseMove(point(x, y))); + dispatch(cx, window, PlatformInput::MouseDown(MouseButton::Left)); + dispatch(cx, window, PlatformInput::MouseUp(MouseButton::Left)); + } + + fn press(cx: &mut App, window: WindowId, key: Key) { + dispatch( + cx, + window, + PlatformInput::KeyDown { + key, + text: None, + is_repeat: false, + }, + ); + } + + /// Two 100×100 squares side by side, logging their clicks. + struct Squares { + log: Log, + stop_propagation: bool, + } + + impl Render for Squares { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let listener = |name: &'static str, log: &Log, stop_propagation: bool| { + let log = log.clone(); + move |_: &_, window: &mut Window, _: &mut App| { + log.borrow_mut().push(name); + if stop_propagation { + window.stop_propagation(); + } + } + }; + let square = || div().style(|s| s.w(px(100.)).h(px(100.))); + div() + .style(|s| s.size_full().flex()) + .on_click(listener("root", &self.log, false)) + .child(square().on_click(listener("a", &self.log, self.stop_propagation))) + .child(square().on_click(listener("b", &self.log, false))) + } + } + + #[test] + fn clicks_bubble_from_the_element_pressed_and_released_on() { + let test = test_app(); + let cx = &mut *test.app.borrow_mut(); + let log = Log::default(); + let window = cx + .open_window(WindowOptions::default(), |_, cx| { + cx.new(|_| Squares { + log: log.clone(), + stop_propagation: false, + }) + }) + .id(); + draw(cx, window); + + click(cx, window, 50., 50.); + assert_eq!(log.take(), ["a", "root"]); + + click(cx, window, 150., 50.); + assert_eq!(log.take(), ["b", "root"]); + + click(cx, window, 250., 50.); + assert_eq!(log.take(), ["root"]); + + // Pressing on one square and releasing on the other clicks their + // common ancestor. + dispatch(cx, window, PlatformInput::MouseMove(point(50., 50.))); + dispatch(cx, window, PlatformInput::MouseDown(MouseButton::Left)); + dispatch(cx, window, PlatformInput::MouseMove(point(150., 50.))); + dispatch(cx, window, PlatformInput::MouseUp(MouseButton::Left)); + assert_eq!(log.take(), ["root"]); + } + + #[test] + fn stopping_propagation_skips_ancestors() { + let test = test_app(); + let cx = &mut *test.app.borrow_mut(); + let log = Log::default(); + let window = cx + .open_window(WindowOptions::default(), |_, cx| { + cx.new(|_| Squares { + log: log.clone(), + stop_propagation: true, + }) + }) + .id(); + draw(cx, window); + + click(cx, window, 50., 50.); + assert_eq!(log.take(), ["a"]); + } + + struct HoverSquares; + + impl Render for HoverSquares { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .style(|s| s.size_full().flex()) + .child( + div() + .style(|s| s.w(px(100.)).h(px(100.)).bg(rgb(0x000000))) + .hover(|s| s.bg(rgb(0xffffff))), + ) + .child(div().style(|s| s.w(px(100.)).h(px(100.)).bg(rgb(0x000000)))) + } + } + + #[test] + fn hover_styles_redraw_when_the_hovered_element_changes() { + let test = test_app(); + let cx = &mut *test.app.borrow_mut(); + let window = cx + .open_window(WindowOptions::default(), |_, cx| cx.new(|_| HoverSquares)) + .id(); + let needs_redraw = |cx: &mut App| cx.windows[&window].needs_redraw; + let first_background = |cx: &mut App| cx.windows[&window].scene.quads[0].background; + draw(cx, window); + assert_eq!(first_background(cx), [0., 0., 0., 1.]); + + // Hovering the square without a hover style doesn't redraw. + dispatch(cx, window, PlatformInput::MouseMove(point(150., 50.))); + assert!(!needs_redraw(cx)); + + dispatch(cx, window, PlatformInput::MouseMove(point(50., 50.))); + assert!(needs_redraw(cx)); + draw(cx, window); + assert_eq!(first_background(cx), [1., 1., 1., 1.]); + + // Moving within the square doesn't redraw. + dispatch(cx, window, PlatformInput::MouseMove(point(60., 60.))); + assert!(!needs_redraw(cx)); + + dispatch(cx, window, PlatformInput::MouseExited); + assert!(needs_redraw(cx)); + draw(cx, window); + assert_eq!(first_background(cx), [0., 0., 0., 1.]); + } + + /// Two focusable squares, logging the keys pressed in them. + struct Fields { + first: FocusHandle, + second: FocusHandle, + log: Log, + } + + impl Render for Fields { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let field = |name: &'static str, handle: &FocusHandle, log: &Log| { + let log = log.clone(); + div() + .style(|s| s.w(px(100.)).h(px(100.))) + .track_focus(handle) + .on_key_down(move |_, _, _| log.borrow_mut().push(name)) + }; + let log = self.log.clone(); + div() + .style(|s| s.size_full().flex()) + .on_key_down(move |_, _, _| log.borrow_mut().push("root")) + .child(field("first", &self.first, &self.log)) + .child(field("second", &self.second, &self.log)) + } + } + + #[test] + fn focus_moves_on_click_and_tab_and_receives_keys() { + let test = test_app(); + let cx = &mut *test.app.borrow_mut(); + let log = Log::default(); + let (first, second) = (cx.focus_handle(), cx.focus_handle()); + let window = cx + .open_window(WindowOptions::default(), |_, cx| { + cx.new(|_| Fields { + first: first.clone(), + second: second.clone(), + log: log.clone(), + }) + }) + .id(); + let focused = |cx: &mut App| cx.windows[&window].focused(); + draw(cx, window); + + // Without focus, keys go to the root. + press(cx, window, Key::Enter); + assert_eq!(log.take(), ["root"]); + + click(cx, window, 50., 50.); + assert_eq!(focused(cx), Some(first.id())); + press(cx, window, Key::Enter); + assert_eq!(log.take(), ["first", "root"]); + + press(cx, window, Key::Tab); + assert_eq!(focused(cx), Some(second.id())); + press(cx, window, Key::Tab); + assert_eq!(focused(cx), Some(first.id())); + dispatch( + cx, + window, + PlatformInput::ModifiersChanged(Modifiers { + shift: true, + ..Default::default() + }), + ); + press(cx, window, Key::Tab); + assert_eq!(focused(cx), Some(second.id())); + log.take(); + + // Clicking something that isn't focusable blurs. + click(cx, window, 250., 50.); + assert_eq!(focused(cx), None); + } + + #[test] + fn focus_is_released_with_its_last_handle() { + let test = test_app(); + let cx = &mut *test.app.borrow_mut(); + let window = cx + .open_window(WindowOptions::default(), |_, cx| cx.new(|_| HoverSquares)) + .id(); + + let handle = cx.focus_handle(); + cx.update_window(window, |window, _| window.focus(&handle)); + assert!(handle.is_focused(&cx.windows[&window])); + drop(handle); + assert_eq!(cx.windows[&window].focused(), None); + } +} -- knotmirror 0.0.1