Skip to main content

declarative/
declarative.rs

1//! Declarative view-function pattern for building UIs.
2//!
3//! This example shows how to use `Elements` and `rebuild` to describe
4//! the UI as a function of state, instead of imperative tree manipulation.
5//! Spinners animate automatically via the tick registration system —
6//! no manual ticking needed.
7//!
8//! Also demonstrates `view()` components: `Card` (composite container
9//! using View for borders) and `Badge` (leaf using Canvas for raw rendering).
10//!
11//! Run with: cargo run --example declarative
12
13use std::io::{self, Write};
14use std::thread;
15use std::time::Duration;
16
17use eye_declare::{
18    BorderType, Canvas, Elements, InlineRenderer, Markdown, Spinner, VStack, View, component,
19    element, props,
20};
21use ratatui_core::style::{Color, Modifier, Style};
22use ratatui_core::{buffer::Buffer, layout::Rect, text::Line, widgets::Widget};
23use ratatui_widgets::paragraph::Paragraph;
24
25// ---------------------------------------------------------------------------
26// Application state — user-owned, not framework-managed
27// ---------------------------------------------------------------------------
28
29struct AppState {
30    thinking: bool,
31    messages: Vec<String>,
32    tool_running: Option<String>,
33}
34
35impl AppState {
36    fn new() -> Self {
37        Self {
38            thinking: false,
39            messages: Vec::new(),
40            tool_running: None,
41        }
42    }
43}
44
45// ---------------------------------------------------------------------------
46// Card: composite container using #[component] + #[props]
47// ---------------------------------------------------------------------------
48
49/// A bordered card with a title.
50#[props]
51struct Card {
52    title: String,
53}
54
55#[component(props = Card, children = Elements)]
56fn card(props: &Card, children: Elements) -> Elements {
57    element!(
58        View(
59            border: BorderType::Rounded,
60            border_style: Style::default().fg(Color::DarkGray),
61            title: props.title.clone(),
62            title_style: Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
63            padding_left: Some(eye_declare::Cells(1)),
64            padding_right: Some(eye_declare::Cells(1)),
65        ) {
66            #(children)
67        }
68    )
69}
70
71// ---------------------------------------------------------------------------
72// Badge: leaf component using #[component] + Canvas
73// ---------------------------------------------------------------------------
74
75/// A colored status badge.
76#[props]
77struct Badge {
78    label: String,
79    #[default(Color::Green)]
80    color: Color,
81}
82
83#[component(props = Badge)]
84fn badge(props: &Badge) -> Elements {
85    let label = props.label.clone();
86    let color = props.color;
87
88    element!(
89        Canvas(render_fn: move |area: Rect, buf: &mut Buffer| {
90            let line = Line::styled(
91                format!(" {} ", label),
92                Style::default().fg(Color::Black).bg(color),
93            );
94            Paragraph::new(line).render(area, buf);
95        })
96    )
97}
98
99// ---------------------------------------------------------------------------
100// View function: state in, elements out
101// ---------------------------------------------------------------------------
102
103fn chat_view(state: &AppState) -> Elements {
104    element!(
105        #(for (i, msg) in state.messages.iter().enumerate() {
106            Card(key: format!("msg-{i}"), title: "Response") {
107                Markdown(key: format!("msg-{i}"), source: msg.clone())
108            }
109        })
110
111        #(if state.thinking {
112            Spinner(key: "thinking", label: "Thinking...")
113        })
114
115        #(if let Some(ref tool) = state.tool_running {
116            Spinner(key: "tool", label: format!("Running {}...", tool))
117        })
118
119        #(if !state.messages.is_empty() && state.tool_running.is_none() && !state.thinking {
120            Badge(key: "done", label: "Done", color: Color::Green)
121        })
122    )
123}
124
125// ---------------------------------------------------------------------------
126// Main: simulate an agent conversation
127// ---------------------------------------------------------------------------
128
129fn main() -> io::Result<()> {
130    let (width, _) = crossterm::terminal::size()?;
131    let mut r = InlineRenderer::new(width);
132    let mut stdout = io::stdout();
133
134    let container = r.push(VStack);
135    let mut state = AppState::new();
136
137    // --- Phase 1: Thinking ---
138    state.thinking = true;
139    r.rebuild(container, chat_view(&state));
140    // Spinner animates automatically — just tick and render
141    animate_while_active(&mut r, &mut stdout, Duration::from_millis(1500))?;
142
143    // --- Phase 2: First response ---
144    state.thinking = false;
145    state.messages.push(
146        "Here's a binary search implementation in Rust:\n\n\
147         ```rust\n\
148         fn binary_search(arr: &[i32], target: i32) -> Option<usize> {\n\
149         \x20   let mut low = 0;\n\
150         \x20   let mut high = arr.len();\n\
151         \x20   while low < high {\n\
152         \x20       let mid = low + (high - low) / 2;\n\
153         \x20       match arr[mid].cmp(&target) {\n\
154         \x20           std::cmp::Ordering::Less => low = mid + 1,\n\
155         \x20           std::cmp::Ordering::Greater => high = mid,\n\
156         \x20           std::cmp::Ordering::Equal => return Some(mid),\n\
157         \x20       }\n\
158         \x20   }\n\
159         \x20   None\n\
160         }\n\
161         ```"
162        .to_string(),
163    );
164    r.rebuild(container, chat_view(&state));
165    flush(&mut r, &mut stdout)?;
166    thread::sleep(Duration::from_millis(800));
167
168    // --- Phase 3: Tool call ---
169    state.tool_running = Some("cargo clippy".to_string());
170    r.rebuild(container, chat_view(&state));
171    // Spinner auto-animates
172    animate_while_active(&mut r, &mut stdout, Duration::from_millis(2000))?;
173
174    // --- Phase 4: Tool complete, add follow-up ---
175    state.tool_running = None;
176    state.messages.push(
177        "The implementation passes **clippy** with no warnings. \
178         The function takes a sorted slice and a target value, \
179         returning `Some(index)` if found or `None` otherwise."
180            .to_string(),
181    );
182    r.rebuild(container, chat_view(&state));
183    flush(&mut r, &mut stdout)?;
184
185    println!();
186    Ok(())
187}
188
189// ---------------------------------------------------------------------------
190// Helpers
191// ---------------------------------------------------------------------------
192
193fn flush(r: &mut InlineRenderer, stdout: &mut impl Write) -> io::Result<()> {
194    let output = r.render();
195    if !output.is_empty() {
196        stdout.write_all(&output)?;
197        stdout.flush()?;
198    }
199    Ok(())
200}
201
202/// Tick and render while there are active animations, up to a max duration.
203fn animate_while_active(
204    r: &mut InlineRenderer,
205    stdout: &mut impl Write,
206    max_duration: Duration,
207) -> io::Result<()> {
208    let start = std::time::Instant::now();
209    while start.elapsed() < max_duration && r.has_active() {
210        r.tick();
211        flush(r, stdout)?;
212        thread::sleep(Duration::from_millis(50));
213    }
214    Ok(())
215}