diff --git a/aspirational_examples/00_bootstrap_and_counter.rs b/aspirational_examples/00_bootstrap_and_counter.rs new file mode 100644 index 0000000..c78481a --- /dev/null +++ b/aspirational_examples/00_bootstrap_and_counter.rs @@ -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::() + .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 } + } + } + } +} diff --git a/aspirational_examples/01_async_data_and_effects.rs b/aspirational_examples/01_async_data_and_effects.rs new file mode 100644 index 0000000..f1ede5a --- /dev/null +++ b/aspirational_examples/01_async_data_and_effects.rs @@ -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::() + .run() + .await +} + +#[component] +fn IssueDashboard() -> impl View { + let repo = use_signal(|| "wtemple/ruin".to_string()); + let selected_issue = use_signal(|| None::); + + 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." + } + }, + } + } + } + } +} diff --git a/aspirational_examples/02_widget_refs_and_commands.rs b/aspirational_examples/02_widget_refs_and_commands.rs new file mode 100644 index 0000000..b35e1cb --- /dev/null +++ b/aspirational_examples/02_widget_refs_and_commands.rs @@ -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::() + .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::(); + let results_list = use_widget_ref::(); + let toast_region = use_widget_ref::(); + + 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) {} +} diff --git a/aspirational_examples/03_fine_grained_list.rs b/aspirational_examples/03_fine_grained_list.rs new file mode 100644 index 0000000..28a02a0 --- /dev/null +++ b/aspirational_examples/03_fine_grained_list.rs @@ -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::() + .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::>() + }); + + 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) -> 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 { + Vec::new() +} diff --git a/aspirational_examples/04_composition_and_context.rs b/aspirational_examples/04_composition_and_context.rs new file mode 100644 index 0000000..050be5d --- /dev/null +++ b/aspirational_examples/04_composition_and_context.rs @@ -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::() + .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::(); + + view! { + provide::(notifications.clone()) { + provide::(Theme::dark()) { + match session.read() { + Pending => view! { ProgressSpinner(label = "Restoring session...") {} }, + Ready(session) => view! { + provide::(session?) { + WorkspaceShell(route = route) {} + } + }, + } + } + } + } +} + +#[component] +fn WorkspaceShell(route: Signal) -> impl View { + let session = use_context::(); + let notifications = use_context::(); + + 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::(); + + 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 { + 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 + } +} diff --git a/aspirational_examples/05_async_runtime_io.rs b/aspirational_examples/05_async_runtime_io.rs new file mode 100644 index 0000000..bef3674 --- /dev/null +++ b/aspirational_examples/05_async_runtime_io.rs @@ -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::() + .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::(&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, +} diff --git a/aspirational_examples/06_renderer_extensions.rs b/aspirational_examples/06_renderer_extensions.rs new file mode 100644 index 0000000..63c5c87 --- /dev/null +++ b/aspirational_examples/06_renderer_extensions.rs @@ -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::() + .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) + } + } + } +} diff --git a/aspirational_examples/07_children_and_slots.rs b/aspirational_examples/07_children_and_slots.rs new file mode 100644 index 0000000..dcc0fe0 --- /dev/null +++ b/aspirational_examples/07_children_and_slots.rs @@ -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::() + .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( + items: impl IntoIterator, + 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 { + Vec::new() +}