Port example 04
This commit is contained in:
@@ -29,3 +29,7 @@ path = "example/02_widget_refs_and_commands.rs"
|
|||||||
[[example]]
|
[[example]]
|
||||||
name = "03_fine_grained_list"
|
name = "03_fine_grained_list"
|
||||||
path = "example/03_fine_grained_list.rs"
|
path = "example/03_fine_grained_list.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "04_composition_and_context"
|
||||||
|
path = "example/04_composition_and_context.rs"
|
||||||
|
|||||||
340
lib/ruin_app/example/04_composition_and_context.rs
Normal file
340
lib/ruin_app/example/04_composition_and_context.rs
Normal 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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
//! This crate is intentionally low-level. It is the substrate that a future proc-macro-driven
|
//! 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.
|
//! 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::cell::{Cell as StdCell, RefCell};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
@@ -25,7 +25,7 @@ use ruin_ui::{
|
|||||||
use ruin_ui_platform_wayland::start_wayland_ui;
|
use ruin_ui_platform_wayland::start_wayland_ui;
|
||||||
|
|
||||||
pub use ResourceState::{Pending, Ready};
|
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>>;
|
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 {
|
pub trait Mountable: 'static {
|
||||||
fn render(&self) -> View;
|
fn render(&self) -> View;
|
||||||
}
|
}
|
||||||
@@ -105,6 +110,10 @@ pub trait Component: 'static {
|
|||||||
fn render(&self) -> View;
|
fn render(&self) -> View;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait ContextKey: 'static {
|
||||||
|
type Value: Clone + 'static;
|
||||||
|
}
|
||||||
|
|
||||||
impl<T: Component> Mountable for T {
|
impl<T: Component> Mountable for T {
|
||||||
fn render(&self) -> View {
|
fn render(&self) -> View {
|
||||||
Component::render(self)
|
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> {
|
pub fn use_signal<T: 'static>(initial: impl FnOnce() -> T) -> Signal<T> {
|
||||||
with_hook_slot(|| Signal::new(initial()), |signal| signal.clone())
|
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> {
|
pub fn use_memo<T: 'static>(compute: impl Fn() -> T + 'static) -> Memo<T> {
|
||||||
let compute: Rc<RefCell<Box<dyn Fn() -> T>>> =
|
let compute: Rc<RefCell<Box<dyn Fn() -> T>>> =
|
||||||
Rc::new(RefCell::new(Box::new(compute) as 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>>,
|
hook_index: Rc<StdCell<usize>>,
|
||||||
element_index: Rc<StdCell<usize>>,
|
element_index: Rc<StdCell<usize>>,
|
||||||
side_effects: Rc<RefCell<RenderSideEffects>>,
|
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 {
|
struct RenderOutput {
|
||||||
@@ -1451,8 +1520,15 @@ fn render_with_context(state: Rc<RenderState>, render: impl FnOnce() -> View) ->
|
|||||||
hook_index: Rc::new(StdCell::new(0)),
|
hook_index: Rc::new(StdCell::new(0)),
|
||||||
element_index: Rc::new(StdCell::new(0)),
|
element_index: Rc::new(StdCell::new(0)),
|
||||||
side_effects: Rc::new(RefCell::new(RenderSideEffects::default())),
|
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| {
|
CURRENT_RENDER_CONTEXT.with(|slot| {
|
||||||
let previous = slot.replace(Some(context.clone()));
|
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 _guard = Guard { slot, previous };
|
||||||
let view = render();
|
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 {
|
fn with_render_context_state<R>(f: impl FnOnce(&RenderContext) -> R) -> R {
|
||||||
CURRENT_RENDER_CONTEXT.with(|slot| {
|
CURRENT_RENDER_CONTEXT.with(|slot| {
|
||||||
let context = slot.borrow();
|
let context = slot
|
||||||
let context = context
|
.borrow()
|
||||||
.as_ref()
|
.clone()
|
||||||
.expect("ruin_app hooks can only run while rendering a mounted component");
|
.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 mod prelude {
|
||||||
pub use crate::{
|
pub use crate::{
|
||||||
App, BlockWidget, ButtonBuilder, Children, Component, ContainerBuilder, FocusScope,
|
App, BlockWidget, ButtonBuilder, Children, Component, ContainerBuilder, ContextKey,
|
||||||
FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, Pending, Ready,
|
FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, Pending,
|
||||||
Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Shortcut,
|
Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Shortcut,
|
||||||
ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, View, WidgetRef, Window, block,
|
ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, View, WidgetRef, Window, block,
|
||||||
button, colors, column, component, row, scroll_box, surfaces, text, use_effect, use_memo,
|
button, colors, column, component, context_provider, provide, row, scroll_box, surfaces,
|
||||||
use_resource, use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref,
|
text, use_context, use_effect, use_memo, use_resource, use_shortcut,
|
||||||
use_window_title, view,
|
use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, view,
|
||||||
};
|
};
|
||||||
pub use ruin_ui::{
|
pub use ruin_ui::{
|
||||||
Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton,
|
Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton,
|
||||||
@@ -1851,6 +1925,68 @@ pub mod prelude {
|
|||||||
TextFontFamily, TextStyle, TextWrap, UiSize,
|
TextFontFamily, TextStyle, TextWrap, UiSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SignalInner<T> {
|
struct SignalInner<T> {
|
||||||
cell: ruin_reactivity::Cell<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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use quote::{format_ident, quote};
|
|||||||
use syn::parse::{Parse, ParseStream};
|
use syn::parse::{Parse, ParseStream};
|
||||||
use syn::punctuated::Punctuated;
|
use syn::punctuated::Punctuated;
|
||||||
use syn::{
|
use syn::{
|
||||||
Error, Expr, FnArg, Ident, ItemFn, Pat, PatIdent, Path, Result, ReturnType, Token, Type,
|
Error, Expr, FnArg, Ident, ItemFn, ItemStruct, Pat, PatIdent, Path, Result, ReturnType, Token,
|
||||||
braced, parenthesized, parse_macro_input,
|
Type, braced, parenthesized, parse_macro_input,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[proc_macro_attribute]
|
#[proc_macro_attribute]
|
||||||
@@ -29,6 +29,16 @@ pub fn view(input: TokenStream) -> TokenStream {
|
|||||||
expand_node(&root.root).into()
|
expand_node(&root.root).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn context_provider(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
let value_ty = parse_macro_input!(attr as Type);
|
||||||
|
let item_struct = parse_macro_input!(item as ItemStruct);
|
||||||
|
match expand_context_provider(item_struct, value_ty) {
|
||||||
|
Ok(tokens) => tokens.into(),
|
||||||
|
Err(error) => error.to_compile_error().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn expand_component(mut function: ItemFn) -> Result<proc_macro2::TokenStream> {
|
fn expand_component(mut function: ItemFn) -> Result<proc_macro2::TokenStream> {
|
||||||
validate_component_function(&function)?;
|
validate_component_function(&function)?;
|
||||||
|
|
||||||
@@ -256,6 +266,34 @@ fn expand_component(mut function: ItemFn) -> Result<proc_macro2::TokenStream> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn expand_context_provider(
|
||||||
|
item_struct: ItemStruct,
|
||||||
|
value_ty: Type,
|
||||||
|
) -> Result<proc_macro2::TokenStream> {
|
||||||
|
if !item_struct.generics.params.is_empty() || item_struct.generics.where_clause.is_some() {
|
||||||
|
return Err(Error::new_spanned(
|
||||||
|
&item_struct.generics,
|
||||||
|
"context providers cannot be generic",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !matches!(item_struct.fields, syn::Fields::Unit) {
|
||||||
|
return Err(Error::new_spanned(
|
||||||
|
&item_struct.fields,
|
||||||
|
"context providers must be unit structs",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ident = &item_struct.ident;
|
||||||
|
|
||||||
|
Ok(quote! {
|
||||||
|
#item_struct
|
||||||
|
|
||||||
|
impl ::ruin_app::ContextKey for #ident {
|
||||||
|
type Value = #value_ty;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_component_function(function: &ItemFn) -> Result<()> {
|
fn validate_component_function(function: &ItemFn) -> Result<()> {
|
||||||
let signature = &function.sig;
|
let signature = &function.sig;
|
||||||
|
|
||||||
@@ -443,6 +481,9 @@ fn looks_like_node(input: ParseStream<'_>) -> Result<bool> {
|
|||||||
|
|
||||||
fn expand_node(node: &Node) -> proc_macro2::TokenStream {
|
fn expand_node(node: &Node) -> proc_macro2::TokenStream {
|
||||||
let path = &node.path;
|
let path = &node.path;
|
||||||
|
if is_provider_path(path) {
|
||||||
|
return expand_provider_node(node);
|
||||||
|
}
|
||||||
let prop_calls = node.props.iter().map(|property| {
|
let prop_calls = node.props.iter().map(|property| {
|
||||||
let name = &property.name;
|
let name = &property.name;
|
||||||
let value = &property.value;
|
let value = &property.value;
|
||||||
@@ -465,6 +506,34 @@ fn expand_node(node: &Node) -> proc_macro2::TokenStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn expand_provider_node(node: &Node) -> proc_macro2::TokenStream {
|
||||||
|
let path = &node.path;
|
||||||
|
let value = match node.props.as_slice() {
|
||||||
|
[Property { name, value }] if name == "value" => value,
|
||||||
|
_ => {
|
||||||
|
return Error::new_spanned(
|
||||||
|
path,
|
||||||
|
"provide::<Context>(...) expects exactly one `value = ...` property",
|
||||||
|
)
|
||||||
|
.to_compile_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let child = match node.children.as_slice() {
|
||||||
|
[child] => expand_child(child),
|
||||||
|
_ => {
|
||||||
|
return Error::new_spanned(path, "provide::<Context>(...) requires exactly one child")
|
||||||
|
.to_compile_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
::ruin_app::#path(
|
||||||
|
#value,
|
||||||
|
|| ::ruin_app::IntoView::into_view(#child),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn expand_children(children: &[Child]) -> proc_macro2::TokenStream {
|
fn expand_children(children: &[Child]) -> proc_macro2::TokenStream {
|
||||||
match children {
|
match children {
|
||||||
[] => quote! { () },
|
[] => quote! { () },
|
||||||
@@ -491,3 +560,12 @@ fn is_component_path(path: &Path) -> bool {
|
|||||||
.map(|ch| ch.is_ascii_uppercase())
|
.map(|ch| ch.is_ascii_uppercase())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_provider_path(path: &Path) -> bool {
|
||||||
|
!is_component_path(path)
|
||||||
|
&& path
|
||||||
|
.segments
|
||||||
|
.last()
|
||||||
|
.map(|segment| segment.ident == "provide")
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|||||||
@@ -383,11 +383,17 @@ fn layout_element(
|
|||||||
perf_stats.container_nodes += 1;
|
perf_stats.container_nodes += 1;
|
||||||
|
|
||||||
if element.children.is_empty() {
|
if element.children.is_empty() {
|
||||||
|
if pushed_clip {
|
||||||
|
scene.pop_clip();
|
||||||
|
}
|
||||||
return interaction;
|
return interaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = inset_rect(rect, content_insets(&element.style));
|
let content = inset_rect(rect, content_insets(&element.style));
|
||||||
if content.size.width <= 0.0 || content.size.height <= 0.0 {
|
if content.size.width <= 0.0 || content.size.height <= 0.0 {
|
||||||
|
if pushed_clip {
|
||||||
|
scene.pop_clip();
|
||||||
|
}
|
||||||
return interaction;
|
return interaction;
|
||||||
}
|
}
|
||||||
interaction.children = layout_container_children(
|
interaction.children = layout_container_children(
|
||||||
@@ -1543,6 +1549,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_rounded_container_pops_clip_before_following_sibling() {
|
||||||
|
let root = Element::column().children([
|
||||||
|
Element::column()
|
||||||
|
.height(56.0)
|
||||||
|
.corner_radius(12.0)
|
||||||
|
.background(Color::rgb(0x22, 0x33, 0x44)),
|
||||||
|
Element::paragraph(
|
||||||
|
"Sibling text should not inherit a stale clip from an empty rounded container.",
|
||||||
|
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let scene = layout_scene(1, UiSize::new(480.0, 240.0), &root);
|
||||||
|
let rounded_push = scene
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.position(|item| matches!(item, DisplayItem::PushClip(_)))
|
||||||
|
.expect("rounded empty container should push a clip");
|
||||||
|
let rounded_pop = scene
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.position(|item| matches!(item, DisplayItem::PopClip))
|
||||||
|
.expect("rounded empty container should pop its clip");
|
||||||
|
let sibling_text = scene
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.position(|item| matches!(item, DisplayItem::Text(text) if text.text.contains("Sibling text")))
|
||||||
|
.expect("following sibling text should be emitted");
|
||||||
|
|
||||||
|
assert!(rounded_push < rounded_pop);
|
||||||
|
assert!(rounded_pop < sibling_text);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shadowed_container_emits_shadow_rect_items() {
|
fn shadowed_container_emits_shadow_rect_items() {
|
||||||
let root = Element::column().child(
|
let root = Element::column().child(
|
||||||
|
|||||||
Reference in New Issue
Block a user