1use std::{
2 fs,
3 path::PathBuf,
4 time::Duration,
5};
6
7use anyhow::{
8 Context,
9 Result,
10};
11use chrono::Local;
12use crossterm::event::{
13 KeyCode,
14 KeyEvent,
15};
16use tiktoken_rs::cl100k_base;
17
18use crate::{
19 decode,
20 encode,
21 tui::{
22 components::FileBrowser,
23 events::{
24 Event,
25 EventHandler,
26 },
27 keybindings::{
28 Action,
29 KeyBindings,
30 },
31 repl_command::ReplCommand,
32 state::{
33 app_state::ConversionStats,
34 AppState,
35 ConversionHistory,
36 },
37 ui,
38 },
39};
40
41pub struct TuiApp<'a> {
43 pub app_state: AppState<'a>,
44 pub file_browser: FileBrowser,
45}
46
47impl<'a> TuiApp<'a> {
48 pub fn new() -> Self {
49 Self {
50 app_state: AppState::new(),
51 file_browser: FileBrowser::new(),
52 }
53 }
54
55 pub fn run<B: ratatui::backend::Backend>(
56 &mut self,
57 terminal: &mut ratatui::Terminal<B>,
58 ) -> Result<()> {
59 loop {
60 terminal.draw(|f| ui::render(f, &mut self.app_state, &mut self.file_browser))?;
61
62 if let Some(event) = EventHandler::poll(Duration::from_millis(100))? {
63 self.handle_event(event)?;
64 }
65
66 if self.app_state.should_quit {
67 break;
68 }
69 }
70 Ok(())
71 }
72
73 fn handle_event(&mut self, event: Event) -> Result<()> {
74 match event {
75 Event::Key(key) => self.handle_key_event(key)?,
76 Event::Resize => {}
77 Event::Tick => {}
78 }
79 Ok(())
80 }
81
82 fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
83 if self.app_state.repl.active {
85 return self.handle_repl_key(key);
86 }
87
88 if self.app_state.show_help
90 || self.app_state.show_file_browser
91 || self.app_state.show_history
92 || self.app_state.show_diff
93 || self.app_state.show_settings
94 {
95 match key.code {
96 KeyCode::Esc => {
97 self.app_state.show_help = false;
98 self.app_state.show_file_browser = false;
99 self.app_state.show_history = false;
100 self.app_state.show_diff = false;
101 self.app_state.show_settings = false;
102 return Ok(());
103 }
104 KeyCode::F(1) if self.app_state.show_help => {
105 self.app_state.show_help = false;
106 return Ok(());
107 }
108 _ => {}
109 }
110
111 if self.app_state.show_file_browser {
112 match key.code {
113 KeyCode::Up => {
114 self.file_browser.move_up();
115 return Ok(());
116 }
117 KeyCode::Down => {
118 let count = self
119 .file_browser
120 .get_entry_count(&self.app_state.file_state.current_dir);
121 self.file_browser.move_down(count);
122 return Ok(());
123 }
124 KeyCode::Enter => {
125 self.handle_file_selection()?;
126 return Ok(());
127 }
128 KeyCode::Char(' ') => {
129 self.handle_file_toggle_selection()?;
130 return Ok(());
131 }
132 _ => {}
133 }
134 }
135
136 if self.app_state.show_settings {
137 match key.code {
138 KeyCode::Esc => {
139 self.app_state.show_settings = false;
140 return Ok(());
141 }
142 KeyCode::Char('d') => {
143 self.app_state.cycle_delimiter();
144 self.perform_conversion();
145 return Ok(());
146 }
147 KeyCode::Char('+') | KeyCode::Char('=') => {
148 self.app_state.increase_indent();
149 self.perform_conversion();
150 return Ok(());
151 }
152 KeyCode::Char('-') | KeyCode::Char('_') => {
153 self.app_state.decrease_indent();
154 self.perform_conversion();
155 return Ok(());
156 }
157 KeyCode::Char('f') => {
158 self.app_state.toggle_fold_keys();
159 self.perform_conversion();
160 return Ok(());
161 }
162 KeyCode::Char('p') => {
163 self.app_state.toggle_expand_paths();
164 self.perform_conversion();
165 return Ok(());
166 }
167 KeyCode::Char('s') => {
168 self.app_state.toggle_strict();
169 self.perform_conversion();
170 return Ok(());
171 }
172 KeyCode::Char('c') => {
173 self.app_state.toggle_coerce_types();
174 self.perform_conversion();
175 return Ok(());
176 }
177 KeyCode::Char('[') | KeyCode::Char('{') => {
178 self.app_state.decrease_flatten_depth();
179 self.perform_conversion();
180 return Ok(());
181 }
182 KeyCode::Char(']') | KeyCode::Char('}') => {
183 self.app_state.increase_flatten_depth();
184 self.perform_conversion();
185 return Ok(());
186 }
187 KeyCode::Char('u') => {
188 self.app_state.toggle_flatten_depth();
189 self.perform_conversion();
190 return Ok(());
191 }
192 _ => {}
193 }
194 }
195 }
196
197 let action = KeyBindings::handle(key);
198 match action {
199 Action::Quit => self.app_state.quit(),
200 Action::ToggleMode => {
201 self.app_state.toggle_mode();
202 self.perform_conversion();
203 }
204 Action::SwitchPanel => {
205 self.app_state.editor.toggle_active();
206 }
207 Action::OpenFile => {
208 self.open_file_dialog()?;
209 }
210 Action::SaveFile => {
211 self.save_output()?;
212 }
213 Action::NewFile => {
214 self.new_file();
215 }
216 Action::Refresh => {
217 self.perform_conversion();
218 }
219 Action::ToggleSettings => {
220 self.app_state.toggle_settings();
221 }
222 Action::ToggleHelp => {
223 self.app_state.toggle_help();
224 }
225 Action::ToggleFileBrowser => {
226 self.app_state.toggle_file_browser();
227 }
228 Action::ToggleHistory => {
229 self.app_state.toggle_history();
230 }
231 Action::ToggleDiff => {
232 self.app_state.toggle_diff();
233 }
234 Action::ToggleTheme => {
235 self.app_state.toggle_theme();
236 }
237 Action::CopyOutput => {
238 self.copy_to_clipboard()?;
239 }
240 Action::OpenRepl => {
241 self.app_state.repl.activate();
242 }
243 Action::CopySelection => {
244 self.copy_selection_to_clipboard()?;
245 }
246 Action::PasteInput => {
247 self.paste_from_clipboard()?;
248 }
249 Action::RoundTrip => {
250 self.perform_round_trip()?;
251 }
252 Action::ClearInput => {
253 self.app_state.editor.clear_input();
254 self.app_state.editor.clear_output();
255 self.app_state.stats = None;
256 }
257 Action::None => {
258 if self.app_state.editor.is_input_active() {
259 self.app_state.editor.input.input(key);
260 self.app_state.file_state.mark_modified();
261 self.perform_conversion();
262 } else if self.app_state.editor.is_output_active() {
263 match key.code {
265 KeyCode::Up
266 | KeyCode::Down
267 | KeyCode::Left
268 | KeyCode::Right
269 | KeyCode::PageUp
270 | KeyCode::PageDown
271 | KeyCode::Home
272 | KeyCode::End => {
273 self.app_state.editor.output.input(key);
274 }
275 _ => {}
276 }
277 }
278 }
279 }
280
281 Ok(())
282 }
283
284 fn perform_conversion(&mut self) {
286 let input = self.app_state.editor.get_input();
287 if input.trim().is_empty() {
288 self.app_state.editor.clear_output();
289 self.app_state.stats = None;
290 self.app_state.clear_error();
291 return;
292 }
293
294 self.app_state.clear_error();
295
296 match self.app_state.mode {
297 crate::tui::state::app_state::Mode::Encode => {
298 self.encode_input(&input);
299 }
300 crate::tui::state::app_state::Mode::Decode => {
301 self.decode_input(&input);
302 }
303 }
304 }
305
306 fn encode_input(&mut self, input: &str) {
307 self.app_state.editor.clear_output();
308
309 match serde_json::from_str::<serde_json::Value>(input) {
310 Ok(json_value) => match encode(&json_value, &self.app_state.encode_options) {
311 Ok(toon_str) => {
312 self.app_state.editor.set_output(toon_str.clone());
313 self.app_state.clear_error();
314
315 if let Ok(bpe) = cl100k_base() {
316 let json_tokens = bpe.encode_with_special_tokens(input).len();
317 let toon_tokens = bpe.encode_with_special_tokens(&toon_str).len();
318 let json_bytes = input.len();
319 let toon_bytes = toon_str.len();
320
321 let token_savings =
322 100.0 * (1.0 - (toon_tokens as f64 / json_tokens as f64));
323 let byte_savings = 100.0 * (1.0 - (toon_bytes as f64 / json_bytes as f64));
324
325 self.app_state.stats = Some(ConversionStats {
326 json_tokens,
327 toon_tokens,
328 json_bytes,
329 toon_bytes,
330 token_savings,
331 byte_savings,
332 });
333
334 self.app_state.file_state.add_to_history(ConversionHistory {
335 timestamp: Local::now(),
336 mode: "Encode".to_string(),
337 input_file: self.app_state.file_state.current_file.clone(),
338 output_file: None,
339 token_savings,
340 byte_savings,
341 });
342 }
343 }
344 Err(e) => {
345 self.app_state.set_error(format!("Encode error: {e}"));
346 }
347 },
348 Err(e) => {
349 self.app_state.set_error(format!("Invalid JSON: {e}"));
350 }
351 }
352 }
353
354 fn decode_input(&mut self, input: &str) {
355 self.app_state.editor.clear_output();
356
357 match decode::<serde_json::Value>(input, &self.app_state.decode_options) {
358 Ok(json_value) => match serde_json::to_string_pretty(&json_value) {
359 Ok(json_str) => {
360 self.app_state.editor.set_output(json_str.clone());
361 self.app_state.clear_error();
362
363 if let Ok(bpe) = cl100k_base() {
364 let toon_tokens = bpe.encode_with_special_tokens(input).len();
365 let json_tokens = bpe.encode_with_special_tokens(&json_str).len();
366 let toon_bytes = input.len();
367 let json_bytes = json_str.len();
368
369 let token_savings =
370 100.0 * (1.0 - (toon_tokens as f64 / json_tokens as f64));
371 let byte_savings = 100.0 * (1.0 - (toon_bytes as f64 / json_bytes as f64));
372
373 self.app_state.stats = Some(ConversionStats {
374 json_tokens,
375 toon_tokens,
376 json_bytes,
377 toon_bytes,
378 token_savings,
379 byte_savings,
380 });
381
382 self.app_state.file_state.add_to_history(ConversionHistory {
383 timestamp: Local::now(),
384 mode: "Decode".to_string(),
385 input_file: self.app_state.file_state.current_file.clone(),
386 output_file: None,
387 token_savings,
388 byte_savings,
389 });
390 }
391 }
392 Err(e) => {
393 self.app_state
394 .set_error(format!("JSON serialization error: {e}"));
395 }
396 },
397 Err(e) => {
398 self.app_state.set_error(format!("Decode error: {e}"));
399 }
400 }
401 }
402
403 fn open_file_dialog(&mut self) -> Result<()> {
404 self.app_state.toggle_file_browser();
405 Ok(())
406 }
407
408 fn save_output(&mut self) -> Result<()> {
409 let output = self.app_state.editor.get_output();
410 if output.trim().is_empty() {
411 self.app_state.set_error("Nothing to save".to_string());
412 return Ok(());
413 }
414
415 let extension = match self.app_state.mode {
416 crate::tui::state::app_state::Mode::Encode => "toon",
417 crate::tui::state::app_state::Mode::Decode => "json",
418 };
419
420 let path = if let Some(current) = &self.app_state.file_state.current_file {
421 current.with_extension(extension)
422 } else {
423 PathBuf::from(format!("output.{extension}"))
424 };
425
426 fs::write(&path, output).context("Failed to save file")?;
427 self.app_state
428 .set_status(format!("Saved to {}", path.display()));
429 self.app_state.file_state.is_modified = false;
430
431 Ok(())
432 }
433
434 fn new_file(&mut self) {
435 if self.app_state.file_state.is_modified {
436 }
438 self.app_state.editor.clear_input();
439 self.app_state.editor.clear_output();
440 self.app_state.file_state.clear_current_file();
441 self.app_state.stats = None;
442 self.app_state.set_status("New file created".to_string());
443 }
444
445 fn copy_to_clipboard(&mut self) -> Result<()> {
446 let output = self.app_state.editor.get_output();
447 if output.trim().is_empty() {
448 self.app_state.set_error("Nothing to copy".to_string());
449 return Ok(());
450 }
451
452 #[cfg(not(target_os = "unknown"))]
453 {
454 use arboard::Clipboard;
455 let mut clipboard = Clipboard::new()?;
456 clipboard.set_text(output)?;
457 self.app_state.set_status("Copied to clipboard".to_string());
458 }
459
460 #[cfg(target_os = "unknown")]
461 {
462 self.app_state
463 .set_error("Clipboard not supported on this platform".to_string());
464 }
465
466 Ok(())
467 }
468
469 fn paste_from_clipboard(&mut self) -> Result<()> {
470 #[cfg(not(target_os = "unknown"))]
471 {
472 use arboard::Clipboard;
473 let mut clipboard = Clipboard::new()?;
474 let text = clipboard.get_text()?;
475 self.app_state.editor.set_input(text);
476 self.app_state.file_state.mark_modified();
477 self.perform_conversion();
478 self.app_state
479 .set_status("Pasted from clipboard".to_string());
480 }
481
482 #[cfg(target_os = "unknown")]
483 {
484 self.app_state
485 .set_error("Clipboard not supported on this platform".to_string());
486 }
487
488 Ok(())
489 }
490
491 fn handle_file_selection(&mut self) -> Result<()> {
492 let current_dir = self.app_state.file_state.current_dir.clone();
493 if let Some(selected_path) = self.file_browser.get_selected_entry(¤t_dir) {
494 if selected_path.is_dir() {
495 self.app_state.file_state.current_dir = selected_path;
497 self.file_browser.selected_index = 0;
498 self.app_state.set_status(format!(
499 "Navigated to {}",
500 self.app_state.file_state.current_dir.display()
501 ));
502 } else if selected_path.is_file() {
503 match fs::read_to_string(&selected_path) {
505 Ok(content) => {
506 self.app_state.editor.set_input(content);
507 self.app_state
508 .file_state
509 .set_current_file(selected_path.clone());
510
511 if let Some(ext) = selected_path.extension().and_then(|e| e.to_str()) {
513 match ext {
514 "json" => {
515 self.app_state.mode =
516 crate::tui::state::app_state::Mode::Encode;
517 }
518 "toon" => {
519 self.app_state.mode =
520 crate::tui::state::app_state::Mode::Decode;
521 }
522 _ => {}
523 }
524 }
525
526 self.perform_conversion();
527 self.app_state.show_file_browser = false;
528 self.app_state
529 .set_status(format!("Opened {}", selected_path.display()));
530 }
531 Err(e) => {
532 self.app_state
533 .set_error(format!("Failed to read file: {e}"));
534 }
535 }
536 }
537 }
538 Ok(())
539 }
540
541 fn handle_file_toggle_selection(&mut self) -> Result<()> {
542 let current_dir = self.app_state.file_state.current_dir.clone();
543 if let Some(selected_path) = self.file_browser.get_selected_entry(¤t_dir) {
544 if selected_path.is_file() {
545 self.app_state
546 .file_state
547 .toggle_file_selection(selected_path.clone());
548 let is_selected = self.app_state.file_state.is_selected(&selected_path);
549 let action = if is_selected {
550 "Selected"
551 } else {
552 "Deselected"
553 };
554 self.app_state
555 .set_status(format!("{} {}", action, selected_path.display()));
556 }
557 }
558 Ok(())
559 }
560
561 fn copy_selection_to_clipboard(&mut self) -> Result<()> {
562 let text = if self.app_state.editor.is_input_active() {
563 self.app_state.editor.input.yank_text()
564 } else {
565 self.app_state.editor.output.yank_text()
566 };
567
568 if text.is_empty() {
569 self.app_state.set_error("Nothing to copy".to_string());
570 return Ok(());
571 }
572
573 #[cfg(not(target_os = "unknown"))]
574 {
575 use arboard::Clipboard;
576 let mut clipboard = Clipboard::new()?;
577 clipboard.set_text(text)?;
578 self.app_state
579 .set_status("Copied selection to clipboard".to_string());
580 }
581
582 #[cfg(target_os = "unknown")]
583 {
584 self.app_state
585 .set_error("Clipboard not supported on this platform".to_string());
586 }
587
588 Ok(())
589 }
590
591 fn perform_round_trip(&mut self) -> Result<()> {
593 let output = self.app_state.editor.get_output();
594 if output.trim().is_empty() {
595 self.app_state
596 .set_error("No output to round-trip test. Convert something first!".to_string());
597 return Ok(());
598 }
599
600 let original_input = self.app_state.editor.get_input();
601 self.app_state.editor.set_input(output.clone());
602 self.app_state.toggle_mode();
603 self.perform_conversion();
604
605 let roundtrip_output = self.app_state.editor.get_output();
606
607 if roundtrip_output.trim().is_empty() {
608 self.app_state.set_error(
609 "Round-trip failed! Conversion produced no output. Check for errors.".to_string(),
610 );
611 return Ok(());
612 }
613
614 let matches = self.compare_data(&original_input, &roundtrip_output);
615
616 if matches {
617 self.app_state
618 .set_status("✓ Round-trip successful! Output matches original.".to_string());
619 } else {
620 self.app_state.set_error(format!(
621 "⚠ Round-trip mismatch! Original had {} chars, round-trip has {} chars.",
622 original_input.len(),
623 roundtrip_output.len()
624 ));
625 }
626
627 Ok(())
628 }
629
630 fn compare_data(&self, original: &str, roundtrip: &str) -> bool {
632 if let (Ok(orig_json), Ok(rt_json)) = (
634 serde_json::from_str::<serde_json::Value>(original),
635 serde_json::from_str::<serde_json::Value>(roundtrip),
636 ) {
637 return orig_json == rt_json;
638 }
639
640 let original_normalized: String = original.split_whitespace().collect();
641 let roundtrip_normalized: String = roundtrip.split_whitespace().collect();
642 original_normalized == roundtrip_normalized
643 }
644
645 fn handle_repl_key(&mut self, key: KeyEvent) -> Result<()> {
647 match key.code {
648 KeyCode::Esc => {
649 self.app_state.repl.deactivate();
650 }
651 KeyCode::Char('r')
652 if key
653 .modifiers
654 .contains(crossterm::event::KeyModifiers::CONTROL) =>
655 {
656 self.app_state.repl.deactivate();
657 }
658 KeyCode::Enter => {
659 let cmd_input = self.app_state.repl.input.clone();
660 if !cmd_input.trim().is_empty() {
661 self.app_state.repl.add_prompt(&cmd_input);
662 self.app_state.repl.add_to_history(cmd_input.clone());
663
664 if let Err(e) = self.execute_repl_command(&cmd_input) {
665 self.app_state.repl.add_error(format!("{e}"));
666 }
667
668 self.app_state.repl.input.clear();
669 self.app_state.repl.scroll_to_bottom();
670 }
671 }
672 KeyCode::Up => {
673 self.app_state.repl.history_up();
674 }
675 KeyCode::Down => {
676 self.app_state.repl.history_down();
677 }
678 KeyCode::PageUp => {
679 self.app_state.repl.scroll_up();
680 }
681 KeyCode::PageDown => {
682 self.app_state.repl.scroll_down(20);
683 }
684 KeyCode::Char(c) => {
685 self.app_state.repl.input.push(c);
686 }
687 KeyCode::Backspace => {
688 self.app_state.repl.input.pop();
689 }
690 _ => {}
691 }
692 Ok(())
693 }
694
695 fn execute_repl_command(&mut self, input: &str) -> Result<()> {
697 let cmd = ReplCommand::parse(input)?;
698
699 match cmd.name.as_str() {
700 "encode" | "e" => {
701 let mut data = cmd
702 .inline_data
703 .as_ref()
704 .map(|s| s.to_string())
705 .unwrap_or_else(String::new);
706
707 data = self.substitute_variables(&data);
708
709 if data.is_empty() {
710 self.app_state
711 .repl
712 .add_error("Usage: encode {\"data\": true} or encode $var".to_string());
713 return Ok(());
714 }
715
716 match serde_json::from_str::<serde_json::Value>(&data) {
717 Ok(json_value) => match encode(&json_value, &self.app_state.encode_options) {
718 Ok(toon_str) => {
719 self.app_state.repl.add_success(toon_str.clone());
720 self.app_state.repl.last_result = Some(toon_str);
721 }
722 Err(e) => {
723 self.app_state.repl.add_error(format!("Encode error: {e}"));
724 }
725 },
726 Err(e) => {
727 self.app_state.repl.add_error(format!("Invalid JSON: {e}"));
728 }
729 }
730 }
731 "decode" | "d" => {
732 let mut data = cmd
733 .inline_data
734 .as_ref()
735 .map(|s| s.to_string())
736 .unwrap_or_else(String::new);
737
738 data = self.substitute_variables(&data);
739
740 if data.is_empty() {
741 self.app_state
742 .repl
743 .add_error("Usage: decode name: Alice or decode $var".to_string());
744 return Ok(());
745 }
746
747 match decode::<serde_json::Value>(&data, &self.app_state.decode_options) {
748 Ok(json_value) => match serde_json::to_string_pretty(&json_value) {
749 Ok(json_str) => {
750 self.app_state.repl.add_success(json_str.clone());
751 self.app_state.repl.last_result = Some(json_str);
752 }
753 Err(e) => {
754 self.app_state.repl.add_error(format!("JSON error: {e}"));
755 }
756 },
757 Err(e) => {
758 self.app_state.repl.add_error(format!("Decode error: {e}"));
759 }
760 }
761 }
762 "let" => {
763 let parts: Vec<&str> = input.splitn(2, '=').collect();
764 if parts.len() == 2 {
765 let var_part = parts[0].trim().trim_start_matches("let").trim();
766 let data_part = parts[1].trim();
767
768 if !var_part.is_empty() && !data_part.is_empty() {
769 let var_name = var_part.trim_start_matches('$');
770 self.app_state
771 .repl
772 .variables
773 .insert(var_name.to_string(), data_part.to_string());
774 self.app_state
775 .repl
776 .add_info(format!("Stored in ${var_name}"));
777 self.app_state.repl.last_result = Some(data_part.to_string());
778 } else {
779 self.app_state
780 .repl
781 .add_error("Usage: let $var = {\"data\": true}".to_string());
782 }
783 } else {
784 self.app_state
785 .repl
786 .add_error("Usage: let $var = {\"data\": true}".to_string());
787 }
788 }
789 "vars" => {
790 if self.app_state.repl.variables.is_empty() {
791 self.app_state
792 .repl
793 .add_info("No variables defined".to_string());
794 } else {
795 let vars: Vec<String> = self
796 .app_state
797 .repl
798 .variables
799 .keys()
800 .map(|k| format!("${k}"))
801 .collect();
802 for var in vars {
803 self.app_state.repl.add_info(var);
804 }
805 }
806 }
807 "clear" => {
808 self.app_state.repl.output.clear();
809 self.app_state
810 .repl
811 .output
812 .push(crate::tui::state::ReplLine {
813 kind: crate::tui::state::ReplLineKind::Info,
814 content: "Cleared".to_string(),
815 });
816 }
817 "help" | "h" => {
818 self.app_state
819 .repl
820 .add_info("📖 REPL Commands:".to_string());
821 self.app_state.repl.add_info("".to_string());
822 self.app_state
823 .repl
824 .add_info(" encode {\"data\": true} - Encode JSON to TOON".to_string());
825 self.app_state
826 .repl
827 .add_info(" decode name: Alice - Decode TOON to JSON".to_string());
828 self.app_state
829 .repl
830 .add_info(" let $var = {...} - Store data in variable".to_string());
831 self.app_state
832 .repl
833 .add_info(" vars - List all variables".to_string());
834 self.app_state
835 .repl
836 .add_info(" clear - Clear session".to_string());
837 self.app_state
838 .repl
839 .add_info(" help - Show this help".to_string());
840 self.app_state
841 .repl
842 .add_info(" exit - Close REPL".to_string());
843 self.app_state.repl.add_info("".to_string());
844 self.app_state
845 .repl
846 .add_info("Press ↑/↓ for history, Esc to close".to_string());
847 }
848 "exit" | "quit" | "q" => {
849 self.app_state.repl.add_info("Closing REPL...".to_string());
850 self.app_state.repl.deactivate();
851 }
852 _ => {
853 self.app_state
854 .repl
855 .add_error(format!("Unknown command: {}. Type 'help'", cmd.name));
856 }
857 }
858
859 Ok(())
860 }
861
862 fn substitute_variables(&self, text: &str) -> String {
864 let mut result = text.to_string();
865
866 if let Some(last) = &self.app_state.repl.last_result {
868 result = result.replace("$_", last);
869 }
870
871 for (var_name, var_value) in &self.app_state.repl.variables {
873 let pattern = format!("${var_name}");
874 result = result.replace(&pattern, var_value);
875 }
876
877 result
878 }
879}
880
881impl<'a> Default for TuiApp<'a> {
882 fn default() -> Self {
883 Self::new()
884 }
885}