Files
ruin/lib/ruin_app/src/lib.rs

2559 lines
78 KiB
Rust

//! Minimal app/runtime glue for RUIN application experiments.
//!
//! This crate is intentionally low-level. It is the substrate that a future proc-macro-driven
//! component system can expand to, not the final ergonomic authoring API.
extern crate self as ruin_app;
use std::any::{Any, TypeId, type_name};
use std::cell::{Cell as StdCell, RefCell};
use std::collections::HashMap;
use std::error::Error;
use std::future::Future;
use std::iter;
use std::time::Instant;
use std::marker::PhantomData;
use std::rc::Rc;
use ruin_reactivity::effect;
use ruin_runtime::queue_future;
use ruin_ui::{
Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree,
KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutCache, LayoutSnapshot, PlatformEvent,
PointerButton, PointerEvent, PointerEventKind, PointerRouter, Quad, RoutedPointerEvent,
RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextSpan, TextSpanWeight, TextStyle,
TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate,
layout_snapshot_with_cache,
};
use ruin_ui_platform_wayland::start_wayland_ui;
pub use ResourceState::{Pending, Ready};
pub use ruin_app_proc_macros::{component, context_provider, view};
pub type Result<T> = std::result::Result<T, Box<dyn Error>>;
#[derive(Clone, Debug)]
pub struct Window {
spec: WindowSpec,
}
impl Window {
pub fn new() -> Self {
Self {
spec: WindowSpec::new("RUIN App"),
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.spec.title = title.into();
self
}
pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
self.spec = self.spec.app_id(app_id);
self
}
pub fn size(mut self, width: f32, height: f32) -> Self {
self.spec = self.spec.requested_inner_size(UiSize::new(width, height));
self
}
}
impl Default for Window {
fn default() -> Self {
Self::new()
}
}
pub struct App {
window: Window,
}
impl App {
pub fn new() -> Self {
Self {
window: Window::new(),
}
}
pub fn window(mut self, window: Window) -> Self {
self.window = window;
self
}
pub fn mount<M: Mountable>(self, root: M) -> MountedApp<M> {
MountedApp {
window: self.window,
root: Rc::new(root),
render_state: Rc::new(RenderState::default()),
}
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
#[doc(hidden)]
pub fn __render_mountable_for_test<M: Mountable>(mountable: &M) -> View {
render_with_context(Rc::new(RenderState::default()), || mountable.render()).view
}
pub trait Mountable: 'static {
fn render(&self) -> View;
}
pub trait Component: 'static {
type Builder;
fn builder() -> Self::Builder;
fn render(&self) -> View;
}
pub trait ContextKey: 'static {
type Value: Clone + 'static;
}
impl<T: Component> Mountable for T {
fn render(&self) -> View {
Component::render(self)
}
}
impl Mountable for View {
fn render(&self) -> View {
self.clone()
}
}
impl Mountable for Element {
fn render(&self) -> View {
View::from_element(self.clone())
}
}
pub struct MountedApp<M: Mountable> {
window: Window,
root: Rc<M>,
render_state: Rc<RenderState>,
}
impl<M: Mountable> MountedApp<M> {
pub async fn run(self) -> Result<()> {
let MountedApp {
window: app_window,
root,
render_state,
} = self;
let mut ui = start_wayland_ui();
let window = ui.create_window(app_window.spec.clone())?;
let initial_viewport = app_window
.spec
.requested_inner_size
.unwrap_or(UiSize::new(960.0, 640.0));
let viewport = ruin_reactivity::cell(initial_viewport);
let scene_version = StdCell::new(0_u64);
let text_system = Rc::new(RefCell::new(TextSystem::new()));
let layout_cache = Rc::new(RefCell::new(LayoutCache::new()));
let interaction_tree = Rc::new(RefCell::new(None::<InteractionTree>));
let bindings = Rc::new(RefCell::new(EventBindings::default()));
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
let current_title = Rc::new(RefCell::new(None::<String>));
let mut input_state = InputState::new();
let mut pointer_router = PointerRouter::new();
let _scene_effect = effect({
let window = window.clone();
let viewport = viewport.clone();
let text_system = Rc::clone(&text_system);
let layout_cache = Rc::clone(&layout_cache);
let interaction_tree = Rc::clone(&interaction_tree);
let bindings = Rc::clone(&bindings);
let shortcuts = Rc::clone(&shortcuts);
let current_title = Rc::clone(&current_title);
let root = Rc::clone(&root);
let render_state = Rc::clone(&render_state);
let text_selection = Rc::clone(&input_state.text_selection);
move || {
let viewport = viewport.get();
let version = scene_version.get().wrapping_add(1);
scene_version.set(version);
let _ = text_selection.version.get();
let t_effect = Instant::now();
let render_output = render_with_context(Rc::clone(&render_state), || root.render());
if render_output.side_effects.window_title != *current_title.borrow() {
if let Some(title) = &render_output.side_effects.window_title {
window
.update(WindowUpdate::new().title(title.clone()))
.expect("window should remain alive while the app is running");
}
*current_title.borrow_mut() = render_output.side_effects.window_title.clone();
}
let render_us = t_effect.elapsed().as_micros();
let t_layout = Instant::now();
let LayoutSnapshot {
mut scene,
interaction_tree: next_interaction_tree,
} = layout_snapshot_with_cache(
version,
viewport,
render_output.view.element(),
&mut text_system.borrow_mut(),
&mut layout_cache.borrow_mut(),
);
let layout_us = t_layout.elapsed().as_micros();
let effect_us = t_effect.elapsed().as_micros();
tracing::debug!(
target: "ruin_app::resize",
version,
width = viewport.width,
height = viewport.height,
render_us,
layout_us,
effect_us,
"scene effect complete, sending ReplaceScene"
);
apply_text_selection_overlay(&mut scene, *text_selection.selection.borrow());
*interaction_tree.borrow_mut() = Some(next_interaction_tree);
*bindings.borrow_mut() = render_output.view.bindings;
*shortcuts.borrow_mut() = render_output.side_effects.shortcuts.clone();
window
.replace_scene(scene)
.expect("window should remain alive while the app is running");
}
});
loop {
let Some(event) = ui.next_event().await else {
break;
};
for event in iter::once(event).chain(ui.take_pending_events()) {
match event {
PlatformEvent::Configured {
window_id,
configuration,
} if window_id == window.id() => {
tracing::debug!(
target: "ruin_app::resize",
width = configuration.actual_inner_size.width,
height = configuration.actual_inner_size.height,
"app received Configured, queuing layout effect"
);
let _ = viewport.set(configuration.actual_inner_size);
}
PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {
Self::handle_pointer_event(
&window,
&interaction_tree,
&bindings,
&mut pointer_router,
&mut input_state,
event,
)?;
}
PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => {
Self::handle_keyboard_event(
&interaction_tree,
&bindings,
&shortcuts,
&input_state,
event,
)?;
}
PlatformEvent::CloseRequested { window_id } if window_id == window.id() => {
let _ = window.update(WindowUpdate::new().open(false));
}
PlatformEvent::Closed { window_id } if window_id == window.id() => {
ui.shutdown()?;
return Ok(());
}
_ => {}
}
}
}
ui.shutdown()?;
Ok(())
}
fn handle_pointer_event(
window: &WindowController,
interaction_tree: &RefCell<Option<InteractionTree>>,
bindings: &RefCell<EventBindings>,
pointer_router: &mut PointerRouter,
input_state: &mut InputState,
event: PointerEvent,
) -> Result<()> {
if matches!(
event.kind,
PointerEventKind::Down {
button: PointerButton::Primary | PointerButton::Middle,
}
) {
let interaction_tree = interaction_tree.borrow();
if let Some(interaction_tree) = interaction_tree.as_ref() {
input_state.focused_element = focused_element_for_pointer(interaction_tree, &event);
}
}
let routed = {
let interaction_tree = interaction_tree.borrow();
let Some(interaction_tree) = interaction_tree.as_ref() else {
return Ok(());
};
pointer_router.route(interaction_tree, event)
};
{
let interaction_tree = interaction_tree.borrow();
let Some(interaction_tree) = interaction_tree.as_ref() else {
return Ok(());
};
let hovered_targets = pointer_router.hovered_targets();
for routed_event in &routed {
bindings
.borrow()
.dispatch(routed_event, interaction_tree, hovered_targets);
Self::handle_text_selection_event(
window,
interaction_tree,
routed_event,
&input_state.text_selection,
)?;
}
}
let next_cursor = pointer_router
.hovered_targets()
.last()
.map(|target| target.cursor)
.unwrap_or(CursorIcon::Default);
if next_cursor != input_state.current_cursor {
input_state.current_cursor = next_cursor;
window.set_cursor_icon(next_cursor)?;
}
Ok(())
}
fn handle_keyboard_event(
interaction_tree: &RefCell<Option<InteractionTree>>,
bindings: &RefCell<EventBindings>,
shortcuts: &RefCell<Vec<ShortcutBinding>>,
input_state: &InputState,
event: KeyboardEvent,
) -> Result<()> {
let interaction_tree = interaction_tree.borrow();
let Some(interaction_tree) = interaction_tree.as_ref() else {
return Ok(());
};
let shortcut_bindings = shortcuts.borrow().clone();
let mut consumed = false;
for shortcut in shortcut_bindings {
if shortcut.matches(&event, input_state.focused_element, interaction_tree) {
shortcut.trigger(interaction_tree);
consumed = true;
}
}
if consumed {
return Ok(());
}
bindings
.borrow()
.dispatch_key(input_state.focused_element, &event, interaction_tree);
Ok(())
}
fn handle_text_selection_event(
window: &WindowController,
interaction_tree: &InteractionTree,
event: &RoutedPointerEvent,
text_selection: &TextSelectionState,
) -> Result<()> {
let mut selection_changed = false;
match event.kind {
RoutedPointerEventKind::Down {
button: PointerButton::Primary,
} => {
if let Some(hit) = interaction_tree.text_hit_test(event.position)
&& let Some(element_id) = hit.target.element_id
{
let next = Some(TextSelection {
element_id,
anchor: hit.byte_offset,
focus: hit.byte_offset,
});
if *text_selection.selection.borrow() != next {
*text_selection.selection.borrow_mut() = next;
selection_changed = true;
}
*text_selection.drag.borrow_mut() = Some(TextSelectionDrag {
element_id,
anchor: hit.byte_offset,
});
} else {
selection_changed = text_selection.selection.borrow_mut().take().is_some();
let _ = text_selection.drag.borrow_mut().take();
}
}
RoutedPointerEventKind::Move => {
let Some(drag) = *text_selection.drag.borrow() else {
return Ok(());
};
let Some(text) = interaction_tree.text_for_element(drag.element_id) else {
return Ok(());
};
let next = Some(TextSelection {
element_id: drag.element_id,
anchor: drag.anchor,
focus: text.byte_offset_for_position(event.position),
});
if *text_selection.selection.borrow() != next {
*text_selection.selection.borrow_mut() = next;
selection_changed = true;
}
}
RoutedPointerEventKind::Up {
button: PointerButton::Primary,
} => {
if text_selection.drag.borrow_mut().take().is_some() {
selection_changed = true;
}
}
_ => {}
}
if selection_changed {
text_selection.version.update(|value| *value += 1);
sync_primary_selection(window, interaction_tree, *text_selection.selection.borrow())?;
}
Ok(())
}
}
#[derive(Clone, Default)]
pub struct View {
element: Element,
bindings: EventBindings,
}
impl View {
pub fn from_element(element: Element) -> Self {
Self {
element,
bindings: EventBindings::default(),
}
}
pub fn element(&self) -> &Element {
&self.element
}
fn with_press_handler(mut self, element_id: ElementId, handler: PressHandler) -> Self {
self.bindings.on_press.insert(element_id, handler);
self
}
fn with_scroll_handler(mut self, element_id: ElementId, handler: ScrollHandler) -> Self {
self.bindings.on_scroll.insert(element_id, handler);
self
}
fn with_key_handler(mut self, element_id: ElementId, handler: KeyHandler) -> Self {
self.bindings.on_key.insert(element_id, handler);
self
}
fn from_container(element: Element, children: Vec<View>) -> Self {
let mut composed = Self::from_element(element);
let mut element = composed.element;
for child in children {
element = element.child(child.element);
composed.bindings.extend(child.bindings);
}
composed.element = element;
composed
}
}
impl From<Element> for View {
fn from(element: Element) -> Self {
Self::from_element(element)
}
}
pub trait IntoView {
fn into_view(self) -> View;
}
impl IntoView for View {
fn into_view(self) -> View {
self
}
}
impl IntoView for Element {
fn into_view(self) -> View {
View::from_element(self)
}
}
impl<T: Component> IntoView for T {
fn into_view(self) -> View {
self.render()
}
}
pub trait Children {
fn into_views(self) -> Vec<View>;
}
#[derive(Clone, Default)]
pub struct ChildViews(Vec<View>);
impl ChildViews {
pub fn from_children(children: impl Children) -> Self {
Self(children.into_views())
}
pub fn into_vec(self) -> Vec<View> {
self.0
}
}
impl Children for () {
fn into_views(self) -> Vec<View> {
Vec::new()
}
}
impl<T: IntoView> Children for T {
fn into_views(self) -> Vec<View> {
vec![self.into_view()]
}
}
impl<T: IntoView> Children for Vec<T> {
fn into_views(self) -> Vec<View> {
self.into_iter().map(IntoView::into_view).collect()
}
}
impl Children for ChildViews {
fn into_views(self) -> Vec<View> {
self.0
}
}
macro_rules! impl_children_tuple {
($($name:ident),+ $(,)?) => {
#[allow(non_camel_case_types)]
impl<$($name: IntoView),+> Children for ($($name,)+) {
fn into_views(self) -> Vec<View> {
let ($($name,)+) = self;
vec![$($name.into_view(),)+]
}
}
};
}
impl_children_tuple!(a, b);
impl_children_tuple!(a, b, c);
impl_children_tuple!(a, b, c, d);
impl_children_tuple!(a, b, c, d, e);
impl_children_tuple!(a, b, c, d, e, f);
impl_children_tuple!(a, b, c, d, e, f, g);
impl_children_tuple!(a, b, c, d, e, f, g, h);
pub trait TextChildren {
fn into_text(self) -> String;
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TextValue(String);
impl TextValue {
pub fn from_text(children: impl TextChildren) -> Self {
Self(children.into_text())
}
pub fn into_string(self) -> String {
self.0
}
}
impl TextChildren for TextValue {
fn into_text(self) -> String {
self.0
}
}
impl TextChildren for &'static str {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for String {
fn into_text(self) -> String {
self
}
}
impl TextChildren for i32 {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for i64 {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for usize {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for u64 {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for f32 {
fn into_text(self) -> String {
self.to_string()
}
}
impl<T: std::fmt::Display + 'static> TextChildren for Signal<T> {
fn into_text(self) -> String {
self.with(|value| value.to_string())
}
}
impl<T: std::fmt::Display + 'static> TextChildren for Memo<T> {
fn into_text(self) -> String {
self.with(|value| value.to_string())
}
}
macro_rules! impl_text_children_tuple {
($($name:ident),+ $(,)?) => {
#[allow(non_camel_case_types)]
impl<$($name: TextChildren),+> TextChildren for ($($name,)+) {
fn into_text(self) -> String {
let ($($name,)+) = self;
let mut text = String::new();
$(text.push_str(&$name.into_text());)+
text
}
}
};
}
impl_text_children_tuple!(a, b);
impl_text_children_tuple!(a, b, c);
impl_text_children_tuple!(a, b, c, d);
impl_text_children_tuple!(a, b, c, d, e);
impl_text_children_tuple!(a, b, c, d, e, f);
pub trait IntoEdges {
fn into_edges(self) -> Edges;
}
impl IntoEdges for Edges {
fn into_edges(self) -> Edges {
self
}
}
impl IntoEdges for f32 {
fn into_edges(self) -> Edges {
Edges::all(self)
}
}
pub trait IntoBorder {
fn into_border(self) -> Border;
}
impl IntoBorder for Border {
fn into_border(self) -> Border {
self
}
}
impl IntoBorder for (f32, Color) {
fn into_border(self) -> Border {
Border::new(self.0, self.1)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextRole {
Body,
Heading(u8),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FontWeight {
Normal,
Medium,
Semibold,
Bold,
}
impl FontWeight {
const fn to_text_span_weight(self) -> TextSpanWeight {
match self {
Self::Normal => TextSpanWeight::Normal,
Self::Medium => TextSpanWeight::Medium,
Self::Semibold => TextSpanWeight::Semibold,
Self::Bold => TextSpanWeight::Bold,
}
}
}
pub mod colors {
use ruin_ui::Color;
pub const fn text() -> Color {
Color::rgb(0xFF, 0xFF, 0xFF)
}
pub const fn muted() -> Color {
Color::rgb(0xB6, 0xC2, 0xD9)
}
pub const fn danger() -> Color {
Color::rgb(0xFF, 0x7B, 0x72)
}
}
pub mod surfaces {
use ruin_ui::Color;
pub const fn canvas() -> Color {
Color::rgb(0x0F, 0x16, 0x25)
}
pub const fn raised() -> Color {
Color::rgb(0x1B, 0x26, 0x3D)
}
pub const fn interactive() -> Color {
Color::rgb(0x2B, 0x3A, 0x67)
}
pub const fn interactive_muted() -> Color {
Color::rgb(0x3D, 0x4B, 0x72)
}
}
pub fn column() -> ContainerBuilder {
ContainerBuilder {
element: Element::column().background(surfaces::canvas()),
widget_ref: None,
}
}
pub fn row() -> ContainerBuilder {
ContainerBuilder {
element: Element::row(),
widget_ref: None,
}
}
pub fn block() -> ContainerBuilder {
ContainerBuilder {
element: Element::column(),
widget_ref: None,
}
}
pub fn text() -> TextBuilder {
TextBuilder::default()
}
pub fn button() -> ButtonBuilder {
ButtonBuilder::default()
}
pub fn scroll_box() -> ScrollBoxBuilder {
ScrollBoxBuilder {
element: Element::scroll_box(0.0),
offset_y: None,
drag: with_hook_slot(|| Signal::new(None::<ScrollbarDrag>), |drag| drag.clone()),
widget_ref: None,
}
}
pub struct ContainerBuilder {
element: Element,
widget_ref: Option<Signal<Option<ElementId>>>,
}
impl ContainerBuilder {
pub fn gap(mut self, gap: f32) -> Self {
self.element = self.element.gap(gap);
self
}
pub fn padding(mut self, padding: impl IntoEdges) -> Self {
self.element = self.element.padding(padding.into_edges());
self
}
pub fn background(mut self, color: Color) -> Self {
self.element = self.element.background(color);
self
}
pub fn border(mut self, border: impl IntoBorder) -> Self {
let border = border.into_border();
self.element = self.element.border(border.width, border.color);
self
}
pub fn border_radius(mut self, radius: f32) -> Self {
self.element = self.element.corner_radius(radius);
self
}
pub fn width(mut self, width: f32) -> Self {
self.element = self.element.width(width);
self
}
pub fn height(mut self, height: f32) -> Self {
self.element = self.element.height(height);
self
}
pub fn flex(mut self, flex: f32) -> Self {
self.element = self.element.flex(flex);
self
}
pub fn widget_ref<T>(mut self, widget_ref: WidgetRef<T>) -> Self {
self.widget_ref = Some(widget_ref.element_id.clone());
self
}
pub fn children(mut self, children: impl Children) -> View {
if let Some(widget_ref) = &self.widget_ref {
let element_id = allocate_element_id();
self.element = self.element.id(element_id);
let _ = widget_ref.set(Some(element_id));
}
View::from_container(self.element, children.into_views())
}
}
pub struct ScrollBoxBuilder {
element: Element,
offset_y: Option<Signal<f32>>,
drag: Signal<Option<ScrollbarDrag>>,
widget_ref: Option<Signal<Option<ElementId>>>,
}
impl ScrollBoxBuilder {
pub fn padding(mut self, padding: impl IntoEdges) -> Self {
self.element = self.element.padding(padding.into_edges());
self
}
pub fn background(mut self, color: Color) -> Self {
self.element = self.element.background(color);
self
}
pub fn border(mut self, border: impl IntoBorder) -> Self {
let border = border.into_border();
self.element = self.element.border(border.width, border.color);
self
}
pub fn border_radius(mut self, radius: f32) -> Self {
self.element = self.element.corner_radius(radius);
self
}
pub fn width(mut self, width: f32) -> Self {
self.element = self.element.width(width);
self
}
pub fn height(mut self, height: f32) -> Self {
self.element = self.element.height(height);
self
}
pub fn flex(mut self, flex: f32) -> Self {
self.element = self.element.flex(flex);
self
}
pub fn scrollbar_style(mut self, style: ScrollbarStyle) -> Self {
self.element = self.element.scrollbar_style(style);
self
}
pub fn offset_y(mut self, offset_y: Signal<f32>) -> Self {
self.element = self.element.scroll_offset(offset_y.get());
self.offset_y = Some(offset_y);
self
}
pub fn widget_ref<T>(mut self, widget_ref: WidgetRef<T>) -> Self {
self.widget_ref = Some(widget_ref.element_id.clone());
self
}
pub fn children(mut self, children: impl Children) -> View {
let element_id = allocate_element_id();
self.element = self.element.id(element_id);
if let Some(widget_ref) = &self.widget_ref {
let _ = widget_ref.set(Some(element_id));
}
let mut view = View::from_container(self.element, children.into_views());
if let Some(offset_y) = self.offset_y {
let drag = self.drag;
let offset_y_for_pointer = offset_y.clone();
let offset_y_for_keys = offset_y.clone();
view = view.with_scroll_handler(
element_id,
Rc::new(move |event, interaction_tree| {
let Some(metrics) = interaction_tree.scroll_metrics_for_element(element_id)
else {
return;
};
match event.kind {
RoutedPointerEventKind::Scroll { delta } => {
offset_y_for_pointer.update(|value| {
*value =
clamp_scroll_offset(*value + delta.y, metrics.max_offset_y);
});
}
RoutedPointerEventKind::Down {
button: PointerButton::Primary,
} => {
let Some(thumb_rect) = metrics.scrollbar_thumb else {
return;
};
if !thumb_rect.contains(event.position) {
return;
}
let _ = drag.set(Some(ScrollbarDrag {
start_pointer_y: event.position.y,
start_offset_y: offset_y_for_pointer.get(),
}));
}
RoutedPointerEventKind::Move => {
let Some(drag_state) = drag.get() else {
return;
};
let Some(track_rect) = metrics.scrollbar_track else {
return;
};
let Some(thumb_rect) = metrics.scrollbar_thumb else {
return;
};
let thumb_travel =
(track_rect.size.height - thumb_rect.size.height).max(0.0);
if thumb_travel <= 0.0 || metrics.max_offset_y <= 0.0 {
return;
}
let pointer_delta = event.position.y - drag_state.start_pointer_y;
let next_offset = drag_state.start_offset_y
+ pointer_delta * (metrics.max_offset_y / thumb_travel);
offset_y_for_pointer.update(|value| {
*value = clamp_scroll_offset(next_offset, metrics.max_offset_y);
});
}
RoutedPointerEventKind::Up {
button: PointerButton::Primary,
} => {
let _ = drag.set(None);
}
_ => {}
}
}),
);
view = view.with_key_handler(
element_id,
Rc::new(move |event, interaction_tree| {
if event.kind != KeyboardEventKind::Pressed
|| event.modifiers.control
|| event.modifiers.alt
|| event.modifiers.super_key
{
return false;
}
let Some(metrics) = interaction_tree.scroll_metrics_for_element(element_id)
else {
return false;
};
let line_step = (metrics.viewport_rect.size.height * 0.12).clamp(28.0, 52.0);
let next_offset = match event.key {
KeyboardKey::ArrowUp => offset_y_for_keys.get() - line_step,
KeyboardKey::ArrowDown => offset_y_for_keys.get() + line_step,
KeyboardKey::Home => 0.0,
KeyboardKey::End => metrics.max_offset_y,
_ => return false,
};
let next_offset = clamp_scroll_offset(next_offset, metrics.max_offset_y);
let changed = (offset_y_for_keys.get() - next_offset).abs() > f32::EPSILON;
if changed {
let _ = offset_y_for_keys.set(next_offset);
}
changed
}),
);
}
view
}
}
#[derive(Default)]
pub struct TextBuilder {
role: Option<TextRole>,
size: Option<f32>,
weight: Option<FontWeight>,
color: Option<Color>,
font_family: Option<TextFontFamily>,
wrap: Option<TextWrap>,
}
impl TextBuilder {
pub fn role(mut self, role: TextRole) -> Self {
self.role = Some(role);
self
}
pub fn size(mut self, size: f32) -> Self {
self.size = Some(size);
self
}
pub fn weight(mut self, weight: FontWeight) -> Self {
self.weight = Some(weight);
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn font_family(mut self, family: TextFontFamily) -> Self {
self.font_family = Some(family);
self
}
pub fn wrap(mut self, wrap: TextWrap) -> Self {
self.wrap = Some(wrap);
self
}
pub fn children(self, children: impl TextChildren) -> View {
let size = self
.size
.unwrap_or_else(|| match self.role.unwrap_or(TextRole::Body) {
TextRole::Body => 18.0,
TextRole::Heading(1) => 32.0,
TextRole::Heading(2) => 28.0,
TextRole::Heading(_) => 24.0,
});
let color = self.color.unwrap_or(colors::text());
let weight = self
.weight
.unwrap_or_else(|| match self.role.unwrap_or(TextRole::Body) {
TextRole::Body => FontWeight::Normal,
TextRole::Heading(_) => FontWeight::Semibold,
});
let mut style = TextStyle::new(size, color)
.with_line_height(size * 1.2)
.with_wrap(self.wrap.unwrap_or(TextWrap::None));
if let Some(font_family) = self.font_family {
style = style.with_font_family(font_family);
}
let span = TextSpan::new(children.into_text())
.color(color)
.weight(weight.to_text_span_weight());
View::from_element(Element::spans([span], style).id(allocate_element_id()))
}
}
#[derive(Default)]
pub struct ButtonBuilder {
on_press: Option<PressHandler>,
}
impl ButtonBuilder {
pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self {
self.on_press = Some(Rc::new(handler));
self
}
pub fn children(self, children: impl TextChildren) -> View {
let id = allocate_element_id();
let label = children.into_text();
let view = View::from_element(
Element::column()
.id(id)
.padding(Edges::symmetric(14.0, 10.0))
.background(surfaces::interactive())
.corner_radius(10.0)
.cursor(CursorIcon::Pointer)
.focusable(true)
.child(
Element::spans(
[TextSpan::new(label).weight(TextSpanWeight::Medium)],
TextStyle::new(18.0, colors::text()).with_line_height(21.6),
)
.pointer_events(false)
.focusable(false),
),
);
match self.on_press {
Some(handler) => view.with_press_handler(id, handler),
None => view,
}
}
}
pub struct Signal<T> {
inner: Rc<SignalInner<T>>,
}
impl<T> Clone for Signal<T> {
fn clone(&self) -> Self {
Self {
inner: Rc::clone(&self.inner),
}
}
}
impl<T: 'static> Signal<T> {
fn new(initial: T) -> Self {
Self {
inner: Rc::new(SignalInner {
cell: ruin_reactivity::cell(initial),
}),
}
}
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
self.inner.cell.with(f)
}
pub fn replace(&self, value: T) -> T {
self.inner.cell.replace(value)
}
pub fn update<R>(&self, f: impl FnOnce(&mut T) -> R) -> R {
self.inner.cell.update(f)
}
}
impl<T: Clone + 'static> Signal<T> {
pub fn get(&self) -> T {
self.inner.cell.get()
}
}
impl<T: PartialEq + 'static> Signal<T> {
pub fn set(&self, value: T) -> Option<T> {
self.inner.cell.set(value)
}
}
pub struct Memo<T> {
compute: Rc<dyn Fn() -> T>,
}
impl<T> Clone for Memo<T> {
fn clone(&self) -> Self {
Self {
compute: Rc::clone(&self.compute),
}
}
}
impl<T> Memo<T> {
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
let value = (self.compute)();
f(&value)
}
}
impl<T: Clone> Memo<T> {
pub fn get(&self) -> T {
(self.compute)()
}
}
#[derive(Clone)]
struct ContextEntry {
key: TypeId,
value: Rc<dyn Any>,
}
impl ContextEntry {
fn new<C: ContextKey>(value: C::Value) -> Self {
Self {
key: TypeId::of::<C>(),
value: Rc::new(value),
}
}
}
pub fn use_signal<T: 'static>(initial: impl FnOnce() -> T) -> Signal<T> {
with_hook_slot(|| Signal::new(initial()), |signal| signal.clone())
}
pub fn use_context<C: ContextKey>() -> C::Value {
with_render_context_state(|context| {
context
.context_entries
.iter()
.rev()
.find_map(|entry| {
(entry.key == TypeId::of::<C>())
.then(|| entry.value.downcast_ref::<C::Value>())
.flatten()
.cloned()
})
.unwrap_or_else(|| {
panic!(
"missing context provider for {} while rendering",
type_name::<C>()
)
})
})
}
pub fn provide<C: ContextKey>(value: C::Value, render: impl FnOnce() -> View) -> View {
with_render_context_state(|context| {
let mut context_entries = (*context.context_entries).clone();
context_entries.push(ContextEntry::new::<C>(value));
with_render_context(
context.with_context_entries(Rc::new(context_entries)),
render,
)
})
}
pub fn use_memo<T: 'static>(compute: impl Fn() -> T + 'static) -> Memo<T> {
let compute: Rc<RefCell<Box<dyn Fn() -> T>>> =
Rc::new(RefCell::new(Box::new(compute) as Box<dyn Fn() -> T>));
with_hook_slot(
{
let compute = Rc::clone(&compute);
move || MemoSlot::new(compute)
},
|slot: &mut MemoSlot<T>| {
slot.replace_compute(Rc::clone(&compute));
slot.handle.clone()
},
)
}
pub fn use_effect(effect: impl Fn() + 'static) {
with_hook_slot(|| ruin_reactivity::effect(effect), |_| ());
}
pub fn use_window_title(compute: impl FnOnce() -> String) {
with_render_context_state(|context| {
context.side_effects.borrow_mut().window_title = Some(compute());
});
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Key {
Character(char),
Enter,
ArrowUp,
ArrowDown,
Home,
End,
}
impl Key {
fn matches(&self, event: &KeyboardEvent) -> bool {
match (self, &event.key) {
(Self::Character(expected), KeyboardKey::Character(actual)) => actual
.chars()
.next()
.is_some_and(|actual| actual.eq_ignore_ascii_case(expected)),
(Self::Enter, KeyboardKey::Enter) => true,
(Self::ArrowUp, KeyboardKey::ArrowUp) => true,
(Self::ArrowDown, KeyboardKey::ArrowDown) => true,
(Self::Home, KeyboardKey::Home) => true,
(Self::End, KeyboardKey::End) => true,
_ => false,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Shortcut {
key: Key,
control: bool,
shift: bool,
alt: bool,
super_key: bool,
}
impl Shortcut {
pub fn new(key: Key) -> Self {
Self {
key,
control: false,
shift: false,
alt: false,
super_key: false,
}
}
pub fn with_ctrl(mut self) -> Self {
self.control = true;
self
}
pub fn with_shift(mut self) -> Self {
self.shift = true;
self
}
fn matches(&self, event: &KeyboardEvent) -> bool {
event.kind == KeyboardEventKind::Pressed
&& self.key.matches(event)
&& event.modifiers.control == self.control
&& event.modifiers.shift == self.shift
&& event.modifiers.alt == self.alt
&& event.modifiers.super_key == self.super_key
}
}
#[derive(Clone)]
pub struct FocusScope {
element_id: Signal<Option<ElementId>>,
}
#[derive(Clone)]
pub enum ShortcutScope {
Application,
FocusedWithin(FocusScope),
}
impl ShortcutScope {
fn matches(
&self,
focused_element: Option<ElementId>,
interaction_tree: &InteractionTree,
) -> bool {
match self {
Self::Application => true,
Self::FocusedWithin(scope) => {
let Some(scope_element) = scope.element_id.get() else {
return false;
};
let Some(focused_element) = focused_element else {
return false;
};
element_contains_element(interaction_tree, scope_element, focused_element)
}
}
}
}
pub struct WidgetRef<T> {
element_id: Signal<Option<ElementId>>,
_marker: PhantomData<fn() -> T>,
}
impl<T> Clone for WidgetRef<T> {
fn clone(&self) -> Self {
Self {
element_id: self.element_id.clone(),
_marker: PhantomData,
}
}
}
impl<T> WidgetRef<T> {
fn new() -> Self {
Self {
element_id: Signal::new(None),
_marker: PhantomData,
}
}
pub fn element_id(&self) -> Option<ElementId> {
self.element_id.get()
}
pub fn focus_scope(&self) -> FocusScope {
FocusScope {
element_id: self.element_id.clone(),
}
}
}
pub struct ScrollBoxWidget;
pub struct BlockWidget;
pub fn use_widget_ref<T: 'static>() -> WidgetRef<T> {
with_hook_slot(WidgetRef::new, |widget_ref: &mut WidgetRef<T>| {
widget_ref.clone()
})
}
pub fn use_shortcut(shortcut: Shortcut, scope: ShortcutScope, action: impl Fn() + 'static) {
use_shortcut_with_context(shortcut, scope, move |_| action());
}
pub fn use_shortcut_with_context(
shortcut: Shortcut,
scope: ShortcutScope,
action: impl Fn(&InteractionTree) + 'static,
) {
with_render_context_state(|context| {
context
.side_effects
.borrow_mut()
.shortcuts
.push(ShortcutBinding {
shortcut,
scope,
action: Rc::new(action),
});
});
}
#[derive(Clone)]
pub struct Resource<T, E> {
state: Signal<ResourceState<T, E>>,
}
impl<T: Clone + 'static, E: Clone + 'static> Resource<T, E> {
pub fn read(&self) -> ResourceState<T, E> {
self.state.get()
}
}
#[derive(Clone)]
pub enum ResourceState<T, E> {
Pending,
Ready(std::result::Result<T, E>),
}
pub fn use_resource<T, E, Fut, F>(factory: F) -> Resource<T, E>
where
T: Clone + 'static,
E: Clone + 'static,
Fut: Future<Output = std::result::Result<T, E>> + 'static,
F: Fn() -> Fut + 'static,
{
with_hook_slot(
|| {
let resource = Resource {
state: Signal::new(ResourceState::Pending),
};
let generation = Rc::new(StdCell::new(0_u64));
let _effect = ruin_reactivity::effect({
let resource = resource.clone();
let generation = Rc::clone(&generation);
move || {
let next_generation = generation.get().wrapping_add(1);
generation.set(next_generation);
let _ = resource.state.replace(ResourceState::Pending);
let resource = resource.clone();
let future = factory();
let generation = Rc::clone(&generation);
std::mem::drop(queue_future(async move {
let result = future.await;
if generation.get() == next_generation {
let _ = resource.state.replace(ResourceState::Ready(result));
}
}));
}
});
ResourceSlot { resource, _effect }
},
|slot: &mut ResourceSlot<T, E>| slot.resource.clone(),
)
}
#[derive(Default)]
struct RenderState {
hooks: RefCell<Vec<Box<dyn Any>>>,
element_ids: RefCell<Vec<ElementId>>,
next_element_id: StdCell<u64>,
}
#[derive(Clone)]
struct ShortcutBinding {
shortcut: Shortcut,
scope: ShortcutScope,
action: Rc<dyn Fn(&InteractionTree)>,
}
impl ShortcutBinding {
fn matches(
&self,
event: &KeyboardEvent,
focused_element: Option<ElementId>,
interaction_tree: &InteractionTree,
) -> bool {
self.shortcut.matches(event) && self.scope.matches(focused_element, interaction_tree)
}
fn trigger(&self, interaction_tree: &InteractionTree) {
(self.action)(interaction_tree);
}
}
#[derive(Clone, Default)]
struct RenderSideEffects {
window_title: Option<String>,
shortcuts: Vec<ShortcutBinding>,
}
#[derive(Clone)]
struct RenderContext {
state: Rc<RenderState>,
hook_index: Rc<StdCell<usize>>,
element_index: Rc<StdCell<usize>>,
side_effects: Rc<RefCell<RenderSideEffects>>,
context_entries: Rc<Vec<ContextEntry>>,
}
impl RenderContext {
fn with_context_entries(&self, context_entries: Rc<Vec<ContextEntry>>) -> Self {
Self {
state: Rc::clone(&self.state),
hook_index: Rc::clone(&self.hook_index),
element_index: Rc::clone(&self.element_index),
side_effects: Rc::clone(&self.side_effects),
context_entries,
}
}
}
struct RenderOutput {
view: View,
side_effects: RenderSideEffects,
}
thread_local! {
static CURRENT_RENDER_CONTEXT: RefCell<Option<RenderContext>> = const { RefCell::new(None) };
}
fn render_with_context(state: Rc<RenderState>, render: impl FnOnce() -> View) -> RenderOutput {
let context = RenderContext {
state,
hook_index: Rc::new(StdCell::new(0)),
element_index: Rc::new(StdCell::new(0)),
side_effects: Rc::new(RefCell::new(RenderSideEffects::default())),
context_entries: Rc::new(Vec::new()),
};
let view = with_render_context(context.clone(), render);
let side_effects = context.side_effects.borrow().clone();
RenderOutput { view, side_effects }
}
fn with_render_context(context: RenderContext, render: impl FnOnce() -> View) -> View {
CURRENT_RENDER_CONTEXT.with(|slot| {
let previous = slot.replace(Some(context.clone()));
struct Guard<'a> {
slot: &'a RefCell<Option<RenderContext>>,
previous: Option<RenderContext>,
}
impl Drop for Guard<'_> {
fn drop(&mut self) {
let _ = self.slot.replace(self.previous.take());
}
}
let _guard = Guard { slot, previous };
render()
})
}
fn with_render_context_state<R>(f: impl FnOnce(&RenderContext) -> R) -> R {
CURRENT_RENDER_CONTEXT.with(|slot| {
let context = slot
.borrow()
.clone()
.expect("ruin_app hooks can only run while rendering a mounted component");
f(&context)
})
}
fn with_hook_slot<T: 'static, R>(init: impl FnOnce() -> T, f: impl FnOnce(&mut T) -> R) -> R {
with_render_context_state(|context| {
let index = context.hook_index.get();
context.hook_index.set(index + 1);
let mut hooks = context.state.hooks.borrow_mut();
if hooks.len() == index {
hooks.push(Box::new(init()));
}
let slot = hooks[index]
.downcast_mut::<T>()
.expect("ruin_app hook call order changed between renders");
f(slot)
})
}
fn allocate_element_id() -> ElementId {
with_render_context_state(|context| {
let index = context.element_index.get();
context.element_index.set(index + 1);
let mut element_ids = context.state.element_ids.borrow_mut();
if element_ids.len() == index {
let next = context.state.next_element_id.get().wrapping_add(1);
context.state.next_element_id.set(next);
element_ids.push(ElementId::new(next));
}
element_ids[index]
})
}
struct MemoSlot<T> {
compute: Rc<RefCell<Box<dyn Fn() -> T>>>,
handle: Memo<T>,
}
struct ResourceSlot<T, E> {
resource: Resource<T, E>,
_effect: ruin_reactivity::EffectHandle,
}
impl<T: 'static> MemoSlot<T> {
fn new(compute: Rc<RefCell<Box<dyn Fn() -> T>>>) -> Self {
let handle = Memo {
compute: Rc::new({
let compute = Rc::clone(&compute);
move || {
let compute = compute.borrow();
(compute.as_ref())()
}
}),
};
Self { compute, handle }
}
fn replace_compute(&mut self, compute: Rc<RefCell<Box<dyn Fn() -> T>>>) {
self.compute = Rc::clone(&compute);
self.handle.compute = Rc::new({
let compute = Rc::clone(&compute);
move || {
let compute = compute.borrow();
(compute.as_ref())()
}
});
}
}
type PressHandler = Rc<dyn Fn(&RoutedPointerEvent) + 'static>;
type ScrollHandler = Rc<dyn Fn(&RoutedPointerEvent, &InteractionTree) + 'static>;
type KeyHandler = Rc<dyn Fn(&KeyboardEvent, &InteractionTree) -> bool + 'static>;
#[derive(Clone, Default)]
struct EventBindings {
on_press: HashMap<ElementId, PressHandler>,
on_scroll: HashMap<ElementId, ScrollHandler>,
on_key: HashMap<ElementId, KeyHandler>,
}
impl EventBindings {
fn extend(&mut self, other: EventBindings) {
self.on_press.extend(other.on_press);
self.on_scroll.extend(other.on_scroll);
self.on_key.extend(other.on_key);
}
fn dispatch(
&self,
event: &RoutedPointerEvent,
interaction_tree: &InteractionTree,
hovered_targets: &[HitTarget],
) {
match event.kind {
RoutedPointerEventKind::Up {
button: PointerButton::Primary,
} => {
if let Some(element_id) = event.target.element_id
&& let Some(handler) = self.on_press.get(&element_id)
{
handler(event);
}
if let Some(handler) = scroll_handler_for_event(
&self.on_scroll,
event.target.element_id,
hovered_targets,
) {
handler(event, interaction_tree);
}
}
RoutedPointerEventKind::Down {
button: PointerButton::Primary,
}
| RoutedPointerEventKind::Move
| RoutedPointerEventKind::Scroll { .. }
| RoutedPointerEventKind::Leave => {
if let Some(handler) = scroll_handler_for_event(
&self.on_scroll,
event.target.element_id,
hovered_targets,
) {
handler(event, interaction_tree);
}
}
_ => {}
}
}
fn dispatch_key(
&self,
focused_element: Option<ElementId>,
event: &KeyboardEvent,
interaction_tree: &InteractionTree,
) {
let Some(handler) = key_handler_for_focus(&self.on_key, focused_element, interaction_tree)
else {
return;
};
let _ = handler(event, interaction_tree);
}
}
#[derive(Clone, Copy, PartialEq)]
struct ScrollbarDrag {
start_pointer_y: f32,
start_offset_y: f32,
}
#[derive(Clone, Copy, PartialEq)]
struct TextSelection {
element_id: ElementId,
anchor: usize,
focus: usize,
}
#[derive(Clone, Copy, PartialEq)]
struct TextSelectionDrag {
element_id: ElementId,
anchor: usize,
}
struct TextSelectionState {
selection: RefCell<Option<TextSelection>>,
drag: RefCell<Option<TextSelectionDrag>>,
version: Signal<u64>,
}
impl TextSelectionState {
fn new() -> Self {
Self {
selection: RefCell::new(None),
drag: RefCell::new(None),
version: Signal::new(0),
}
}
}
struct InputState {
current_cursor: CursorIcon,
focused_element: Option<ElementId>,
text_selection: Rc<TextSelectionState>,
}
impl InputState {
fn new() -> Self {
Self {
current_cursor: CursorIcon::Default,
focused_element: None,
text_selection: Rc::new(TextSelectionState::new()),
}
}
}
fn clamp_scroll_offset(offset_y: f32, max_offset_y: f32) -> f32 {
offset_y.clamp(0.0, max_offset_y.max(0.0))
}
fn apply_text_selection_overlay(
scene: &mut ruin_ui::SceneSnapshot,
selection: Option<TextSelection>,
) {
let Some(selection) = selection else {
return;
};
let mut next_items = Vec::with_capacity(scene.items.len());
for item in scene.items.drain(..) {
match item {
DisplayItem::Text(mut text) if text.element_id == Some(selection.element_id) => {
for rect in text.selection_rects(selection.anchor, selection.focus) {
next_items.push(DisplayItem::Quad(Quad::new(
rect,
text.selection_style.highlight_color,
)));
}
text.apply_selected_text_color(selection.anchor, selection.focus);
next_items.push(DisplayItem::Text(text));
}
other => next_items.push(other),
}
}
scene.items = next_items;
}
fn sync_primary_selection(
window: &WindowController,
interaction_tree: &InteractionTree,
selection: Option<TextSelection>,
) -> Result<()> {
let Some(selection) = selection else {
window.set_primary_selection_text(String::new())?;
return Ok(());
};
let Some(text) = interaction_tree.text_for_element(selection.element_id) else {
return Ok(());
};
let copied = text
.selected_text(selection.anchor, selection.focus)
.unwrap_or_default()
.to_owned();
window.set_primary_selection_text(copied)?;
Ok(())
}
fn scroll_handler_for_event<'a>(
handlers: &'a HashMap<ElementId, ScrollHandler>,
direct_target: Option<ElementId>,
hovered_targets: &[HitTarget],
) -> Option<&'a ScrollHandler> {
if let Some(element_id) = direct_target
&& let Some(handler) = handlers.get(&element_id)
{
return Some(handler);
}
hovered_targets
.iter()
.rev()
.filter_map(|target| target.element_id)
.find_map(|element_id| handlers.get(&element_id))
}
fn focused_element_for_pointer(
interaction_tree: &InteractionTree,
event: &PointerEvent,
) -> Option<ElementId> {
let hit_path = interaction_tree.hit_path(event.position);
hit_path
.iter()
.rev()
.find_map(|target| target.focusable.then_some(target.element_id).flatten())
.or_else(|| hit_path.iter().rev().find_map(|target| target.element_id))
}
fn element_contains_element(
interaction_tree: &InteractionTree,
ancestor: ElementId,
descendant: ElementId,
) -> bool {
fn contains_descendant(node: &ruin_ui::LayoutNode, descendant: ElementId) -> bool {
if node.element_id == Some(descendant) {
return true;
}
node.children
.iter()
.any(|child| contains_descendant(child, descendant))
}
fn ancestor_contains(
node: &ruin_ui::LayoutNode,
ancestor: ElementId,
descendant: ElementId,
) -> bool {
if node.element_id == Some(ancestor) {
return contains_descendant(node, descendant);
}
node.children
.iter()
.any(|child| ancestor_contains(child, ancestor, descendant))
}
ancestor_contains(&interaction_tree.root, ancestor, descendant)
}
fn key_handler_for_focus<'a>(
handlers: &'a HashMap<ElementId, KeyHandler>,
focused_element: Option<ElementId>,
interaction_tree: &InteractionTree,
) -> Option<&'a KeyHandler> {
let focused_element = focused_element?;
focused_ancestor_chain(&interaction_tree.root, focused_element)?
.into_iter()
.find_map(|element_id| handlers.get(&element_id))
}
fn focused_ancestor_chain(
node: &ruin_ui::LayoutNode,
focused_element: ElementId,
) -> Option<Vec<ElementId>> {
let mut chain = Vec::new();
if build_focused_ancestor_chain(node, focused_element, &mut chain) {
Some(chain)
} else {
None
}
}
fn build_focused_ancestor_chain(
node: &ruin_ui::LayoutNode,
focused_element: ElementId,
chain: &mut Vec<ElementId>,
) -> bool {
if node.element_id == Some(focused_element) {
if let Some(element_id) = node.element_id {
chain.push(element_id);
}
return true;
}
for child in &node.children {
if build_focused_ancestor_chain(child, focused_element, chain) {
if let Some(element_id) = node.element_id {
chain.push(element_id);
}
return true;
}
}
false
}
pub mod prelude {
pub use crate::{
App, BlockWidget, ButtonBuilder, ChildViews, Children, Component, ContainerBuilder,
ContextKey, FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable,
Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget,
Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View,
WidgetRef, Window, block, button, colors, column, component, context_provider, provide,
row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource, use_shortcut,
use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, view,
};
pub use ruin_ui::{
Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton,
PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle,
TextFontFamily, TextStyle, TextWrap, UiSize,
};
}
struct SignalInner<T> {
cell: ruin_reactivity::Cell<T>,
}
#[cfg(test)]
mod tests {
use super::*;
use ruin_ui::{KeyboardModifiers, Point, UiRuntime, WindowSpec};
#[derive(Clone, Debug, PartialEq, Eq)]
struct NamedValue(&'static str);
struct OuterContext;
struct InnerContext;
impl ContextKey for OuterContext {
type Value = NamedValue;
}
impl ContextKey for InnerContext {
type Value = NamedValue;
}
#[component]
fn ContractProbe(label: TextValue, actions: ChildViews, children: ChildViews) -> impl IntoView {
column().children((
text().children(label),
row().children(actions),
block().children(children),
))
}
#[test]
fn use_context_distinguishes_marker_types_for_same_value_type() {
let seen_outer = Rc::new(RefCell::new(None::<NamedValue>));
let seen_inner = Rc::new(RefCell::new(None::<NamedValue>));
let _ = render_with_context(Rc::new(RenderState::default()), {
let seen_outer = Rc::clone(&seen_outer);
let seen_inner = Rc::clone(&seen_inner);
move || {
provide::<OuterContext>(NamedValue("outer"), || {
provide::<InnerContext>(NamedValue("inner"), || {
*seen_outer.borrow_mut() = Some(use_context::<OuterContext>());
*seen_inner.borrow_mut() = Some(use_context::<InnerContext>());
View::from_element(Element::column())
})
})
}
});
assert_eq!(*seen_outer.borrow(), Some(NamedValue("outer")));
assert_eq!(*seen_inner.borrow(), Some(NamedValue("inner")));
}
#[test]
fn nearer_provider_shadows_outer_provider_of_same_marker() {
let seen_value = Rc::new(RefCell::new(None::<NamedValue>));
let _ = render_with_context(Rc::new(RenderState::default()), {
let seen_value = Rc::clone(&seen_value);
move || {
provide::<OuterContext>(NamedValue("outer"), || {
provide::<OuterContext>(NamedValue("inner"), || {
*seen_value.borrow_mut() = Some(use_context::<OuterContext>());
View::from_element(Element::column())
})
})
}
});
assert_eq!(*seen_value.borrow(), Some(NamedValue("inner")));
}
#[test]
fn components_accept_child_contracts_and_child_like_slot_props() {
let render = render_with_context(Rc::new(RenderState::default()), || {
IntoView::into_view(view! {
ContractProbe(
label = "slot label",
actions = (
text().children("action a"),
text().children("action b"),
)
) {
text() { "body child" }
}
})
});
let debug = format!("{:?}", render.view.element());
assert!(debug.contains("slot label"), "{debug}");
assert!(debug.contains("action a"), "{debug}");
assert!(debug.contains("action b"), "{debug}");
assert!(debug.contains("body child"), "{debug}");
}
#[test]
fn key_dispatch_prefers_the_nearest_focused_ancestor_handler() {
let outer_id = ElementId::new(41);
let inner_id = ElementId::new(42);
let root = Element::column().pointer_events(false).child(
Element::column()
.id(outer_id)
.width(160.0)
.height(120.0)
.focusable(true)
.child(
Element::column()
.id(inner_id)
.width(120.0)
.height(80.0)
.focusable(true),
),
);
let snapshot = ruin_ui::layout_snapshot(1, UiSize::new(200.0, 120.0), &root);
let outer_hits = Rc::new(StdCell::new(0usize));
let inner_hits = Rc::new(StdCell::new(0usize));
let mut handlers = HashMap::<ElementId, KeyHandler>::new();
handlers.insert(
outer_id,
Rc::new({
let outer_hits = Rc::clone(&outer_hits);
move |_, _| {
outer_hits.set(outer_hits.get() + 1);
true
}
}),
);
handlers.insert(
inner_id,
Rc::new({
let inner_hits = Rc::clone(&inner_hits);
move |_, _| {
inner_hits.set(inner_hits.get() + 1);
true
}
}),
);
let handler = key_handler_for_focus(&handlers, Some(inner_id), &snapshot.interaction_tree)
.expect("focused element should resolve a key handler");
let _ = handler(
&KeyboardEvent::new(
0,
KeyboardEventKind::Pressed,
KeyboardKey::ArrowDown,
KeyboardModifiers::default(),
None,
),
&snapshot.interaction_tree,
);
assert_eq!(inner_hits.get(), 1);
assert_eq!(outer_hits.get(), 0);
}
#[test]
fn scroll_box_arrow_keys_work_after_clicking_text_content() {
let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>));
let render = render_with_context(Rc::new(RenderState::default()), {
let offset_slot = Rc::clone(&offset_slot);
move || {
let offset = use_signal(|| 0.0_f32);
*offset_slot.borrow_mut() = Some(offset.clone());
scroll_box()
.height(120.0)
.offset_y(offset)
.children(text().children(
"line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09\nline 10\nline 11\nline 12",
))
}
});
let offset = offset_slot
.borrow()
.clone()
.expect("scroll signal should have been captured");
let scrollbox_id = render
.view
.element
.id
.expect("scroll box should receive an element id");
let snapshot =
ruin_ui::layout_snapshot(1, UiSize::new(260.0, 160.0), render.view.element());
let focused = focused_element_for_pointer(
&snapshot.interaction_tree,
&PointerEvent::new(
1,
Point::new(12.0, 12.0),
PointerEventKind::Down {
button: PointerButton::Primary,
},
),
);
assert_eq!(focused, Some(scrollbox_id));
render.view.bindings.dispatch_key(
focused,
&KeyboardEvent::new(
0,
KeyboardEventKind::Pressed,
KeyboardKey::ArrowDown,
KeyboardModifiers::default(),
None,
),
&snapshot.interaction_tree,
);
assert!(offset.get() > 0.0);
}
#[test]
fn scroll_box_thumb_drag_updates_offset_signal() {
let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>));
let render = render_with_context(Rc::new(RenderState::default()), {
let offset_slot = Rc::clone(&offset_slot);
move || {
let offset = use_signal(|| 0.0_f32);
*offset_slot.borrow_mut() = Some(offset.clone());
scroll_box()
.height(120.0)
.offset_y(offset)
.children(text().children(
"line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09\nline 10\nline 11\nline 12",
))
}
});
let offset = offset_slot
.borrow()
.clone()
.expect("scroll signal should have been captured");
let scrollbox_id = render
.view
.element
.id
.expect("scroll box should receive an element id");
let snapshot =
ruin_ui::layout_snapshot(1, UiSize::new(260.0, 160.0), render.view.element());
let metrics = snapshot
.interaction_tree
.scroll_metrics_for_element(scrollbox_id)
.expect("scroll metrics should exist for the scroll box");
let thumb = metrics
.scrollbar_thumb
.expect("overflowing scroll box should expose a scrollbar thumb");
let thumb_center = Point::new(
thumb.origin.x + (thumb.size.width * 0.5),
thumb.origin.y + (thumb.size.height * 0.5),
);
let hovered_targets = snapshot.interaction_tree.hit_path(thumb_center);
let handler = scroll_handler_for_event(
&render.view.bindings.on_scroll,
Some(scrollbox_id),
&hovered_targets,
)
.expect("scroll box should resolve its scroll handler")
.clone();
handler(
&RoutedPointerEvent {
kind: RoutedPointerEventKind::Down {
button: PointerButton::Primary,
},
target: hovered_targets
.last()
.cloned()
.expect("thumb center should hit the scroll box"),
pointer_id: 1,
position: thumb_center,
},
&snapshot.interaction_tree,
);
handler(
&RoutedPointerEvent {
kind: RoutedPointerEventKind::Move,
target: hovered_targets
.last()
.cloned()
.expect("thumb center should hit the scroll box"),
pointer_id: 1,
position: Point::new(thumb_center.x, thumb_center.y + 24.0),
},
&snapshot.interaction_tree,
);
assert!(offset.get() > 0.0);
}
#[test]
fn live_input_path_scrolls_a_scroll_box_rendered_inside_a_branch() {
let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>));
let render = render_with_context(Rc::new(RenderState::default()), {
let offset_slot = Rc::clone(&offset_slot);
move || match true {
true => {
let offset = use_signal(|| 0.0_f32);
*offset_slot.borrow_mut() = Some(offset.clone());
column()
.background(surfaces::raised())
.gap(10.0)
.children((
text().children(("bytes = ", 4096)),
scroll_box()
.height(420.0)
.offset_y(offset.clone())
.padding(12.0)
.background(surfaces::canvas())
.border_radius(10.0)
.border((2.0, colors::muted()))
.children(
text()
.color(colors::muted())
.font_family(TextFontFamily::Monospace)
.children(
"line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24",
),
),
))
}
false => View::from_element(Element::column()),
}
});
let offset = offset_slot
.borrow()
.clone()
.expect("scroll signal should have been captured");
let snapshot =
ruin_ui::layout_snapshot(1, UiSize::new(1080.0, 760.0), render.view.element());
let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone()));
let bindings = RefCell::new(render.view.bindings.clone());
let mut pointer_router = PointerRouter::new();
let mut input_state = InputState::new();
let window = UiRuntime::headless()
.create_window(WindowSpec::new("scrollbox-test"))
.expect("headless window should be created");
MountedApp::<View>::handle_pointer_event(
&window,
&interaction_tree,
&bindings,
&mut pointer_router,
&mut input_state,
PointerEvent::new(
1,
Point::new(24.0, 64.0),
PointerEventKind::Down {
button: PointerButton::Primary,
},
),
)
.expect("pointer down should succeed");
MountedApp::<View>::handle_keyboard_event(
&interaction_tree,
&bindings,
&RefCell::new(Vec::new()),
&input_state,
KeyboardEvent::new(
0,
KeyboardEventKind::Pressed,
KeyboardKey::ArrowDown,
KeyboardModifiers::default(),
None,
),
)
.expect("keyboard event should succeed");
assert!(offset.get() > 0.0);
}
#[test]
fn scroll_box_stays_interactive_when_it_appears_on_a_later_render() {
let state = Rc::new(RenderState::default());
let ready_slot = Rc::new(RefCell::new(None::<Signal<bool>>));
let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>));
let render_once =
|state: Rc<RenderState>,
ready_slot: Rc<RefCell<Option<Signal<bool>>>>,
offset_slot: Rc<RefCell<Option<Signal<f32>>>>| {
render_with_context(state, move || {
let ready = use_signal(|| false);
let offset = use_signal(|| 0.0_f32);
*ready_slot.borrow_mut() = Some(ready.clone());
*offset_slot.borrow_mut() = Some(offset.clone());
if ready.get() {
column()
.background(surfaces::raised())
.gap(10.0)
.children((
text().children(("bytes = ", 4096)),
scroll_box()
.height(420.0)
.offset_y(offset.clone())
.padding(12.0)
.background(surfaces::canvas())
.border_radius(10.0)
.border((2.0, colors::muted()))
.children(
text()
.color(colors::muted())
.font_family(TextFontFamily::Monospace)
.children(
"line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24",
),
),
))
} else {
text().children("Loading file contents...")
}
})
};
let _initial = render_once(
Rc::clone(&state),
Rc::clone(&ready_slot),
Rc::clone(&offset_slot),
);
let ready = ready_slot
.borrow()
.clone()
.expect("ready signal should have been captured");
let offset = offset_slot
.borrow()
.clone()
.expect("offset signal should have been captured");
let _ = ready.set(true);
let render = render_once(state, ready_slot, Rc::clone(&offset_slot));
let snapshot =
ruin_ui::layout_snapshot(1, UiSize::new(1080.0, 760.0), render.view.element());
let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone()));
let bindings = RefCell::new(render.view.bindings.clone());
let mut pointer_router = PointerRouter::new();
let mut input_state = InputState::new();
let window = UiRuntime::headless()
.create_window(WindowSpec::new("scrollbox-transition-test"))
.expect("headless window should be created");
MountedApp::<View>::handle_pointer_event(
&window,
&interaction_tree,
&bindings,
&mut pointer_router,
&mut input_state,
PointerEvent::new(
1,
Point::new(24.0, 64.0),
PointerEventKind::Down {
button: PointerButton::Primary,
},
),
)
.expect("pointer down should succeed after branch switch");
MountedApp::<View>::handle_keyboard_event(
&interaction_tree,
&bindings,
&RefCell::new(Vec::new()),
&input_state,
KeyboardEvent::new(
0,
KeyboardEventKind::Pressed,
KeyboardKey::ArrowDown,
KeyboardModifiers::default(),
None,
),
)
.expect("keyboard event should succeed after branch switch");
assert!(offset.get() > 0.0);
}
#[test]
fn live_input_path_scrolls_with_real_cargo_lock_contents() {
let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>));
let render = render_with_context(Rc::new(RenderState::default()), {
let offset_slot = Rc::clone(&offset_slot);
move || {
let offset = use_signal(|| 0.0_f32);
*offset_slot.borrow_mut() = Some(offset.clone());
column().background(surfaces::raised()).gap(10.0).children((
text().children(("bytes = ", include_str!("../../../Cargo.lock").len())),
scroll_box()
.height(420.0)
.offset_y(offset.clone())
.padding(12.0)
.background(surfaces::canvas())
.border_radius(10.0)
.border((2.0, colors::muted()))
.children(
text()
.color(colors::muted())
.font_family(TextFontFamily::Monospace)
.children(include_str!("../../../Cargo.lock")),
),
))
}
});
let offset = offset_slot
.borrow()
.clone()
.expect("scroll signal should have been captured");
let snapshot =
ruin_ui::layout_snapshot(1, UiSize::new(1080.0, 760.0), render.view.element());
let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone()));
let bindings = RefCell::new(render.view.bindings.clone());
let mut pointer_router = PointerRouter::new();
let mut input_state = InputState::new();
let window = UiRuntime::headless()
.create_window(WindowSpec::new("scrollbox-cargo-lock-test"))
.expect("headless window should be created");
MountedApp::<View>::handle_pointer_event(
&window,
&interaction_tree,
&bindings,
&mut pointer_router,
&mut input_state,
PointerEvent::new(
1,
Point::new(24.0, 64.0),
PointerEventKind::Scroll {
delta: Point::new(0.0, 48.0),
},
),
)
.expect("wheel event should succeed");
assert!(offset.get() > 0.0);
}
#[test]
fn rerendered_scroll_box_element_carries_the_updated_offset() {
let state = Rc::new(RenderState::default());
let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>));
let render_once =
|state: Rc<RenderState>, offset_slot: Rc<RefCell<Option<Signal<f32>>>>| {
render_with_context(state, move || {
let offset = use_signal(|| 0.0_f32);
*offset_slot.borrow_mut() = Some(offset.clone());
scroll_box()
.height(120.0)
.offset_y(offset.clone())
.children(
text().children("line 01\nline 02\nline 03\nline 04\nline 05\nline 06"),
)
})
};
let _initial = render_once(Rc::clone(&state), Rc::clone(&offset_slot));
let offset = offset_slot
.borrow()
.clone()
.expect("offset signal should have been captured");
let _ = offset.set(96.0);
let render = render_once(state, offset_slot);
let debug = format!("{:?}", render.view.element());
assert!(debug.contains("offset_y: 96.0"), "{debug}");
}
}