From da7b4c565803bc43a38b7f9b7f08b30be4b11d068e974aab962391f285185563 Mon Sep 17 00:00:00 2001 From: Tyler King Date: Sun, 5 Apr 2026 14:38:14 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20dioxus-ratatui=20v0.1.0=20=E2=80=94=20R?= =?UTF-8?q?atatui=20renderer=20for=20Dioxus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge between Dioxus Core (reactive components) and Ratatui (terminal rendering). Architecture: retained TuiNode tree + immediate Ratatui render Dioxus VirtualDom → WriteMutations → TuiTree → Frame → Terminal Modules: tree.rs — retained node tree (Element, Text, Placeholder) renderer.rs — WriteMutations impl (14 methods) layout.rs — Dioxus attrs → Ratatui Constraint/Direction style.rs — attrs → Ratatui Style (colors, bold, italic) events.rs — crossterm quit detection render.rs — tree walker → Ratatui widget rendering lib.rs — launch() + event loop + terminal lifecycle Elements: div, p, span, h1-h3, hr Layout: vertical/horizontal split, percentage/length/fill constraints Styling: named colors, hex, bold, italic, underline, dim Events: keyboard quit (Ctrl+C, q) 453 lines. Zero governance/substrate dependencies. Apache 2.0 — pure community crate. Signed-off-by: Tyler King --- .gitignore | 2 + Cargo.toml | 20 +++++ LICENSE | 202 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 49 +++++++++++ examples/hello.rs | 15 ++++ src/events.rs | 11 +++ src/layout.rs | 40 +++++++++ src/lib.rs | 78 ++++++++++++++++++ src/render.rs | 106 ++++++++++++++++++++++++ src/renderer.rs | 106 ++++++++++++++++++++++++ src/style.rs | 49 +++++++++++ src/tree.rs | 63 +++++++++++++++ 12 files changed, 741 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/hello.rs create mode 100644 src/events.rs create mode 100644 src/layout.rs create mode 100644 src/lib.rs create mode 100644 src/render.rs create mode 100644 src/renderer.rs create mode 100644 src/style.rs create mode 100644 src/tree.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86d2e35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target/ +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9d7741f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dioxus-ratatui" +version = "0.1.0" +edition = "2021" +description = "Ratatui renderer for Dioxus — build reactive terminal UIs with Dioxus components" +license = "Apache-2.0" +keywords = ["dioxus", "ratatui", "tui", "terminal"] +categories = ["gui", "command-line-interface"] + +[dependencies] +dioxus-core = "0.6" +ratatui = { version = "0.29", default-features = false, features = ["crossterm"] } +crossterm = "0.28" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +tracing = "0.1" +anyhow = "1" + +[dev-dependencies] +dioxus = { version = "0.6", default-features = false, features = ["macro", "hooks", "signals", "html"] } +tracing-subscriber = "0.3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81fbaf6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d791ec2 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# dioxus-ratatui + +Ratatui renderer for Dioxus — build reactive terminal UIs with Dioxus components. + +**Early development** — API will change. + +## Quick Start + +```rust +use dioxus::prelude::*; + +fn main() { + dioxus_ratatui::launch(App); +} + +fn App() -> Element { + rsx! { + div { + h1 { "Hello from Dioxus + Ratatui!" } + p { "Press 'q' or Ctrl+C to quit." } + } + } +} +``` + +## Architecture + +Dioxus VirtualDom sends mutations via `WriteMutations` trait. The bridge maintains a retained tree of `TuiNode` structs. Each frame, Ratatui walks the tree and renders widgets. + +``` +Dioxus VirtualDom → WriteMutations → TuiTree → Ratatui Frame → Terminal +``` + +## Supported Elements + +| Element | Widget | Status | +|---|---|---| +| `div` | Block container | v0.1 | +| `p`, `span` | Paragraph | v0.1 | +| `h1`-`h3` | Bold paragraph | v0.1 | +| `hr` | Horizontal line | v0.1 | + +## Layout + +Children of `div` elements are stacked vertically by default. Ratatui constraints are derived from element attributes via the layout module. + +## License + +Apache 2.0 diff --git a/examples/hello.rs b/examples/hello.rs new file mode 100644 index 0000000..ea83dbb --- /dev/null +++ b/examples/hello.rs @@ -0,0 +1,15 @@ +use dioxus::prelude::*; + +fn main() { + dioxus_ratatui::launch(App); +} + +fn App() -> Element { + rsx! { + div { + h1 { "Hello from Dioxus + Ratatui!" } + p { "This is a reactive terminal UI." } + p { "Press 'q' or Ctrl+C to quit." } + } + } +} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..5e62c7f --- /dev/null +++ b/src/events.rs @@ -0,0 +1,11 @@ +//! Map crossterm events to Dioxus events. + +use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers}; + +pub fn is_quit_event(event: &CtEvent) -> bool { + matches!(event, + CtEvent::Key(KeyEvent { code: KeyCode::Char('c'), modifiers, .. }) if modifiers.contains(KeyModifiers::CONTROL) + ) || matches!(event, + CtEvent::Key(KeyEvent { code: KeyCode::Char('q'), .. }) + ) +} diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..879cf39 --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,40 @@ +//! Map Dioxus layout attributes to Ratatui Layout/Constraint. + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use std::collections::HashMap; + +pub fn direction_from_attrs(attrs: &HashMap) -> Direction { + match attrs.get("flex_direction").or(attrs.get("direction")).map(|s| s.as_str()) { + Some("row") | Some("horizontal") => Direction::Horizontal, + _ => Direction::Vertical, + } +} + +pub fn constraint_from_attrs(attrs: &HashMap, direction: Direction) -> Constraint { + let size_attr = match direction { + Direction::Vertical => attrs.get("height"), + Direction::Horizontal => attrs.get("width"), + }; + if let Some(size) = size_attr { + return parse_constraint(size); + } + if let Some(flex) = attrs.get("flex") { + if let Ok(r) = flex.parse::() { return Constraint::Fill(r); } + } + Constraint::Fill(1) +} + +fn parse_constraint(s: &str) -> Constraint { + let s = s.trim(); + if s.ends_with('%') { + if let Ok(p) = s.trim_end_matches('%').parse::() { return Constraint::Percentage(p); } + } + let numeric = s.trim_end_matches(|c: char| c.is_alphabetic()); + if let Ok(n) = numeric.parse::() { return Constraint::Length(n); } + Constraint::Fill(1) +} + +pub fn split_area(area: Rect, direction: Direction, constraints: &[Constraint]) -> Vec { + if constraints.is_empty() { return vec![area]; } + Layout::default().direction(direction).constraints(constraints).split(area).to_vec() +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9359005 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,78 @@ +//! # dioxus-ratatui +//! +//! Ratatui renderer for Dioxus — build reactive terminal UIs with Dioxus components. + +pub mod tree; +pub mod renderer; +pub mod layout; +pub mod style; +pub mod events; +pub mod render; + +use std::io; +use std::time::Duration; +use crossterm::{ + terminal::{enable_raw_mode, disable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + execute, + event, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use dioxus_core::VirtualDom; + +use crate::renderer::TuiRenderer; +use crate::render::render_tree; +use crate::events::is_quit_event; + +/// Launch a Dioxus app in the terminal. +pub fn launch(app: fn() -> dioxus_core::Element) { + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + original_hook(info); + })); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime"); + + rt.block_on(async { + if let Err(e) = run(app).await { + eprintln!("dioxus-ratatui error: {}", e); + } + }); +} + +async fn run(app: fn() -> dioxus_core::Element) -> anyhow::Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + let mut vdom = VirtualDom::new(app); + let mut renderer = TuiRenderer::new(); + + vdom.rebuild(&mut renderer); + + loop { + terminal.draw(|frame| { + render_tree(&renderer.tree, frame, frame.area()); + })?; + + if event::poll(Duration::from_millis(50))? { + let evt = event::read()?; + if is_quit_event(&evt) { break; } + } + + vdom.render_immediate(&mut renderer); + } + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..325fb17 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,106 @@ +//! Walk the TuiTree and render to Ratatui Frame. + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::tree::{TuiTree, TuiNode, NodeId}; +use crate::style::style_from_attrs; +use crate::layout::{direction_from_attrs, constraint_from_attrs, split_area}; + +pub fn render_tree(tree: &TuiTree, frame: &mut Frame, area: Rect) { + let roots = tree.roots(); + if roots.is_empty() { return; } + if roots.len() == 1 { + render_node(tree, roots[0], frame, area); + return; + } + let constraints: Vec<_> = roots.iter().map(|_| ratatui::layout::Constraint::Fill(1)).collect(); + let areas = split_area(area, ratatui::layout::Direction::Vertical, &constraints); + for (i, &root_id) in roots.iter().enumerate() { + if i < areas.len() { render_node(tree, root_id, frame, areas[i]); } + } +} + +fn render_node(tree: &TuiTree, id: NodeId, frame: &mut Frame, area: Rect) { + let node = match tree.get(id) { Some(n) => n.clone(), None => return }; + match node { + TuiNode::Element { ref tag, ref attrs, ref children, .. } => { + render_element(tree, tag, attrs, children, frame, area); + } + TuiNode::Text(ref text) => { + frame.render_widget(Paragraph::new(text.as_str()), area); + } + TuiNode::Placeholder => {} + } +} + +fn render_element( + tree: &TuiTree, tag: &str, attrs: &std::collections::HashMap, + children: &[NodeId], frame: &mut Frame, area: Rect, +) { + let style = style_from_attrs(attrs); + let has_border = attrs.contains_key("border") || attrs.contains_key("bordered"); + + match tag { + "div" | "section" | "main" | "header" | "footer" | "nav" => { + let mut inner = area; + if has_border { + let title = attrs.get("title").cloned().unwrap_or_default(); + let block = Block::default().borders(Borders::ALL).title(title).style(style); + inner = block.inner(area); + frame.render_widget(block, area); + } + if !children.is_empty() { + let dir = direction_from_attrs(attrs); + let empty = std::collections::HashMap::new(); + let constraints: Vec<_> = children.iter().map(|&cid| { + let a = if let Some(TuiNode::Element { attrs, .. }) = tree.get(cid) { attrs } else { &empty }; + constraint_from_attrs(a, dir) + }).collect(); + let child_areas = split_area(inner, dir, &constraints); + for (i, &cid) in children.iter().enumerate() { + if i < child_areas.len() { render_node(tree, cid, frame, child_areas[i]); } + } + } + } + "p" | "span" | "h1" | "h2" | "h3" | "label" => { + let text = collect_text(tree, children); + let mut s = style; + if tag.starts_with('h') { s = s.add_modifier(ratatui::style::Modifier::BOLD); } + let p = Paragraph::new(text); + if has_border { + frame.render_widget(p.style(s).block(Block::default().borders(Borders::ALL)), area); + } else { + frame.render_widget(p.style(s), area); + } + } + "hr" => { + frame.render_widget(Block::default().borders(Borders::BOTTOM).style(style), area); + } + _ => { + if !children.is_empty() { + let dir = direction_from_attrs(attrs); + let constraints: Vec<_> = children.iter().map(|_| ratatui::layout::Constraint::Fill(1)).collect(); + let areas = split_area(area, dir, &constraints); + for (i, &cid) in children.iter().enumerate() { + if i < areas.len() { render_node(tree, cid, frame, areas[i]); } + } + } + } + } +} + +fn collect_text(tree: &TuiTree, children: &[NodeId]) -> String { + let mut text = String::new(); + for &cid in children { + match tree.get(cid) { + Some(TuiNode::Text(t)) => text.push_str(t), + Some(TuiNode::Element { tag, children: gc, .. }) if tag == "span" || tag == "b" => { + text.push_str(&collect_text(tree, gc)); + } + _ => {} + } + } + text +} diff --git a/src/renderer.rs b/src/renderer.rs new file mode 100644 index 0000000..1a3bc3a --- /dev/null +++ b/src/renderer.rs @@ -0,0 +1,106 @@ +//! Dioxus WriteMutations implementation for Ratatui. + +use dioxus_core::{AttributeValue, ElementId, Template, TemplateNode, WriteMutations}; +use crate::tree::{TuiTree, TuiNode}; + +pub struct TuiRenderer { + pub tree: TuiTree, +} + +impl TuiRenderer { + pub fn new() -> Self { Self { tree: TuiTree::new() } } +} + +impl WriteMutations for TuiRenderer { + fn append_children(&mut self, id: ElementId, m: usize) { + let child_ids = self.tree.pop_stack(m); + self.tree.append_children(id.0, &child_ids); + } + + fn assign_node_id(&mut self, _path: &'static [u8], _id: ElementId) {} + + fn create_placeholder(&mut self, id: ElementId) { + self.tree.insert(id.0, TuiNode::Placeholder); + self.tree.push_stack(id.0); + } + + fn create_text_node(&mut self, value: &str, id: ElementId) { + self.tree.insert(id.0, TuiNode::Text(value.to_string())); + self.tree.push_stack(id.0); + } + + fn load_template(&mut self, template: Template, index: usize, id: ElementId) { + let tag = match template.roots[index] { + TemplateNode::Element { tag, .. } => tag.to_string(), + TemplateNode::Text { text } => { + self.tree.insert(id.0, TuiNode::Text(text.to_string())); + self.tree.push_stack(id.0); + return; + } + _ => "div".to_string(), + }; + self.tree.insert(id.0, TuiNode::Element { + tag, + attrs: std::collections::HashMap::new(), + children: Vec::new(), + listeners: std::collections::HashSet::new(), + }); + self.tree.push_stack(id.0); + } + + fn replace_node_with(&mut self, id: ElementId, m: usize) { + let _new = self.tree.pop_stack(m); + self.tree.remove(id.0); + } + + fn replace_placeholder_with_nodes(&mut self, _path: &'static [u8], m: usize) { + let _new = self.tree.pop_stack(m); + } + + fn insert_nodes_after(&mut self, _id: ElementId, m: usize) { + let _new = self.tree.pop_stack(m); + } + + fn insert_nodes_before(&mut self, _id: ElementId, m: usize) { + let _new = self.tree.pop_stack(m); + } + + fn set_attribute(&mut self, name: &'static str, _ns: Option<&'static str>, value: &AttributeValue, id: ElementId) { + if let Some(TuiNode::Element { attrs, .. }) = self.tree.get_mut(id.0) { + let val = match value { + AttributeValue::Text(t) => t.to_string(), + AttributeValue::Int(i) => i.to_string(), + AttributeValue::Float(f) => f.to_string(), + AttributeValue::Bool(b) => b.to_string(), + _ => return, + }; + attrs.insert(name.to_string(), val); + } + } + + fn set_node_text(&mut self, value: &str, id: ElementId) { + if let Some(node) = self.tree.get_mut(id.0) { + *node = TuiNode::Text(value.to_string()); + } + } + + fn create_event_listener(&mut self, name: &'static str, id: ElementId) { + if let Some(TuiNode::Element { listeners, .. }) = self.tree.get_mut(id.0) { + listeners.insert(name.to_string()); + } + } + + fn remove_event_listener(&mut self, name: &'static str, id: ElementId) { + if let Some(TuiNode::Element { listeners, .. }) = self.tree.get_mut(id.0) { + listeners.remove(name); + } + } + + fn remove_node(&mut self, id: ElementId) { + self.tree.remove(id.0); + } + + fn push_root(&mut self, id: ElementId) { + self.tree.push_stack(id.0); + } +} diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..a6030ae --- /dev/null +++ b/src/style.rs @@ -0,0 +1,49 @@ +//! Map Dioxus style attributes to Ratatui Style. + +use ratatui::style::{Color, Modifier, Style}; +use std::collections::HashMap; + +pub fn style_from_attrs(attrs: &HashMap) -> Style { + let mut style = Style::default(); + + if let Some(c) = attrs.get("color").or(attrs.get("fg")).and_then(|v| parse_color(v)) { + style = style.fg(c); + } + if let Some(c) = attrs.get("bg").or(attrs.get("background")).and_then(|v| parse_color(v)) { + style = style.bg(c); + } + if attrs.get("bold").map(|v| v != "false").unwrap_or(false) { + style = style.add_modifier(Modifier::BOLD); + } + if attrs.get("italic").map(|v| v != "false").unwrap_or(false) { + style = style.add_modifier(Modifier::ITALIC); + } + if attrs.get("underline").map(|v| v != "false").unwrap_or(false) { + style = style.add_modifier(Modifier::UNDERLINED); + } + if attrs.get("dim").map(|v| v != "false").unwrap_or(false) { + style = style.add_modifier(Modifier::DIM); + } + style +} + +fn parse_color(s: &str) -> Option { + match s.to_lowercase().as_str() { + "black" => Some(Color::Black), + "red" => Some(Color::Red), + "green" => Some(Color::Green), + "yellow" => Some(Color::Yellow), + "blue" => Some(Color::Blue), + "magenta" => Some(Color::Magenta), + "cyan" => Some(Color::Cyan), + "white" => Some(Color::White), + "gray" | "grey" => Some(Color::Gray), + s if s.starts_with('#') && s.len() == 7 => { + let r = u8::from_str_radix(&s[1..3], 16).ok()?; + let g = u8::from_str_radix(&s[3..5], 16).ok()?; + let b = u8::from_str_radix(&s[5..7], 16).ok()?; + Some(Color::Rgb(r, g, b)) + } + _ => None, + } +} diff --git a/src/tree.rs b/src/tree.rs new file mode 100644 index 0000000..479600c --- /dev/null +++ b/src/tree.rs @@ -0,0 +1,63 @@ +//! Retained tree of TUI nodes built by Dioxus mutations. + +use std::collections::{HashMap, HashSet}; + +pub type NodeId = usize; + +#[derive(Debug, Clone)] +pub enum TuiNode { + Element { + tag: String, + attrs: HashMap, + children: Vec, + listeners: HashSet, + }, + Text(String), + Placeholder, +} + +pub struct TuiTree { + nodes: HashMap, + roots: Vec, + stack: Vec, +} + +impl TuiTree { + pub fn new() -> Self { + Self { nodes: HashMap::new(), roots: Vec::new(), stack: Vec::new() } + } + + pub fn get(&self, id: NodeId) -> Option<&TuiNode> { self.nodes.get(&id) } + pub fn get_mut(&mut self, id: NodeId) -> Option<&mut TuiNode> { self.nodes.get_mut(&id) } + pub fn roots(&self) -> &[NodeId] { &self.roots } + + pub fn insert(&mut self, id: NodeId, node: TuiNode) { + self.nodes.insert(id, node); + } + + pub fn remove(&mut self, id: NodeId) { + if let Some(node) = self.nodes.remove(&id) { + if let TuiNode::Element { children, .. } = node { + for child_id in children { self.remove(child_id); } + } + self.roots.retain(|r| *r != id); + } + } + + pub fn append_children(&mut self, parent_id: NodeId, child_ids: &[NodeId]) { + if let Some(TuiNode::Element { children, .. }) = self.nodes.get_mut(&parent_id) { + children.extend_from_slice(child_ids); + } else { + // Parent might be a root placeholder — add as roots + self.roots.extend_from_slice(child_ids); + } + } + + pub fn push_stack(&mut self, id: NodeId) { self.stack.push(id); } + + pub fn pop_stack(&mut self, count: usize) -> Vec { + let len = self.stack.len(); + let start = len.saturating_sub(count); + self.stack.drain(start..).collect() + } +}