diff --git a/.gitignore b/.gitignore index 4259bf9..11195a5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /rusty/target /rusty-macros/target /rusty-server/target +/rusty-docs/target +/rusty-docs/src/generated/ # Frontend src/frontend/node_modules/ diff --git a/Cargo.lock b/Cargo.lock index f4e6c9b..aafdfb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "rusty-docs" +version = "0.1.0" +dependencies = [ + "rusty", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "rusty-macros" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ee73cd7..e104248 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["rusty", "rusty-macros", "rusty-server"] +members = ["rusty", "rusty-macros", "rusty-server", "rusty-docs"] resolver = "2" [workspace.package] diff --git a/rusty-docs/Cargo.toml b/rusty-docs/Cargo.toml new file mode 100644 index 0000000..f21260c --- /dev/null +++ b/rusty-docs/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rusty-docs" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "rusty-docs" +path = "src/main.rs" + +[dependencies] +rusty = { path = "../rusty" } +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/rusty-docs/build.rs b/rusty-docs/build.rs new file mode 100644 index 0000000..bb32d88 --- /dev/null +++ b/rusty-docs/build.rs @@ -0,0 +1,271 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + let docs_dir = Path::new("docs"); + let out_dir = Path::new("src/generated"); + + println!("cargo:rerun-if-changed=docs"); + + // Clean generated directory + if out_dir.exists() { + fs::remove_dir_all(out_dir).expect("failed to clean generated dir"); + } + fs::create_dir_all(out_dir).expect("failed to create generated dir"); + + let mut sections: BTreeMap = BTreeMap::new(); + + // Walk top-level directories in docs/ + let mut entries: Vec<_> = fs::read_dir(docs_dir) + .expect("failed to read docs dir") + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .collect(); + entries.sort_by_key(|e| e.file_name()); + + for section_entry in &entries { + let section_dir = section_entry.path(); + let section_name = section_entry.file_name().to_string_lossy().to_string(); + let (order, clean_name) = parse_prefix(§ion_name); + let module_name = clean_name.to_lowercase(); + + let mut pages = Vec::new(); + + let mut page_entries: Vec<_> = fs::read_dir(§ion_dir) + .expect("failed to read section dir") + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name().to_string_lossy().to_string(); + name.ends_with(".md") && name != "_index.md" + }) + .collect(); + page_entries.sort_by_key(|e| e.file_name()); + + for page_entry in &page_entries { + let file_name = page_entry.file_name().to_string_lossy().to_string(); + let (page_order, page_clean) = parse_prefix(file_name.trim_end_matches(".md")); + let page_module = page_clean.to_lowercase(); + + pages.push(Page { + order: page_order, + module_name: page_module, + display_name: to_title_case(&page_clean), + relative_path: page_entry + .path() + .strip_prefix(".") + .unwrap_or(&page_entry.path()) + .to_path_buf(), + }); + } + + // Read _index.md for section title + let index_path = section_dir.join("_index.md"); + let section_title = if index_path.exists() { + let content = fs::read_to_string(&index_path).unwrap_or_default(); + extract_title(&content).unwrap_or_else(|| to_title_case(&clean_name)) + } else { + to_title_case(&clean_name) + }; + + sections.insert( + module_name.clone(), + Section { + order, + module_name, + display_name: section_title, + pages, + }, + ); + } + + // Generate a module file per section + for section in sections.values() { + let section_dir = out_dir.join(§ion.module_name); + fs::create_dir_all(§ion_dir).expect("failed to create section dir"); + + let mut section_mod = String::new(); + for page in §ion.pages { + generate_page_module(§ion_dir, section, page); + section_mod.push_str(&format!("pub mod {};\n", page.module_name)); + } + + fs::write(section_dir.join("mod.rs"), section_mod).expect("failed to write section mod.rs"); + } + + // Generate top-level mod.rs with page registry + let mut mod_rs = String::new(); + + for section in sections.values() { + mod_rs.push_str(&format!("pub mod {};\n", section.module_name)); + } + + mod_rs.push_str("\nuse rusty::prelude::*;\n\n"); + mod_rs.push_str("#[allow(dead_code)]\n"); + mod_rs.push_str("pub struct DocPage {\n"); + mod_rs.push_str(" pub section: &'static str,\n"); + mod_rs.push_str(" pub title: &'static str,\n"); + mod_rs.push_str(" pub id: &'static str,\n"); + mod_rs.push_str(" pub view_factory: fn() -> Box,\n"); + mod_rs.push_str("}\n\n"); + + mod_rs.push_str("pub fn all_pages() -> Vec {\n"); + mod_rs.push_str(" vec![\n"); + + for section in sections.values() { + for page in §ion.pages { + let struct_name = to_pascal_case(&page.module_name); + mod_rs.push_str(&format!( + " DocPage {{ section: \"{}\", title: \"{}\", id: \"{}_{}\", view_factory: || Box::new({}::{}::{}Page) }},\n", + section.display_name, + page.display_name, + section.module_name, + page.module_name, + section.module_name, + page.module_name, + struct_name, + )); + } + } + + mod_rs.push_str(" ]\n"); + mod_rs.push_str("}\n"); + + fs::write(out_dir.join("mod.rs"), mod_rs).expect("failed to write generated/mod.rs"); +} + +fn generate_page_module(section_dir: &Path, section: &Section, page: &Page) { + let struct_name = to_pascal_case(&page.module_name); + + // Compute the relative path from generated source file to the docs markdown + let md_path = format!( + "../../../docs/{}/{}.md", + find_original_dir_name("docs", §ion.module_name), + find_original_file_name( + &format!( + "docs/{}", + find_original_dir_name("docs", §ion.module_name) + ), + &page.module_name, + ), + ); + + let source = format!( + r#"use rusty::prelude::*; + +pub struct {struct_name}Page; + +impl View for {struct_name}Page {{ + fn build(&self, _ctx: &mut BuildContext) -> Element {{ + Layout::vertical() + .padding(24.0) + .gap(16.0) + .child(TextBlock::h1("{title}")) + .child(TextBlock::markdown(include_str!("{md_path}"))) + .into() + }} +}} +"#, + struct_name = struct_name, + title = page.display_name, + md_path = md_path, + ); + + fs::write(section_dir.join(format!("{}.rs", page.module_name)), source) + .expect("failed to write page module"); +} + +fn find_original_dir_name(base: &str, module_name: &str) -> String { + let base_path = Path::new(base); + if let Ok(entries) = fs::read_dir(base_path) { + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let name = entry.file_name().to_string_lossy().to_string(); + let (_, clean) = parse_prefix(&name); + if clean.to_lowercase() == module_name { + return name; + } + } + } + } + module_name.to_string() +} + +fn find_original_file_name(dir: &str, module_name: &str) -> String { + let dir_path = Path::new(dir); + if let Ok(entries) = fs::read_dir(dir_path) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.ends_with(".md") && name != "_index.md" { + let stem = name.trim_end_matches(".md"); + let (_, clean) = parse_prefix(stem); + if clean.to_lowercase() == module_name { + return name.trim_end_matches(".md").to_string(); + } + } + } + } + module_name.to_string() +} + +struct Section { + #[allow(dead_code)] + order: u32, + module_name: String, + display_name: String, + pages: Vec, +} + +struct Page { + #[allow(dead_code)] + order: u32, + module_name: String, + display_name: String, + #[allow(dead_code)] + relative_path: PathBuf, +} + +fn parse_prefix(name: &str) -> (u32, String) { + if let Some(pos) = name.find('_') { + if let Ok(num) = name[..pos].parse::() { + return (num, name[pos + 1..].to_string()); + } + } + (0, name.to_string()) +} + +fn to_title_case(s: &str) -> String { + s.split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +fn to_pascal_case(s: &str) -> String { + s.split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(), + None => String::new(), + } + }) + .collect::>() + .join("") +} + +fn extract_title(content: &str) -> Option { + for line in content.lines() { + let trimmed = line.trim(); + if let Some(title) = trimmed.strip_prefix("# ") { + return Some(title.trim().to_string()); + } + } + None +} diff --git a/rusty-docs/docs/01_getting_started/01_introduction.md b/rusty-docs/docs/01_getting_started/01_introduction.md new file mode 100644 index 0000000..c04f84f --- /dev/null +++ b/rusty-docs/docs/01_getting_started/01_introduction.md @@ -0,0 +1,30 @@ +## What is Rusty-Framework? + +Rusty-Framework is a Rust-native reactive UI framework inspired by [Ivy-Framework](https://github.com/Ivy-Interactive/Ivy-Framework). It lets you build interactive web applications entirely in Rust using a component-based architecture with server-side rendering over WebSocket. + +### Key Features + +- **Pure Rust** — write your entire UI in Rust, no JavaScript required +- **Reactive** — fine-grained reactivity via hooks (`use_state`, `use_effect`, `use_memo`) +- **Server-side** — your views run on the server; the browser renders a lightweight widget tree +- **Diff-based updates** — only changed widgets are sent to the client +- **Type-safe** — leverage Rust's type system for widget props and events + +### How It Works + +1. You define a **View** — a struct that implements the `View` trait +2. The `build()` method returns an `Element` tree made of widgets +3. `RustyServer` serves your view over WebSocket +4. The client renders the widget tree and sends events back +5. Events trigger state changes, which trigger rebuilds, which produce diffs + +### Comparison with Ivy-Framework + +| Feature | Ivy (C#) | Rusty (Rust) | +|---------|----------|--------------| +| Language | C# | Rust | +| Runtime | .NET | Tokio | +| Transport | WebSocket | WebSocket | +| State | `UseState` | `use_state(ctx, T)` | +| Components | `IView` | `View` trait | +| Widgets | Class-based | Builder pattern | diff --git a/rusty-docs/docs/01_getting_started/02_installation.md b/rusty-docs/docs/01_getting_started/02_installation.md new file mode 100644 index 0000000..1f789c8 --- /dev/null +++ b/rusty-docs/docs/01_getting_started/02_installation.md @@ -0,0 +1,27 @@ +## Installation + +### Prerequisites + +- Rust 1.75+ (install via [rustup](https://rustup.rs/)) +- A modern web browser + +### Adding to an Existing Workspace + +Add `rusty` as a dependency in your crate's `Cargo.toml`: + +```toml +[dependencies] +rusty = { path = "../rusty" } +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = "0.3" +``` + +### Creating a New Project + +```bash +cargo new my-app +cd my-app +``` + +Then add the dependencies above and you're ready to build your first view. diff --git a/rusty-docs/docs/01_getting_started/03_basics.md b/rusty-docs/docs/01_getting_started/03_basics.md new file mode 100644 index 0000000..c815297 --- /dev/null +++ b/rusty-docs/docs/01_getting_started/03_basics.md @@ -0,0 +1,54 @@ +## Basics + +### The View Trait + +Every UI component in Rusty-Framework implements the `View` trait: + +```rust +pub trait View: Send + Sync + 'static { + fn build(&self, ctx: &mut BuildContext) -> Element; +} +``` + +The `build()` method receives a `BuildContext` and returns an `Element` — the tree of widgets to render. + +### Your First View + +```rust +use rusty::prelude::*; + +struct HelloApp; + +impl View for HelloApp { + fn build(&self, _ctx: &mut BuildContext) -> Element { + Layout::vertical() + .gap(16.0) + .padding(24.0) + .child(TextBlock::h1("Hello, World!")) + .child(TextBlock::paragraph("Welcome to Rusty-Framework.")) + .into() + } +} +``` + +### Running the Server + +```rust +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + RustyServer::new(3000, || HelloApp).serve().await +} +``` + +This starts a WebSocket server on port 3000. Open your browser and connect to see the rendered UI. + +### The Element Tree + +`Element` is an enum with three variants: + +- `Element::Widget(Box)` — a concrete widget +- `Element::Fragment(Vec)` — a list of elements +- `Element::Empty` — renders nothing + +Widgets convert to `Element` via the `.into()` method, which is called at the end of a builder chain. diff --git a/rusty-docs/docs/01_getting_started/_index.md b/rusty-docs/docs/01_getting_started/_index.md new file mode 100644 index 0000000..bad5562 --- /dev/null +++ b/rusty-docs/docs/01_getting_started/_index.md @@ -0,0 +1 @@ +# Getting Started diff --git a/rusty-docs/docs/02_concepts/01_views.md b/rusty-docs/docs/02_concepts/01_views.md new file mode 100644 index 0000000..5c5608f --- /dev/null +++ b/rusty-docs/docs/02_concepts/01_views.md @@ -0,0 +1,54 @@ +## Views + +Views are the fundamental building blocks of a Rusty-Framework application. A view is any type that implements the `View` trait. + +### The View Trait + +```rust +pub trait View: Send + Sync + 'static { + fn build(&self, ctx: &mut BuildContext) -> Element; +} +``` + +### BuildContext + +`BuildContext` is the mutable context passed to every `build()` call. It provides: + +- **Hook management** — `next_hook_index()` for ordered hook calls +- **Event registration** — `register_event()` to bind handlers to widget events +- **Widget IDs** — `next_widget_id()` generates unique IDs +- **Child views** — `child_view()` embeds sub-views with isolated hook stores +- **Context propagation** — `find_ancestor_context()` for dependency injection + +### Lifecycle + +1. The server creates a `Runtime` per client connection +2. On connect, `build()` is called to produce the initial widget tree +3. When state changes, `build()` is called again +4. The framework diffs the old and new trees and sends only changes to the client + +### Closures as Views + +Any closure matching `Fn(&mut BuildContext) -> Element + Send + Sync + 'static` also implements `View`: + +```rust +let my_view = |ctx: &mut BuildContext| -> Element { + TextBlock::paragraph("I'm a closure view!").into() +}; +``` + +### Child Views + +Use `ctx.child_view()` to embed a child view with its own isolated hook store: + +```rust +impl View for ParentView { + fn build(&self, ctx: &mut BuildContext) -> Element { + Layout::vertical() + .child(ctx.child_view(ChildView, None)) + .into() + } +} +``` + +This ensures each child maintains independent state across rebuilds. diff --git a/rusty-docs/docs/02_concepts/02_widgets.md b/rusty-docs/docs/02_concepts/02_widgets.md new file mode 100644 index 0000000..bb14f3b --- /dev/null +++ b/rusty-docs/docs/02_concepts/02_widgets.md @@ -0,0 +1,55 @@ +## Widgets + +Widgets are the visual primitives of Rusty-Framework. They are serializable data structures that describe what to render on the client. + +### The WidgetData Trait + +Every widget implements `WidgetData`, which provides: + +- `widget_type() -> &'static str` — the type name sent to the client +- `to_json() -> serde_json::Value` — serialization for the wire protocol +- `clone_box() -> Box` — dynamic cloning +- `assign_id(id: String)` / `get_id() -> Option` — ID management + +### Builder Pattern + +All widgets use a builder pattern: + +```rust +let button = Button::new("Click me") + .variant(ButtonVariant::Primary) + .icon(Icon::from("check")) + .disabled(false) + .color(Color::Named(NamedColor::Success)); +``` + +### Converting to Element + +Every widget implements `From for Element`. Call `.into()` at the end of a builder chain: + +```rust +fn build(&self, _ctx: &mut BuildContext) -> Element { + Button::new("OK").into() +} +``` + +Container widgets accept `impl Into` in their `.child()` method, so nested widgets convert automatically. + +### Custom Widgets with Derive + +Use the `#[derive(Widget)]` macro for custom widgets: + +```rust +#[derive(Widget, Clone, Debug)] +struct MyWidget { + #[prop] + label: String, + #[prop] + count: i32, + #[event] + on_click: Option>, +} +``` + +- `#[prop]` marks serializable properties +- `#[event]` marks event handler fields diff --git a/rusty-docs/docs/02_concepts/03_hooks.md b/rusty-docs/docs/02_concepts/03_hooks.md new file mode 100644 index 0000000..28dfa38 --- /dev/null +++ b/rusty-docs/docs/02_concepts/03_hooks.md @@ -0,0 +1,36 @@ +## Hooks + +Hooks let you add state and side effects to views. They must be called in the same order on every render — never inside conditionals or loops. + +### Available Hooks + +| Hook | Purpose | +|------|---------| +| `use_state` | Reactive state that triggers rebuilds | +| `use_ref` | Mutable state that does NOT trigger rebuilds | +| `use_effect` | Run side effects after every build | +| `use_effect_with_deps` | Run side effects when dependencies change | +| `use_memo` | Memoize expensive computations | +| `use_callback` | Memoize closures | +| `use_reducer` | Dispatch-based state management | +| `use_interval` | Run a callback on a timer | +| `use_context` / `create_context` | Dependency injection through the view tree | + +### Usage Pattern + +All hooks take `&mut BuildContext` as the first argument: + +```rust +fn build(&self, ctx: &mut BuildContext) -> Element { + let count = use_state(ctx, || 0i32); + let name = use_ref(ctx, || String::from("world")); + + // ... +} +``` + +### Rules of Hooks + +1. Only call hooks at the top level of `build()` +2. Never call hooks inside `if`, `match`, `for`, or closures +3. Hooks must be called in the same order every render diff --git a/rusty-docs/docs/02_concepts/04_state.md b/rusty-docs/docs/02_concepts/04_state.md new file mode 100644 index 0000000..0df4177 --- /dev/null +++ b/rusty-docs/docs/02_concepts/04_state.md @@ -0,0 +1,67 @@ +## State + +State management in Rusty-Framework is hook-based. The primary hook is `use_state`. + +### use_state + +```rust +let count = use_state(ctx, || 0i32); +``` + +Returns a `State` handle that is `Clone`, `Send`, and `Sync`. + +### Reading State + +```rust +let value = count.get(); // Returns a copy of the current value +``` + +### Writing State + +```rust +count.set(42); // Replace the value +count.update(|v| v + 1); // Update based on current value +``` + +Both `.set()` and `.update()` trigger a rebuild of the owning view. + +### Sharing State with Closures + +`State` is cheaply cloneable. Clone it before moving into event closures: + +```rust +let count = use_state(ctx, || 0i32); +let count_for_click = count.clone(); + +Button::new(format!("Count: {}", count.get())) + .on_click(move || { + count_for_click.update(|v| v + 1); + }) + .into() +``` + +### use_ref (Non-Reactive State) + +For state that should NOT trigger rebuilds: + +```rust +let render_count = use_ref(ctx, || 0u32); +render_count.update(|v| v + 1); +``` + +`Ref` has the same API as `State` but mutations are silent. + +### use_reducer + +For complex state with many transitions: + +```rust +fn reducer(state: &MyState, action: MyAction) -> MyState { + match action { + MyAction::Increment => MyState { count: state.count + 1 }, + MyAction::Reset => MyState { count: 0 }, + } +} + +let (state, dispatch) = use_reducer(ctx, reducer, MyState { count: 0 }); +``` diff --git a/rusty-docs/docs/02_concepts/05_effects.md b/rusty-docs/docs/02_concepts/05_effects.md new file mode 100644 index 0000000..0ca1520 --- /dev/null +++ b/rusty-docs/docs/02_concepts/05_effects.md @@ -0,0 +1,47 @@ +## Effects + +Effects let you run side effects (logging, data fetching, timers) in response to builds and state changes. + +### use_effect + +Runs after every build: + +```rust +use_effect(ctx, || { + println!("View was built!"); +}); +``` + +### use_effect_with_deps + +Runs only when dependencies change: + +```rust +let count = use_state(ctx, || 0i32); + +use_effect_with_deps(ctx, { + let current = count.get(); + move |_| { + println!("Count changed to: {}", current); + } +}, count.get()); +``` + +The second argument is the effect closure. The third argument is the dependency value — the effect re-runs when this value changes (compared via `DynEq`). + +### use_interval + +Runs a callback periodically: + +```rust +let ticks = use_state(ctx, || 0u64); +let ticks_clone = ticks.clone(); + +use_interval(ctx, move || { + ticks_clone.update(|v| v + 1); +}, 1000); // every 1000ms +``` + +### Cleanup + +Effects can return a cleanup function that runs before the next effect execution or when the view is unmounted. This pattern follows the same semantics as React's `useEffect` cleanup. diff --git a/rusty-docs/docs/02_concepts/06_layout.md b/rusty-docs/docs/02_concepts/06_layout.md new file mode 100644 index 0000000..c070c7e --- /dev/null +++ b/rusty-docs/docs/02_concepts/06_layout.md @@ -0,0 +1,52 @@ +## Layout + +Layout widgets control how children are arranged on screen. + +### Vertical Layout + +Stack children top-to-bottom: + +```rust +Layout::vertical() + .gap(16.0) + .padding(24.0) + .child(TextBlock::h1("Title")) + .child(TextBlock::paragraph("Body text")) + .into() +``` + +### Horizontal Layout + +Arrange children left-to-right: + +```rust +Layout::horizontal() + .gap(8.0) + .align(Align::Center) + .child(Button::new("Cancel")) + .child(Button::new("OK")) + .into() +``` + +### Grid Layout + +Arrange children in a grid with a fixed number of columns: + +```rust +Layout::grid(3) + .gap(12.0) + .children(items.iter().map(|item| { + Card::new().title(&item.name).into() + }).collect()) + .into() +``` + +### Alignment and Justification + +- `.align(Align)` — cross-axis alignment: `Start`, `Center`, `End`, `Stretch` +- `.justify(Justify)` — main-axis justification: `Start`, `Center`, `End`, `SpaceBetween`, `SpaceAround`, `SpaceEvenly` + +### Spacing + +- `.gap(f64)` — space between children +- `.padding(f64)` — inner padding on all sides diff --git a/rusty-docs/docs/02_concepts/_index.md b/rusty-docs/docs/02_concepts/_index.md new file mode 100644 index 0000000..74d42e1 --- /dev/null +++ b/rusty-docs/docs/02_concepts/_index.md @@ -0,0 +1 @@ +# Concepts diff --git a/rusty-docs/docs/03_widgets/01_button.md b/rusty-docs/docs/03_widgets/01_button.md new file mode 100644 index 0000000..3fa339b --- /dev/null +++ b/rusty-docs/docs/03_widgets/01_button.md @@ -0,0 +1,41 @@ +## Button + +An interactive button widget. + +### Constructor + +```rust +Button::new("Click me") +``` + +### Properties + +| Property | Method | Type | Description | +|----------|--------|------|-------------| +| Title | `new(title)` | `&str` | Button label text | +| Variant | `.variant(v)` | `ButtonVariant` | `Default`, `Primary`, `Ghost` | +| Icon | `.icon(i)` | `Icon` | Optional leading icon | +| Color | `.color(c)` | `Color` | Button color | +| Density | `.density(d)` | `Density` | `Compact`, `Normal`, `Comfortable` | +| Disabled | `.disabled(b)` | `bool` | Disable interaction | +| Loading | `.loading(b)` | `bool` | Show loading spinner | + +### Events + +| Event | Method | Signature | +|-------|--------|-----------| +| Click | `.on_click(f)` | `Fn() + Send + Sync` | + +### Example + +```rust +let count = use_state(ctx, || 0i32); +let count_click = count.clone(); + +Button::new(format!("Clicked {} times", count.get())) + .variant(ButtonVariant::Primary) + .on_click(move || { + count_click.update(|v| v + 1); + }) + .into() +``` diff --git a/rusty-docs/docs/03_widgets/02_text_block.md b/rusty-docs/docs/03_widgets/02_text_block.md new file mode 100644 index 0000000..e83a37f --- /dev/null +++ b/rusty-docs/docs/03_widgets/02_text_block.md @@ -0,0 +1,37 @@ +## TextBlock + +A text display widget with semantic variants. + +### Constructors + +```rust +TextBlock::new("plain text") +TextBlock::h1("Heading 1") +TextBlock::h2("Heading 2") +TextBlock::h3("Heading 3") +TextBlock::paragraph("Body text") +TextBlock::code("let x = 42;") +TextBlock::markdown("**bold** and *italic*") +TextBlock::label("Field Label") +``` + +### Properties + +| Property | Method | Type | Description | +|----------|--------|------|-------------| +| Content | `new(text)` | `&str` | Text content | +| Variant | `.variant(v)` | `TextVariant` | `H1`, `H2`, `H3`, `Paragraph`, `Code`, `Markdown`, `Label` | +| Color | `.color(c)` | `Color` | Text color | +| Bold | `.bold()` | — | Make text bold | +| Italic | `.italic()` | — | Make text italic | + +### Example + +```rust +Layout::vertical() + .gap(8.0) + .child(TextBlock::h1("Welcome")) + .child(TextBlock::paragraph("This is a paragraph of text.")) + .child(TextBlock::code("println!(\"Hello!\");")) + .into() +``` diff --git a/rusty-docs/docs/03_widgets/03_layout.md b/rusty-docs/docs/03_widgets/03_layout.md new file mode 100644 index 0000000..acfa61e --- /dev/null +++ b/rusty-docs/docs/03_widgets/03_layout.md @@ -0,0 +1,40 @@ +## Layout + +A container widget that arranges children in vertical, horizontal, or grid arrangements. + +### Constructors + +```rust +Layout::vertical() // Stack top-to-bottom +Layout::horizontal() // Arrange left-to-right +Layout::grid(columns) // Grid with N columns +``` + +### Properties + +| Property | Method | Type | Description | +|----------|--------|------|-------------| +| Gap | `.gap(n)` | `f64` | Space between children | +| Padding | `.padding(n)` | `f64` | Inner padding | +| Align | `.align(a)` | `Align` | Cross-axis alignment | +| Justify | `.justify(j)` | `Justify` | Main-axis justification | + +### Children + +```rust +Layout::vertical() + .child(widget1) // Add single child + .children(vec![w1, w2]) // Add multiple children + .into() +``` + +### Example + +```rust +Layout::horizontal() + .gap(8.0) + .align(Align::Center) + .child(Button::new("Cancel").variant(ButtonVariant::Ghost)) + .child(Button::new("Save").variant(ButtonVariant::Primary)) + .into() +``` diff --git a/rusty-docs/docs/03_widgets/04_card.md b/rusty-docs/docs/03_widgets/04_card.md new file mode 100644 index 0000000..df33634 --- /dev/null +++ b/rusty-docs/docs/03_widgets/04_card.md @@ -0,0 +1,42 @@ +## Card + +A container with optional title, subtitle, and footer. + +### Constructor + +```rust +Card::new() +``` + +### Properties + +| Property | Method | Type | Description | +|----------|--------|------|-------------| +| Title | `.title(s)` | `&str` | Card header title | +| Subtitle | `.subtitle(s)` | `&str` | Card header subtitle | +| Padding | `.padding(n)` | `f64` | Content padding | + +### Children + +```rust +Card::new() + .title("User Profile") + .child(TextBlock::paragraph("Card content goes here")) + .footer(Button::new("Edit")) + .into() +``` + +### Example + +```rust +Card::new() + .title("Statistics") + .subtitle("Last 30 days") + .child( + Layout::horizontal() + .gap(16.0) + .child(TextBlock::h2("1,234")) + .child(TextBlock::label("Total views")) + ) + .into() +``` diff --git a/rusty-docs/docs/03_widgets/05_table.md b/rusty-docs/docs/03_widgets/05_table.md new file mode 100644 index 0000000..d0ffbd6 --- /dev/null +++ b/rusty-docs/docs/03_widgets/05_table.md @@ -0,0 +1,37 @@ +## Table + +A data table with columns, rows, and optional sorting. + +### Constructor + +```rust +Table::new(vec![ + Column { key: "name".into(), label: "Name".into() }, + Column { key: "age".into(), label: "Age".into() }, +]) +``` + +### Properties + +| Property | Method | Type | Description | +|----------|--------|------|-------------| +| Columns | `new(cols)` | `Vec` | Column definitions | +| Rows | `.rows(r)` | `Vec` | Data rows | +| Sort By | `.sort_by(key)` | `&str` | Default sort column | + +### Example + +```rust +use rusty::widgets::table::{Column, Row}; +use std::collections::HashMap; + +Table::new(vec![ + Column { key: "name".into(), label: "Name".into() }, + Column { key: "role".into(), label: "Role".into() }, +]) +.rows(vec![ + Row { values: HashMap::from([("name".into(), "Alice".into()), ("role".into(), "Admin".into())]) }, + Row { values: HashMap::from([("name".into(), "Bob".into()), ("role".into(), "User".into())]) }, +]) +.into() +``` diff --git a/rusty-docs/docs/03_widgets/06_inputs.md b/rusty-docs/docs/03_widgets/06_inputs.md new file mode 100644 index 0000000..1dfb79f --- /dev/null +++ b/rusty-docs/docs/03_widgets/06_inputs.md @@ -0,0 +1,71 @@ +## Input Widgets + +Rusty-Framework provides several input widgets for forms and user interaction. + +### TextInput + +```rust +let name = use_state(ctx, || String::new()); +let name_change = name.clone(); + +TextInput::new() + .value(&name.get()) + .label("Name") + .placeholder("Enter your name") + .on_change(move |val: String| { + name_change.set(val); + }) + .into() +``` + +### NumberInput + +```rust +let age = use_state(ctx, || 0.0f64); +let age_change = age.clone(); + +NumberInput::new() + .value(age.get()) + .label("Age") + .min(0.0) + .max(150.0) + .step(1.0) + .on_change(move |val: f64| { + age_change.set(val); + }) + .into() +``` + +### Select + +```rust +use rusty::widgets::input::SelectOption; + +let choice = use_state(ctx, || String::from("a")); +let choice_change = choice.clone(); + +Select::new(vec![ + SelectOption { value: "a".into(), label: "Option A".into() }, + SelectOption { value: "b".into(), label: "Option B".into() }, +]) +.value(&choice.get()) +.label("Choose one") +.on_change(move |val: String| { + choice_change.set(val); +}) +.into() +``` + +### Checkbox + +```rust +let agreed = use_state(ctx, || false); +let agreed_change = agreed.clone(); + +Checkbox::new(agreed.get()) + .label("I agree to the terms") + .on_change(move |val: bool| { + agreed_change.set(val); + }) + .into() +``` diff --git a/rusty-docs/docs/03_widgets/07_dialog.md b/rusty-docs/docs/03_widgets/07_dialog.md new file mode 100644 index 0000000..f2c2d6c --- /dev/null +++ b/rusty-docs/docs/03_widgets/07_dialog.md @@ -0,0 +1,55 @@ +## Dialog + +A modal dialog overlay. + +### Constructor + +```rust +Dialog::new(is_open) +``` + +### Properties + +| Property | Method | Type | Description | +|----------|--------|------|-------------| +| Open | `new(open)` | `bool` | Controls visibility | +| Title | `.title(s)` | `&str` | Dialog header | + +### Children + +```rust +Dialog::new(true) + .title("Confirm Delete") + .child(TextBlock::paragraph("Are you sure?")) + .footer( + Layout::horizontal() + .gap(8.0) + .child(Button::new("Cancel")) + .child(Button::new("Delete").color(Color::Named(NamedColor::Danger))) + ) + .into() +``` + +### Example with State + +```rust +let open = use_state(ctx, || false); +let open_show = open.clone(); +let open_hide = open.clone(); + +Layout::vertical() + .child( + Button::new("Open Dialog") + .on_click(move || open_show.set(true)) + ) + .child( + Dialog::new(open.get()) + .title("My Dialog") + .child(TextBlock::paragraph("Dialog content")) + .footer( + Button::new("Close") + .on_click(move || open_hide.set(false)) + ) + ) + .into() +``` diff --git a/rusty-docs/docs/03_widgets/08_badge.md b/rusty-docs/docs/03_widgets/08_badge.md new file mode 100644 index 0000000..64b5728 --- /dev/null +++ b/rusty-docs/docs/03_widgets/08_badge.md @@ -0,0 +1,28 @@ +## Badge + +A small label for status indicators and tags. + +### Constructor + +```rust +Badge::new("Active") +``` + +### Properties + +| Property | Method | Type | Description | +|----------|--------|------|-------------| +| Label | `new(label)` | `&str` | Badge text | +| Variant | `.variant(v)` | `BadgeVariant` | Visual style | +| Color | `.color(c)` | `Color` | Badge color | + +### Example + +```rust +Layout::horizontal() + .gap(8.0) + .child(Badge::new("Active").color(Color::Named(NamedColor::Success))) + .child(Badge::new("Pending").color(Color::Named(NamedColor::Warning))) + .child(Badge::new("Error").color(Color::Named(NamedColor::Danger))) + .into() +``` diff --git a/rusty-docs/docs/03_widgets/09_progress.md b/rusty-docs/docs/03_widgets/09_progress.md new file mode 100644 index 0000000..114dbee --- /dev/null +++ b/rusty-docs/docs/03_widgets/09_progress.md @@ -0,0 +1,31 @@ +## Progress + +A progress bar widget. + +### Constructors + +```rust +Progress::new(0.75) // 75% progress +Progress::indeterminate() // Animated loading bar +``` + +### Properties + +| Property | Method | Type | Description | +|----------|--------|------|-------------| +| Value | `new(value)` | `f64` | Progress value (0.0 to 1.0) | +| Max | `.max(n)` | `f64` | Maximum value | +| Label | `.label(s)` | `&str` | Descriptive label | +| Color | `.color(c)` | `Color` | Bar color | + +### Example + +```rust +let progress = use_state(ctx, || 0.0f64); + +Layout::vertical() + .gap(8.0) + .child(Progress::new(progress.get()).label("Upload progress")) + .child(TextBlock::label(&format!("{:.0}%", progress.get() * 100.0))) + .into() +``` diff --git a/rusty-docs/docs/03_widgets/10_tooltip.md b/rusty-docs/docs/03_widgets/10_tooltip.md new file mode 100644 index 0000000..592e527 --- /dev/null +++ b/rusty-docs/docs/03_widgets/10_tooltip.md @@ -0,0 +1,32 @@ +## Tooltip + +Wraps a child widget with a hover tooltip. + +### Constructor + +```rust +Tooltip::new("Tooltip text", child_element) +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| Content | `&str` | Tooltip text shown on hover | +| Child | `impl Into` | The widget to wrap | + +### Example + +```rust +Tooltip::new( + "Click to submit the form", + Button::new("Submit").variant(ButtonVariant::Primary), +) +.into() +``` + +### Usage Notes + +- Tooltip wraps exactly one child element +- The tooltip appears on hover over the child +- Keep tooltip text concise diff --git a/rusty-docs/docs/03_widgets/_index.md b/rusty-docs/docs/03_widgets/_index.md new file mode 100644 index 0000000..097bbee --- /dev/null +++ b/rusty-docs/docs/03_widgets/_index.md @@ -0,0 +1 @@ +# Widgets diff --git a/rusty-docs/docs/04_api_reference/01_prelude.md b/rusty-docs/docs/04_api_reference/01_prelude.md new file mode 100644 index 0000000..a59d7ac --- /dev/null +++ b/rusty-docs/docs/04_api_reference/01_prelude.md @@ -0,0 +1,86 @@ +## Prelude + +The `rusty::prelude` module re-exports everything you need to build applications. Import it with: + +```rust +use rusty::prelude::*; +``` + +### Core Types + +| Type | Description | +|------|-------------| +| `Runtime` | Manages the view lifecycle and reconciliation | +| `ViewTree` | The tree structure holding views and their state | + +### Traits + +| Trait | Description | +|-------|-------------| +| `View` | The core trait — implement `build()` to define UI | +| `WidgetData` | Trait for serializable widget data | + +### Views + +| Type | Description | +|------|-------------| +| `BuildContext` | Mutable context passed to `View::build()` | +| `Element` | The element tree enum (`Widget`, `Fragment`, `Empty`) | + +### Hooks + +| Function | Description | +|----------|-------------| +| `use_state(ctx, init)` | Reactive state | +| `use_ref(ctx, init)` | Non-reactive mutable state | +| `use_effect(ctx, f)` | Side effect on every build | +| `use_effect_with_deps(ctx, f, deps)` | Side effect on dependency change | +| `use_memo(ctx, f, deps)` | Memoized computation | +| `use_callback(ctx, f, deps)` | Memoized closure | +| `use_reducer(ctx, reducer, init)` | Dispatch-based state | +| `use_interval(ctx, f, ms)` | Periodic timer | +| `create_context(ctx, value)` | Provide context value | +| `use_context::(ctx)` | Consume context value | + +### State Types + +| Type | Description | +|------|-------------| +| `State` | Reactive state handle (`.get()`, `.set()`, `.update()`) | +| `Ref` | Non-reactive state handle | + +### Widgets + +| Widget | Description | +|--------|-------------| +| `Layout` | Container with vertical/horizontal/grid arrangement | +| `TextBlock` | Text display with semantic variants | +| `Button` | Clickable button | +| `Card` | Container with title and footer | +| `Dialog` | Modal overlay | +| `TextInput` | Text input field | +| `NumberInput` | Number input field | +| `Select` | Dropdown select | +| `Checkbox` | Boolean toggle | +| `Badge` | Status label | +| `Table` | Data table | +| `Progress` | Progress bar | +| `Tooltip` | Hover tooltip wrapper | + +### Shared Types + +| Type | Description | +|------|-------------| +| `Color` | `Named(NamedColor)`, `Hex(String)`, `Rgba { r, g, b, a }` | +| `NamedColor` | `Primary`, `Secondary`, `Success`, `Warning`, `Danger`, `Info`, `Muted`, `White`, `Black` | +| `Size` | `Px(f64)`, `Percent(f64)`, `Auto` | +| `Density` | `Compact`, `Normal`, `Comfortable` | +| `Align` | `Start`, `Center`, `End`, `Stretch` | +| `Justify` | `Start`, `Center`, `End`, `SpaceBetween`, `SpaceAround`, `SpaceEvenly` | +| `Icon` | Icon identifier (`Icon::from("name")`) | + +### Server + +| Type | Description | +|------|-------------| +| `RustyServer` | WebSocket server — `new(port, factory).serve().await` | diff --git a/rusty-docs/docs/04_api_reference/_index.md b/rusty-docs/docs/04_api_reference/_index.md new file mode 100644 index 0000000..b0d5c88 --- /dev/null +++ b/rusty-docs/docs/04_api_reference/_index.md @@ -0,0 +1 @@ +# API Reference diff --git a/rusty-docs/src/main.rs b/rusty-docs/src/main.rs new file mode 100644 index 0000000..8a062a5 --- /dev/null +++ b/rusty-docs/src/main.rs @@ -0,0 +1,12 @@ +use rusty::prelude::*; + +mod generated; +mod server; + +use server::DocsShellView; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + RustyServer::new(3001, || DocsShellView).serve().await +} diff --git a/rusty-docs/src/server.rs b/rusty-docs/src/server.rs new file mode 100644 index 0000000..b7daa2c --- /dev/null +++ b/rusty-docs/src/server.rs @@ -0,0 +1,78 @@ +use rusty::prelude::*; + +use crate::generated::all_pages; + +/// Wrapper view that renders a specific page by index. +struct PageView { + page_index: usize, +} + +impl View for PageView { + fn build(&self, _ctx: &mut BuildContext) -> Element { + let pages = all_pages(); + if let Some(page) = pages.into_iter().nth(self.page_index) { + let view = (page.view_factory)(); + view.build(_ctx) + } else { + Element::Empty + } + } +} + +pub struct DocsShellView; + +impl View for DocsShellView { + fn build(&self, ctx: &mut BuildContext) -> Element { + let pages = all_pages(); + let active_index = use_state(ctx, 0usize); + + // Build sidebar navigation + let mut sidebar = Layout::vertical().gap(4.0).padding(16.0); + let mut current_section = String::new(); + + for (i, page) in pages.iter().enumerate() { + if page.section != current_section { + current_section = page.section.to_string(); + sidebar = sidebar.child( + TextBlock::new(¤t_section) + .bold() + .color(Color::Named(NamedColor::Muted)), + ); + } + + let active = active_index.clone(); + let is_active = active_index.get() == i; + + let btn = if is_active { + Button::new(page.title) + .variant(rusty::widgets::button::ButtonVariant::Ghost) + .color(Color::Named(NamedColor::Primary)) + .on_click(move || { + active.set(i); + }) + } else { + Button::new(page.title) + .variant(rusty::widgets::button::ButtonVariant::Ghost) + .on_click(move || { + active.set(i); + }) + }; + + sidebar = sidebar.child(btn); + } + + // Build content area using child_view + let page_view = PageView { + page_index: active_index.get(), + }; + let (content, _view_id, _hook_store) = ctx.child_view(page_view, None); + + let content_area = Layout::vertical().padding(32.0).gap(16.0).child(content); + + // Shell layout: sidebar + content + Layout::horizontal() + .child(sidebar) + .child(content_area) + .into() + } +}