Aspirational examples for app developer contract.

This commit is contained in:
2026-03-21 17:05:25 -04:00
parent d4ff472a14
commit f59d519448
8 changed files with 866 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
//! Aspirational RUIN app API.
//!
//! This file is intentionally not wired into the workspace build. It exists to illustrate the
//! desired developer-facing programming model for a component-driven app framework.
use ruin::prelude::*;
#[ruin_runtime::async_main]
async fn main() -> ruin::Result<()> {
App::new()
.window(Window::new().title("RUIN Counter").size(960.0, 640.0))
.mount::<CounterApp>()
.run()
.await
}
#[component]
fn CounterApp() -> impl View {
let count = use_signal(|| 0);
let doubled = use_memo(move || count.get() * 2);
use_window_title(move || format!("RUIN Counter ({})", count.get()));
use_effect(move || tracing::info!(count = count.get(), "counter changed"));
view! {
column(gap = 16, padding = 24) {
text(
role = TextRole::Heading(1),
size = 32,
weight = FontWeight::Semibold,
) {
"RUIN counter"
}
text(color = colors::muted()) {
"The obvious primitives here should be layout, text, and input surfaces. Higher-level \
ideas like cards or panels should probably be composites layered on top of `block`."
}
row(gap = 8) {
button(on_press = move |_| count.update(|value| *value -= 1)) { "-1" }
button(on_press = move |_| count.set(0)) { "Reset" }
button(on_press = move |_| count.update(|value| *value += 1)) { "+1" }
}
block(
padding = 16,
gap = 8,
background = surfaces::raised(),
border_radius = 12,
) {
text(size = 18) { "count = "; count }
text(color = colors::muted()) { "double = "; doubled }
}
}
}
}

View File

@@ -0,0 +1,105 @@
//! Aspirational RUIN app API for async resources and effects.
//!
//! Intentionally non-compiling; this is a design sketch.
use ruin::prelude::*;
#[ruin_runtime::async_main]
async fn main() -> ruin::Result<()> {
App::new()
.window(Window::new().title("RUIN Issue Dashboard").size(1240.0, 820.0))
.mount::<IssueDashboard>()
.run()
.await
}
#[component]
fn IssueDashboard() -> impl View {
let repo = use_signal(|| "wtemple/ruin".to_string());
let selected_issue = use_signal(|| None::<u64>);
let issues = use_resource(move || {
let repo = repo.get();
async move { github::list_issues(&repo).await }
});
let details = use_resource(move || {
let repo = repo.get();
let issue_id = selected_issue.get();
async move {
let issue_id = issue_id?;
github::issue_details(&repo, issue_id).await.ok()
}
});
use_effect(move || {
tracing::info!(
repo = %repo.get(),
selected_issue = ?selected_issue.get(),
"dashboard dependencies changed"
);
});
view! {
row(fill = true, gap = 20, padding = 20) {
block(width = 360, gap = 12) {
text(role = TextRole::Heading(1), size = 28, weight = FontWeight::Semibold) {
"Issues"
}
text_input(value = repo, placeholder = "owner/repo") {}
suspense(fallback = || view! { ProgressSpinner(label = "Loading issues...") {} }) {
match issues.read() {
Ok(items) => view! {
list(gap = 8) {
for issue in items keyed by issue.id {
button(
kind = if selected_issue.get() == Some(issue.id) {
ButtonKind::Primary
} else {
ButtonKind::Secondary
},
on_press = move |_| selected_issue.set(Some(issue.id)),
) {
issue.title
}
}
}
},
Err(error) => view! {
ErrorPanel(
title = "Failed to load issues",
detail = error.to_string(),
) {}
},
}
}
}
block(fill = true, gap = 16) {
text(role = TextRole::Heading(2), size = 24, weight = FontWeight::Semibold) {
"Details"
}
match details.read() {
Pending => view! { ProgressSpinner(label = "Loading issue details...") {} },
Ready(Some(issue)) => view! {
column(gap = 12) {
text(role = TextRole::Heading(3), size = 22, weight = FontWeight::Semibold) {
issue.title
}
Markdown(source = issue.body) {}
}
},
Ready(None) => view! {
EmptyState(title = "Pick an issue") {
"The detail panel should track the current selection and re-run only \
the async work that actually depends on it."
}
},
}
}
}
}
}

View File

