Port example 07
This commit is contained in:
@@ -37,3 +37,7 @@ path = "example/04_composition_and_context.rs"
|
||||
[[example]]
|
||||
name = "05_async_runtime_io"
|
||||
path = "example/05_async_runtime_io.rs"
|
||||
|
||||
[[example]]
|
||||
name = "07_children_and_slots"
|
||||
path = "example/07_children_and_slots.rs"
|
||||
|
||||
282
lib/ruin_app/example/07_children_and_slots.rs
Normal file
282
lib/ruin_app/example/07_children_and_slots.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
use ruin_app::prelude::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Project {
|
||||
id: u64,
|
||||
name: &'static str,
|
||||
status: &'static str,
|
||||
summary: &'static str,
|
||||
}
|
||||
|
||||
#[ruin_runtime::async_main]
|
||||
async fn main() -> ruin_app::Result<()> {
|
||||
App::new()
|
||||
.window(
|
||||
Window::new()
|
||||
.title("RUIN Children and Slots")
|
||||
.app_id("dev.ruin.children-and-slots")
|
||||
.size(1180.0, 820.0),
|
||||
)
|
||||
.mount(view! {
|
||||
ChildrenAndSlotsExample() {}
|
||||
})
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ChildrenAndSlotsExample() -> impl IntoView {
|
||||
let active_project = use_signal(|| 1_u64);
|
||||
let dialog_open = use_signal(|| false);
|
||||
let projects = [
|
||||
Project {
|
||||
id: 1,
|
||||
name: "Renderer clip recovery",
|
||||
status: "Ready for validation",
|
||||
summary: "Nested clip propagation now preserves empty intersections all the way down the tree.",
|
||||
},
|
||||
Project {
|
||||
id: 2,
|
||||
name: "Runtime I/O dashboard",
|
||||
status: "Shipped",
|
||||
summary: "Filesystem snapshots and local TCP-backed endpoint previews now update immediately after async work completes.",
|
||||
},
|
||||
Project {
|
||||
id: 3,
|
||||
name: "Children contract support",
|
||||
status: "In progress",
|
||||
summary: "Custom components can now accept unnamed child content and typed child-like slot props without faking overlay APIs.",
|
||||
},
|
||||
];
|
||||
let active = projects
|
||||
.iter()
|
||||
.find(|project| project.id == active_project.get())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| projects[0].clone());
|
||||
|
||||
view! {
|
||||
column(gap = 18.0, padding = 22.0, background = surfaces::canvas()) {
|
||||
text(role = TextRole::Heading(1), size = 30.0, weight = FontWeight::Semibold) {
|
||||
"Children and slots"
|
||||
}
|
||||
|
||||
text(color = colors::muted(), wrap = TextWrap::Word) {
|
||||
"This honest slice implements unnamed child contracts plus typed child-like slot props. It does not pretend modal overlays or keyed list primitives exist yet; the dialog below is rendered inline on purpose."
|
||||
}
|
||||
|
||||
SplitLayout(
|
||||
sidebar = vec![
|
||||
view! {
|
||||
text(role = TextRole::Heading(2), size = 22.0, weight = FontWeight::Semibold) {
|
||||
"Views"
|
||||
}
|
||||
},
|
||||
IntoView::into_view(FilterChip::builder().selected(active.id == 1).children("Rendering")),
|
||||
IntoView::into_view(FilterChip::builder().selected(active.id == 2).children("Runtime I/O")),
|
||||
IntoView::into_view(FilterChip::builder().selected(active.id == 3).children("Framework")),
|
||||
view! {
|
||||
text(color = colors::muted(), wrap = TextWrap::Word) {
|
||||
"The sidebar itself is passed as a typed child-view slot, while the chips use a text-only child contract."
|
||||
}
|
||||
},
|
||||
]
|
||||
) {
|
||||
column(gap = 16.0) {
|
||||
CardFrame(
|
||||
title = view! {
|
||||
text(role = TextRole::Heading(2), size = 24.0, weight = FontWeight::Semibold) {
|
||||
"Workspace focus"
|
||||
}
|
||||
},
|
||||
toolbar = vec![
|
||||
view! {
|
||||
button(on_press = {
|
||||
let dialog_open = dialog_open.clone();
|
||||
move |_| {
|
||||
let _ = dialog_open.set(true);
|
||||
}
|
||||
}) { "Open review dialog" }
|
||||
},
|
||||
view! {
|
||||
button(on_press = {
|
||||
let active_project = active_project.clone();
|
||||
move |_| {
|
||||
let next = if active_project.get() == 3 { 1 } else { active_project.get() + 1 };
|
||||
let _ = active_project.set(next);
|
||||
}
|
||||
}) { "Cycle project" }
|
||||
},
|
||||
]
|
||||
) {
|
||||
text(color = colors::muted()) {
|
||||
("Active project: ", active.name)
|
||||
}
|
||||
|
||||
text(color = colors::muted(), wrap = TextWrap::Word) {
|
||||
active.summary
|
||||
}
|
||||
|
||||
column(gap = 10.0) {
|
||||
button(on_press = {
|
||||
let active_project = active_project.clone();
|
||||
move |_| {
|
||||
let _ = active_project.set(1);
|
||||
}
|
||||
}) { "Show rendering work" }
|
||||
|
||||
button(on_press = {
|
||||
let active_project = active_project.clone();
|
||||
move |_| {
|
||||
let _ = active_project.set(2);
|
||||
}
|
||||
}) { "Show runtime I/O work" }
|
||||
|
||||
button(on_press = {
|
||||
let active_project = active_project.clone();
|
||||
move |_| {
|
||||
let _ = active_project.set(3);
|
||||
}
|
||||
}) { "Show framework work" }
|
||||
}
|
||||
}
|
||||
|
||||
InlineDialog(
|
||||
open = dialog_open.get(),
|
||||
title = view! {
|
||||
text(role = TextRole::Heading(2), size = 22.0, weight = FontWeight::Semibold) {
|
||||
"Inline review dialog"
|
||||
}
|
||||
},
|
||||
actions = vec![
|
||||
view! {
|
||||
button(on_press = {
|
||||
let dialog_open = dialog_open.clone();
|
||||
move |_| {
|
||||
let _ = dialog_open.set(false);
|
||||
}
|
||||
}) { "Dismiss" }
|
||||
},
|
||||
view! {
|
||||
button(on_press = {
|
||||
let dialog_open = dialog_open.clone();
|
||||
move |_| {
|
||||
eprintln!("example07: accepted review for {}", active.name);
|
||||
let _ = dialog_open.set(false);
|
||||
}
|
||||
}) { "Accept" }
|
||||
},
|
||||
]
|
||||
) {
|
||||
text(color = colors::muted(), wrap = TextWrap::Word) {
|
||||
"This body arrives through the unnamed child contract. The title and action row are separate typed slot props."
|
||||
}
|
||||
|
||||
text(color = colors::muted()) {
|
||||
("Selected item status: ", active.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SplitLayout(sidebar: ChildViews, children: ChildViews) -> impl IntoView {
|
||||
view! {
|
||||
row(gap = 18.0) {
|
||||
block(
|
||||
width = 280.0,
|
||||
gap = 12.0,
|
||||
padding = 16.0,
|
||||
background = surfaces::raised(),
|
||||
border_radius = 16.0,
|
||||
) {
|
||||
sidebar
|
||||
}
|
||||
|
||||
block(
|
||||
flex = 1.0,
|
||||
gap = 16.0,
|
||||
padding = 16.0,
|
||||
background = surfaces::raised(),
|
||||
border_radius = 16.0,
|
||||
) {
|
||||
children
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn FilterChip(selected: bool, children: TextValue) -> impl IntoView {
|
||||
let background = if selected {
|
||||
surfaces::interactive()
|
||||
} else {
|
||||
surfaces::interactive_muted()
|
||||
};
|
||||
let border = if selected {
|
||||
colors::text()
|
||||
} else {
|
||||
colors::muted()
|
||||
};
|
||||
|
||||
view! {
|
||||
block(
|
||||
padding = Edges::symmetric(14.0, 10.0),
|
||||
background = background,
|
||||
border = (1.0, border),
|
||||
border_radius = 999.0,
|
||||
) {
|
||||
text(weight = FontWeight::Medium) {
|
||||
children
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn CardFrame(title: View, toolbar: ChildViews, children: ChildViews) -> impl IntoView {
|
||||
let header = view! {
|
||||
row(gap = 12.0) {
|
||||
block(flex = 1.0) {
|
||||
title
|
||||
}
|
||||
|
||||
row(gap = 10.0) {
|
||||
toolbar
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut body = vec![header];
|
||||
body.extend(children.into_vec());
|
||||
block()
|
||||
.gap(14.0)
|
||||
.padding(18.0)
|
||||
.background(surfaces::canvas())
|
||||
.border_radius(16.0)
|
||||
.children(body)
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn InlineDialog(open: bool, title: View, actions: ChildViews, children: ChildViews) -> impl IntoView {
|
||||
if open {
|
||||
let actions_row = view! {
|
||||
row(gap = 10.0) {
|
||||
actions
|
||||
}
|
||||
};
|
||||
let mut body = vec![title];
|
||||
body.extend(children.into_vec());
|
||||
body.push(actions_row);
|
||||
block()
|
||||
.gap(14.0)
|
||||
.padding(18.0)
|
||||
.background(surfaces::raised())
|
||||
.border((1.0, colors::text()))
|
||||
.border_radius(16.0)
|
||||
.children(body)
|
||||
} else {
|
||||
column().children(())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! 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;
|
||||
@@ -496,6 +498,19 @@ 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()
|
||||
@@ -514,6 +529,12 @@ impl<T: IntoView> Children for Vec<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Children for ChildViews {
|
||||
fn into_views(self) -> Vec<View> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_children_tuple {
|
||||
($($name:ident),+ $(,)?) => {
|
||||
#[allow(non_camel_case_types)]
|
||||
@@ -538,6 +559,25 @@ 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()
|
||||
@@ -1911,12 +1951,12 @@ fn build_focused_ancestor_chain(
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::{
|
||||
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, context_provider, provide, row, scroll_box, surfaces,
|
||||
text, use_context, use_effect, use_memo, use_resource, use_shortcut,
|
||||
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::{
|
||||
@@ -1949,6 +1989,15 @@ mod tests {
|
||||
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>));
|
||||
@@ -1991,6 +2040,29 @@ mod tests {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user