unskunk the examples

This commit is contained in:
2026-03-21 20:23:33 -04:00
parent 82ca487bbf
commit 497dff987e
2 changed files with 915 additions and 82 deletions

View File

@@ -3,39 +3,24 @@
//! 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.
use std::any::Any;
use std::cell::{Cell as StdCell, RefCell};
use std::collections::HashMap;
use std::error::Error;
use std::iter;
use std::rc::Rc;
use ruin_reactivity::effect;
use ruin_ui::{
CursorIcon, Element, InteractionTree, LayoutSnapshot, PlatformEvent, PointerEvent,
PointerRouter, RoutedPointerEvent, TextSystem, UiSize, WindowController, WindowSpec,
WindowUpdate, layout_snapshot_with_text_system,
Color, CursorIcon, Edges, Element, ElementId, InteractionTree, LayoutSnapshot, PlatformEvent,
PointerButton, PointerEvent, PointerRouter, RoutedPointerEvent, RoutedPointerEventKind,
TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextSystem, UiSize, WindowController,
WindowSpec, WindowUpdate, layout_snapshot_with_text_system,
};
use ruin_ui_platform_wayland::start_wayland_ui;
pub use ruin_reactivity::{Cell, Memo, cell, memo};
pub use ruin_ui::{
Color, Edges, ElementId, Point, PointerButton, PointerEventKind, Rect, RoutedPointerEventKind,
TextAlign, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextWrap,
};
pub type View = Element;
pub type Result<T> = std::result::Result<T, Box<dyn Error>>;
#[derive(Clone, Copy, Debug)]
pub struct BuildCx {
pub viewport: UiSize,
}
pub trait RootComponent: 'static {
fn build(&self, cx: &BuildCx) -> View;
fn handle_pointer(&self, _event: &RoutedPointerEvent) {}
}
#[derive(Clone, Debug)]
pub struct Window {
spec: WindowSpec,
@@ -86,10 +71,11 @@ impl App {
self
}
pub fn mount<R: RootComponent>(self, root: R) -> MountedApp<R> {
pub fn mount<M: Mountable>(self, root: M) -> MountedApp<M> {
MountedApp {
window: self.window,
root: Rc::new(root),
render_state: Rc::new(RenderState::default()),
}
}
}
@@ -100,17 +86,49 @@ impl Default for App {
}
}
pub struct MountedApp<R: RootComponent> {
window: Window,
root: Rc<R>,
pub trait Mountable: 'static {
fn render(&self) -> View;
}
impl<R: RootComponent> MountedApp<R> {
pub trait Component: 'static {
type Builder;
fn builder() -> Self::Builder;
fn render(&self) -> View;
}
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
@@ -122,7 +140,8 @@ impl<R: RootComponent> MountedApp<R> {
let scene_version = StdCell::new(0_u64);
let text_system = Rc::new(RefCell::new(TextSystem::new()));
let interaction_tree = Rc::new(RefCell::new(None::<InteractionTree>));
let root = Rc::clone(&root);
let bindings = Rc::new(RefCell::new(EventBindings::default()));
let current_title = Rc::new(RefCell::new(None::<String>));
let mut pointer_router = PointerRouter::new();
let mut current_cursor = CursorIcon::Default;
@@ -131,23 +150,38 @@ impl<R: RootComponent> MountedApp<R> {
let viewport = viewport.clone();
let text_system = Rc::clone(&text_system);
let interaction_tree = Rc::clone(&interaction_tree);
let bindings = Rc::clone(&bindings);
let current_title = Rc::clone(&current_title);
let root = Rc::clone(&root);
let render_state = Rc::clone(&render_state);
move || {
let viewport = viewport.get();
let version = scene_version.get().wrapping_add(1);
scene_version.set(version);
let tree = root.build(&BuildCx { viewport });
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 LayoutSnapshot {
scene,
interaction_tree: next_interaction_tree,
} = layout_snapshot_with_text_system(
version,
viewport,
&tree,
render_output.view.element(),
&mut text_system.borrow_mut(),
);
*interaction_tree.borrow_mut() = Some(next_interaction_tree);
*bindings.borrow_mut() = render_output.view.bindings;
window
.replace_scene(scene)
.expect("window should remain alive while the app is running");
@@ -169,9 +203,9 @@ impl<R: RootComponent> MountedApp<R> {
}
PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {
Self::handle_pointer_event(
&root,
&window,
&interaction_tree,
&bindings,
&mut pointer_router,
&mut current_cursor,
event,
@@ -194,9 +228,9 @@ impl<R: RootComponent> MountedApp<R> {
}
fn handle_pointer_event(
root: &Rc<R>,
window: &WindowController,
interaction_tree: &RefCell<Option<InteractionTree>>,
bindings: &RefCell<EventBindings>,
pointer_router: &mut PointerRouter,
current_cursor: &mut CursorIcon,
event: PointerEvent,
@@ -210,7 +244,7 @@ impl<R: RootComponent> MountedApp<R> {
};
for routed_event in &routed {
root.handle_pointer(routed_event);
bindings.borrow().dispatch_press(routed_event);
}
let next_cursor = pointer_router
@@ -227,13 +261,701 @@ impl<R: RootComponent> MountedApp<R> {
}
}
pub mod prelude {
pub use crate::{
App, BuildCx, Cell, Color, Edges, ElementId, Memo, Point, Result, RootComponent, View,
Window, cell, memo,
};
pub use ruin_ui::{
CursorIcon, Element, PointerButton, PointerEventKind, RoutedPointerEvent,
RoutedPointerEventKind, TextFontFamily, TextStyle, TextWrap, UiSize,
#[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 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>;
}
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()]
}
}
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;
}
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)
}
}
#[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 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()),
}
}
pub fn row() -> ContainerBuilder {
ContainerBuilder {
element: Element::row(),
}
}
pub fn block() -> ContainerBuilder {
ContainerBuilder {
element: Element::column(),
}
}
pub fn text() -> TextBuilder {
TextBuilder::default()
}
pub fn button() -> ButtonBuilder {
ButtonBuilder::default()
}
pub struct ContainerBuilder {
element: Element,
}
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_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 children(self, children: impl Children) -> View {
View::from_container(self.element, children.into_views())
}
}
#[derive(Default)]
pub struct TextBuilder {
role: Option<TextRole>,
size: Option<f32>,
weight: Option<FontWeight>,
color: Option<Color>,
font_family: Option<TextFontFamily>,
}
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 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);
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))
}
}
#[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)()
}
}
pub fn use_signal<T: 'static>(initial: impl FnOnce() -> T) -> Signal<T> {
with_hook_slot(|| Signal::new(initial()), |signal| signal.clone())
}
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_window_title(compute: impl FnOnce() -> String) {
with_render_context_state(|context| {
context.side_effects.borrow_mut().window_title = Some(compute());
});
}
#[derive(Default)]
struct RenderState {
hooks: RefCell<Vec<Box<dyn Any>>>,
element_ids: RefCell<Vec<ElementId>>,
next_element_id: StdCell<u64>,
}
#[derive(Clone, Default, PartialEq)]
struct RenderSideEffects {
window_title: Option<String>,
}
#[derive(Clone)]
struct RenderContext {
state: Rc<RenderState>,
hook_index: Rc<StdCell<usize>>,
element_index: Rc<StdCell<usize>>,
side_effects: Rc<RefCell<RenderSideEffects>>,
}
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())),
};
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 };
let view = render();
let side_effects = context.side_effects.borrow().clone();
RenderOutput { view, side_effects }
})
}
fn with_render_context_state<R>(f: impl FnOnce(&RenderContext) -> R) -> R {
CURRENT_RENDER_CONTEXT.with(|slot| {
let context = slot.borrow();
let context = context
.as_ref()
.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>,
}
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>;
#[derive(Clone, Default)]
struct EventBindings {
on_press: HashMap<ElementId, PressHandler>,
}
impl EventBindings {
fn extend(&mut self, other: EventBindings) {
self.on_press.extend(other.on_press);
}
fn dispatch_press(&self, event: &RoutedPointerEvent) {
let Some(element_id) = event.target.element_id else {
return;
};
if !matches!(
event.kind,
RoutedPointerEventKind::Up {
button: PointerButton::Primary
}
) {
return;
}
if let Some(handler) = self.on_press.get(&element_id) {
handler(event);
}
}
}
pub mod prelude {
pub use crate::{
App, ButtonBuilder, Children, Component, ContainerBuilder, FontWeight, IntoEdges, IntoView,
Memo, Mountable, Result, Signal, TextBuilder, TextChildren, TextRole, View, Window, block,
button, colors, column, row, surfaces, text, use_memo, use_signal, use_window_title,
};
pub use ruin_ui::{
Color, CursorIcon, Edges, Element, ElementId, PointerButton, PointerEventKind,
RoutedPointerEvent, RoutedPointerEventKind, TextFontFamily, TextStyle, TextWrap, UiSize,
};
}
struct SignalInner<T> {
cell: ruin_reactivity::Cell<T>,
}