@@ -0,0 +1,116 @@
//! Aspirational RUIN app API for imperative widget refs, typed shortcuts, and overlay layers.
//!
//! Intentionally non-compiling; this is a design sketch.
use ruin::prelude::*;
#[ruin_runtime::async_main]
async fn main() -> ruin::Result<()> {
App::new()
.window(Window::new().title("RUIN Search").size(1100.0, 760.0))
.mount::<CommandSearchApp>()
.run()
.await
}
#[component]
fn CommandSearchApp() -> impl View {
let query = use_signal(String::new);
let selected_index = use_signal(|| 0usize);
let search_input = use_widget_ref::<TextInput>();
let results_list = use_widget_ref::<ScrollArea>();
let toast_region = use_widget_ref::<ToastRegion>();
let results = use_memo(move || fuzzy::search(COMMANDS, &query.get()));
use_mount({
let search_input = search_input.clone();
move || search_input.focus()
});
use_shortcut(
Shortcut::new(Key::Character('K')).with_ctrl(),
ShortcutScope::Application,
{
let search_input = search_input.clone();
move || search_input.focus()
},
);
use_shortcut(
Shortcut::new(Key::ArrowDown),
ShortcutScope::FocusedWithin(results_list.focus_scope()),
move || {
selected_index.update(|index| {
*index = (*index + 1).min(results.with(|items| items.len().saturating_sub(1)));
});
results_list.scroll_selected_into_view();
},
);
use_shortcut(
Shortcut::new(Key::Enter),
ShortcutScope::FocusedItem(results_list.focus_scope()),
{
let toast_region = toast_region.clone();
move || {
if let Some(command) = results.with(|items| items.get(selected_index.get()).cloned()) {
toast_region.show(command.title.to_string());
command.run();
}
}
},
);
view! {
layers(fill = true) {
layer(kind = LayerKind::Content) {
column(fill = true, gap = 16, padding = 20) {
text(role = TextRole::Heading(1), size = 28, weight = FontWeight::Semibold) {
"Command search"
}
text_input(
ref = search_input,
value = query,
placeholder = "Search commands...",
) {}
scroll_area(ref = results_list, fill = true) {
list(gap = 8) {
for (row_index, command) in results.iter().enumerate() keyed by command.id {
CommandRow(
selected = row_index == selected_index.get(),
title = command.title,
subtitle = command.subtitle,
on_press = move |_| command.run(),
) {}
}
}
}
}
}
layer(
kind = LayerKind::Overlay,
anchor = Anchor::bottom_right(20.0, 20.0),
pointer_events = PointerEvents::PassThrough,
) {
ToastRegion(ref = toast_region) {}
}
}
}
}
static COMMANDS: &[Command] = &[];
struct Command {
id: &'static str,
title: &'static str,
subtitle: &'static str,
}
impl Command {
fn run(&self) {}
}

View File

