Skip to content

Commit ba678b1

Browse files
authored
Merge pull request #213 from ryanoneill/feature/production-app-example
Add production app example demonstrating full runtime lifecycle
2 parents 18a5375 + 79c0794 commit ba678b1

File tree

2 files changed

+374
-0
lines changed

2 files changed

+374
-0
lines changed

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,10 @@ required-features = ["display-components"]
190190
name = "tooltip"
191191
required-features = ["overlay-components"]
192192

193+
[[example]]
194+
name = "production_app"
195+
required-features = ["full"]
196+
193197
[[bench]]
194198
name = "capture_backend"
195199
harness = false

examples/production_app.rs

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
//! Production App — full lifecycle example for real-world Envision applications.
2+
//!
3+
//! This example demonstrates the complete production lifecycle that every real
4+
//! Envision application needs, going beyond self-contained demos to show:
5+
//!
6+
//! 1. **CLI-style configuration** — Build initial state from external config
7+
//! 2. **`with_state` construction** — Bypass `App::init()` with pre-built state
8+
//! 3. **External channel subscription** — Receive progress from a background worker
9+
//! 4. **Lifecycle hooks** — `on_setup` / `on_teardown` for logging configuration
10+
//! 5. **Background work** — Tokio task simulating file processing with progress
11+
//! 6. **Final state extraction** — Access state after `run_terminal()` returns
12+
//!
13+
//! The application simulates a batch file processor:
14+
//! - Takes a list of "files" to process (configured at startup)
15+
//! - Shows a progress bar and scrolling status log
16+
//! - A background worker sends progress updates via an unbounded channel
17+
//! - Press 'q' to quit early
18+
//! - On exit, prints a summary of how many files were processed
19+
//!
20+
//! Run with: `cargo run --example production_app --features full`
21+
22+
use std::sync::Arc;
23+
use std::time::Duration;
24+
25+
use envision::app::UnboundedChannelSubscription;
26+
use envision::prelude::*;
27+
use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph};
28+
29+
// =============================================================================
30+
// Step 1: Configuration (simulates CLI argument parsing)
31+
// =============================================================================
32+
33+
/// Configuration derived from command-line arguments or a config file.
34+
///
35+
/// In a real application, this would come from `clap`, `config`, or similar.
36+
struct AppConfig {
37+
/// Files to process.
38+
files: Vec<String>,
39+
/// Output directory (illustrative; not used in the simulation).
40+
output_dir: String,
41+
}
42+
43+
impl AppConfig {
44+
/// Builds a sample configuration as if parsed from CLI args.
45+
fn from_simulated_args() -> Self {
46+
Self {
47+
files: (1..=20).map(|i| format!("document_{:02}.pdf", i)).collect(),
48+
output_dir: "/tmp/processed".into(),
49+
}
50+
}
51+
}
52+
53+
// =============================================================================
54+
// Step 2: Application state (built from config, not from App::init)
55+
// =============================================================================
56+
57+
/// Complete application state for the file processor.
58+
#[derive(Clone)]
59+
struct ProcessorState {
60+
/// Total number of files to process.
61+
total_files: usize,
62+
/// Number of files fully processed so far.
63+
processed: usize,
64+
/// The file currently being worked on, if any.
65+
current_file: Option<String>,
66+
/// Whether all files have finished processing.
67+
all_done: bool,
68+
69+
// Sub-component states
70+
progress: ProgressBarState,
71+
log: StatusLogState,
72+
}
73+
74+
impl ProcessorState {
75+
/// Constructs initial state from an `AppConfig`.
76+
fn from_config(config: &AppConfig) -> Self {
77+
let mut progress = ProgressBarState::with_progress(0.0);
78+
progress.set_label(Some("Processing".to_string()));
79+
80+
let mut log = StatusLogState::new()
81+
.with_title("Activity Log")
82+
.with_max_entries(50);
83+
log.set_focused(true);
84+
log.info(format!(
85+
"Batch started: {} files queued (output: {})",
86+
config.files.len(),
87+
config.output_dir,
88+
));
89+
90+
Self {
91+
total_files: config.files.len(),
92+
processed: 0,
93+
current_file: None,
94+
all_done: false,
95+
progress,
96+
log,
97+
}
98+
}
99+
}
100+
101+
// =============================================================================
102+
// Step 3: Messages
103+
// =============================================================================
104+
105+
/// Messages that drive state transitions.
106+
///
107+
/// `FileStarted`, `FileCompleted`, and `AllDone` arrive from the background
108+
/// tokio task via the unbounded channel subscription. `Quit` and `Log` come
109+
/// from keyboard input.
110+
#[derive(Clone, Debug)]
111+
enum ProcessorMsg {
112+
/// A file has started processing.
113+
FileStarted(String),
114+
/// A file has finished processing.
115+
FileCompleted(String),
116+
/// All files are done.
117+
AllDone,
118+
/// User requested quit.
119+
Quit,
120+
/// Delegated status log message (scrolling via Up/Down keys).
121+
Log(StatusLogMessage),
122+
}
123+
124+
// =============================================================================
125+
// Step 4: App implementation
126+
// =============================================================================
127+
128+
/// The application marker type.
129+
struct ProcessorApp;
130+
131+
impl App for ProcessorApp {
132+
type State = ProcessorState;
133+
type Message = ProcessorMsg;
134+
135+
/// Stub init — we use `with_state` instead, so this is never called.
136+
fn init() -> (Self::State, Command<Self::Message>) {
137+
// Provide a valid fallback in case someone constructs the runtime
138+
// without `with_state`. The real entrypoint builds state from config.
139+
let stub_config = AppConfig {
140+
files: Vec::new(),
141+
output_dir: String::new(),
142+
};
143+
(ProcessorState::from_config(&stub_config), Command::none())
144+
}
145+
146+
fn update(state: &mut Self::State, msg: Self::Message) -> Command<Self::Message> {
147+
match msg {
148+
ProcessorMsg::FileStarted(name) => {
149+
state.current_file = Some(name.clone());
150+
state.log.info(format!("Processing: {}", name));
151+
}
152+
ProcessorMsg::FileCompleted(name) => {
153+
state.processed += 1;
154+
let fraction = state.processed as f32 / state.total_files as f32;
155+
state.progress.set_progress(fraction);
156+
state.log.success(format!("Completed: {}", name));
157+
state.current_file = None;
158+
}
159+
ProcessorMsg::AllDone => {
160+
state.all_done = true;
161+
state.progress.set_progress(1.0);
162+
state.log.success(format!(
163+
"All {} files processed! Press 'q' to exit.",
164+
state.total_files,
165+
));
166+
}
167+
ProcessorMsg::Quit => {
168+
return Command::quit();
169+
}
170+
ProcessorMsg::Log(m) => {
171+
StatusLog::update(&mut state.log, m);
172+
}
173+
}
174+
Command::none()
175+
}
176+
177+
fn view(state: &Self::State, frame: &mut Frame) {
178+
let theme = Theme::catppuccin_mocha();
179+
let area = frame.area();
180+
181+
// Main layout: title, progress, current file, log, status bar
182+
let sections = Layout::vertical([
183+
Constraint::Length(3), // Title
184+
Constraint::Length(3), // Progress bar
185+
Constraint::Length(3), // Current file indicator
186+
Constraint::Min(6), // Status log
187+
Constraint::Length(1), // Bottom status bar
188+
])
189+
.split(area);
190+
191+
// -- Title --
192+
let title_text = format!(
193+
" File Processor [{}/{}] ",
194+
state.processed, state.total_files,
195+
);
196+
let title = Paragraph::new(title_text)
197+
.style(
198+
Style::default()
199+
.fg(Color::Cyan)
200+
.add_modifier(Modifier::BOLD),
201+
)
202+
.alignment(Alignment::Center)
203+
.block(
204+
Block::default()
205+
.borders(Borders::ALL)
206+
.border_type(BorderType::Rounded),
207+
);
208+
frame.render_widget(title, sections[0]);
209+
210+
// -- Progress bar --
211+
let progress_block = Block::default()
212+
.borders(Borders::ALL)
213+
.border_type(BorderType::Rounded)
214+
.title(Span::styled(
215+
" Progress ",
216+
Style::default().add_modifier(Modifier::BOLD),
217+
))
218+
.padding(Padding::horizontal(1));
219+
let progress_inner = progress_block.inner(sections[1]);
220+
frame.render_widget(progress_block, sections[1]);
221+
ProgressBar::view(&state.progress, frame, progress_inner, &theme);
222+
223+
// -- Current file indicator --
224+
let current_text = match &state.current_file {
225+
Some(name) => format!(" Working on: {}", name),
226+
None if state.all_done => " Status: All files processed".to_string(),
227+
None if state.processed == 0 => " Status: Waiting for worker...".to_string(),
228+
None => " Status: Idle (between files)".to_string(),
229+
};
230+
let current_style = if state.current_file.is_some() {
231+
Style::default().fg(Color::Yellow)
232+
} else if state.all_done {
233+
Style::default().fg(Color::Green)
234+
} else {
235+
Style::default().fg(Color::DarkGray)
236+
};
237+
let current = Paragraph::new(current_text).style(current_style).block(
238+
Block::default()
239+
.borders(Borders::ALL)
240+
.border_type(BorderType::Rounded),
241+
);
242+
frame.render_widget(current, sections[2]);
243+
244+
// -- Status log --
245+
StatusLog::view(&state.log, frame, sections[3], &theme);
246+
247+
// -- Bottom status bar --
248+
let status = Line::from(vec![
249+
Span::styled("[Up/Down] ", Style::default().fg(Color::Cyan)),
250+
Span::raw("Scroll log "),
251+
Span::styled("[q] ", Style::default().fg(Color::Magenta)),
252+
Span::raw("Quit"),
253+
]);
254+
let status_bar = Paragraph::new(status).alignment(Alignment::Center);
255+
frame.render_widget(status_bar, sections[4]);
256+
}
257+
258+
fn handle_event_with_state(state: &Self::State, event: &Event) -> Option<Self::Message> {
259+
if let Some(key) = event.as_key() {
260+
match key.code {
261+
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
262+
return Some(ProcessorMsg::Quit);
263+
}
264+
_ => {}
265+
}
266+
}
267+
// Delegate to the status log for scroll events (Up/Down keys).
268+
state.log.handle_event(event).map(ProcessorMsg::Log)
269+
}
270+
}
271+
272+
// =============================================================================
273+
// Step 5: Main — full production lifecycle
274+
// =============================================================================
275+
276+
#[tokio::main]
277+
async fn main() -> std::io::Result<()> {
278+
// ── 1. Parse configuration ──────────────────────────────────────────
279+
let config = AppConfig::from_simulated_args();
280+
281+
// ── 2. Build initial state from config ──────────────────────────────
282+
let state = ProcessorState::from_config(&config);
283+
284+
// ── 3. Create the unbounded channel for background worker updates ───
285+
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ProcessorMsg>();
286+
287+
// ── 4. Configure runtime with lifecycle hooks ───────────────────────
288+
//
289+
// on_setup runs *after* the terminal enters raw/alternate-screen mode.
290+
// In a real app you would redirect stderr to a log file here.
291+
//
292+
// on_teardown runs *before* the terminal is restored. Flush logs, etc.
293+
let runtime_config = RuntimeConfig::new()
294+
.on_setup(Arc::new(|| {
295+
// Example: redirect stderr or initialise a tracing subscriber.
296+
// For this demo we simply do nothing.
297+
Ok(())
298+
}))
299+
.on_teardown(Arc::new(|| {
300+
// Example: flush any buffered log output.
301+
Ok(())
302+
}));
303+
304+
// ── 5. Create the runtime with pre-built state (bypass App::init) ──
305+
let mut runtime = TerminalRuntime::<ProcessorApp>::terminal_with_state_and_config(
306+
state,
307+
Command::none(),
308+
runtime_config,
309+
)?;
310+
311+
// ── 6. Subscribe to background worker updates ───────────────────────
312+
//
313+
// Messages sent to `tx` will appear as `ProcessorMsg` in the runtime's
314+
// event loop, exactly as if they were produced by `handle_event` or
315+
// `on_tick`.
316+
runtime.subscribe(UnboundedChannelSubscription::new(rx));
317+
318+
// ── 7. Spawn the background worker ──────────────────────────────────
319+
//
320+
// This simulates a CPU-bound or I/O-bound task running off the main
321+
// thread, sending progress updates through the channel.
322+
let files = config.files.clone();
323+
tokio::spawn(async move {
324+
for file in &files {
325+
// Notify the UI that we are starting this file.
326+
if tx.send(ProcessorMsg::FileStarted(file.clone())).is_err() {
327+
return; // Runtime shut down; stop the worker.
328+
}
329+
330+
// Simulate processing time.
331+
tokio::time::sleep(Duration::from_millis(400)).await;
332+
333+
// Notify the UI that this file is done.
334+
if tx.send(ProcessorMsg::FileCompleted(file.clone())).is_err() {
335+
return;
336+
}
337+
}
338+
339+
// Signal completion.
340+
let _ = tx.send(ProcessorMsg::AllDone);
341+
});
342+
343+
// ── 8. Run the interactive event loop ───────────────────────────────
344+
//
345+
// `run_terminal()` blocks until the user presses 'q' or all files
346+
// finish and the user quits. It returns ownership of the final state.
347+
let final_state = runtime.run_terminal().await?;
348+
349+
// ── 9. Use the final state after the TUI has exited ─────────────────
350+
//
351+
// The terminal has been restored to normal mode at this point, so
352+
// regular println! works.
353+
println!();
354+
println!("Processing summary");
355+
println!("──────────────────");
356+
println!(
357+
"Files processed: {}/{}",
358+
final_state.processed, final_state.total_files,
359+
);
360+
if final_state.processed < final_state.total_files {
361+
println!(
362+
"Cancelled early ({} files remaining)",
363+
final_state.total_files - final_state.processed,
364+
);
365+
} else {
366+
println!("All files processed successfully.");
367+
}
368+
369+
Ok(())
370+
}

0 commit comments

Comments
 (0)