Aspirational examples for app developer contract.
This commit is contained in:
57
aspirational_examples/00_bootstrap_and_counter.rs
Normal file
57
aspirational_examples/00_bootstrap_and_counter.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
aspirational_examples/01_async_data_and_effects.rs
Normal file
105
aspirational_examples/01_async_data_and_effects.rs
Normal 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."
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
aspirational_examples/02_widget_refs_and_commands.rs
Normal file
116
aspirational_examples/02_widget_refs_and_commands.rs
Normal 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) {}
|
||||
}
|
||||
148
aspirational_examples/03_fine_grained_list.rs
Normal file
148
aspirational_examples/03_fine_grained_list.rs
Normal 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()
|
||||
}
|
||||
167
aspirational_examples/04_composition_and_context.rs
Normal file
167
aspirational_examples/04_composition_and_context.rs
Normal 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
|
||||
}
|
||||
}
|
||||
105
aspirational_examples/05_async_runtime_io.rs
Normal file
105
aspirational_examples/05_async_runtime_io.rs
Normal 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,
|
||||
}
|
||||
56
aspirational_examples/06_renderer_extensions.rs
Normal file
56
aspirational_examples/06_renderer_extensions.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
aspirational_examples/07_children_and_slots.rs
Normal file
112
aspirational_examples/07_children_and_slots.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user