@@ -0,0 +1,148 @@
//! Aspirational RUIN app API for fine-grained reactivity and keyed layout islands.
//!
//! Intentionally non-compiling; this is a design sketch.
use ruin::prelude::*;
#[ruin_runtime::async_main]
async fn main() -> ruin::Result<()> {
App::new()
.window(Window::new().title("RUIN Tasks").size(1180.0, 780.0))
.mount::<TaskBoard>()
.run()
.await
}
#[component]
fn TaskBoard() -> impl View {
let filter = use_signal(|| TaskFilter::All);
let tasks = use_signal_vec(seed_tasks());
let visible_ids = use_memo(move || {
tasks.iter()
.filter(|task| filter.get().matches(task.status))
.map(|task| task.id)
.collect::<Vec<_>>()
});
view! {
column(fill = true, gap = 16, padding = 20) {
text(role = TextRole::Heading(1), size = 28, weight = FontWeight::Semibold) {
"Task board"
}
row(gap = 8) {
button(
kind = if matches!(filter.get(), TaskFilter::All) {
ButtonKind::Primary
} else {
ButtonKind::Secondary
},
on_press = move |_| filter.set(TaskFilter::All),
) { "All" }
button(
kind = if matches!(filter.get(), TaskFilter::OpenOnly) {
ButtonKind::Primary
} else {
ButtonKind::Secondary
},
on_press = move |_| filter.set(TaskFilter::OpenOnly),
) { "Open" }
button(
kind = if matches!(filter.get(), TaskFilter::CompletedOnly) {
ButtonKind::Primary
} else {
ButtonKind::Secondary
},
on_press = move |_| filter.set(TaskFilter::CompletedOnly),
) { "Completed" }
}
text(color = colors::muted()) {
"The goal here is fine-grained change tracking: reordering, toggling, and editing \
a single row should not force unrelated rows to rebuild or the entire layout tree \
to be recomputed."
}
column(fill = true, gap = 10) {
for task_id in visible_ids keyed by *task_id {
layout_boundary(key = task_id) {
TaskRow(task = tasks.item(task_id)) {}
}
}
}
}
}
}
#[component]
fn TaskRow(task: ItemSignal<Task>) -> impl View {
let expanded = use_signal(|| false);
let title = use_memo(move || task.with(|task| task.title.clone()));
let done = use_memo(move || task.with(|task| task.done));
view! {
row(gap = 12, padding = 12, align = Align::Center) {
checkblock(
checked = done,
on_toggle = move |checked| task.update(|task| task.done = checked),
) {}
column(fill = true, gap = 6) {
text(weight = if done.get() { FontWeight::Medium } else { FontWeight::Semibold }) {
title
}
if expanded.get() {
text(color = colors::muted()) {
task.with(|task| task.notes.clone())
}
}
}
icon_button(
icon = if expanded.get() { Icon::ChevronUp } else { Icon::ChevronDown },
on_press = move |_| expanded.toggle(),
) {}
}
}
}
#[derive(Clone)]
struct Task {
id: u64,
title: String,
notes: String,
status: TaskStatus,
done: bool,
}
#[derive(Clone, Copy)]
enum TaskStatus {
Backlog,
Doing,
Done,
}
#[derive(Clone, Copy)]
enum TaskFilter {
All,
OpenOnly,
CompletedOnly,
}
impl TaskFilter {
fn matches(self, status: TaskStatus) -> bool {
match self {
TaskFilter::All => true,
TaskFilter::OpenOnly => !matches!(status, TaskStatus::Done),
TaskFilter::CompletedOnly => matches!(status, TaskStatus::Done),
}
}
}
fn seed_tasks() -> Vec<Task> {
Vec::new()
}

View File

@@ -0,0 +1,167 @@
//! Aspirational RUIN app API for composition, typed context providers, and app-wide services.
//!
//! Intentionally non-compiling; this is a design sketch.
use ruin::prelude::*;
#[ruin_runtime::async_main]
async fn main() -> ruin::Result<()> {
App::new()
.window(Window::new().title("RUIN Workspace").size(1400.0, 900.0))
.mount::<WorkspaceRoot>()
.run()
.await
}
#[component]
fn WorkspaceRoot() -> impl View {
let route = use_router(Route::Home);
let session = use_resource(|| async { Session::restore().await });
let notifications = use_service::<Notifications>();
view! {
provide::<NotificationsContext>(notifications.clone()) {
provide::<ThemeContext>(Theme::dark()) {
match session.read() {
Pending => view! { ProgressSpinner(label = "Restoring session...") {} },
Ready(session) => view! {
provide::<SessionContext>(session?) {
WorkspaceShell(route = route) {}
}
},
}
}
}
}
}
#[component]
fn WorkspaceShell(route: Signal<Route>) -> impl View {
let session = use_context::<SessionContext>();
let notifications = use_context::<NotificationsContext>();
use_effect(move || {
if !session.is_authenticated() {
notifications.info("Signed in as guest");
}
});
view! {
row(fill = true) {
block(
role = LandmarkRole::Navigation,
width = 280,
gap = 12,
padding = 20,
) {
BrandMark() {}
NavLink(route = Route::Home, active = route.is(Route::Home)) { "Home" }
NavLink(route = Route::Projects, active = route.is(Route::Projects)) { "Projects" }
NavLink(route = Route::Settings, active = route.is(Route::Settings)) { "Settings" }
}
block(fill = true, padding = 20) {
match route.get() {
Route::Home => view! { HomeScreen() {} },
Route::Projects => view! { ProjectsScreen() {} },
Route::Settings => view! { SettingsScreen() {} },
}
}
}
}
}
#[component]
fn HomeScreen() -> impl View {
let theme = use_context::<ThemeContext>();
view! {
column(fill = true, gap = 16) {
text(role = TextRole::Heading(1), size = 30, weight = FontWeight::Semibold) {
"Workspace"
}
text(color = theme.muted_text()) {
"Context should be keyed by provider identity, not only by value type. That allows \
multiple providers for the same `T` to coexist in the tree while descendants borrow \
the correct value by asking for a specific provider marker."
}
}
}
}
#[component]
fn ProjectsScreen() -> impl View {
view! {
EmptyState(title = "Projects go here") {
"This screen exists only to show route switching and app-level composition."
}
}
}
#[component]
fn SettingsScreen() -> impl View {
view! {
column(gap = 12) {
text(role = TextRole::Heading(2), size = 24, weight = FontWeight::Semibold) {
"Settings"
}
toggle(label = "Use reduced motion", value = Preferences::reduced_motion()) {}
toggle(label = "Use system theme", value = Preferences::system_theme()) {}
}
}
}
#[context_provider(Theme)]
struct ThemeContext;
#[context_provider(Session)]
struct SessionContext;
#[context_provider(Notifications)]
struct NotificationsContext;
struct Session;
struct Notifications;
struct Theme;
struct Preferences;
#[derive(Clone, Copy)]
enum Route {
Home,
Projects,
Settings,
}
impl Session {
async fn restore() -> ruin::Result<Self> {
Ok(Self)
}
fn is_authenticated(&self) -> bool {
false
}
}
impl Notifications {
fn info(&self, _message: &str) {}
}
impl Theme {
fn dark() -> Self {
Self
}
fn muted_text(&self) -> Color {
Color::rgb(0x94, 0xA3, 0xB8)
}
}
impl Preferences {
fn reduced_motion() -> bool {
false
}
fn system_theme() -> bool {
true
}
}

