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//! Run with: cargo run --example declarative
9
10use std::io::{self, Write};
11use std::thread;
12use std::time::Duration;
13
14use eye_declare::{Elements, InlineRenderer, Markdown, Spinner, TextBlock, VStack};
15use ratatui_core::style::{Color, Style};
16
17// ---------------------------------------------------------------------------
18// Application state — user-owned, not framework-managed
19// ---------------------------------------------------------------------------
20
21struct AppState {
22    thinking: bool,
23    messages: Vec<String>,
24    tool_running: Option<String>,
25}
26
27impl AppState {
28    fn new() -> Self {
29        Self {
30            thinking: false,
31            messages: Vec::new(),
32            tool_running: None,
33        }
34    }
35}
36
37// ---------------------------------------------------------------------------
38// View function: state in, elements out
39// ---------------------------------------------------------------------------
40
41fn chat_view(state: &AppState) -> Elements {
42    let mut els = Elements::new();
43
44    // Render all messages with stable keys
45    for (i, msg) in state.messages.iter().enumerate() {
46        els.add(Markdown::new(msg)).key(format!("msg-{i}"));
47    }
48
49    // Show thinking spinner if active (auto-animates via tick registration)
50    if state.thinking {
51        els.add(Spinner::new("Thinking...")).key("thinking");
52    }
53
54    // Show tool call spinner if active (auto-animates via tick registration)
55    if let Some(ref tool) = state.tool_running {
56        els.add(Spinner::new(format!("Running {}...", tool)))
57            .key("tool");
58    }
59
60    // Separator at the bottom
61    if !state.messages.is_empty() || state.thinking || state.tool_running.is_some() {
62        els.add(TextBlock::new().line("---", Style::default().fg(Color::DarkGray)));
63    }
64
65    els
66}
67
68// ---------------------------------------------------------------------------
69// Main: simulate an agent conversation
70// ---------------------------------------------------------------------------
71
72fn main() -> io::Result<()> {
73    let (width, _) = crossterm::terminal::size()?;
74    let mut r = InlineRenderer::new(width);
75    let mut stdout = io::stdout();
76
77    let container = r.push(VStack);
78    let mut state = AppState::new();
79
80    // --- Phase 1: Thinking ---
81    state.thinking = true;
82    r.rebuild(container, chat_view(&state));
83    // Spinner animates automatically — just tick and render
84    animate_while_active(&mut r, &mut stdout, Duration::from_millis(1500))?;
85
86    // --- Phase 2: First response ---
87    state.thinking = false;
88    state.messages.push(
89        "Here's a binary search implementation in Rust:\n\n\
90         ```rust\n\
91         fn binary_search(arr: &[i32], target: i32) -> Option<usize> {\n\
92         \x20   let mut low = 0;\n\
93         \x20   let mut high = arr.len();\n\
94         \x20   while low < high {\n\
95         \x20       let mid = low + (high - low) / 2;\n\
96         \x20       match arr[mid].cmp(&target) {\n\
97         \x20           std::cmp::Ordering::Less => low = mid + 1,\n\
98         \x20           std::cmp::Ordering::Greater => high = mid,\n\
99         \x20           std::cmp::Ordering::Equal => return Some(mid),\n\
100         \x20       }\n\
101         \x20   }\n\
102         \x20   None\n\
103         }\n\
104         ```"
105        .to_string(),
106    );
107    r.rebuild(container, chat_view(&state));
108    flush(&mut r, &mut stdout)?;
109    thread::sleep(Duration::from_millis(800));
110
111    // --- Phase 3: Tool call ---
112    state.tool_running = Some("cargo clippy".to_string());
113    r.rebuild(container, chat_view(&state));
114    // Spinner auto-animates
115    animate_while_active(&mut r, &mut stdout, Duration::from_millis(2000))?;
116
117    // --- Phase 4: Tool complete, add follow-up ---
118    state.tool_running = None;
119    state.messages.push(
120        "The implementation passes **clippy** with no warnings. \
121         The function takes a sorted slice and a target value, \
122         returning `Some(index)` if found or `None` otherwise."
123            .to_string(),
124    );
125    r.rebuild(container, chat_view(&state));
126    flush(&mut r, &mut stdout)?;
127
128    println!();
129    Ok(())
130}
131
132// ---------------------------------------------------------------------------
133// Helpers
134// ---------------------------------------------------------------------------
135
136fn flush(r: &mut InlineRenderer, stdout: &mut impl Write) -> io::Result<()> {
137    let output = r.render();
138    if !output.is_empty() {
139        stdout.write_all(&output)?;
140        stdout.flush()?;
141    }
142    Ok(())
143}
144
145/// Tick and render while there are active animations, up to a max duration.
146fn animate_while_active(
147    r: &mut InlineRenderer,
148    stdout: &mut impl Write,
149    max_duration: Duration,
150) -> io::Result<()> {
151    let start = std::time::Instant::now();
152    while start.elapsed() < max_duration && r.has_active() {
153        r.tick();
154        flush(r, stdout)?;
155        thread::sleep(Duration::from_millis(50));
156    }
157    Ok(())
158}