2559 lines
78 KiB
Rust
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(¤t_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}");
|
|
}
|
|
}
|