View File

@@ -0,0 +1,105 @@
//! Aspirational RUIN app API for runtime-integrated filesystem and network I/O.
//!
//! Intentionally non-compiling; this is a design sketch.
use ruin::prelude::*;
#[ruin_runtime::async_main]
async fn main() -> ruin::Result<()> {
App::new()
.window(Window::new().title("RUIN Runtime I/O").size(1280.0, 820.0))
.mount::<RuntimeIoExample>()
.run()
.await
}
#[component]
fn RuntimeIoExample() -> impl View {
let manifest_path = use_signal(|| "./Cargo.toml".to_string());
let release_url =
use_signal(|| "https://api.github.com/repos/wtemple/ruin/releases/latest".to_string());
let manifest = use_resource(move || {
let path = manifest_path.get();
async move { ruin_runtime::fs::read_to_string(path).await }
});
let release = use_resource(move || {
let url = release_url.get();
async move { ruin_http::get_json::<ReleaseSummary>(&url).await }
});
let save_snapshot = use_action(move |_| {
let manifest = manifest.latest_cloned();
async move {
let manifest = manifest.ok_or_else(|| anyhow!("manifest is not loaded yet"))?;
ruin_runtime::fs::write("./tmp/manifest-snapshot.toml", manifest).await?;
Ok::<_, anyhow::Error>(())
}
});
use_async_effect(move || {
let url = release_url.get();
async move {
tracing::info!(%url, "release endpoint changed");
Ok::<_, anyhow::Error>(())
}
});
view! {
row(fill = true, gap = 20, padding = 20) {
block(fill = true, gap = 12) {
text(role = TextRole::Heading(1), size = 26, weight = FontWeight::Semibold) {
"Filesystem"
}
text_input(value = manifest_path, placeholder = "./Cargo.toml") {}
match manifest.read() {
Pending => view! { ProgressSpinner(label = "Reading file...") {} },
Ready(Ok(contents)) => view! {
column(gap = 12) {
button(
on_press = save_snapshot.dispatcher(),
disabled = save_snapshot.pending(),
) {
"Save snapshot"
}
CodeBlock(language = "toml") { contents }
}
},
Ready(Err(error)) => view! {
ErrorPanel(title = "Failed to read file", detail = error.to_string()) {}
},
}
}
block(fill = true, gap = 12) {
text(role = TextRole::Heading(1), size = 26, weight = FontWeight::Semibold) {
"Network"
}
text_input(value = release_url, placeholder = "https://...") {}
match release.read() {
Pending => view! { ProgressSpinner(label = "Fetching release summary...") {} },
Ready(Ok(release)) => view! {
column(gap = 8) {
text(size = 20, weight = FontWeight::Semibold) { release.name.clone() }
text(color = colors::muted()) { release.published_at.clone() }
text { release.notes.clone() }
}
},
Ready(Err(error)) => view! {
ErrorPanel(title = "Failed to fetch release", detail = error.to_string()) {}
},
}
}
}
}
}
struct ReleaseSummary {
name: String,
published_at: String,
notes: String,
}

