diff --git a/Cargo.lock b/Cargo.lock index a07ff18..2419b86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -987,6 +987,21 @@ dependencies = [ "winx", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1209,7 +1224,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width 0.2.2", + "unicode-width 0.1.14", ] [[package]] @@ -1248,6 +1263,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.36" @@ -1651,6 +1680,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "mio 1.1.1", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1710,6 +1764,40 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -3146,6 +3234,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", "serde", ] @@ -3473,6 +3563,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3568,6 +3664,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inotify" version = "0.9.6" @@ -3609,6 +3714,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "instant" version = "0.1.13" @@ -3976,6 +4094,15 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -4363,10 +4490,6 @@ dependencies = [ "chrono", "clap", "dirs 5.0.1", - "gpui", - "gpui-component", - "gpui-component-assets", - "gpui-router", "indexmap", "nemo-config", "nemo-data", @@ -4377,6 +4500,8 @@ dependencies = [ "nemo-macros", "nemo-plugin-api", "nemo-registry", + "nemo-tui", + "nemo-ui", "proptest", "serde", "serde_json", @@ -4539,6 +4664,57 @@ dependencies = [ "tracing", ] +[[package]] +name = "nemo-tui" +version = "0.6.0" +dependencies = [ + "anyhow", + "crossterm", + "nemo-config", + "nemo-data", + "nemo-events", + "nemo-layout", + "nemo-ui", + "ratatui", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "nemo-ui" +version = "0.6.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "dirs 5.0.1", + "gpui", + "gpui-component", + "gpui-component-assets", + "gpui-router", + "indexmap", + "nemo-config", + "nemo-data", + "nemo-events", + "nemo-extension", + "nemo-integration", + "nemo-layout", + "nemo-macros", + "nemo-plugin-api", + "nemo-registry", + "proptest", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "toml 0.8.23", + "tracing", +] + [[package]] name = "nemo-wasm" version = "0.6.0" @@ -5738,6 +5914,27 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -6743,6 +6940,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 1.1.1", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -7417,7 +7635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "unicode-linebreak", - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -8078,6 +8296,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-vo" version = "0.1.0" @@ -8092,9 +8321,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" @@ -8787,7 +9016,7 @@ dependencies = [ "bumpalo", "leb128fmt", "memchr", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "wasm-encoder 0.245.0", ] diff --git a/Cargo.toml b/Cargo.toml index 03e1901..8f87aa0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ resolver = "2" members = [ "crates/nemo", + "crates/nemo-ui", + "crates/nemo-tui", "crates/nemo-config", "crates/nemo-registry", "crates/nemo-layout", @@ -34,6 +36,10 @@ gpui-component = "0.5.1" gpui-component-assets = "0.5" gpui-router = "0.2" +# TUI +ratatui = "0.29" +crossterm = "0.28" + # Async tokio = { version = "1", features = ["full"] } async-trait = "0.1" @@ -96,4 +102,6 @@ nemo-events = { path = "crates/nemo-events" } nemo-plugin-api = { version = "0.6.0", path = "crates/nemo-plugin-api" } nemo-plugin = { version = "0.6.0", path = "crates/nemo-plugin" } nemo-macros = { path = "crates/nemo-macros" } +nemo-ui = { path = "crates/nemo-ui" } +nemo-tui = { path = "crates/nemo-tui" } nemo-wasm = { path = "crates/nemo-wasm" } diff --git a/crates/nemo-tui/Cargo.toml b/crates/nemo-tui/Cargo.toml new file mode 100644 index 0000000..122e03a --- /dev/null +++ b/crates/nemo-tui/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "nemo-tui" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +nemo-ui = { workspace = true } +nemo-config = { workspace = true } +nemo-layout = { workspace = true } +nemo-data = { workspace = true } +nemo-events = { workspace = true } +ratatui = { workspace = true } +crossterm = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/nemo-tui/src/app.rs b/crates/nemo-tui/src/app.rs new file mode 100644 index 0000000..39b0a0b --- /dev/null +++ b/crates/nemo-tui/src/app.rs @@ -0,0 +1,107 @@ +//! TUI application with crossterm event loop. + +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use nemo_layout::BuiltComponent; +use nemo_ui::runtime::NemoRuntime; +use ratatui::{DefaultTerminal, Frame}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use crate::renderer; + +/// The main TUI application. +pub struct TuiApp { + runtime: Arc, + should_quit: bool, +} + +impl TuiApp { + pub fn new(runtime: Arc) -> Self { + Self { + runtime, + should_quit: false, + } + } + + /// Run the TUI event loop. + pub fn run(&mut self) -> Result<()> { + let mut terminal = ratatui::init(); + + // Install a panic hook that restores the terminal + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = ratatui::restore(); + original_hook(panic_info); + })); + + let result = self.event_loop(&mut terminal); + + ratatui::restore(); + result + } + + fn event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { + loop { + // Apply any pending data updates + self.runtime.apply_pending_data_updates(); + + terminal.draw(|frame| self.render(frame))?; + + // Poll for events with 50ms timeout + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + self.handle_key(key.code); + } + } + } + + if self.should_quit { + self.runtime.shutdown(); + break; + } + } + Ok(()) + } + + fn handle_key(&mut self, code: KeyCode) { + match code { + KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + _ => {} + } + } + + fn render(&self, frame: &mut Frame) { + let (root_id, components) = self.snapshot_components(); + + if let Some(root_id) = root_id { + if let Some(root) = components.get(&root_id) { + renderer::render_component(frame, frame.area(), root, &components); + return; + } + } + + // Fallback: show a message + use ratatui::widgets::Paragraph; + let msg = Paragraph::new("No layout configured. Check your app.xml."); + frame.render_widget(msg, frame.area()); + } + + /// Snapshot the current layout components from the runtime. + fn snapshot_components(&self) -> (Option, HashMap) { + let layout_manager = self + .runtime + .layout_manager + .read() + .expect("layout_manager lock poisoned"); + let root_id = layout_manager.root_id(); + let components: HashMap = layout_manager + .component_ids() + .into_iter() + .filter_map(|id| layout_manager.get_component(&id).cloned().map(|c| (id, c))) + .collect(); + (root_id, components) + } +} diff --git a/crates/nemo-tui/src/components/label.rs b/crates/nemo-tui/src/components/label.rs new file mode 100644 index 0000000..7571284 --- /dev/null +++ b/crates/nemo-tui/src/components/label.rs @@ -0,0 +1,17 @@ +//! Label component -> ratatui Paragraph (single line). + +use nemo_layout::BuiltComponent; +use ratatui::layout::Rect; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +pub fn render(frame: &mut Frame, area: Rect, component: &BuiltComponent) { + let text = component + .properties + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let paragraph = Paragraph::new(text.to_string()); + frame.render_widget(paragraph, area); +} diff --git a/crates/nemo-tui/src/components/mod.rs b/crates/nemo-tui/src/components/mod.rs new file mode 100644 index 0000000..3c8c799 --- /dev/null +++ b/crates/nemo-tui/src/components/mod.rs @@ -0,0 +1,6 @@ +pub mod label; +pub mod panel; +pub mod progress; +pub mod stack; +pub mod table; +pub mod text; diff --git a/crates/nemo-tui/src/components/panel.rs b/crates/nemo-tui/src/components/panel.rs new file mode 100644 index 0000000..0b77a38 --- /dev/null +++ b/crates/nemo-tui/src/components/panel.rs @@ -0,0 +1,42 @@ +//! Panel component -> ratatui Block with borders and title. + +use nemo_layout::BuiltComponent; +use ratatui::layout::Rect; +use ratatui::widgets::{Block, Borders}; +use ratatui::Frame; +use std::collections::HashMap; + +use crate::renderer; + +pub fn render( + frame: &mut Frame, + area: Rect, + component: &BuiltComponent, + components: &HashMap, +) { + let title = component + .properties + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let show_border = component + .properties + .get("border") + .and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i != 0))) + .unwrap_or(true); + + let mut block = Block::default(); + if show_border { + block = block.borders(Borders::ALL); + } + if !title.is_empty() { + block = block.title(title.to_string()); + } + + let inner = block.inner(area); + frame.render_widget(block, area); + + // Render children inside the block's inner area + renderer::render_children_vertical(frame, inner, component, components); +} diff --git a/crates/nemo-tui/src/components/progress.rs b/crates/nemo-tui/src/components/progress.rs new file mode 100644 index 0000000..a43379e --- /dev/null +++ b/crates/nemo-tui/src/components/progress.rs @@ -0,0 +1,39 @@ +//! Progress component -> ratatui Gauge. + +use nemo_layout::BuiltComponent; +use ratatui::layout::Rect; +use ratatui::widgets::Gauge; +use ratatui::Frame; + +pub fn render(frame: &mut Frame, area: Rect, component: &BuiltComponent) { + let value = component + .properties + .get("value") + .and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64))) + .unwrap_or(0.0); + + let max = component + .properties + .get("max") + .and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64))) + .unwrap_or(100.0); + + let ratio = if max > 0.0 { + (value / max).clamp(0.0, 1.0) + } else { + 0.0 + }; + + let label = component + .properties + .get("label") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("{:.0}%", ratio * 100.0)); + + let gauge = Gauge::default() + .label(label) + .ratio(ratio); + + frame.render_widget(gauge, area); +} diff --git a/crates/nemo-tui/src/components/stack.rs b/crates/nemo-tui/src/components/stack.rs new file mode 100644 index 0000000..8113678 --- /dev/null +++ b/crates/nemo-tui/src/components/stack.rs @@ -0,0 +1,88 @@ +//! Stack component -> ratatui Layout with Direction. + +use nemo_layout::BuiltComponent; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::Frame; +use ratatui::layout::Rect; +use std::collections::HashMap; + +use crate::renderer; + +pub fn render( + frame: &mut Frame, + area: Rect, + component: &BuiltComponent, + components: &HashMap, +) { + let children: Vec<&BuiltComponent> = component + .children + .iter() + .filter_map(|id| components.get(id)) + .collect(); + + if children.is_empty() { + return; + } + + let direction = match component + .properties + .get("direction") + .and_then(|v| v.as_str()) + { + Some("horizontal") => Direction::Horizontal, + _ => Direction::Vertical, + }; + + // Build constraints: use flex property if available, otherwise equal distribution + let constraints: Vec = children + .iter() + .map(|child| { + if let Some(flex) = child + .properties + .get("flex") + .and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64))) + { + Constraint::Ratio(flex as u32, total_flex(&children) as u32) + } else if let Some(height) = child.properties.get("height").and_then(|v| v.as_i64()) { + if direction == Direction::Vertical { + Constraint::Length(height as u16) + } else { + Constraint::Min(1) + } + } else if let Some(width) = child.properties.get("width").and_then(|v| v.as_i64()) { + if direction == Direction::Horizontal { + Constraint::Length(width as u16) + } else { + Constraint::Min(1) + } + } else { + Constraint::Min(1) + } + }) + .collect(); + + let chunks = Layout::default() + .direction(direction) + .constraints(constraints) + .split(area); + + for (child, chunk) in children.iter().zip(chunks.iter()) { + renderer::render_component(frame, *chunk, child, components); + } +} + +fn total_flex(children: &[&BuiltComponent]) -> f64 { + let sum: f64 = children + .iter() + .filter_map(|c| { + c.properties + .get("flex") + .and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64))) + }) + .sum(); + if sum > 0.0 { + sum + } else { + children.len() as f64 + } +} diff --git a/crates/nemo-tui/src/components/table.rs b/crates/nemo-tui/src/components/table.rs new file mode 100644 index 0000000..e32589a --- /dev/null +++ b/crates/nemo-tui/src/components/table.rs @@ -0,0 +1,117 @@ +//! Table component -> ratatui Table. + +use nemo_config::Value; +use nemo_layout::BuiltComponent; +use ratatui::layout::Rect; +use ratatui::widgets::{Block, Borders, Cell, Row, Table as RatatuiTable}; +use ratatui::Frame; + +pub fn render(frame: &mut Frame, area: Rect, component: &BuiltComponent) { + let title = component + .properties + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Extract column definitions + let columns: Vec = component + .properties + .get("columns") + .and_then(|v| match v { + Value::Array(arr) => Some( + arr.iter() + .filter_map(|col| { + col.as_str() + .map(|s| s.to_string()) + .or_else(|| { + col.as_object() + .and_then(|obj| obj.get("label")) + .and_then(|l| l.as_str()) + .map(|s| s.to_string()) + }) + }) + .collect(), + ), + _ => None, + }) + .unwrap_or_default(); + + // Extract column keys (for looking up row data) + let column_keys: Vec = component + .properties + .get("columns") + .and_then(|v| match v { + Value::Array(arr) => Some( + arr.iter() + .filter_map(|col| { + col.as_str() + .map(|s| s.to_string()) + .or_else(|| { + col.as_object() + .and_then(|obj| obj.get("key")) + .and_then(|k| k.as_str()) + .map(|s| s.to_string()) + }) + }) + .collect(), + ), + _ => None, + }) + .unwrap_or_default(); + + // Extract row data + let rows: Vec = component + .properties + .get("data") + .and_then(|v| match v { + Value::Array(arr) => Some( + arr.iter() + .map(|row_val| { + let cells: Vec = column_keys + .iter() + .map(|key| { + let cell_text = row_val + .as_object() + .and_then(|obj| obj.get(key)) + .map(|v| value_to_string(v)) + .unwrap_or_default(); + Cell::from(cell_text) + }) + .collect(); + Row::new(cells) + }) + .collect(), + ), + _ => None, + }) + .unwrap_or_default(); + + let header = Row::new(columns.iter().map(|c| Cell::from(c.as_str()))); + + let widths: Vec = column_keys + .iter() + .map(|_| ratatui::layout::Constraint::Min(8)) + .collect(); + + let mut block = Block::default().borders(Borders::ALL); + if !title.is_empty() { + block = block.title(title.to_string()); + } + + let table = RatatuiTable::new(rows, widths) + .header(header) + .block(block); + + frame.render_widget(table, area); +} + +fn value_to_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Integer(n) => n.to_string(), + Value::Float(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + other => format!("{:?}", other), + } +} diff --git a/crates/nemo-tui/src/components/text.rs b/crates/nemo-tui/src/components/text.rs new file mode 100644 index 0000000..fc73183 --- /dev/null +++ b/crates/nemo-tui/src/components/text.rs @@ -0,0 +1,18 @@ +//! Text component -> ratatui Paragraph with word wrap. + +use nemo_layout::BuiltComponent; +use ratatui::layout::Rect; +use ratatui::widgets::{Paragraph, Wrap}; +use ratatui::Frame; + +pub fn render(frame: &mut Frame, area: Rect, component: &BuiltComponent) { + let content = component + .properties + .get("content") + .or_else(|| component.properties.get("text")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let paragraph = Paragraph::new(content.to_string()).wrap(Wrap { trim: true }); + frame.render_widget(paragraph, area); +} diff --git a/crates/nemo-tui/src/lib.rs b/crates/nemo-tui/src/lib.rs new file mode 100644 index 0000000..53a5b0f --- /dev/null +++ b/crates/nemo-tui/src/lib.rs @@ -0,0 +1,25 @@ +//! Nemo TUI - ratatui terminal rendering backend. +//! +//! This crate renders Nemo configurations as terminal user interfaces +//! using ratatui and crossterm. + +mod app; +pub mod components; +pub mod renderer; + +use anyhow::Result; +use nemo_ui::runtime::NemoRuntime; +use std::sync::Arc; +use tracing::info; + +/// Run the TUI application. +/// +/// This is the main entry point for the terminal UI backend. It initializes +/// the terminal, enters the ratatui event loop, and restores the terminal on exit. +pub fn run_tui(runtime: Arc) -> Result<()> { + info!("Starting TUI application..."); + let mut app = app::TuiApp::new(runtime); + let result = app.run(); + info!("TUI shutdown complete"); + result +} diff --git a/crates/nemo-tui/src/renderer.rs b/crates/nemo-tui/src/renderer.rs new file mode 100644 index 0000000..0c67650 --- /dev/null +++ b/crates/nemo-tui/src/renderer.rs @@ -0,0 +1,63 @@ +//! Component rendering dispatch for ratatui. +//! +//! Maps BuiltComponent types to ratatui widgets. + +use nemo_layout::BuiltComponent; +use ratatui::Frame; +use ratatui::layout::Rect; +use std::collections::HashMap; + +use crate::components::{label, panel, progress, stack, table, text}; + +/// Render a component tree into the given area. +pub fn render_component( + frame: &mut Frame, + area: Rect, + component: &BuiltComponent, + components: &HashMap, +) { + match component.component_type.as_str() { + "stack" => stack::render(frame, area, component, components), + "panel" => panel::render(frame, area, component, components), + "label" => label::render(frame, area, component), + "text" => text::render(frame, area, component), + "progress" => progress::render(frame, area, component), + "table" => table::render(frame, area, component), + _ => { + // Graceful degradation: render children in a vertical stack + render_children_vertical(frame, area, component, components); + } + } +} + +/// Render children of a component in a vertical stack (fallback for unsupported types). +pub fn render_children_vertical( + frame: &mut Frame, + area: Rect, + component: &BuiltComponent, + components: &HashMap, +) { + let children: Vec<&BuiltComponent> = component + .children + .iter() + .filter_map(|id| components.get(id)) + .collect(); + + if children.is_empty() { + return; + } + + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + children + .iter() + .map(|_| ratatui::layout::Constraint::Min(1)) + .collect::>(), + ) + .split(area); + + for (child, chunk) in children.iter().zip(chunks.iter()) { + render_component(frame, *chunk, child, components); + } +} diff --git a/crates/nemo-tui/tests/components.rs b/crates/nemo-tui/tests/components.rs new file mode 100644 index 0000000..dde938b --- /dev/null +++ b/crates/nemo-tui/tests/components.rs @@ -0,0 +1,312 @@ +//! Integration tests for TUI component rendering. + +use nemo_config::Value; +use nemo_layout::BuiltComponent; +use ratatui::backend::TestBackend; +use ratatui::Terminal; +use std::collections::HashMap; + +fn make_component(id: &str, component_type: &str) -> BuiltComponent { + BuiltComponent { + id: id.to_string(), + component_type: component_type.to_string(), + properties: HashMap::new(), + handlers: HashMap::new(), + children: Vec::new(), + parent: None, + } +} + +fn render_to_string(width: u16, height: u16, f: impl FnOnce(&mut ratatui::Frame)) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + f(frame); + }) + .unwrap(); + // Extract buffer content + let buf = terminal.backend().buffer().clone(); + let mut lines = Vec::new(); + for y in 0..height { + let mut line = String::new(); + for x in 0..width { + line.push_str(buf.cell((x, y)).unwrap().symbol()); + } + lines.push(line.trim_end().to_string()); + } + lines.join("\n") +} + +// ── Label ─────────────────────────────────────────────────────────── + +#[test] +fn test_label_renders_text() { + let mut comp = make_component("lbl", "label"); + comp.properties + .insert("text".into(), Value::String("Hello World".into())); + + let output = render_to_string(20, 1, |frame| { + nemo_tui::components::label::render(frame, frame.area(), &comp); + }); + + assert!(output.contains("Hello World")); +} + +#[test] +fn test_label_empty_text() { + let comp = make_component("lbl", "label"); + + let output = render_to_string(20, 1, |frame| { + nemo_tui::components::label::render(frame, frame.area(), &comp); + }); + + // Should render without panic, just whitespace + assert_eq!(output.trim(), ""); +} + +// ── Text ──────────────────────────────────────────────────────────── + +#[test] +fn test_text_renders_content() { + let mut comp = make_component("txt", "text"); + comp.properties.insert( + "content".into(), + Value::String("Multi-line text content".into()), + ); + + let output = render_to_string(30, 2, |frame| { + nemo_tui::components::text::render(frame, frame.area(), &comp); + }); + + assert!(output.contains("Multi-line text content")); +} + +#[test] +fn test_text_falls_back_to_text_property() { + let mut comp = make_component("txt", "text"); + comp.properties + .insert("text".into(), Value::String("Fallback text".into())); + + let output = render_to_string(30, 1, |frame| { + nemo_tui::components::text::render(frame, frame.area(), &comp); + }); + + assert!(output.contains("Fallback text")); +} + +// ── Progress ──────────────────────────────────────────────────────── + +#[test] +fn test_progress_renders_gauge() { + let mut comp = make_component("prog", "progress"); + comp.properties + .insert("value".into(), Value::Integer(50)); + comp.properties + .insert("max".into(), Value::Integer(100)); + + let output = render_to_string(30, 1, |frame| { + nemo_tui::components::progress::render(frame, frame.area(), &comp); + }); + + // Gauge should show percentage + assert!(output.contains("50%")); +} + +#[test] +fn test_progress_custom_label() { + let mut comp = make_component("prog", "progress"); + comp.properties + .insert("value".into(), Value::Integer(75)); + comp.properties + .insert("max".into(), Value::Integer(100)); + comp.properties + .insert("label".into(), Value::String("CPU: 75%".into())); + + let output = render_to_string(30, 1, |frame| { + nemo_tui::components::progress::render(frame, frame.area(), &comp); + }); + + assert!(output.contains("CPU: 75%")); +} + +// ── Panel ─────────────────────────────────────────────────────────── + +#[test] +fn test_panel_renders_with_title() { + let mut comp = make_component("pnl", "panel"); + comp.properties + .insert("title".into(), Value::String("My Panel".into())); + + let components: HashMap = HashMap::new(); + + let output = render_to_string(30, 5, |frame| { + nemo_tui::components::panel::render(frame, frame.area(), &comp, &components); + }); + + assert!(output.contains("My Panel")); +} + +#[test] +fn test_panel_renders_children() { + let mut panel = make_component("pnl", "panel"); + panel + .properties + .insert("title".into(), Value::String("Container".into())); + panel.children = vec!["child_lbl".into()]; + + let mut child = make_component("child_lbl", "label"); + child + .properties + .insert("text".into(), Value::String("Inside".into())); + child.parent = Some("pnl".into()); + + let mut components: HashMap = HashMap::new(); + components.insert("pnl".into(), panel.clone()); + components.insert("child_lbl".into(), child); + + let output = render_to_string(30, 5, |frame| { + nemo_tui::components::panel::render(frame, frame.area(), &panel, &components); + }); + + assert!(output.contains("Container")); + assert!(output.contains("Inside")); +} + +// ── Stack ─────────────────────────────────────────────────────────── + +#[test] +fn test_stack_vertical_layout() { + let mut stack = make_component("stk", "stack"); + stack + .properties + .insert("direction".into(), Value::String("vertical".into())); + stack.children = vec!["lbl1".into(), "lbl2".into()]; + + let mut lbl1 = make_component("lbl1", "label"); + lbl1.properties + .insert("text".into(), Value::String("First".into())); + lbl1.parent = Some("stk".into()); + + let mut lbl2 = make_component("lbl2", "label"); + lbl2.properties + .insert("text".into(), Value::String("Second".into())); + lbl2.parent = Some("stk".into()); + + let mut components: HashMap = HashMap::new(); + components.insert("stk".into(), stack.clone()); + components.insert("lbl1".into(), lbl1); + components.insert("lbl2".into(), lbl2); + + let output = render_to_string(20, 4, |frame| { + nemo_tui::components::stack::render(frame, frame.area(), &stack, &components); + }); + + assert!(output.contains("First")); + assert!(output.contains("Second")); +} + +#[test] +fn test_stack_horizontal_layout() { + let mut stack = make_component("stk", "stack"); + stack + .properties + .insert("direction".into(), Value::String("horizontal".into())); + stack.children = vec!["lbl1".into(), "lbl2".into()]; + + let mut lbl1 = make_component("lbl1", "label"); + lbl1.properties + .insert("text".into(), Value::String("Left".into())); + lbl1.parent = Some("stk".into()); + + let mut lbl2 = make_component("lbl2", "label"); + lbl2.properties + .insert("text".into(), Value::String("Right".into())); + lbl2.parent = Some("stk".into()); + + let mut components: HashMap = HashMap::new(); + components.insert("stk".into(), stack.clone()); + components.insert("lbl1".into(), lbl1); + components.insert("lbl2".into(), lbl2); + + let output = render_to_string(30, 2, |frame| { + nemo_tui::components::stack::render(frame, frame.area(), &stack, &components); + }); + + assert!(output.contains("Left")); + assert!(output.contains("Right")); +} + +// ── Table ─────────────────────────────────────────────────────────── + +#[test] +fn test_table_renders_with_data() { + let mut comp = make_component("tbl", "table"); + comp.properties + .insert("title".into(), Value::String("Services".into())); + + let columns = Value::Array(vec![ + Value::Object( + [ + ("key".into(), Value::String("name".into())), + ("label".into(), Value::String("Name".into())), + ] + .into_iter() + .collect(), + ), + Value::Object( + [ + ("key".into(), Value::String("status".into())), + ("label".into(), Value::String("Status".into())), + ] + .into_iter() + .collect(), + ), + ]); + comp.properties.insert("columns".into(), columns); + + let data = Value::Array(vec![Value::Object( + [ + ("name".into(), Value::String("API".into())), + ("status".into(), Value::String("Running".into())), + ] + .into_iter() + .collect(), + )]); + comp.properties.insert("data".into(), data); + + let output = render_to_string(40, 6, |frame| { + nemo_tui::components::table::render(frame, frame.area(), &comp); + }); + + assert!(output.contains("Services")); + assert!(output.contains("Name")); + assert!(output.contains("Status")); + assert!(output.contains("API")); + assert!(output.contains("Running")); +} + +// ── Renderer dispatch ─────────────────────────────────────────────── + +#[test] +fn test_renderer_unsupported_type_renders_children() { + // An unsupported component type should still render its children + let mut comp = make_component("unk", "unknown_widget"); + comp.children = vec!["child".into()]; + + let mut child = make_component("child", "label"); + child + .properties + .insert("text".into(), Value::String("Fallback".into())); + child.parent = Some("unk".into()); + + let mut components: HashMap = HashMap::new(); + components.insert("unk".into(), comp.clone()); + components.insert("child".into(), child); + + let output = render_to_string(20, 3, |frame| { + nemo_tui::renderer::render_component(frame, frame.area(), &comp, &components); + }); + + assert!(output.contains("Fallback")); +} diff --git a/crates/nemo-ui/Cargo.toml b/crates/nemo-ui/Cargo.toml new file mode 100644 index 0000000..e839140 --- /dev/null +++ b/crates/nemo-ui/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "nemo-ui" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +nemo-config = { workspace = true } +nemo-registry = { workspace = true } +nemo-layout = { workspace = true } +nemo-data = { workspace = true } +nemo-extension = { workspace = true, features = ["wasm"] } +nemo-integration = { workspace = true } +nemo-events = { workspace = true } +nemo-plugin-api = { workspace = true } +nemo-macros = { workspace = true } +gpui = { workspace = true } +gpui-component = { workspace = true } +gpui-component-assets = { workspace = true } +gpui-router = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +indexmap = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +dirs = { workspace = true } +toml = { workspace = true } +chrono = { workspace = true } +clap = { workspace = true } + +[dev-dependencies] +tempfile = "3" +proptest = "1" diff --git a/crates/nemo/src/app.rs b/crates/nemo-ui/src/app.rs similarity index 100% rename from crates/nemo/src/app.rs rename to crates/nemo-ui/src/app.rs diff --git a/crates/nemo/src/components/accordion.rs b/crates/nemo-ui/src/components/accordion.rs similarity index 100% rename from crates/nemo/src/components/accordion.rs rename to crates/nemo-ui/src/components/accordion.rs diff --git a/crates/nemo/src/components/alert.rs b/crates/nemo-ui/src/components/alert.rs similarity index 100% rename from crates/nemo/src/components/alert.rs rename to crates/nemo-ui/src/components/alert.rs diff --git a/crates/nemo/src/components/area_chart.rs b/crates/nemo-ui/src/components/area_chart.rs similarity index 100% rename from crates/nemo/src/components/area_chart.rs rename to crates/nemo-ui/src/components/area_chart.rs diff --git a/crates/nemo/src/components/avatar.rs b/crates/nemo-ui/src/components/avatar.rs similarity index 100% rename from crates/nemo/src/components/avatar.rs rename to crates/nemo-ui/src/components/avatar.rs diff --git a/crates/nemo/src/components/badge.rs b/crates/nemo-ui/src/components/badge.rs similarity index 100% rename from crates/nemo/src/components/badge.rs rename to crates/nemo-ui/src/components/badge.rs diff --git a/crates/nemo/src/components/bar_chart.rs b/crates/nemo-ui/src/components/bar_chart.rs similarity index 100% rename from crates/nemo/src/components/bar_chart.rs rename to crates/nemo-ui/src/components/bar_chart.rs diff --git a/crates/nemo/src/components/bubble_chart.rs b/crates/nemo-ui/src/components/bubble_chart.rs similarity index 100% rename from crates/nemo/src/components/bubble_chart.rs rename to crates/nemo-ui/src/components/bubble_chart.rs diff --git a/crates/nemo/src/components/button.rs b/crates/nemo-ui/src/components/button.rs similarity index 100% rename from crates/nemo/src/components/button.rs rename to crates/nemo-ui/src/components/button.rs diff --git a/crates/nemo/src/components/candlestick_chart.rs b/crates/nemo-ui/src/components/candlestick_chart.rs similarity index 100% rename from crates/nemo/src/components/candlestick_chart.rs rename to crates/nemo-ui/src/components/candlestick_chart.rs diff --git a/crates/nemo/src/components/chart_utils.rs b/crates/nemo-ui/src/components/chart_utils.rs similarity index 100% rename from crates/nemo/src/components/chart_utils.rs rename to crates/nemo-ui/src/components/chart_utils.rs diff --git a/crates/nemo/src/components/checkbox.rs b/crates/nemo-ui/src/components/checkbox.rs similarity index 100% rename from crates/nemo/src/components/checkbox.rs rename to crates/nemo-ui/src/components/checkbox.rs diff --git a/crates/nemo/src/components/clustered_bar_chart.rs b/crates/nemo-ui/src/components/clustered_bar_chart.rs similarity index 100% rename from crates/nemo/src/components/clustered_bar_chart.rs rename to crates/nemo-ui/src/components/clustered_bar_chart.rs diff --git a/crates/nemo/src/components/clustered_column_chart.rs b/crates/nemo-ui/src/components/clustered_column_chart.rs similarity index 100% rename from crates/nemo/src/components/clustered_column_chart.rs rename to crates/nemo-ui/src/components/clustered_column_chart.rs diff --git a/crates/nemo/src/components/code_editor.rs b/crates/nemo-ui/src/components/code_editor.rs similarity index 100% rename from crates/nemo/src/components/code_editor.rs rename to crates/nemo-ui/src/components/code_editor.rs diff --git a/crates/nemo/src/components/collapsible.rs b/crates/nemo-ui/src/components/collapsible.rs similarity index 100% rename from crates/nemo/src/components/collapsible.rs rename to crates/nemo-ui/src/components/collapsible.rs diff --git a/crates/nemo/src/components/column_chart.rs b/crates/nemo-ui/src/components/column_chart.rs similarity index 100% rename from crates/nemo/src/components/column_chart.rs rename to crates/nemo-ui/src/components/column_chart.rs diff --git a/crates/nemo/src/components/dropdown_button.rs b/crates/nemo-ui/src/components/dropdown_button.rs similarity index 100% rename from crates/nemo/src/components/dropdown_button.rs rename to crates/nemo-ui/src/components/dropdown_button.rs diff --git a/crates/nemo/src/components/funnel_chart.rs b/crates/nemo-ui/src/components/funnel_chart.rs similarity index 100% rename from crates/nemo/src/components/funnel_chart.rs rename to crates/nemo-ui/src/components/funnel_chart.rs diff --git a/crates/nemo/src/components/heatmap_chart.rs b/crates/nemo-ui/src/components/heatmap_chart.rs similarity index 100% rename from crates/nemo/src/components/heatmap_chart.rs rename to crates/nemo-ui/src/components/heatmap_chart.rs diff --git a/crates/nemo/src/components/icon.rs b/crates/nemo-ui/src/components/icon.rs similarity index 100% rename from crates/nemo/src/components/icon.rs rename to crates/nemo-ui/src/components/icon.rs diff --git a/crates/nemo/src/components/image.rs b/crates/nemo-ui/src/components/image.rs similarity index 100% rename from crates/nemo/src/components/image.rs rename to crates/nemo-ui/src/components/image.rs diff --git a/crates/nemo/src/components/input.rs b/crates/nemo-ui/src/components/input.rs similarity index 100% rename from crates/nemo/src/components/input.rs rename to crates/nemo-ui/src/components/input.rs diff --git a/crates/nemo/src/components/label.rs b/crates/nemo-ui/src/components/label.rs similarity index 100% rename from crates/nemo/src/components/label.rs rename to crates/nemo-ui/src/components/label.rs diff --git a/crates/nemo/src/components/line_chart.rs b/crates/nemo-ui/src/components/line_chart.rs similarity index 100% rename from crates/nemo/src/components/line_chart.rs rename to crates/nemo-ui/src/components/line_chart.rs diff --git a/crates/nemo/src/components/list.rs b/crates/nemo-ui/src/components/list.rs similarity index 100% rename from crates/nemo/src/components/list.rs rename to crates/nemo-ui/src/components/list.rs diff --git a/crates/nemo/src/components/mod.rs b/crates/nemo-ui/src/components/mod.rs similarity index 96% rename from crates/nemo/src/components/mod.rs rename to crates/nemo-ui/src/components/mod.rs index 8df2a5c..742d1c1 100644 --- a/crates/nemo/src/components/mod.rs +++ b/crates/nemo-ui/src/components/mod.rs @@ -7,7 +7,7 @@ mod bar_chart; mod bubble_chart; mod button; mod candlestick_chart; -pub(crate) mod chart_utils; +pub mod chart_utils; mod checkbox; mod clustered_bar_chart; mod clustered_column_chart; @@ -17,7 +17,7 @@ mod column_chart; mod dropdown_button; mod funnel_chart; mod heatmap_chart; -pub(crate) mod icon; +pub mod icon; mod image; mod input; mod label; @@ -34,15 +34,15 @@ mod radio; mod realtime_chart; mod scatter_chart; mod select; -pub(crate) mod sidenav_bar; -pub(crate) mod slider; +pub mod sidenav_bar; +pub mod slider; mod spinner; mod stack; mod stacked_bar_chart; mod stacked_column_chart; -pub(crate) mod state; +pub mod state; mod switch; -pub(crate) mod table; +pub mod table; mod tabs; mod tag; mod text; @@ -50,7 +50,7 @@ mod text_editor; mod textarea; mod toggle; mod tooltip; -pub(crate) mod tree; +pub mod tree; use gpui::*; use gpui_component::ActiveTheme; @@ -60,7 +60,7 @@ use gpui_component::ActiveTheme; /// Supports two formats: /// - Theme reference: `"theme.border"`, `"theme.accent"`, `"theme.danger"`, etc. /// - Hex color: `"#4c566a"`, `"4c566aff"`, `"#FF0000"` -pub(crate) fn resolve_color(value: &str, cx: &App) -> Option { +pub fn resolve_color(value: &str, cx: &App) -> Option { if let Some(name) = value.strip_prefix("theme.") { resolve_theme_color(name, cx) } else { @@ -139,7 +139,7 @@ fn resolve_theme_color(name: &str, cx: &App) -> Option { /// Applies a shadow preset to a div element. /// /// Supported sizes: "sm", "md", "lg", "xl", "2xl" -pub(crate) fn apply_shadow(base: Div, shadow: Option<&str>) -> Div { +pub fn apply_shadow(base: Div, shadow: Option<&str>) -> Div { match shadow { Some("sm") => base.shadow_sm(), Some("md") => base.shadow_md(), @@ -153,7 +153,7 @@ pub(crate) fn apply_shadow(base: Div, shadow: Option<&str>) -> Div { /// Applies a rounded corner preset to a div element. /// /// Supported sizes: "sm", "md", "lg", "xl", "full" -pub(crate) fn apply_rounded(base: Div, rounded: Option<&str>) -> Div { +pub fn apply_rounded(base: Div, rounded: Option<&str>) -> Div { match rounded { Some("sm") => base.rounded_sm(), Some("md") => base.rounded_md(), diff --git a/crates/nemo/src/components/modal.rs b/crates/nemo-ui/src/components/modal.rs similarity index 100% rename from crates/nemo/src/components/modal.rs rename to crates/nemo-ui/src/components/modal.rs diff --git a/crates/nemo/src/components/notification.rs b/crates/nemo-ui/src/components/notification.rs similarity index 100% rename from crates/nemo/src/components/notification.rs rename to crates/nemo-ui/src/components/notification.rs diff --git a/crates/nemo/src/components/panel.rs b/crates/nemo-ui/src/components/panel.rs similarity index 100% rename from crates/nemo/src/components/panel.rs rename to crates/nemo-ui/src/components/panel.rs diff --git a/crates/nemo/src/components/pie_chart.rs b/crates/nemo-ui/src/components/pie_chart.rs similarity index 100% rename from crates/nemo/src/components/pie_chart.rs rename to crates/nemo-ui/src/components/pie_chart.rs diff --git a/crates/nemo/src/components/progress.rs b/crates/nemo-ui/src/components/progress.rs similarity index 100% rename from crates/nemo/src/components/progress.rs rename to crates/nemo-ui/src/components/progress.rs diff --git a/crates/nemo/src/components/pyramid_chart.rs b/crates/nemo-ui/src/components/pyramid_chart.rs similarity index 100% rename from crates/nemo/src/components/pyramid_chart.rs rename to crates/nemo-ui/src/components/pyramid_chart.rs diff --git a/crates/nemo/src/components/radar_chart.rs b/crates/nemo-ui/src/components/radar_chart.rs similarity index 100% rename from crates/nemo/src/components/radar_chart.rs rename to crates/nemo-ui/src/components/radar_chart.rs diff --git a/crates/nemo/src/components/radio.rs b/crates/nemo-ui/src/components/radio.rs similarity index 100% rename from crates/nemo/src/components/radio.rs rename to crates/nemo-ui/src/components/radio.rs diff --git a/crates/nemo/src/components/realtime_chart.rs b/crates/nemo-ui/src/components/realtime_chart.rs similarity index 100% rename from crates/nemo/src/components/realtime_chart.rs rename to crates/nemo-ui/src/components/realtime_chart.rs diff --git a/crates/nemo/src/components/scatter_chart.rs b/crates/nemo-ui/src/components/scatter_chart.rs similarity index 100% rename from crates/nemo/src/components/scatter_chart.rs rename to crates/nemo-ui/src/components/scatter_chart.rs diff --git a/crates/nemo/src/components/select.rs b/crates/nemo-ui/src/components/select.rs similarity index 100% rename from crates/nemo/src/components/select.rs rename to crates/nemo-ui/src/components/select.rs diff --git a/crates/nemo/src/components/sidenav_bar.rs b/crates/nemo-ui/src/components/sidenav_bar.rs similarity index 100% rename from crates/nemo/src/components/sidenav_bar.rs rename to crates/nemo-ui/src/components/sidenav_bar.rs diff --git a/crates/nemo/src/components/slider.rs b/crates/nemo-ui/src/components/slider.rs similarity index 100% rename from crates/nemo/src/components/slider.rs rename to crates/nemo-ui/src/components/slider.rs diff --git a/crates/nemo/src/components/spinner.rs b/crates/nemo-ui/src/components/spinner.rs similarity index 100% rename from crates/nemo/src/components/spinner.rs rename to crates/nemo-ui/src/components/spinner.rs diff --git a/crates/nemo/src/components/stack.rs b/crates/nemo-ui/src/components/stack.rs similarity index 100% rename from crates/nemo/src/components/stack.rs rename to crates/nemo-ui/src/components/stack.rs diff --git a/crates/nemo/src/components/stacked_bar_chart.rs b/crates/nemo-ui/src/components/stacked_bar_chart.rs similarity index 100% rename from crates/nemo/src/components/stacked_bar_chart.rs rename to crates/nemo-ui/src/components/stacked_bar_chart.rs diff --git a/crates/nemo/src/components/stacked_column_chart.rs b/crates/nemo-ui/src/components/stacked_column_chart.rs similarity index 100% rename from crates/nemo/src/components/stacked_column_chart.rs rename to crates/nemo-ui/src/components/stacked_column_chart.rs diff --git a/crates/nemo/src/components/state.rs b/crates/nemo-ui/src/components/state.rs similarity index 100% rename from crates/nemo/src/components/state.rs rename to crates/nemo-ui/src/components/state.rs diff --git a/crates/nemo/src/components/switch.rs b/crates/nemo-ui/src/components/switch.rs similarity index 100% rename from crates/nemo/src/components/switch.rs rename to crates/nemo-ui/src/components/switch.rs diff --git a/crates/nemo/src/components/table.rs b/crates/nemo-ui/src/components/table.rs similarity index 100% rename from crates/nemo/src/components/table.rs rename to crates/nemo-ui/src/components/table.rs diff --git a/crates/nemo/src/components/tabs.rs b/crates/nemo-ui/src/components/tabs.rs similarity index 100% rename from crates/nemo/src/components/tabs.rs rename to crates/nemo-ui/src/components/tabs.rs diff --git a/crates/nemo/src/components/tag.rs b/crates/nemo-ui/src/components/tag.rs similarity index 100% rename from crates/nemo/src/components/tag.rs rename to crates/nemo-ui/src/components/tag.rs diff --git a/crates/nemo/src/components/text.rs b/crates/nemo-ui/src/components/text.rs similarity index 100% rename from crates/nemo/src/components/text.rs rename to crates/nemo-ui/src/components/text.rs diff --git a/crates/nemo/src/components/text_editor.rs b/crates/nemo-ui/src/components/text_editor.rs similarity index 100% rename from crates/nemo/src/components/text_editor.rs rename to crates/nemo-ui/src/components/text_editor.rs diff --git a/crates/nemo/src/components/textarea.rs b/crates/nemo-ui/src/components/textarea.rs similarity index 100% rename from crates/nemo/src/components/textarea.rs rename to crates/nemo-ui/src/components/textarea.rs diff --git a/crates/nemo/src/components/toggle.rs b/crates/nemo-ui/src/components/toggle.rs similarity index 100% rename from crates/nemo/src/components/toggle.rs rename to crates/nemo-ui/src/components/toggle.rs diff --git a/crates/nemo/src/components/tooltip.rs b/crates/nemo-ui/src/components/tooltip.rs similarity index 100% rename from crates/nemo/src/components/tooltip.rs rename to crates/nemo-ui/src/components/tooltip.rs diff --git a/crates/nemo/src/components/tree.rs b/crates/nemo-ui/src/components/tree.rs similarity index 100% rename from crates/nemo/src/components/tree.rs rename to crates/nemo-ui/src/components/tree.rs diff --git a/crates/nemo/src/config/app.rs b/crates/nemo-ui/src/config/app.rs similarity index 100% rename from crates/nemo/src/config/app.rs rename to crates/nemo-ui/src/config/app.rs diff --git a/crates/nemo/src/config/mod.rs b/crates/nemo-ui/src/config/mod.rs similarity index 100% rename from crates/nemo/src/config/mod.rs rename to crates/nemo-ui/src/config/mod.rs diff --git a/crates/nemo/src/config/recent.rs b/crates/nemo-ui/src/config/recent.rs similarity index 100% rename from crates/nemo/src/config/recent.rs rename to crates/nemo-ui/src/config/recent.rs diff --git a/crates/nemo-ui/src/lib.rs b/crates/nemo-ui/src/lib.rs new file mode 100644 index 0000000..dfed71a --- /dev/null +++ b/crates/nemo-ui/src/lib.rs @@ -0,0 +1,183 @@ +//! Nemo UI - GPUI desktop rendering backend. +//! +//! This crate contains all GPUI-specific code for rendering Nemo applications +//! as desktop GUI windows. + +use anyhow::Result; +use gpui::*; +use gpui_component::Root; +use gpui_router::{init as router_init, use_navigate}; +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::Arc; +use tracing::info; + +pub mod app; +pub mod components; +pub mod config; +pub mod project; +pub mod runtime; +pub mod theme; +pub mod window; +pub mod workspace; + +use config::NemoConfig; +use project::ActiveProject; +use window::get_window_options; +use workspace::actions::{ + CloseProject, CloseSettings, OpenProject, OpenSettings, QuitApp, ReloadConfig, + ShowKeyboardShortcuts, ToggleTheme, +}; +use workspace::utils::{apply_theme_from_runtime, create_runtime}; +use workspace::{FooterBar, HeaderBar, Workspace, WorkspaceArgs}; + +/// Run the GPUI desktop application. +/// +/// This is the main entry point for the desktop UI backend. It creates a GPUI +/// application window, sets up routing, key bindings, and renders the workspace. +pub fn run_gpui( + nemo_config: NemoConfig, + app_config_path: Option, + extension_dirs: Vec, +) -> Result<()> { + info!("Starting GPUI application..."); + let gpui_app = Application::new().with_assets(gpui_component_assets::Assets); + + let ws_args = WorkspaceArgs { + extension_dirs: extension_dirs.clone(), + }; + + gpui_app.run(move |cx| { + gpui_component::init(cx); + router_init(cx); + + // Apply theme from TOML config (base app settings) + if nemo_config.app.theme_name != "default" { + theme::apply_configured_theme(&nemo_config.app.theme_name, "system", None, cx); + } + + cx.bind_keys([ + KeyBinding::new("ctrl-shift-r", ReloadConfig, None), + KeyBinding::new("ctrl-q", QuitApp, None), + KeyBinding::new("ctrl-w", CloseProject, None), + KeyBinding::new("ctrl-o", OpenProject, None), + KeyBinding::new("ctrl-shift-t", ToggleTheme, None), + KeyBinding::new("ctrl-p", OpenSettings, None), + KeyBinding::new("escape", CloseSettings, None), + KeyBinding::new("f10", ShowKeyboardShortcuts, None), + ]); + + // Store workspace entity for window close handler + let workspace_entity: Rc>>> = + Rc::new(RefCell::new(None)); + + cx.on_window_closed({ + let workspace_entity = workspace_entity.clone(); + move |cx| { + if let Some(ws) = workspace_entity.borrow().clone() { + ws.update(cx, |ws, cx| { + ws.shutdown(cx); + }); + } + cx.quit(); + } + }) + .detach(); + + // Default window options + let window_options = get_window_options(cx, None, None, None, None); + + cx.open_window(window_options, |window, cx| { + let nemo_config = nemo_config.clone(); + let ws_args = ws_args.clone(); + let app_config_path = app_config_path.clone(); + + let ws = cx.new(|cx| { + let mut current_route = "/".to_string(); + + // If app_config provided via CLI, load project immediately + if let Some(ref config_path) = app_config_path { + info!("Loading project from: {:?}", config_path); + + let mut recent_projects = config::recent::RecentProjects::load(); + recent_projects.add(config_path.clone()); + recent_projects.save(); + + match create_runtime(config_path, &ws_args.extension_dirs) { + Ok(rt) => { + apply_theme_from_runtime(&rt, cx); + let title = rt + .get_config("app.window.title") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "Nemo Application".to_string()); + let github_url = rt + .get_config("app.window.header_bar.github_url") + .and_then(|v| v.as_str().map(|s| s.to_string())); + let theme_toggle = rt + .get_config("app.window.header_bar.theme_toggle") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let header_bar = cx.new(|cx| { + HeaderBar::new(title, github_url, theme_toggle, window, cx) + }); + let footer_bar_enabled = rt + .get_config("app.window.footer_bar.enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let footer_bar = if footer_bar_enabled { + Some(cx.new(|cx| FooterBar::new(window, cx))) + } else { + None + }; + let app_entity = + cx.new(|cx| app::App::new(Arc::clone(&rt), window, cx)); + cx.set_global(ActiveProject { + runtime: rt, + app_entity, + header_bar, + footer_bar, + settings_view: None, + }); + current_route = "/app".to_string(); + } + Err(e) => { + tracing::error!("Failed to load project: {}", e); + } + } + } + + let focus_handle = cx.focus_handle(); + focus_handle.focus(window); + + let loader = Workspace::create_loader(&nemo_config, window, cx); + + Workspace { + nemo_config, + ws_args, + current_config_path: app_config_path.clone(), + pending_project_path: None, + pending_close_project: false, + focus_handle, + current_route: current_route.clone(), + loader, + } + }); + + // Navigate to the initial route after window creation + let route = ws.read(cx).current_route.clone(); + let needs_refresh = route != "/"; + use_navigate(cx)(route.into()); + if needs_refresh { + window.refresh(); + } + + *workspace_entity.borrow_mut() = Some(ws.clone()); + cx.new(|_cx| Root::new(ws, window, _cx)) + }) + .expect("Failed to open window"); + }); + + info!("Nemo shutdown complete"); + Ok(()) +} diff --git a/crates/nemo/src/project/mod.rs b/crates/nemo-ui/src/project/mod.rs similarity index 100% rename from crates/nemo/src/project/mod.rs rename to crates/nemo-ui/src/project/mod.rs diff --git a/crates/nemo/src/runtime.rs b/crates/nemo-ui/src/runtime.rs similarity index 100% rename from crates/nemo/src/runtime.rs rename to crates/nemo-ui/src/runtime.rs diff --git a/crates/nemo/src/theme/catppuccin-macchiato.json b/crates/nemo-ui/src/theme/catppuccin-macchiato.json similarity index 100% rename from crates/nemo/src/theme/catppuccin-macchiato.json rename to crates/nemo-ui/src/theme/catppuccin-macchiato.json diff --git a/crates/nemo/src/theme/catppuccin.json b/crates/nemo-ui/src/theme/catppuccin.json similarity index 100% rename from crates/nemo/src/theme/catppuccin.json rename to crates/nemo-ui/src/theme/catppuccin.json diff --git a/crates/nemo/src/theme/gruvbox.json b/crates/nemo-ui/src/theme/gruvbox.json similarity index 100% rename from crates/nemo/src/theme/gruvbox.json rename to crates/nemo-ui/src/theme/gruvbox.json diff --git a/crates/nemo/src/theme/kanagawa-dragon.json b/crates/nemo-ui/src/theme/kanagawa-dragon.json similarity index 100% rename from crates/nemo/src/theme/kanagawa-dragon.json rename to crates/nemo-ui/src/theme/kanagawa-dragon.json diff --git a/crates/nemo/src/theme/kanagawa.json b/crates/nemo-ui/src/theme/kanagawa.json similarity index 100% rename from crates/nemo/src/theme/kanagawa.json rename to crates/nemo-ui/src/theme/kanagawa.json diff --git a/crates/nemo/src/theme/mod.rs b/crates/nemo-ui/src/theme/mod.rs similarity index 100% rename from crates/nemo/src/theme/mod.rs rename to crates/nemo-ui/src/theme/mod.rs diff --git a/crates/nemo/src/theme/nord.json b/crates/nemo-ui/src/theme/nord.json similarity index 100% rename from crates/nemo/src/theme/nord.json rename to crates/nemo-ui/src/theme/nord.json diff --git a/crates/nemo/src/theme/theme.rs b/crates/nemo-ui/src/theme/theme.rs similarity index 100% rename from crates/nemo/src/theme/theme.rs rename to crates/nemo-ui/src/theme/theme.rs diff --git a/crates/nemo/src/theme/tokyo-night.json b/crates/nemo-ui/src/theme/tokyo-night.json similarity index 100% rename from crates/nemo/src/theme/tokyo-night.json rename to crates/nemo-ui/src/theme/tokyo-night.json diff --git a/crates/nemo/src/window.rs b/crates/nemo-ui/src/window.rs similarity index 100% rename from crates/nemo/src/window.rs rename to crates/nemo-ui/src/window.rs diff --git a/crates/nemo/src/workspace/actions.rs b/crates/nemo-ui/src/workspace/actions.rs similarity index 100% rename from crates/nemo/src/workspace/actions.rs rename to crates/nemo-ui/src/workspace/actions.rs diff --git a/crates/nemo/src/workspace/footer_bar.rs b/crates/nemo-ui/src/workspace/footer_bar.rs similarity index 100% rename from crates/nemo/src/workspace/footer_bar.rs rename to crates/nemo-ui/src/workspace/footer_bar.rs diff --git a/crates/nemo/src/workspace/header_bar.rs b/crates/nemo-ui/src/workspace/header_bar.rs similarity index 100% rename from crates/nemo/src/workspace/header_bar.rs rename to crates/nemo-ui/src/workspace/header_bar.rs diff --git a/crates/nemo/src/workspace/layout.rs b/crates/nemo-ui/src/workspace/layout.rs similarity index 100% rename from crates/nemo/src/workspace/layout.rs rename to crates/nemo-ui/src/workspace/layout.rs diff --git a/crates/nemo/src/workspace/main_view.rs b/crates/nemo-ui/src/workspace/main_view.rs similarity index 100% rename from crates/nemo/src/workspace/main_view.rs rename to crates/nemo-ui/src/workspace/main_view.rs diff --git a/crates/nemo/src/workspace/mod.rs b/crates/nemo-ui/src/workspace/mod.rs similarity index 100% rename from crates/nemo/src/workspace/mod.rs rename to crates/nemo-ui/src/workspace/mod.rs diff --git a/crates/nemo/src/workspace/project_loader.rs b/crates/nemo-ui/src/workspace/project_loader.rs similarity index 100% rename from crates/nemo/src/workspace/project_loader.rs rename to crates/nemo-ui/src/workspace/project_loader.rs diff --git a/crates/nemo/src/workspace/settings.rs b/crates/nemo-ui/src/workspace/settings.rs similarity index 100% rename from crates/nemo/src/workspace/settings.rs rename to crates/nemo-ui/src/workspace/settings.rs diff --git a/crates/nemo/src/workspace/utils.rs b/crates/nemo-ui/src/workspace/utils.rs similarity index 100% rename from crates/nemo/src/workspace/utils.rs rename to crates/nemo-ui/src/workspace/utils.rs diff --git a/crates/nemo/Cargo.toml b/crates/nemo/Cargo.toml index a1a0a27..f0df7d8 100644 --- a/crates/nemo/Cargo.toml +++ b/crates/nemo/Cargo.toml @@ -9,6 +9,8 @@ name = "nemo" path = "src/main.rs" [dependencies] +nemo-ui = { workspace = true } +nemo-tui = { workspace = true } nemo-config = { workspace = true } nemo-registry = { workspace = true } nemo-layout = { workspace = true } @@ -18,10 +20,6 @@ nemo-integration = { workspace = true } nemo-events = { workspace = true } nemo-plugin-api = { workspace = true } nemo-macros = { workspace = true } -gpui = { workspace = true } -gpui-component = { workspace = true } -gpui-component-assets = { workspace = true } -gpui-router = { workspace = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/nemo/src/args.rs b/crates/nemo/src/args.rs index 9eb5de2..668063c 100644 --- a/crates/nemo/src/args.rs +++ b/crates/nemo/src/args.rs @@ -36,6 +36,14 @@ pub struct Args { /// Validate configuration and exit #[arg(long)] pub validate_only: bool, + + /// Run in terminal UI mode (ratatui) + #[arg(long, conflicts_with = "ui")] + pub tui: bool, + + /// Run in desktop UI mode (GPUI) — this is the default + #[arg(long, conflicts_with = "tui")] + pub ui: bool, } impl Args { diff --git a/crates/nemo/src/main.rs b/crates/nemo/src/main.rs index d3aa245..622e59d 100644 --- a/crates/nemo/src/main.rs +++ b/crates/nemo/src/main.rs @@ -4,38 +4,17 @@ //! - Parses CLI arguments //! - Loads configuration from XML files //! - Initializes all subsystems -//! - Launches the GPUI window with router-based navigation +//! - Dispatches to the appropriate UI backend (GPUI or TUI) use anyhow::{Context as _, Result}; -use gpui::*; -use gpui_component::Root; -use gpui_router::{init as router_init, use_navigate}; -use std::cell::RefCell; -use std::rc::Rc; -use std::sync::Arc; use tracing::info; use tracing_subscriber::FmtSubscriber; -mod app; mod args; -mod components; -pub mod config; -mod project; -mod runtime; -mod theme; -mod window; -mod workspace; use args::Args; -use config::NemoConfig; -use project::ActiveProject; -use window::get_window_options; -use workspace::actions::{ - CloseProject, CloseSettings, OpenProject, OpenSettings, QuitApp, ReloadConfig, - ShowKeyboardShortcuts, ToggleTheme, -}; -use workspace::utils::{apply_theme_from_runtime, create_runtime}; -use workspace::{FooterBar, HeaderBar, Workspace, WorkspaceArgs}; +use nemo_ui::config::NemoConfig; +use nemo_ui::runtime; fn main() -> Result<()> { let args = Args::parse(); @@ -82,148 +61,31 @@ fn main() -> Result<()> { } } - // Launch GPUI application - info!("Starting GPUI application..."); - let gpui_app = Application::new().with_assets(gpui_component_assets::Assets); - - let app_config_path = args.app_config.clone(); - let ws_args = WorkspaceArgs { - extension_dirs: args.extension_dirs.clone(), + // Determine UI mode: CLI flag > XML config default > fallback to GPUI + let use_tui = if args.tui { + true + } else if args.ui { + false + } else { + // Check XML config for + args.app_config + .as_ref() + .and_then(|config_path| { + let rt = runtime::NemoRuntime::new(config_path).ok()?; + rt.load_config().ok()?; + rt.get_config("app.default") + .and_then(|v| v.as_str().map(|s| s == "tui")) + }) + .unwrap_or(false) }; - gpui_app.run(move |cx| { - gpui_component::init(cx); - router_init(cx); - - // Apply theme from TOML config (base app settings) - if nemo_config.app.theme_name != "default" { - theme::apply_configured_theme(&nemo_config.app.theme_name, "system", None, cx); - } - - cx.bind_keys([ - KeyBinding::new("ctrl-shift-r", ReloadConfig, None), - KeyBinding::new("ctrl-q", QuitApp, None), - KeyBinding::new("ctrl-w", CloseProject, None), - KeyBinding::new("ctrl-o", OpenProject, None), - KeyBinding::new("ctrl-shift-t", ToggleTheme, None), - KeyBinding::new("ctrl-p", OpenSettings, None), - KeyBinding::new("escape", CloseSettings, None), - KeyBinding::new("f10", ShowKeyboardShortcuts, None), - ]); - - // Store workspace entity for window close handler - let workspace_entity: Rc>>> = Rc::new(RefCell::new(None)); - - cx.on_window_closed({ - let workspace_entity = workspace_entity.clone(); - move |cx| { - if let Some(ws) = workspace_entity.borrow().clone() { - ws.update(cx, |ws, cx| { - ws.shutdown(cx); - }); - } - cx.quit(); - } - }) - .detach(); - - // Default window options - let window_options = get_window_options(cx, None, None, None, None); - - cx.open_window(window_options, |window, cx| { - let nemo_config = nemo_config.clone(); - let ws_args = ws_args.clone(); - let app_config_path = app_config_path.clone(); - - let ws = cx.new(|cx| { - let mut current_route = "/".to_string(); - - // If app_config provided via CLI, load project immediately - if let Some(config_path) = app_config_path { - info!("Loading project from: {:?}", config_path); - - let mut recent_projects = config::recent::RecentProjects::load(); - recent_projects.add(config_path.clone()); - recent_projects.save(); - - match create_runtime(&config_path, &ws_args.extension_dirs) { - Ok(rt) => { - apply_theme_from_runtime(&rt, cx); - let title = rt - .get_config("app.window.title") - .and_then(|v| v.as_str().map(|s| s.to_string())) - .unwrap_or_else(|| "Nemo Application".to_string()); - let github_url = rt - .get_config("app.window.header_bar.github_url") - .and_then(|v| v.as_str().map(|s| s.to_string())); - let theme_toggle = rt - .get_config("app.window.header_bar.theme_toggle") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let header_bar = cx.new(|cx| { - HeaderBar::new(title, github_url, theme_toggle, window, cx) - }); - let footer_bar_enabled = rt - .get_config("app.window.footer_bar.enabled") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let footer_bar = if footer_bar_enabled { - Some(cx.new(|cx| FooterBar::new(window, cx))) - } else { - None - }; - let app_entity = - cx.new(|cx| app::App::new(Arc::clone(&rt), window, cx)); - cx.set_global(ActiveProject { - runtime: rt, - app_entity, - header_bar, - footer_bar, - settings_view: None, - }); - current_route = "/app".to_string(); - } - Err(e) => { - tracing::error!("Failed to load project: {}", e); - } - } - } - - let focus_handle = cx.focus_handle(); - focus_handle.focus(window); - - let loader = Workspace::create_loader(&nemo_config, window, cx); - - Workspace { - nemo_config, - ws_args, - current_config_path: if current_route == "/app" { - args.app_config.clone() - } else { - None - }, - pending_project_path: None, - pending_close_project: false, - focus_handle, - current_route, - loader, - } - }); - - // Navigate to the initial route after window creation - let route = ws.read(cx).current_route.clone(); - let needs_refresh = route != "/"; - use_navigate(cx)(route.into()); - if needs_refresh { - window.refresh(); - } - - *workspace_entity.borrow_mut() = Some(ws.clone()); - cx.new(|_cx| Root::new(ws, window, _cx)) - }) - .expect("Failed to open window"); - }); - - info!("Nemo shutdown complete"); - Ok(()) + if use_tui { + let app_config = args.app_config.as_ref().ok_or_else(|| { + anyhow::anyhow!("TUI mode requires --app-config to be specified") + })?; + let rt = nemo_ui::workspace::utils::create_runtime(app_config, &args.extension_dirs)?; + nemo_tui::run_tui(rt) + } else { + nemo_ui::run_gpui(nemo_config, args.app_config.clone(), args.extension_dirs.clone()) + } } diff --git a/examples/tui-basic/app.xml b/examples/tui-basic/app.xml new file mode 100644 index 0000000..7a52c0d --- /dev/null +++ b/examples/tui-basic/app.xml @@ -0,0 +1,32 @@ + + + + + + + + +