Port example 04

This commit is contained in:
2026-03-22 00:33:43 -04:00
parent 0d8bc38113
commit ed4c216f96
5 changed files with 615 additions and 17 deletions

View File

@@ -29,3 +29,7 @@ path = "example/02_widget_refs_and_commands.rs"
[[example]]
name = "03_fine_grained_list"
path = "example/03_fine_grained_list.rs"
[[example]]
name = "04_composition_and_context"
path = "example/04_composition_and_context.rs"

View File

@@ -0,0 +1,340 @@
use ruin_app::prelude::*;
#[ruin_runtime::async_main]
async fn main() -> ruin_app::Result<()> {
App::new()
.window(
Window::new()
.title("RUIN Workspace")
.app_id("dev.ruin.composition-context")
.size(1280.0, 840.0),
)
.mount(view! {
WorkspaceRoot() {}
})
.run()
.await
}
#[component]
fn WorkspaceRoot() -> impl IntoView {
let notifications = Notifications::new(use_signal(|| {
"No workspace actions yet. Click a navigation button to emit a context-driven update."
.to_string()
}));
let session = SessionInfo::guest();
view! {
provide::<NotificationsContext>(value = notifications.clone()) {
provide::<SessionContext>(value = session.clone()) {
provide::<AppThemeContext>(value = ThemePalette::workspace()) {
WorkspaceShell() {}
}
}
}
}
}
#[component]
fn WorkspaceShell() -> impl IntoView {
let session = use_context::<SessionContext>();
let notifications = use_context::<NotificationsContext>();
let theme = use_context::<AppThemeContext>();
use_window_title({
let session = session.clone();
move || format!("RUIN Workspace - {}", session.workspace)
});
view! {
column(gap = 16.0, padding = 20.0, background = theme.canvas) {
block(
padding = 14.0,
gap = 8.0,
background = theme.panel,
border_radius = 12.0,
border = (1.0, theme.accent),
) {
text(role = TextRole::Heading(1), size = 30.0, weight = FontWeight::Semibold) {
"Composition and typed context"
}
text(color = theme.muted, wrap = TextWrap::Word) {
"This example implements real marker-keyed context providers. `AppThemeContext` \
and `SidebarThemeContext` both carry the same `ThemePalette` value type, but \
descendants resolve them independently by marker type. Nested providers of the \
same marker also shadow outer values as you would expect."
}
text(color = theme.muted) {
"Last notification: ";
notifications.last_message.clone()
}
}
row(gap = 16.0) {
provide::<SidebarThemeContext>(value = ThemePalette::sidebar()) {
Sidebar() {}
}
Dashboard() {}
}
}
}
}
#[component]
fn Sidebar() -> impl IntoView {
let theme = use_context::<SidebarThemeContext>();
let session = use_context::<SessionContext>();
let notifications = use_context::<NotificationsContext>();
view! {
block(
width = 300.0,
padding = 16.0,
gap = 12.0,
background = theme.panel,
border_radius = 14.0,
border = (1.0, theme.accent),
) {
text(size = 24.0, weight = FontWeight::Semibold, color = theme.text) {
"Workspace nav"
}
text(color = theme.muted, wrap = TextWrap::Word) {
"Signed in as ";
session.user.clone();
" in ";
session.workspace.clone();
}
button(on_press = {
let notifications = notifications.clone();
move |_| notifications.info("Opened the Home workspace panel")
}) { "Home" }
button(on_press = {
let notifications = notifications.clone();
move |_| notifications.info("Opened the Projects workspace panel")
}) { "Projects" }
button(on_press = {
let notifications = notifications.clone();
move |_| notifications.info("Opened the Settings workspace panel")
}) { "Settings" }
block(
padding = 12.0,
gap = 8.0,
background = theme.canvas,
border_radius = 10.0,
) {
text(weight = FontWeight::Semibold, color = theme.accent) { "Sidebar theme" }
text(color = theme.muted, wrap = TextWrap::Word) {
"This panel resolves `SidebarThemeContext`, even though the rest of the \
workspace uses `AppThemeContext` with the same `ThemePalette` value type."
}
}
}
}
}
#[component]
fn Dashboard() -> impl IntoView {
let theme = use_context::<AppThemeContext>();
let session = use_context::<SessionContext>();
view! {
column(flex = 1.0, gap = 16.0) {
block(
padding = 16.0,
gap = 10.0,
background = theme.panel,
border_radius = 14.0,
border = (1.0, theme.accent),
) {
text(size = 24.0, weight = FontWeight::Semibold, color = theme.text) {
"Workspace dashboard"
}
text(color = theme.muted, wrap = TextWrap::Word) {
"The main content resolves `AppThemeContext` and `SessionContext` from the \
outer providers. These values are regular cloned Rust values; the context \
system is responsible only for scoping and lookup by marker type."
}
text(color = theme.muted) {
"Active workspace: ";
session.workspace.clone()
}
}
column(gap = 16.0) {
ThemeCard(
title = "App theme".to_string(),
subtitle = "Resolved from the outer `AppThemeContext` provider.".to_string(),
theme = theme.clone(),
) {}
provide::<AppThemeContext>(value = ThemePalette::inspector()) {
InspectorPanel() {}
}
}
}
}
}
#[component]
fn ThemeCard(title: String, subtitle: String, theme: ThemePalette) -> impl IntoView {
view! {
block(
padding = 16.0,
gap = 10.0,
background = theme.panel,
border_radius = 14.0,
border = (1.0, theme.accent),
) {
text(size = 20.0, weight = FontWeight::Semibold, color = theme.text) { title }
text(color = theme.muted, wrap = TextWrap::Word) { subtitle }
block(
height = 56.0,
background = theme.canvas,
border_radius = 10.0,
border = (2.0, theme.accent),
) {}
}
}
}
#[component]
fn InspectorPanel() -> impl IntoView {
let theme = use_context::<AppThemeContext>();
let notifications = use_context::<NotificationsContext>();
view! {
block(
padding = 16.0,
gap = 12.0,
background = theme.canvas,
border_radius = 14.0,
border = (1.0, theme.accent),
) {
text(size = 18.0, weight = FontWeight::Semibold, color = theme.accent) {
"Inspector panel"
}
ThemeCard(
title = "Nested override".to_string(),
subtitle = "This panel shadows the outer `AppThemeContext` with a nearer provider of the same marker type.".to_string(),
theme = theme.clone(),
) {}
button(on_press = {
let notifications = notifications.clone();
move |_| notifications.info("Inspector theme override is active")
}) { "Emit inspector notification" }
}
}
}
#[context_provider(ThemePalette)]
struct AppThemeContext;
#[context_provider(ThemePalette)]
struct SidebarThemeContext;
#[context_provider(SessionInfo)]
struct SessionContext;
#[context_provider(Notifications)]
struct NotificationsContext;
#[derive(Clone)]
struct ThemePalette {
canvas: Color,
panel: Color,
accent: Color,
text: Color,
muted: Color,
}
impl ThemePalette {
fn workspace() -> Self {
Self {
canvas: Color::rgb(0x0E, 0x14, 0x20),
panel: Color::rgb(0x16, 0x1F, 0x30),
accent: Color::rgb(0x71, 0xA7, 0xF7),
text: Color::rgb(0xF5, 0xF7, 0xFB),
muted: Color::rgb(0x9A, 0xA9, 0xC2),
}
}
fn sidebar() -> Self {
Self {
canvas: Color::rgb(0x0F, 0x17, 0x24),
panel: Color::rgb(0x1A, 0x24, 0x36),
accent: Color::rgb(0x8B, 0xC3, 0x7E),
text: Color::rgb(0xF3, 0xF7, 0xEF),
muted: Color::rgb(0xA7, 0xB5, 0xA4),
}
}
fn inspector() -> Self {
Self {
canvas: Color::rgb(0x22, 0x17, 0x0F),
panel: Color::rgb(0x35, 0x24, 0x16),
accent: Color::rgb(0xF4, 0xB7, 0x56),
text: Color::rgb(0xFC, 0xF6, 0xEB),
muted: Color::rgb(0xCF, 0xB8, 0x98),
}
}
}
#[derive(Clone)]
struct SessionInfo {
user: String,
workspace: String,
}
impl SessionInfo {
fn guest() -> Self {
Self {
user: "guest".to_string(),
workspace: "ruin".to_string(),
}
}
}
#[derive(Clone)]
struct Notifications {
last_message: Signal<String>,
}
impl Notifications {
fn new(last_message: Signal<String>) -> Self {
Self { last_message }
}
fn info(&self, message: impl Into<String>) {
self.last_message.replace(message.into());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inspector_panel_text_reaches_the_scene() {
let root = WorkspaceRoot::builder().children(());
let view = ruin_app::__render_mountable_for_test(&root);
let snapshot = ruin_ui::layout_snapshot(1, UiSize::new(1280.0, 840.0), view.element());
assert!(
snapshot.scene.items.iter().any(|item| matches!(
item,
ruin_ui::DisplayItem::Text(text)
if text.text.contains("Nested override")
|| text.text.contains("Inspector panel")
)),
"expected InspectorPanel text to appear in the rendered scene",
);
}
}

View File

@@ -3,7 +3,7 @@
//! 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::any::{Any, TypeId, type_name};
use std::cell::{Cell as StdCell, RefCell};
use std::collections::HashMap;
use std::error::Error;
@@ -25,7 +25,7 @@ use ruin_ui::{
use ruin_ui_platform_wayland::start_wayland_ui;
pub use ResourceState::{Pending, Ready};
pub use ruin_app_proc_macros::{component, view};
pub use ruin_app_proc_macros::{component, context_provider, view};
pub type Result<T> = std::result::Result<T, Box<dyn Error>>;
@@ -94,6 +94,11 @@ impl Default for App {
}
}
#[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;
}
@@ -105,6 +110,10 @@ pub trait Component: 'static {
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)
@@ -1147,10 +1156,57 @@ impl<T: Clone> Memo<T> {
}
}
#[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>));
@@ -1434,6 +1490,19 @@ struct RenderContext {
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 {
@@ -1451,8 +1520,15 @@ fn render_with_context(state: Rc<RenderState>, render: impl FnOnce() -> View) ->
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()));
@@ -1468,19 +1544,17 @@ fn render_with_context(state: Rc<RenderState>, render: impl FnOnce() -> View) ->
}
let _guard = Guard { slot, previous };
let view = render();
let side_effects = context.side_effects.borrow().clone();
RenderOutput { view, side_effects }
render()
})
}
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()
let context = slot
.borrow()
.clone()
.expect("ruin_app hooks can only run while rendering a mounted component");
f(context)
f(&context)
})
}
@@ -1837,13 +1911,13 @@ fn build_focused_ancestor_chain(
pub mod prelude {
pub use crate::{
App, BlockWidget, ButtonBuilder, Children, Component, ContainerBuilder, FocusScope,
FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, Pending, Ready,
Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Shortcut,
App, BlockWidget, ButtonBuilder, 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, View, WidgetRef, Window, block,
button, colors, column, component, row, scroll_box, surfaces, text, use_effect, use_memo,
use_resource, use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref,
use_window_title, view,
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,
@@ -1851,6 +1925,68 @@ pub mod prelude {
TextFontFamily, TextStyle, TextWrap, UiSize,
};
}
struct SignalInner<T> {
cell: ruin_reactivity::Cell<T>,
}
#[cfg(test)]
mod tests {
use super::*;
#[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;
}
#[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")));
}
}