View File

@@ -0,0 +1,56 @@
//! Aspirational RUIN app API for explicit renderer/platform extensions.
//!
//! Intentionally non-compiling; this is a design sketch.
use ruin::prelude::*;
use ruin::renderer::native::prelude::*;
use ruin::renderer::web::prelude::*;
#[ruin_runtime::async_main]
async fn main() -> ruin::Result<()> {
App::new()
.window(Window::new().title("RUIN Renderer Extensions").size(980.0, 720.0))
.mount::<PortableRoot>()
.run()
.await
}
#[component]
fn PortableRoot() -> impl View {
let selected_tab = use_signal(|| 0usize);
let shared_content = view! {
column(fill = true, gap = 16, padding = 20) {
text(role = TextRole::Heading(1), size = 28, weight = FontWeight::Semibold) {
"Portable baseline first"
}
text(color = colors::muted()) {
"The common subset should live in the main prelude. Anything renderer-specific \
should require an explicit extension trait so the callsite makes the portability \
tradeoff obvious."
}
segmented_control(value = selected_tab) {
segment(index = 0) { "Overview" }
segment(index = 1) { "Details" }
}
}
};
view! {
stack(fill = true) {
#[cfg(target_family = "wasm")]
{
shared_content
.dom_element("section")
.dom_attribute("data-tab", selected_tab.get().to_string())
}
#[cfg(not(target_family = "wasm"))]
{
shared_content
.native_surface_role(SurfaceRole::Panel)
.native_backdrop_blur(18.0)
}
}
}
}

View File

@@ -0,0 +1,112 @@
//! Aspirational RUIN app API for typed children, slots, and builder finalization.
//!
//! Intentionally non-compiling; this is a design sketch.
use ruin::prelude::*;
#[ruin_runtime::async_main]
async fn main() -> ruin::Result<()> {
App::new()
.window(Window::new().title("RUIN Children Contracts").size(1100.0, 760.0))
.mount::<ChildrenContractsExample>()
.run()
.await
}
#[component]
fn ChildrenContractsExample() -> impl View {
let projects = projects();
view! {
column(fill = true, gap = 20, padding = 24) {
text(role = TextRole::Heading(1), size = 30, weight = FontWeight::Semibold) {
"Typed children and slots"
}
text(color = colors::muted()) {
"This sketch focuses on declaration-side child contracts. The `view!` syntax stays \
uniform for all components, while the generated builder enforces props first and \
`children` as the finalizing step."
}
SplitLayout(sidebar = view! {
block(gap = 12, width = 280) {
text(role = TextRole::Heading(2), size = 22, weight = FontWeight::Semibold) {
"Filters"
}
FilterChip(selected = true) { "All projects" }
FilterChip(selected = false) { "Recent" }
}
}) {
column(gap = 12) {
text(role = TextRole::Heading(2), size = 22, weight = FontWeight::Semibold) {
"Projects"
}
ForEach(items = projects, key = |project| project.id) { |project|
ProjectRow(project = project.clone()) {}
}
}
}
Dialog(
open = true,
title = view! {
text(role = TextRole::Heading(2), size = 22, weight = FontWeight::Semibold) {
"Dialog with custom title and actions"
}
},
actions = view! {
button(kind = ButtonKind::Secondary) { "Cancel" }
button(kind = ButtonKind::Primary) { "Delete" }
},
) {
text(color = colors::muted()) {
"A dialog can declare exactly one main child body plus separately typed \
title/actions slots."
}
}
}
}
}
#[component]
fn FilterChip(children: impl InlineChildren, selected: bool) -> impl View {
let background = if selected {
colors::accent()
} else {
colors::muted().with_alpha(0.12)
};
view! {
block(
padding = (8, 16),
background = background,
corner_radius = 1000,
) {
children
}
}
}
#[component]
fn ForEach<T, C>(
items: impl IntoIterator<Item = T>,
key: impl Fn(&T) -> u64,
children: impl Fn(T) -> C,
) -> impl View
where
C: Children,
{
todo!("The framework would lower this into a keyed mapper primitive or composite")
}
#[derive(Clone)]
struct Project {
id: u64,
name: String,
}
fn projects() -> Vec<Project> {
Vec::new()
}