Files
ruin/lib/ruin_app/example/04_composition_and_context.rs
2026-03-22 00:33:43 -04:00

341 lines
10 KiB
Rust

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",
);
}
}