rust_bf/
ide.rs

1use std::collections::HashMap;
2use std::io::{self};
3use std::sync::{mpsc, Arc, Mutex};
4use std::sync::atomic::AtomicBool;
5use std::{fs, thread};
6use std::path::{Path, PathBuf};
7use std::time::{Duration, Instant};
8
9use crossterm::{
10    event::{
11        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
12    },
13    execute,
14    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
15};
16use ratatui::prelude::*;
17use ratatui::{backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, Frame, Terminal};
18use ratatui::widgets::{Cell, Clear, Row, Table};
19use crate::{BrainfuckReader, BrainfuckReaderError, bf_only};
20use crate::reader::StepControl;
21use crate::config::colors;
22
23#[derive(Copy, Clone, Debug, PartialEq, Eq)]
24enum Focus {
25    Editor,
26    Output,
27    Tape,
28}
29
30#[derive(Copy, Clone, Debug, PartialEq, Eq)]
31enum OutputMode {
32    Raw,
33    Escaped,
34}
35
36// Vi mode state
37#[derive(Copy, Clone, Debug, PartialEq, Eq)]
38enum ViMode {
39    Insert,
40    Normal,
41}
42
43// Runner wiring: messages and commands between UI and runner
44#[derive(Debug)]
45enum RunnerMsg {
46    // Program produced output bytes (batch as needed)
47    Output(Vec<u8>),
48    // Snapshot of current tape state (ptr index and 128-cell window)
49    Tape { ptr: usize, base: usize, window: [u8; 128] },
50    // Runner is awaiting input for `,` instruction
51    NeedsInput,
52    // Program finished (Ok) or errored
53    Halted(Result<(), BrainfuckReaderError>),
54}
55
56#[derive(Debug)]
57enum UiCmd {
58    // Provide input byte for `,` instruction; None = EOF
59    ProvideInput(Option<u8>),
60    // Request to stop the program
61    Stop,
62}
63
64struct RunnerHandle {
65    // Send commands to the runner
66    tx_cmd: mpsc::Sender<UiCmd>,
67    // Receive messages from the runner
68    rx_msg: mpsc::Receiver<RunnerMsg>,
69    // Cooperative cancellation flag (also flipped by Stop)
70    cancel: Arc<AtomicBool>,
71    // Join handle is kept in worker (detached); we just hold channels and flag
72}
73
74pub struct App {
75    // Editor
76    buffer: Vec<String>,
77    cursor_row: usize,
78    cursor_col: usize,
79    scroll_row: usize,
80
81    // Output pane
82    output: Vec<u8>,
83
84    // Tape pane
85    tape_ptr: usize,
86    tape_window_base: usize,
87    tape_window: [u8; 128],
88
89    // Status
90    focused: Focus,
91    dirty: bool,
92    filename: Option<String>,
93    running: bool,
94    output_mode: OutputMode,
95
96    // Help
97    show_help: bool,
98
99    // Timing
100    last_tick: Instant,
101
102    // Runner wiring
103    runner: Option<RunnerHandle>,
104
105    // Save dialog
106    show_save_dialog: bool,
107    save_name_input: String,
108    save_error: Option<String>,
109
110    // Open dialog
111    show_open_dialog: bool,
112    open_name_input: String,
113    open_error: Option<String>,
114
115    // Confirm dialog (for destructive actions like "open" with unsaved changes)
116    show_confirm_dialog: bool,
117    confirm_message: String,
118    confirm_pending_open: Option<PathBuf>,
119
120    // Input dialog (for `,` instruction)
121    show_input_dialog: bool,
122    input_buffer: String,
123    input_error: Option<String>,
124
125    // Line numbers toggle
126    show_line_numbers: bool,
127
128    // Last status message (auto-expires)
129    status_message: Option<(String, Instant)>,
130
131    // Vi mode
132    vi_enabled: bool,
133    vi_mode: ViMode,
134    vi_pending_op: Option<char>,
135
136    // Quit flow
137    should_quit: bool,
138    confirm_pending_quit: bool,
139
140    // New file
141    confirm_pending_new: bool,
142}
143
144impl Default for App {
145    fn default() -> Self {
146        Self {
147            buffer: vec![String::new()],
148            cursor_row: 0,
149            cursor_col: 0,
150            scroll_row: 0,
151            output: Vec::new(),
152            tape_ptr: 0,
153            tape_window_base: 0,
154            tape_window: [0u8; 128],
155            focused: Focus::Editor,
156            dirty: false,
157            filename: None,
158            running: false,
159            output_mode: OutputMode::Raw,
160            show_help: false,
161            last_tick: Instant::now(),
162            runner: None,
163
164            show_save_dialog: false,
165            save_name_input: String::new(),
166            save_error: None,
167
168            show_open_dialog: false,
169            open_name_input: String::new(),
170            open_error: None,
171
172            show_confirm_dialog: false,
173            confirm_message: String::new(),
174            confirm_pending_open: None,
175
176            show_input_dialog: false,
177            input_buffer: String::new(),
178            input_error: None,
179
180            show_line_numbers: true,
181
182            status_message: None,
183
184            vi_enabled: false,
185            vi_mode: ViMode::Insert,
186            vi_pending_op: None,
187
188            should_quit: false,
189            confirm_pending_quit: false,
190
191            confirm_pending_new: false,
192        }
193    }
194}
195
196pub fn run() -> io::Result<()> {
197    // For backwards compatibility, delegate to run_with_file(None)
198    run_with_file(None)
199}
200
201// Entry point that accepts an optional initial file to open
202pub fn run_with_file(initial_file: Option<PathBuf>) -> io::Result<()> {
203    run_with_options(initial_file, false)
204}
205
206pub fn run_with_options(initial_file: Option<PathBuf>, vi_enabled: bool) -> io::Result<()> {
207    // terminal setup
208    enable_raw_mode()?;
209    let mut stdout = io::stdout();
210    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
211    let backend = CrosstermBackend::new(stdout);
212    let mut terminal = Terminal::new(backend)?;
213    terminal.clear()?;
214
215    let res = run_app(&mut terminal, initial_file, vi_enabled);
216
217    // restore terminal
218    disable_raw_mode()?;
219    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
220    terminal.show_cursor()?;
221
222    res
223}
224
225fn run_app(
226    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
227    initial_file: Option<PathBuf>,
228    vi_enabled: bool,
229) -> io::Result<()> {
230    let mut app = App::default();
231    app.vi_enabled = vi_enabled;
232    app.vi_mode = if vi_enabled { ViMode::Normal } else { ViMode::Insert };
233    let tick_rate = Duration::from_millis(33);
234
235    // If an initial file was provided, attempt to open it
236    if let Some(path) = initial_file {
237        if let Err(err) = app_open_file(&mut app, &path) {
238            // If opening fails, leave app in default state
239            set_status(&mut app, &format!("Failed to open {}: {}", path.display(), err));
240            eprintln!("Failed to open {}: {}", path.display(), err);
241        }
242    }
243
244    loop {
245        terminal.draw(|f| ui(f, &app))?;
246
247        let timeout = tick_rate
248            .checked_sub(app.last_tick.elapsed())
249            .unwrap_or(Duration::from_secs(0));
250
251        if event::poll(timeout)? {
252            if let Event::Key(key) = event::read()? {
253                if key.kind == KeyEventKind::Press {
254                    if handle_key(&mut app, key)? {
255                        break;
256                    }
257                }
258            }
259        }
260
261        let mut should_clear_runner = false;
262
263        // We store deferred actions here
264        let mut deferred_status: Option<String> = None;
265        let mut saw_halted: bool = false;
266
267        // Drain runner messages without blocking
268        if let Some(handle) = app.runner.as_mut() {
269            while let Ok(msg) = handle.rx_msg.try_recv() {
270                match msg {
271                    RunnerMsg::Output(bytes) => {
272                        app.output.extend_from_slice(&bytes);
273                    }
274                    RunnerMsg::Tape { ptr, base, window } => {
275                        app.tape_ptr = ptr;
276                        app.tape_window_base = base;
277                        app.tape_window.copy_from_slice(&window);
278                        app.dirty = true;
279                    }
280                    RunnerMsg::NeedsInput => {
281                        // Show input dialog; the runner is blocked until we respond
282                        app.show_input_dialog = true;
283                        app.input_buffer.clear();
284                        app.input_error = None;
285                        deferred_status = Some("Program requested input (auto-EOF sent)".to_string());
286                    }
287                    RunnerMsg::Halted(res) => {
288                        app.running = false;
289                        should_clear_runner = true;
290                        saw_halted = true;
291                        match res {
292                            Ok(()) => {
293                                deferred_status = Some("Program finished".to_string());
294                            }
295                            Err(e) => {
296                                deferred_status = Some(format!("Error: {}", e));
297                            }
298                        }
299                    }
300                }
301            }
302        }
303
304        // Now, with no active mutable borrow of app.runner, perform deferred actions
305        if let Some(msg) = deferred_status.take() {
306            set_status(&mut app, &msg);
307        }
308
309        if should_clear_runner || saw_halted {
310            // Now it's safe to clear the runner
311            app.runner = None;
312        }
313
314        if app.last_tick.elapsed() >= tick_rate {
315            app.last_tick = Instant::now();
316
317            // Expire status messages after 5 seconds
318            if let Some((_, since)) = app.status_message.as_ref() {
319                if since.elapsed() >= Duration::from_secs(5) {
320                    app.status_message = None;
321                }
322            }
323        }
324
325        // Break out if a confirmed quit was requested
326        if app.should_quit { break; }
327    }
328
329    Ok(())
330}
331
332fn ui(f: &mut Frame, app: &App) {
333    let size = f.area();
334
335    // Root: vertical layout -> editor + status bar
336    let root = Layout::default()
337        .direction(Direction::Vertical)
338        .margin(0)
339        .constraints([Constraint::Min(1), Constraint::Length(1)].as_ref())
340        .split(size);
341
342    let main_area = root[0];
343    let status_bar = root[1];
344
345    // Main area: two columns (left, right)
346    // Right (Tape) is fixed to 25% of the width; Left gets the remaining 75%
347    let cols = Layout::default()
348        .direction(Direction::Horizontal)
349        .constraints([Constraint::Percentage(75), Constraint::Percentage(25)].as_ref())
350        .split(main_area);
351
352    let left = cols[0];
353    let right = cols[1];
354
355    // Determine output height
356    // - Use as few lines as required by the current output (count lines)
357    // - Add 2 for the output block borders
358    // - Cap to at most 50% of the available vertical space
359    // - Ensure a minimal height (3 rows including borders) so the block is visible
360    let output_inner_lines: u16 = output_display_lines(app);
361    let output_block_height: u16 = output_inner_lines.saturating_add(2);
362    let max_output_block_height: u16 = (main_area.height / 2).max(3);
363    let desired_output_block_height: u16 = output_block_height
364        .min(max_output_block_height)
365        .max(3);
366
367    // Left: editor (top), output (bottom)
368    // Editor takes the rest, Output gets the computed fixed height
369    let left_rows = Layout::default()
370        .direction(Direction::Vertical)
371        .constraints([Constraint::Min(1), Constraint::Length(desired_output_block_height)].as_ref())
372        .split(left);
373
374    let editor_area = left_rows[0];
375    let output_area = left_rows[1];
376
377    draw_editor(f, editor_area, app);
378    draw_output(f, output_area, app);
379    draw_tape(f, right, app);
380    draw_status(f, status_bar, app);
381
382    if app.show_help {
383        draw_help_overlay(f, size);
384    }
385    if app.show_save_dialog {
386        draw_save_dialog(f, size, app);
387    }
388    if app.show_open_dialog {
389        draw_open_dialog(f, size, app);
390    }
391    if app.show_confirm_dialog {
392        draw_confirm_dialog(f, size, app);
393    }
394    if app.show_input_dialog {
395        draw_input_dialog(f, size, app);
396    }
397}
398
399fn draw_editor(f: &mut Frame, area: Rect, app: &App) {
400    let title = match app.filename.as_deref() {
401        Some(path) => format!("Editor - {}{}", path, if app.dirty { " *" } else { "" }),
402        None => format!("Editor - <untitled>{}", if app.dirty { " *" } else { "" },),
403    };
404    let block = Block::default()
405        .title(Span::styled(
406            title,
407            Style::default().fg(if app.focused == Focus::Editor { colors().editor_title_focused } else { colors().editor_title_unfocused }),
408        ))
409        .borders(Borders::ALL);
410
411    let inner = block.inner(area);
412    f.render_widget(block, area);
413
414    // Determine optional gutter for line numbers
415    let show_ln = app.show_line_numbers;
416    let total_lines = app.buffer.len().max(1);
417    let gutter_width = if show_ln {
418        compute_gutter_width(total_lines, 3)
419    } else { 0 };
420
421    // Split inner area into gutter + text content
422    let (gutter_rect, text_rect) = if gutter_width > 0 {
423        let chunks = Layout::default()
424            .direction(Direction::Horizontal)
425            .constraints([Constraint::Length(gutter_width), Constraint::Min(1)])
426            .split(inner);
427        (Some(chunks[0]), chunks[1])
428    } else {
429        (None, inner)
430    };
431
432    let max_lines = text_rect.height as usize;
433    let start = app.scroll_row.min(app.buffer.len().saturating_sub(1));
434    let end = (start + max_lines).min(app.buffer.len());
435
436    // Render gutter if enabled
437    if let Some(gut) = gutter_rect {
438        let mut glines: Vec<Line> = Vec::with_capacity(end.saturating_sub(start));
439        for (i, _) in app.buffer[start..end].iter().enumerate() {
440            let line_no = start + i + 1;
441            let s = format!("{:>width$} ", line_no, width = (gutter_width - 1) as usize);
442            glines.push(Line::from(Span::styled(s, Style::default().fg(colors().gutter_text))));
443        }
444        let gutter = Paragraph::new(glines).wrap(Wrap { trim: false });
445        f.render_widget(gutter, gut);
446    }
447
448    // Prepare highlighted lines within visible window
449    let mut lines: Vec<Line> = Vec::with_capacity(end.saturating_sub(start));
450    for (idx, line) in app.buffer[start..end].iter().enumerate() {
451        lines.push(highlight_bf_line(line, app, start + idx));
452    }
453
454    let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
455    f.render_widget(paragraph, text_rect);
456
457    // Cursor rendering (if editor is focused)
458    if app.focused == Focus::Editor {
459        let row = app
460            .cursor_row
461            .saturating_sub(app.scroll_row)
462            .min(text_rect.height.saturating_sub(1) as usize);
463        let col = app.cursor_col.min(text_rect.width.saturating_sub(1) as usize);
464        f.set_cursor_position(Position::new(text_rect.x + col as u16, text_rect.y + row as u16));
465    }
466}
467
468fn draw_output(f: &mut Frame, area: Rect, app: &App) {
469    let mode = match app.output_mode {
470        OutputMode::Raw => "Raw",
471        OutputMode::Escaped => "Esc",
472    };
473    let block = Block::default()
474        .title(Span::styled(
475            format!("Output - {mode}"),
476            Style::default().fg(if app.focused == Focus::Output { colors().output_title_focused } else { colors().output_title_unfocused }),
477        ))
478        .borders(Borders::ALL);
479    let inner = block.inner(area);
480    f.render_widget(block, area);
481
482    let paragraph = if app.output.is_empty() {
483        Paragraph::new("<no output yet>")
484    } else {
485        match app.output_mode {
486            OutputMode::Raw => {
487                // Best-effort: display bytes as UTF-8 (lossy) to avoid panics on invalid UTF-8
488                let s = String::from_utf8_lossy(&app.output);
489                Paragraph::new(s.into_owned())
490            }
491            OutputMode::Escaped => {
492                let s = bytes_to_escaped(&app.output);
493                Paragraph::new(s)
494            }
495        }
496    };
497    f.render_widget(paragraph, inner);
498}
499
500fn draw_tape(f: &mut Frame, area: Rect, app: &App) {
501    let border_style = if app.focused == Focus::Tape {
502        Style::default().fg(colors().tape_border_focused)
503    } else {
504        Style::default().fg(colors().tape_border_unfocused)
505    };
506    let block = Block::default()
507        .title(Line::raw("Tape (128 cells)"))
508        .borders(Borders::ALL)
509        .border_style(border_style);
510
511    // Compute inner area to estimate available width/height for the table
512    let inner = block.inner(area);
513
514    // "Responsive" grid sizing
515    // Each cell renders like "[XX]" (4 chars). We give it width 4 and 1 column space between cells
516    let cell_content_width: u16 = 4;
517
518    let mut cols = (inner.width / cell_content_width).max(1) as usize;
519    cols = cols.min(128);
520    if cols == 0 { cols = 1; }
521
522    let rows = ((128 + cols - 1) / cols).max(1);
523
524    // Build rows
525    let mut table_rows: Vec<Row> = Vec::with_capacity(rows);
526    for r in 0..rows {
527        let mut cells: Vec<Cell> = Vec::with_capacity(cols + 1);
528        for c in 0..cols {
529            let idx = r * cols + c;
530            if idx < 128 {
531                let byte = app.tape_window[idx];
532                let abs_idx = app.tape_window_base + idx;
533
534                let mut style = Style::default().fg(colors().tape_cell_empty).add_modifier(Modifier::BOLD);
535                if byte > 0 {
536                    style = style.fg(colors().tape_cell_nonzero);
537                }
538
539                if abs_idx == app.tape_ptr {
540                    style = style.fg(colors().tape_cell_pointer);
541                }
542
543                cells.push(Cell::from(format!("[{byte:02X}]")).style(style));
544            } else {
545                // Pad remaining cells in the last row to keep grid aligned
546                cells.push(Cell::from("    "));
547            }
548
549        }
550        table_rows.push(Row::new(cells));
551    }
552
553    // Constraints
554    // - First cols - columns are fixed width
555    // - Last column expands by the exact leftover (ignoring spacing)
556    let base_width_no_spacing = (cols as u16) * cell_content_width;
557    let leftover_no_spacing = inner.width.saturating_sub(base_width_no_spacing);
558
559    // Column width constraints for the table
560    let mut constraints: Vec<Constraint> =
561        std::iter::repeat(Constraint::Length(cell_content_width))
562            .take(cols.saturating_sub(1))
563            .collect();
564
565    let last_width = cell_content_width + leftover_no_spacing;
566    constraints.push(Constraint::Length(last_width));
567
568    let table = Table::new(table_rows, constraints)
569        .block(block)
570        .column_spacing(0);
571    f.render_widget(table, area);
572}
573
574fn draw_status(f: &mut Frame, area: Rect, app: &App) {
575    let filename = app
576        .filename
577        .as_deref()
578        .unwrap_or("<untitled>");
579    let dirty = if app.dirty { "*" } else { "" };
580    let run_state = if app.running { "Running" } else { "Stopped" };
581    let output_mode = match app.output_mode {
582        OutputMode::Raw => "Raw",
583        OutputMode::Escaped => "Esc",
584    };
585    let cell_val = current_cell_value(app)
586        .map(|v| format!("{v}"))
587        .unwrap_or_else(|| "--".to_string());
588    let msg = app
589        .status_message
590        .as_ref()
591        .map(|(m, _)| m.as_str())
592        .unwrap_or("");
593    let vi_str = if app.vi_enabled {
594        match app.vi_mode {
595            ViMode::Insert => " | Vi: Insert",
596            ViMode::Normal => " | Vi: Normal",
597        }
598    } else { "" };
599
600    let status = format!(
601        " {}{} | {} | Ptr: {} | Cell: {} | Output: {}{} | {} ",
602        filename, dirty, run_state, app.tape_ptr, cell_val, output_mode, vi_str, msg
603    );
604    let block = Block::default().borders(Borders::TOP);
605    f.render_widget(block, area);
606    let inner = Rect {
607        x: area.x + 1,
608        y: area.y,
609        width: area.width.saturating_sub(2),
610        height: area.height,
611    };
612    let line = Line::from(Span::styled(status, Style::default().fg(colors().status_text)));
613    f.render_widget(Paragraph::new(line), inner);
614}
615
616fn draw_help_overlay(f: &mut Frame, area: Rect) {
617    let block = Block::default()
618        .title("Help")
619        .borders(Borders::ALL);
620
621    let w = area.width.saturating_sub(area.width / 4);
622    let h = area.height.saturating_sub(area.height / 3);
623    let x = area.x + (area.width - w) / 2;
624    let y = area.y + (area.height - h) / 2;
625    let rect = Rect { x, y, width: w, height: h };
626    f.render_widget(block, rect);
627
628    let mut text = vec![
629        Line::raw("F5/Ctrl+R: Run"),
630        Line::raw("Ctrl+N: New file  Ctrl+O: Open  Ctrl+S: Save"),
631        Line::raw("Tab/Shift+Tab: Switch pane focus"),
632        Line::raw("Ctrl+E: Toggle output mode (Raw/Esc)"),
633        Line::raw("F1/Ctrl+H: Toggle this help"),
634        Line::raw("Ctrl+L: Toggle line numbers"),
635        Line::raw("Ctrl+P: Jump to matching bracket, [ or ]"),
636        Line::raw(""),
637        Line::raw("Editor: Arrows, PageUp/PageDown, Home/End, typing, Enter, Backspace"),
638        Line::raw("Tape pane: [ and ] to shift window"),
639        Line::raw(""),
640        Line::raw("Input on ',': prompts for input; Esc at prompt sends EOF"),
641        Line::raw("Output Raw mode may render control bytes; switch to Escaped mode if your terminal glitches"),
642        Line::raw(""),
643        Line::raw("Ctrl+q/Esc: Quit"),
644    ];
645
646    // Show quick Vi reference if Vi mode is enabled
647    if tui_has_vi_enabled() {
648        text.push(Line::raw(""));
649        text.push(Line::raw("Vi mode (Normal/Insert) basics:"));
650        text.push(Line::raw("  Normal: h j k l to move, 0/$ line start/end, gg/G top/end"));
651        text.push(Line::raw("  i insert, a append, o/O new line below/above"));
652        text.push(Line::raw("  x delete char, dd delete line, Esc -> Normal"));
653    }
654
655    let inner = Rect {
656        x: rect.x + 2,
657        y: rect.y + 2,
658        width: rect.width.saturating_sub(4),
659        height: rect.height.saturating_sub(4),
660    };
661
662    f.render_widget(Paragraph::new(text).wrap(Wrap { trim: false }), inner);
663}
664
665fn draw_save_dialog(f: &mut Frame, area: Rect, app: &App) {
666    // Content to display
667    let title = "Save As";
668    let prompt = "Enter file name (Esc to cancel):";
669    let input_line = format!("> {}", app.save_name_input);
670    let err_line = app.save_error.as_deref().unwrap_or("");
671
672    // Compute minimal dialog size based on content
673    let mut longest = prompt.len().max(input_line.len()).max(title.len());
674    if !err_line.is_empty() {
675        longest = longest.max(err_line.len());
676    }
677
678    // Borders add 2 columns; add a tiny horizontal padding of 1 char per side
679    let horizontal_padding = 2u16;
680    let min_w = 10u16;
681    let max_w = area.width.saturating_sub(2);
682    let w = ((longest as u16) + 2 /* borders */ + horizontal_padding).clamp(min_w, max_w);
683
684    // Lines:
685    // - 1: prompt
686    // - 2: input
687    // - 3: error(optional)
688
689    let base_lines = 2u16;
690    let lines = base_lines + if err_line.is_empty() { 0 } else { 1 };
691    // Borders add 2 rows; add a tiny vertical padding of 0 (keep minimal)
692    let min_h = 4u16;
693    let max_h = area.height.saturating_sub(2);
694    let h = (lines + 2 /* borders */).clamp(min_h, max_h);
695
696    // Center dialog
697    let x = area.x + (area.width.saturating_sub(w)) / 2;
698    let y = area.y + (area.height.saturating_sub(h)) / 2;
699    let rect = Rect { x, y, width: w, height: h };
700
701    // Ensure a solid background and then draw the block
702    f.render_widget(Clear, rect);
703
704    let block = Block::default()
705        .title(Span::styled(title, Style::default().fg(Color::White)))
706        .borders(Borders::ALL)
707        .style(Style::default().bg(Color::Black));
708    f.render_widget(block.clone(), rect);
709
710    // Inner area
711    let inner = block.inner(rect);
712
713    // Render content with minimal padding: one leading space
714    let left_pad = " ";
715    let mut lines: Vec<Line> = Vec::new();
716    lines.push(Line::raw(format!("{left_pad}{prompt}")));
717    lines.push(Line::raw(format!("{left_pad}{input_line}")));
718    if !err_line.is_empty() {
719        lines.push(Line::from(Span::styled(
720            format!("{left_pad}{err_line}"),
721            Style::default().fg(Color::Red),
722        )));
723    }
724
725    let paragraph = Paragraph::new(lines)
726        .wrap(Wrap { trim: false })
727        .style(Style::default().bg(Color::Black).fg(Color::White));
728    f.render_widget(paragraph, inner);
729
730    // Show the text cursor at the end of the input line
731    let cursor_x = inner
732        .x
733        .saturating_add(1) // left_pad
734        .saturating_add(2) // "> "
735        .saturating_add(app.save_name_input.len() as u16)
736        .min(inner.x.saturating_add(inner.width.saturating_sub(1)));
737    let cursor_y = inner
738        .y
739        .saturating_add(1) // second rendered line
740        .min(inner.y.saturating_add(area.height.saturating_sub(1)));
741    f.set_cursor_position(Position::new(cursor_x, cursor_y));
742}
743
744fn draw_open_dialog(f: &mut Frame, area: Rect, app: &App) {
745    // Content to display
746    let title = "Open File";
747    let prompt = "Enter file name to open (Esc to cancel):";
748    let input_line = format!("> {}", app.open_name_input);
749    let err_line = app.open_error.as_deref().unwrap_or("");
750
751    // Compute minimal dialog size based on content
752    let mut longest = prompt.len().max(input_line.len()).max(title.len());
753    if !err_line.is_empty() {
754        longest = longest.max(err_line.len());
755    }
756
757    // Borders add 2 columns; add a tiny horizontal padding of 1 char per side
758    let horizontal_padding = 2u16;
759    let min_w = 10u16;
760    let max_w = area.width.saturating_sub(2);
761    let w = ((longest as u16) + 2 /* borders */ + horizontal_padding).clamp(min_w, max_w);
762
763    // Lines:
764    // - 1: prompt
765    // - 2: input
766    // - 3: error(optional)
767
768    let base_lines = 2u16;
769    let lines = base_lines + if err_line.is_empty() { 0 } else { 1 };
770    // Borders add 2 rows; add a tiny vertical padding of 0 (keep minimal)
771    let min_h = 4u16;
772    let max_h = area.height.saturating_sub(2);
773    let h = (lines + 2 /* borders */).clamp(min_h, max_h);
774
775    // Center dialog
776    let x = area.x + (area.width.saturating_sub(w)) / 2;
777    let y = area.y + (area.height.saturating_sub(h)) / 2;
778    let rect = Rect { x, y, width: w, height: h };
779
780    // Ensure a solid background and then draw the block
781    f.render_widget(Clear, rect);
782
783    let block = Block::default()
784        .title(Span::styled(title, Style::default().fg(Color::White)))
785        .borders(Borders::ALL)
786        .style(Style::default().bg(Color::Black));
787    f.render_widget(block.clone(), rect);
788
789    // Inner area
790    let inner = block.inner(rect);
791
792    // Render content with minimal padding: one leading space
793    let left_pad = " ";
794    let mut lines_vec: Vec<Line> = Vec::new();
795    lines_vec.push(Line::raw(format!("{left_pad}{prompt}")));
796    lines_vec.push(Line::raw(format!("{left_pad}{input_line}")));
797    if !err_line.is_empty() {
798        lines_vec.push(Line::from(Span::styled(
799            format!("{left_pad}{err_line}"),
800            Style::default().fg(Color::Red),
801        )));
802    }
803
804    let paragraph = Paragraph::new(lines_vec)
805        .wrap(Wrap { trim: false })
806        .style(Style::default().bg(Color::Black).fg(Color::White));
807    f.render_widget(paragraph, inner);
808
809    let cursor_x = inner
810        .x
811        .saturating_add(1)
812        .saturating_add(2)
813        .saturating_add(app.open_name_input.len() as u16)
814        .min(inner.x.saturating_add(inner.width.saturating_sub(1)));
815    let cursor_y = inner
816        .y
817        .saturating_add(1)
818        .min(inner.y.saturating_add(area.height.saturating_sub(1)));
819    f.set_cursor_position(Position::new(cursor_x, cursor_y));
820}
821
822fn draw_confirm_dialog(f: &mut Frame, area: Rect, app: &App) {
823    let title = "Confirm";
824    let hint = "(Enter = Yes, Esc = No)";
825    let longest = title.len().max(app.confirm_message.len()).max(hint.len());
826
827    let horizontal_padding = 2u16;
828    let min_w = 20u16;
829    let max_w = area.width.saturating_sub(2);
830    let w = ((longest as u16) + 2  + horizontal_padding).clamp(min_w, max_w);
831
832    let h = 5u16; // title + message + hint + borders
833    let x = area.x + (area.width.saturating_sub(w)) / 2;
834    let y = area.y + (area.height.saturating_sub(h)) / 2;
835    let rect = Rect { x, y, width: w, height: h };
836
837    f.render_widget(Clear, rect);
838
839    let block = Block::default()
840        .title(Span::styled(title, Style::default().fg(Color::White)))
841        .borders(Borders::ALL)
842        .style(Style::default().bg(Color::Black));
843    f.render_widget(block.clone(), rect);
844
845    let inner = block.inner(rect);
846
847    // Center the hint within the inner width
848    let hint_centered = if (hint.len() as u16) < inner.width {
849        let pad = ((inner.width as usize).saturating_sub(hint.len())) / 2;
850        format!("{}{}", " ".repeat(pad), hint)
851    } else {
852        hint.to_string()
853    };
854
855    let lines = vec![
856        Line::raw(format!(" {}", app.confirm_message)),
857        Line::from(Span::styled(hint_centered, Style::default().fg(Color::Gray))),
858    ];
859    let paragraph = Paragraph::new(lines)
860        .wrap(Wrap { trim: false })
861        .style(Style::default().bg(Color::Black).fg(Color::White));
862    f.render_widget(paragraph, inner);
863}
864
865fn draw_input_dialog(f: &mut Frame, area: Rect, app: &App) {
866    let title = "Program input";
867    let prompt = "Type a byte and press Enter (Esc to send EOF):";
868    let input_line = format!("> {}", app.input_buffer);
869    let err_line = app.input_error.as_deref().unwrap_or("");
870
871    // Compute dialog size
872    let mut longest = prompt.len().max(input_line.len()).max(title.len());
873    if !err_line.is_empty() {
874        longest = longest.max(err_line.len());
875    }
876    let horizontal_padding = 2u16;
877    let min_w = 24u16;
878    let max_w = area.width.saturating_sub(2);
879    let w = ((longest as u16) + 2 + horizontal_padding).clamp(min_w, max_w);
880
881    let base_lines = 2u16;
882    let lines = base_lines + if err_line.is_empty() { 0 } else { 1 };
883    let min_h = 4u16;
884    let max_h = area.height.saturating_sub(2);
885    let h = (lines + 2).clamp(min_h, max_h);
886
887    let x = area.x + (area.width.saturating_sub(w)) / 2;
888    let y = area.y + (area.height.saturating_sub(h)) / 2;
889    let rect = Rect { x, y, width: w, height: h };
890
891    f.render_widget(Clear, rect);
892
893    let block = Block::default()
894        .title(Span::styled(title, Style::default().fg(Color::White)))
895        .borders(Borders::ALL)
896        .style(Style::default().bg(Color::Black));
897    f.render_widget(block.clone(), rect);
898
899    let inner = block.inner(rect);
900    let left_pad = " ";
901    let mut lines_vec: Vec<Line> = Vec::new();
902    lines_vec.push(Line::raw(format!("{left_pad}{prompt}")));
903    lines_vec.push(Line::raw(format!("{left_pad}{input_line}")));
904    if !err_line.is_empty() {
905        lines_vec.push(Line::from(Span::styled(
906            format!("{left_pad}{err_line}"),
907            Style::default().fg(Color::Red),
908        )));
909    }
910
911    let paragraph = Paragraph::new(lines_vec)
912        .wrap(Wrap { trim: false })
913        .style(Style::default().bg(Color::Black).fg(Color::White));
914    f.render_widget(paragraph, inner);
915
916    // Cursor at end of input line
917    let cursor_x = inner
918        .x
919        .saturating_add(1)
920        .saturating_add(2)
921        .saturating_add(app.input_buffer.len() as u16)
922        .min(inner.x.saturating_add(inner.width.saturating_sub(1)));
923    let cursor_y = inner.y.saturating_add(1);
924    f.set_cursor_position(Position::new(cursor_x, cursor_y));
925}
926
927fn handle_key(app: &mut App, key: KeyEvent) -> io::Result<bool> {
928    // When modal is open, it captures all keys
929    if app.show_save_dialog {
930        handle_save_dialog_key(app, key)?;
931        return Ok(false);
932    }
933    if app.show_open_dialog {
934        handle_open_dialog_key(app, key)?;
935        return Ok(false);
936    }
937    if app.show_confirm_dialog {
938        handle_confirm_dialog_key(app, key)?;
939        return Ok(false);
940    }
941    if app.show_input_dialog {
942        handle_input_dialog_key(app, key)?;
943        return Ok(false);
944    }
945
946    // Global keys
947    if key.modifiers.contains(KeyModifiers::CONTROL) {
948        match key.code {
949            KeyCode::Char('q') => {
950                if app.dirty {
951                    app.show_confirm_dialog = true;
952                    app.confirm_message = "You have unsaved changes. Quit anyway?".to_string();
953                    app.confirm_pending_quit = true;
954                    // Wait for confirmation
955                    return Ok(false);
956                }
957                return Ok(true)
958            }, // Quit
959            KeyCode::Char('h') | KeyCode::F(1) => {
960                app.show_help = !app.show_help;
961                return Ok(false);
962            }
963            KeyCode::Char('r') | KeyCode::F(5) => {
964                // Start runner
965                start_runner(app);
966                return Ok(false);
967            }
968            KeyCode::Char('o') => {
969                // Open file: show path prompt
970                app.show_open_dialog = true;
971                app.open_error = None;
972                // Prefill with current filename if present
973                app.open_name_input = app.filename.clone().unwrap_or_default();
974                return Ok(false);
975            }
976            KeyCode::Char('s') => {
977                // Save current file
978                if app.filename.is_none() {
979                    app.show_save_dialog = true;
980                    app.save_name_input = "untitled.bf".to_string();
981                    app.save_error = None;
982                } else {
983                    match app_save_current(app) {
984                        Ok(_) => { /* saved; dirty cleared */ }
985                        Err(err) => {
986                            // TODO: show status message
987                            eprintln!("Save failed: {}", err);
988                        }
989                    }
990                }
991                return Ok(false);
992            }
993            KeyCode::Char('e') => {
994                app.output_mode = match app.output_mode {
995                    OutputMode::Raw => OutputMode::Escaped,
996                    OutputMode::Escaped => OutputMode::Raw,
997                };
998                return Ok(false);
999            }
1000            KeyCode::Char('n') => {
1001                if app.dirty {
1002                    app.show_confirm_dialog = true;
1003                    app.confirm_message = "You have unsaved changes. Discard and create new file?".to_string();
1004                    app.confirm_pending_new = true;
1005                } else {
1006                    app_new_file(app);
1007                }
1008                return Ok(false);
1009            }
1010            _ => {}
1011        }
1012    }
1013
1014    match key.code {
1015        KeyCode::F(1) => {
1016            app.show_help = !app.show_help;
1017            Ok(false)
1018        }
1019        KeyCode::F(5) => {
1020            // Start runner
1021            start_runner(app);
1022            Ok(false)
1023        }
1024        KeyCode::Char('.') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1025            if let Some(h) = app.runner.as_ref() {
1026                h.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
1027                let _ = h.tx_cmd.send(UiCmd::Stop);
1028            }
1029            app.running = false;
1030            Ok(false)
1031        }
1032        KeyCode::F(17) /* Shift+F5 */ => {
1033            if let Some(h) = app.runner.as_ref() {
1034                h.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
1035                let _ = h.tx_cmd.send(UiCmd::Stop);
1036            }
1037            app.running = false;
1038            Ok(false)
1039        }
1040        KeyCode::Tab => {
1041            app.focused = match app.focused {
1042                Focus::Editor => Focus::Output,
1043                Focus::Output => Focus::Tape,
1044                Focus::Tape => Focus::Editor,
1045            };
1046            Ok(false)
1047        }
1048        KeyCode::BackTab => {
1049            app.focused = match app.focused {
1050                Focus::Editor => Focus::Tape,
1051                Focus::Output => Focus::Editor,
1052                Focus::Tape => Focus::Output,
1053            };
1054            Ok(false)
1055        }
1056        KeyCode::Esc => {
1057            // In Vi mode within editor, Esc leaves Insert -> Normal (or no-op)
1058            if app.focused == Focus::Editor && app.vi_enabled {
1059                handle_editor_key_vi(app, key);
1060                return Ok(false);
1061            }
1062            // Hide help if it's open; otherwise confirm-or-quit
1063            if app.show_help {
1064                app.show_help = false;
1065                Ok(false)
1066            } else {
1067                if app.dirty {
1068                    app.show_confirm_dialog = true;
1069                    app.confirm_message = "You have unsaved changes. Quit anyway?".to_string();
1070                    app.confirm_pending_quit = true;
1071                    // Wait for confirmation
1072                    Ok(false)
1073                } else {
1074                    Ok(true) // Quit
1075                }
1076            }
1077        }
1078        _ => match app.focused {
1079            Focus::Editor => {
1080                if app.vi_enabled {
1081                    handle_editor_key_vi(app, key);
1082                } else {
1083                    handle_editor_key(app, key);
1084                }
1085                Ok(false)
1086            },
1087            Focus::Output => Ok(false), // No keys handled in output pane
1088            Focus::Tape => {
1089                handle_tape_key(app, key);
1090                Ok(false)
1091            },
1092        },
1093    }
1094}
1095
1096fn handle_tape_key(app: &mut App, key: KeyEvent) {
1097    match key.code {
1098        // Page to previous/next 128-cell chunk
1099        KeyCode::Char('[') | KeyCode::Left | KeyCode::PageUp => {
1100            let page = 128usize;
1101            let new_base = app.tape_window_base.saturating_sub(page);
1102            if new_base != app.tape_window_base {
1103                app.tape_window_base = new_base;
1104                app.dirty = true;
1105            }
1106        }
1107        KeyCode::Char(']') | KeyCode::Right | KeyCode::PageDown => {
1108            let page = 128usize;
1109            // avoid overflow / beyond memory size; if you have memory size available, clamp to it
1110            app.tape_window_base = app.tape_window_base.saturating_add(page);
1111            app.dirty = true;
1112        }
1113        // Center current pointer into its page
1114        KeyCode::Char('c') if key.modifiers.is_empty() => {
1115            let page = 128usize;
1116            app.tape_window_base = app.tape_ptr - (app.tape_ptr % page);
1117            app.dirty = true;
1118        }
1119        _ => {}
1120    }
1121}
1122
1123fn handle_editor_key(app: &mut App, key: KeyEvent) {
1124    match key.code {
1125        KeyCode::Left => {
1126            if app.cursor_col > 0 {
1127                app.cursor_col -= 1;
1128            } else if app.cursor_row > 0 {
1129                app.cursor_row -= 1;
1130                app.cursor_col = app.buffer[app.cursor_row].len();
1131                ensure_cursor_visible(app);
1132            }
1133        }
1134        KeyCode::Right => {
1135            let len = app.buffer[app.cursor_row].len();
1136            if app.cursor_col < len {
1137                app.cursor_col += 1;
1138            } else if app.cursor_row + 1 < app.buffer.len() {
1139                app.cursor_row += 1;
1140                app.cursor_col = 0;
1141                ensure_cursor_visible(app);
1142            }
1143        }
1144        KeyCode::Up => {
1145            if app.cursor_row > 0 {
1146                app.cursor_row -= 1;
1147                app.cursor_col = app.cursor_col.min(app.buffer[app.cursor_row].len());
1148                ensure_cursor_visible(app);
1149            }
1150        }
1151        KeyCode::Down => {
1152            if app.cursor_row + 1 < app.buffer.len() {
1153                app.cursor_row += 1;
1154                app.cursor_col = app.cursor_col.min(app.buffer[app.cursor_row].len());
1155                ensure_cursor_visible(app);
1156            }
1157        }
1158        KeyCode::Home => { app.cursor_col = 0; }
1159        KeyCode::End => { app.cursor_col = app.buffer[app.cursor_row].len(); }
1160        KeyCode::PageUp => {
1161            let jump = 10usize;
1162            app.cursor_row = app.cursor_row.saturating_sub(jump);
1163            app.cursor_col = app.cursor_col.min(app.buffer[app.cursor_row].len());
1164            ensure_cursor_visible(app);
1165        }
1166        KeyCode::PageDown => {
1167            let jump = 10usize;
1168            app.cursor_row = (app.cursor_row + jump).min(app.buffer.len().saturating_sub(1));
1169            app.cursor_col = app.cursor_col.min(app.buffer[app.cursor_row].len());
1170            ensure_cursor_visible(app);
1171        }
1172        KeyCode::Enter => {
1173            let line = app.buffer[app.cursor_row].clone();
1174            let (left, right) = line.split_at(app.cursor_col);
1175            app.buffer[app.cursor_row] = left.to_string();
1176            app.buffer.insert(app.cursor_row + 1, right.to_string());
1177            app.cursor_row += 1;
1178            app.cursor_col = 0;
1179            app.dirty = true;
1180            ensure_cursor_visible(app);
1181        }
1182        KeyCode::Backspace => {
1183            if app.cursor_col > 0 {
1184                let line = &mut app.buffer[app.cursor_row];
1185                let prev_byte_idx = nth_char_to_byte_idx(line, app.cursor_col - 1);
1186                line.drain(prev_byte_idx..nth_char_to_byte_idx(line, app.cursor_col));
1187                app.cursor_col -= 1;
1188                app.dirty = true;
1189            } else if app.cursor_row > 0 {
1190                let cur = app.buffer.remove(app.cursor_row);
1191                app.cursor_row -= 1;
1192                let prev_len_chars = app.buffer[app.cursor_row].chars().count();
1193                app.buffer[app.cursor_row].push_str(&cur);
1194                app.cursor_row = prev_len_chars;
1195                app.dirty = true;
1196                ensure_cursor_visible(app);
1197            } else {
1198                // At start of file, do nothing
1199            }
1200        }
1201        KeyCode::Delete => {
1202            let len_chars = app.buffer[app.cursor_row].chars().count();
1203            if app.cursor_col < len_chars {
1204                let line = &mut app.buffer[app.cursor_row];
1205                let start = nth_char_to_byte_idx(line, app.cursor_col);
1206                let end = nth_char_to_byte_idx(line, app.cursor_col + 1);
1207                line.drain(start..end);
1208                app.dirty = true;
1209            } else if app.cursor_row + 1 < app.buffer.len() {
1210                let next = app.buffer.remove(app.cursor_row + 1);
1211                app.buffer[app.cursor_row].push_str(&next);
1212                app.dirty = true;
1213            }
1214        }
1215        KeyCode::Char('l') | KeyCode::Char('L') => {
1216            if key.modifiers.contains(KeyModifiers::CONTROL) {
1217                app.show_line_numbers = !app.show_line_numbers;
1218            }
1219        }
1220        KeyCode::Char('p') | KeyCode::Char('P') => {
1221            if key.modifiers.contains(KeyModifiers::CONTROL) {
1222                if !jump_to_matching_bracket(app) {
1223                    set_status(app, "No matching bracket at cursor")
1224                }
1225                return;
1226            }
1227        }
1228        KeyCode::Char(ch) => {
1229            // Only insert when no modifiers are held; avoid inserting on Ctrl/Alt/Shift combos
1230            if key.modifiers.is_empty() && !ch.is_control() {
1231                app.buffer[app.cursor_row].insert(app.cursor_col, ch);
1232                app.cursor_col += 1;
1233                app.dirty = true;
1234                ensure_cursor_visible(app);
1235            }
1236        }
1237        _ => {}
1238    }
1239
1240    if app.buffer.is_empty() {
1241        app.buffer.push(String::new());
1242        app.cursor_row = 0;
1243        app.cursor_col = 0;
1244        app.scroll_row = 0;
1245    } else {
1246        app.cursor_row = app.cursor_row.min(app.buffer.len() - 1);
1247        let cur_len = app.buffer[app.cursor_row].chars().count();
1248        app.cursor_col = app.cursor_col.min(cur_len);
1249    }
1250}
1251
1252/// Vi-mode aware editor handling
1253fn handle_editor_key_vi(app: &mut App, key: KeyEvent) {
1254    match app.vi_mode {
1255        ViMode::Insert => {
1256            // Insert mode: same as regular editor, but Esc returns to Normal
1257            if let KeyCode::Esc = key.code {
1258                app.vi_mode = ViMode::Normal;
1259                app.vi_pending_op = None;
1260                return;
1261            }
1262            handle_editor_key(app, key);
1263        }
1264        ViMode::Normal => {
1265            // Clear pending op unless this is part of a chord
1266            let mut consumed = false;
1267            match key.code {
1268                KeyCode::Char('i') if key.modifiers.is_empty() => {
1269                    app.vi_mode = ViMode::Insert;
1270                    app.vi_pending_op = None;
1271                    consumed = true;
1272                }
1273                KeyCode::Char('a') if key.modifiers.is_empty() => {
1274                    // Append after cursor (move right if possible), then insert
1275                    let len = app.buffer[app.cursor_row].chars().count();
1276                    if app.cursor_col < len {
1277                        app.cursor_col += 1;
1278                    }
1279                    app.vi_mode = ViMode::Insert;
1280                    app.vi_pending_op = None;
1281                    consumed = true;
1282                }
1283                KeyCode::Char('o') if key.modifiers.is_empty() => {
1284                    // New line below, move to it, then insert
1285                    let next_idx = app.cursor_row + 1;
1286                    app.buffer.insert(next_idx, String::new());
1287                    app.cursor_row = next_idx;
1288                    app.cursor_col = 0;
1289                    app.vi_mode = ViMode::Insert;
1290                    app.dirty = true;
1291                    ensure_cursor_visible(app);
1292                    app.vi_pending_op = None;
1293                    consumed = true;
1294                }
1295                KeyCode::Char('O') if key.modifiers.is_empty() => {
1296                    // New line above, move to it, then insert
1297                    let cur_idx = app.cursor_row;
1298                    app.buffer.insert(cur_idx, String::new());
1299                    app.cursor_col = 0;
1300                    app.dirty = true;
1301                    ensure_cursor_visible(app);
1302                    app.vi_mode = ViMode::Insert;
1303                    app.vi_pending_op = None;
1304                    consumed = true;
1305                }
1306                KeyCode::Char('h') if key.modifiers.is_empty() => {
1307                    move_left(app);
1308                    consumed = true;
1309                }
1310                KeyCode::Char('l') if key.modifiers.is_empty() => {
1311                    move_right(app);
1312                    consumed = true;
1313                }
1314                KeyCode::Char('j') if key.modifiers.is_empty() => {
1315                    move_down(app);
1316                    consumed = true;
1317                }
1318                KeyCode::Char('k') if key.modifiers.is_empty() => {
1319                    move_up(app);
1320                    consumed = true;
1321                }
1322                KeyCode::Char('x') if key.modifiers.is_empty() => {
1323                    // Delete char under cursor
1324                    let len_chars = app.buffer[app.cursor_row].chars().count();
1325                    if app.cursor_col < len_chars {
1326                        let line = &mut app.buffer[app.cursor_row];
1327                        let start = nth_char_to_byte_idx(line, app.cursor_col);
1328                        let end = nth_char_to_byte_idx(line, app.cursor_col + 1);
1329                        line.drain(start..end);
1330                        app.dirty = true;
1331                    }
1332                    consumed = true;
1333                }
1334                KeyCode::Char('0') if key.modifiers.is_empty() => {
1335                    app.cursor_col = 0;
1336                    ensure_cursor_visible(app);
1337                    consumed = true;
1338                }
1339                KeyCode::Char('$') if key.modifiers.is_empty() => {
1340                    app.cursor_col = app.buffer[app.cursor_row].chars().count();
1341                    ensure_cursor_visible(app);
1342                    consumed = true;
1343                }
1344                KeyCode::Char('d') if key.modifiers.is_empty() => {
1345                    if matches!(app.vi_pending_op, Some('d')) {
1346                        // dd: delete current line
1347                        app.vi_pending_op = None;
1348                        delete_current_line(app);
1349                    } else {
1350                        // Start d operation
1351                        app.vi_pending_op = Some('d');
1352                    }
1353                    consumed = true;
1354                }
1355                KeyCode::Char('g') if key.modifiers.is_empty() => {
1356                    if matches!(app.vi_pending_op, Some('g')) {
1357                        // gg: go to top
1358                        app.cursor_row = 0;
1359                        app.cursor_col = 0;
1360                        ensure_cursor_visible(app);
1361                        app.vi_pending_op = None;
1362                    } else {
1363                        // Start g operation
1364                        app.vi_pending_op = Some('g');
1365                    }
1366                    consumed = true;
1367                }
1368                KeyCode::Char('G') if key.modifiers.is_empty() => {
1369                    // End of file
1370                    app.cursor_row = app.buffer.len().saturating_sub(1);
1371                    app.cursor_col = app.buffer[app.cursor_row].chars().count();
1372                    ensure_cursor_visible(app);
1373                    consumed = true;
1374                }
1375                KeyCode::Char('p') | KeyCode::Char('P') => {
1376                    if key.modifiers.contains(KeyModifiers::CONTROL) {
1377                        if !jump_to_matching_bracket(app) {
1378                            set_status(app, "No matching bracket at cursor")
1379                        }
1380                        consumed = true;
1381                    }
1382                }
1383                KeyCode::Enter => {
1384                    // In Normal mode, Enter: do nothing
1385                    consumed = true;
1386                }
1387                KeyCode::Esc => {
1388                    // Already normal; clear pending op
1389                    app.vi_pending_op = None;
1390                    consumed = true;
1391                }
1392                _ => {}
1393            }
1394            if !consumed {
1395                // Any other key cancels pending op
1396                app.vi_pending_op = None;
1397            }
1398        }
1399    }
1400
1401    // Clamp and maintain invariants
1402    if app.buffer.is_empty() {
1403        app.buffer.push(String::new());
1404        app.cursor_row = 0;
1405        app.cursor_col = 0;
1406        app.scroll_row = 0;
1407    } else {
1408        app.cursor_row = app.cursor_row.min(app.buffer.len() - 1);
1409        let cur_len = app.buffer[app.cursor_row].chars().count();
1410        app.cursor_col = app.cursor_col.min(cur_len);
1411    }
1412}
1413
1414fn handle_save_dialog_key(app: &mut App, key: KeyEvent) -> io::Result<()> {
1415    match key.code {
1416        KeyCode::Esc => {
1417            app.show_save_dialog = false;
1418            app.save_error = None;
1419        }
1420        KeyCode::Enter => {
1421            let name = app.save_name_input.trim().to_string();
1422            if name.is_empty() {
1423                app.save_error = Some("File name cannot be empty".to_string());
1424            } else {
1425                match save_to_filename(app, &name) {
1426                    Ok(_) => {
1427                        set_status(app, &format!("Saved {}", name));
1428                        app.show_save_dialog = false;
1429                        app.save_error = None;
1430                    }
1431                    Err(err) => {
1432                        app.save_error = Some(format!("Save failed: {}", err));
1433                        set_status(app, "Save failed");
1434                    }
1435                }
1436            }
1437        }
1438        KeyCode::Backspace => {
1439            app.save_name_input.pop();
1440        }
1441        KeyCode::Char(ch) => {
1442            if key.modifiers.is_empty() && !ch.is_control() {
1443                app.save_name_input.push(ch);
1444            }
1445        }
1446        _ => {}
1447    }
1448    Ok(())
1449}
1450
1451fn handle_open_dialog_key(app: &mut App, key: KeyEvent) -> io::Result<()> {
1452    match key.code {
1453        KeyCode::Esc => {
1454            app.show_open_dialog = false;
1455            app.open_error = None;
1456        }
1457        KeyCode::Enter => {
1458            let name = app.open_name_input.trim().to_string();
1459            if name.is_empty() {
1460                app.open_error = Some("Path cannot be empty".to_string());
1461            } else {
1462                // Resolve to absolute path for consistent filename display
1463                let mut path = PathBuf::from(&name);
1464                if path.is_relative() {
1465                    path = std::env::current_dir()?.join(path);
1466                }
1467
1468                // If there are unsaved changes, ask for confirmation first
1469                if app.dirty {
1470                    app.confirm_message = "You have unsaved changes. Open anyway? Unsaved changes will be lost.".to_string();
1471                    app.confirm_pending_open = Some(path);
1472                    app.show_open_dialog = false;
1473                    app.show_confirm_dialog = true;
1474                } else {
1475                    match app_open_file(app, &path) {
1476                        Ok(_) => {
1477                            app.show_open_dialog = false;
1478                            app.open_error = None;
1479                        }
1480                        Err(err) => {
1481                            app.open_error = Some(format!("Open failed: {}", err));
1482                            set_status(app, "Open failed");
1483                        }
1484                    }
1485                }
1486            }
1487        }
1488        KeyCode::Backspace => {
1489            app.open_name_input.pop();
1490        }
1491        KeyCode::Char(ch) => {
1492            if key.modifiers.is_empty() && !ch.is_control() {
1493                app.open_name_input.push(ch);
1494            }
1495        }
1496        _ => {}
1497    }
1498    Ok(())
1499}
1500
1501fn handle_confirm_dialog_key(app: &mut App, key: KeyEvent) -> io::Result<()> {
1502    match key.code {
1503        KeyCode::Enter => {
1504            if let Some(path) = app.confirm_pending_open.take() {
1505                // Attempt to open; on error, return to open dialog with error message
1506                match app_open_file(app, &path) {
1507                    Ok(_) => {
1508                        app.show_confirm_dialog = false;
1509                        app.open_error = None;
1510                        app.show_confirm_dialog = false;
1511                    }
1512                    Err(err) => {
1513                        app.open_error = Some(format!("Open failed: {}", err));
1514                        app.show_confirm_dialog = false;
1515                        app.show_open_dialog = true;
1516                        set_status(app, "Open failed");
1517                    }
1518                }
1519            } else if app.confirm_pending_quit {
1520                // Quit confirmed
1521                app.confirm_pending_quit = false;
1522                // Hide confirm dialog
1523                app.show_confirm_dialog = false;
1524                // Signal to main loop to exit
1525                app.should_quit = true;
1526            } else if app.confirm_pending_new {
1527                // New file confirmed
1528                app.confirm_pending_new = false;
1529                app.show_confirm_dialog = false;
1530                app_new_file(app);
1531            } else {
1532                // No pending action; just close
1533                app.show_confirm_dialog = false;
1534            }
1535
1536        }
1537        KeyCode::Esc => {
1538            // Cancel; return to the open dialog if we came from there
1539            app.show_confirm_dialog = false;
1540            if app.confirm_pending_open.is_some() {
1541                app.show_open_dialog = true;
1542            }
1543            // Keep the pending path so the user can adjust; or clear it
1544            // Clear to avoid accidental reuse
1545            app.confirm_pending_open = None;
1546            app.confirm_pending_quit = false;
1547            app.confirm_pending_new = false;
1548            app.show_confirm_dialog = false;
1549        }
1550        _ => {}
1551    }
1552    Ok(())
1553}
1554
1555fn handle_input_dialog_key(app: &mut App, key: KeyEvent) -> io::Result<()> {
1556    match key.code {
1557        KeyCode::Esc => {
1558            // Send EOF
1559            if let Some(h) = app.runner.as_ref() {
1560                let _ = h.tx_cmd.send(UiCmd::ProvideInput(None));
1561            }
1562            app.show_input_dialog = false;
1563            app.input_error = None;
1564            set_status(app, "Sent EOF");
1565        }
1566        KeyCode::Enter => {
1567            if app.input_buffer.is_empty() {
1568                app.input_error = Some("Type a byte or Esc for EOF".to_string());
1569            } else {
1570                // Take first Unicode scalar's first byte
1571                let mut bytes_iter = app.input_buffer.bytes();
1572                if let Some(b) = bytes_iter.next() {
1573                    if let Some(h) = app.runner.as_ref() {
1574                        let _ = h.tx_cmd.send(UiCmd::ProvideInput(Some(b)));
1575                    }
1576                    app.show_input_dialog = false;
1577                    app.input_error = None;
1578                    set_status(app, &format!("Sent byte: 0x{:02X}", b));
1579                } else {
1580                    app.input_error = Some("Invalid input".to_string());
1581                }
1582            }
1583        }
1584        KeyCode::Backspace => {
1585            app.input_buffer.pop();
1586        }
1587        KeyCode::Char(ch) => {
1588            if key.modifiers.is_empty() && !ch.is_control() {
1589                app.input_buffer.push(ch);
1590                app.input_error = None;
1591            }
1592        }
1593        _ => {}
1594    }
1595    Ok(())
1596}
1597
1598fn nth_char_to_byte_idx(s: &str, nth: usize) -> usize {
1599    if nth == 0 {
1600        return 0;
1601    }
1602    match s.char_indices().nth(nth) {
1603        Some((i, _)) => i,
1604        None => s.len(),
1605    }
1606}
1607
1608fn ensure_cursor_visible(app: &mut App) {
1609    let margin = 3usize;
1610    if app.cursor_row < app.scroll_row.saturating_add(margin) {
1611        app.scroll_row = app.cursor_row.saturating_sub(margin);
1612    }
1613    let end = app.scroll_row + margin * 2;
1614    if app.cursor_row > end {
1615        app.scroll_row = app.cursor_row.saturating_sub(margin);
1616    }
1617}
1618
1619// Syntax highlighting for BF tokens + matching bracket highlighting
1620fn highlight_bf_line(line: &str, app: &App, row: usize) -> Line<'static> {
1621    let (match_row_col, cursor_on_bracket) = if app.focused == Focus::Editor
1622        && row == app.cursor_row
1623        && app.cursor_col < line.chars().count()
1624    {
1625        let ch = line.chars().nth(app.cursor_col).unwrap_or('\0');
1626        if ch == '[' || ch == ']' {
1627            (find_matching_bracket(app, (app.cursor_row, app.cursor_col)), true)
1628        } else {
1629            (None, false)
1630        }
1631    } else {
1632        (None, false)
1633    };
1634
1635    let mut spans: Vec<Span<'static>> = Vec::with_capacity(line.len().max(1));
1636    for (i, ch) in line.chars().enumerate() {
1637        let base = match ch {
1638            '>' => Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
1639            '<' => Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
1640            '+' => Style::default().fg(Color::LightGreen).add_modifier(Modifier::BOLD),
1641            '-' => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1642            '.' => Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
1643            ',' => Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
1644            '[' | ']' => Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD),
1645            _ => Style::default().fg(Color::Gray),
1646        };
1647
1648        // Highlight current bracket and its match
1649        let styled = if cursor_on_bracket && (row, i) == (app.cursor_row, app.cursor_col) {
1650            base.add_modifier(Modifier::REVERSED | Modifier::BOLD)
1651        } else if let Some((mr, mc)) = match_row_col {
1652            if (row, i) == (mr, mc) {
1653                base.add_modifier(Modifier::REVERSED)
1654            } else {
1655                base
1656            }
1657        } else {
1658            base
1659        };
1660
1661        spans.push(Span::styled(ch.to_string(), styled));
1662    }
1663
1664    if spans.is_empty() {
1665        spans.push(Span::raw(" "))
1666    }
1667    Line::from(spans)
1668}
1669
1670fn find_matching_bracket(app: &App, pos: (usize, usize)) -> Option<(usize, usize)> {
1671    let Mapping {
1672        bf_seq,
1673        orig_to_bf_idx,
1674        bf_idx_to_orig,
1675    } = build_bf_mapping(&app.buffer);
1676
1677    let bf_idx = *orig_to_bf_idx.get(&pos)?;
1678
1679    let chars: Vec<char> = bf_seq.chars().collect();
1680    let cur = *chars.get(bf_idx)?;
1681    if cur != '[' && cur != ']' {
1682        return None;
1683    }
1684
1685    if cur == '[' {
1686        let mut depth: isize = 0;
1687        for i in (bf_idx + 1)..chars.len() {
1688            match chars[i] {
1689                '[' => depth += 1,
1690                ']' => {
1691                    if depth == 0 {
1692                        return bf_idx_to_orig.get(&i).copied();
1693                    } else {
1694                        depth -= 1;
1695                    }
1696                }
1697                _ => {}
1698            }
1699        }
1700    } else {
1701        let mut depth: isize = 0;
1702        let mut i = bf_idx;
1703        while i > 0 {
1704            i -= 1;
1705            match chars[i] {
1706                ']' => depth += 1,
1707                '[' => {
1708                    if depth == 0 {
1709                        return bf_idx_to_orig.get(&i).copied();
1710                    } else {
1711                        depth -= 1;
1712                    }
1713                }
1714                _ => {}
1715            }
1716        }
1717    }
1718    None
1719}
1720
1721fn jump_to_matching_bracket(app: &mut App) -> bool {
1722    // Must be on a `[` or `]` character to jump
1723    let line = match app.buffer.get(app.cursor_row) {
1724        Some(l) => l,
1725        None => return false,
1726    };
1727    let len_chars = line.chars().count();
1728    if app.cursor_col >= len_chars {
1729        return false;
1730    }
1731    let cur_ch = line.chars().nth(app.cursor_col).unwrap_or('\0');
1732    if cur_ch != '[' && cur_ch != ']' {
1733        return false;
1734    }
1735
1736    if let Some((r, c)) = find_matching_bracket(app, (app.cursor_row, app.cursor_col)) {
1737        app.cursor_row = r;
1738        app.cursor_col = c;
1739        ensure_cursor_visible(app);
1740        true
1741    } else { false }
1742}
1743
1744struct Mapping {
1745    bf_seq: String,
1746    // Original (row, col) -> index in bf_seq (only for BF tokens)
1747    orig_to_bf_idx: HashMap<(usize, usize), usize>,
1748    // Index in bf_seq -> Original (row, col)
1749    bf_idx_to_orig: HashMap<usize, (usize, usize)>,
1750}
1751
1752fn build_bf_mapping(lines: &[String]) -> Mapping {
1753    let mut bf_seq = String::new();
1754    let mut orig_to_bf_idx: HashMap<(usize, usize), usize>  = HashMap::new();
1755    let mut bf_idx_to_orig: HashMap<usize, (usize, usize)>  = HashMap::new();
1756
1757    let is_bf = |c: char| matches!(c, '>' | '<' | '+' | '-' | '.' | ',' | '[' | ']');
1758
1759    let mut idx = 0usize;
1760    for (r, line) in lines.iter().enumerate() {
1761        for (c, ch) in line.chars().enumerate() {
1762            if is_bf(ch) {
1763                bf_seq.push(ch);
1764                orig_to_bf_idx.insert((r, c), idx);
1765                bf_idx_to_orig.insert(idx, (r, c));
1766                idx += 1;
1767            }
1768        }
1769    }
1770
1771    Mapping {
1772        bf_seq,
1773        orig_to_bf_idx,
1774        bf_idx_to_orig,
1775    }
1776}
1777
1778// Start the Brainfuck runner thread with cooperative cancellation and channels
1779fn start_runner(app: &mut App) {
1780    // If a runner is already active, ignore
1781    if app.runner.is_some() {
1782        return;
1783    }
1784
1785    // Prepare source (keep only BF tokens)
1786    let source = app_current_source(app);
1787    let filtered = bf_only(&source);
1788    if filtered.trim().is_empty() {
1789        // No BF code to run
1790        set_status(app, "Nothing to run");
1791        return;
1792    }
1793
1794    // Channels
1795    let (tx_msg, rx_msg) = mpsc::channel::<RunnerMsg>();
1796    let (tx_cmd, rx_cmd) = mpsc::channel::<UiCmd>();
1797
1798    // Cancel flag and step control
1799    let cancel = Arc::new(AtomicBool::new(false));
1800    let cancel_for_timer = cancel.clone();
1801
1802    // Limits from environment
1803    let timeout_ms = std::env::var("BF_TIMEOUT_MS").ok().and_then(|s| s.parse::<usize>().ok()).unwrap_or(2_000);
1804    let max_steps = std::env::var("BF_MAX_STEPS").ok().and_then(|s| s.parse::<usize>().ok());
1805
1806    // Make rx_cmd accessible from callbacks invoked during execution
1807    let rx_cmd_shared = Arc::new(Mutex::new(rx_cmd));
1808
1809    // Spawn worker thread
1810    let program = filtered.clone();
1811    thread::spawn(move || {
1812        // Timer thread: flip cancel after wall-clock timeout
1813        let cancel_for_timer = cancel_for_timer.clone();
1814        let cancel_clone = cancel_for_timer.clone();
1815        thread::spawn(move || {
1816            thread::sleep(Duration::from_millis(timeout_ms as u64));
1817            cancel_clone.store(true, std::sync::atomic::Ordering::Relaxed);
1818        });
1819
1820        // Build the reader and wire callbacks
1821        let mut bf = BrainfuckReader::new(program);
1822
1823        // Output: forward produced bytes to UI
1824        let tx_out = tx_msg.clone();
1825        bf.set_output_sink(Box::new(move |bytes: &[u8]| {
1826            // Send as a batch; UI appends to its buffer
1827            let _ = tx_out.send(RunnerMsg::Output(bytes.to_vec()));
1828        }));
1829
1830        // Input: ask UI, block until ProvideInput arrives (or channel closes)
1831        let tx_needs_input = tx_msg.clone();
1832        let rx_input = rx_cmd_shared.clone();
1833        bf.set_input_provider(Box::new(move || {
1834            let _ = tx_needs_input.send(RunnerMsg::NeedsInput);
1835            // Wait for a ProvideInput command; None if UI side dropped
1836            let recv_res = {
1837                let lock = rx_input.lock().expect("rx_cmd mutex poisoned");
1838                lock.recv()
1839            };
1840            match recv_res {
1841                Ok(UiCmd::ProvideInput(b)) => b,
1842                Ok(UiCmd::Stop) => None, // treat Stop as EOF for input
1843                Err(_) => None, // channel closed
1844            }
1845        }));
1846
1847        // Tape observer: emit 128-cell window snapshots
1848        bf.set_tape_observer(
1849            128, { // Window size requested from the engine
1850                let tx = tx_msg.clone();
1851                move |ptr, base, window| {
1852                    // copy to fixed array expected by UI
1853                    let mut buf = [0u8; 128];
1854                    buf[..window.len().min(128)].copy_from_slice(&window[..window.len().min(128)]);
1855                    let _ = tx.send(RunnerMsg::Tape { ptr, base, window: buf });
1856                }
1857            }
1858        );
1859
1860        // Run BF with cooperative cancellation
1861        let ctrl = StepControl::new(max_steps, cancel_for_timer.clone());
1862        let res = {
1863            bf.run_with_control(ctrl)
1864        };
1865
1866        // Report completion
1867        let _ = tx_msg.send(RunnerMsg::Halted(res));
1868    });
1869
1870    // Save handle in app
1871    app.runner = Some(RunnerHandle {
1872        tx_cmd,
1873        rx_msg,
1874        cancel,
1875    });
1876    app.running = true;
1877
1878    // Reset previous output buffer for a fresh run
1879    app.output.clear();
1880    set_status(app, "Running...");
1881}
1882
1883// Helper: get the current editor buffer as a newline-joined string
1884fn app_current_source(app: &App) -> String {
1885    if app.buffer.is_empty() {
1886        String::new()
1887    } else {
1888        let mut s = String::new();
1889        for (i, line) in app.buffer.iter().enumerate() {
1890            if i > 0 {
1891                s.push('\n');
1892            }
1893            s.push_str(line);
1894        }
1895        s
1896    }
1897}
1898
1899// Helper: convert bytes into "escaped" string: printable ASCII as-is, others as \xHH
1900fn bytes_to_escaped(bytes: &[u8]) -> String {
1901    let mut out = String::with_capacity(bytes.len());
1902    for &b in bytes {
1903        match b {
1904            0x20..=0x7E => out.push(b as char), // Printable ASCII
1905            b'\n' => out.push('\n'),
1906            b'\r' => out.push('\r'),
1907            b'\t' => out.push('\t'),
1908            _ => {
1909                use std::fmt::Write as _;
1910                let _ = write!(&mut out, "\\x{:02X}", b);
1911            }
1912        }
1913    }
1914    out
1915}
1916
1917fn output_display_lines(app: &App) -> u16 {
1918    if app.output.is_empty() {
1919        return 1;
1920    }
1921    let line_count = match app.output_mode {
1922        OutputMode::Raw => {
1923            let s = String::from_utf8_lossy(&app.output);
1924            let n = s.lines().count();
1925            n.max(1)
1926        }
1927        OutputMode::Escaped => {
1928            let s = bytes_to_escaped(&app.output);
1929            let n = s.lines().count();
1930            n.max(1)
1931        }
1932    };
1933
1934    line_count as u16
1935}
1936
1937// Open a file into the editor buffer
1938// Set filename and clear dirty flag on success
1939fn app_open_file(app: &mut App, path: &Path) -> io::Result<()> {
1940    let content = fs::read_to_string(path)?;
1941    // Split preserving empty final line if present
1942    let mut lines: Vec<String> = content.split('\n').map(|s| s.to_string()).collect();
1943    if lines.is_empty() {
1944        lines.push(String::new());
1945    }
1946    app.buffer = lines;
1947    app.cursor_row = 0;
1948    app.cursor_col = 0;
1949    app.scroll_row = 0;
1950    app.filename = Some(path.to_string_lossy().to_string());
1951    app.dirty = false;
1952
1953    // Clear runtime/output state for new file
1954    app.output.clear();
1955    app.tape_ptr = 0;
1956    app.tape_window_base = 0;
1957    app.tape_window = [0u8; 128];
1958    
1959    // Position cursor at end of the file and ensure it's visible
1960    app.cursor_row = app.buffer.len().saturating_sub(1);
1961    app.cursor_col = app.buffer[app.cursor_row].chars().count();
1962    ensure_cursor_visible(app);
1963
1964    set_status(app, &format!("Opened {}", path.display()));
1965    Ok(())
1966}
1967
1968// Save the current editor buffer to the existing filename
1969// Errors if no filename is set or on I/O errors
1970fn app_save_current(app: &mut App) -> io::Result<()> {
1971    let filename_owned: String;
1972    let filename = match app.filename.as_deref() {
1973        Some(p) => p,
1974        None => {
1975            // No filename set
1976            let new_path = generate_new_filename()?;
1977            let s = new_path.to_string_lossy().to_string();
1978            app.filename = Some(s.clone());
1979            filename_owned = s;
1980            &filename_owned
1981        }
1982    };
1983    let content = app_current_source(app);
1984    // Ensure parent directory exists or let fs::write return an error
1985    fs::write(Path::new(filename), content)?;
1986    app.dirty = false;
1987    set_status(app, &format!("Saved {}", filename));
1988    Ok(())
1989}
1990
1991// Create a new untitled file in the editor (resets state)
1992fn app_new_file(app: &mut App) {
1993    app.buffer.clear();
1994    app.buffer.push(String::new());
1995    app.cursor_row = 0;
1996    app.cursor_col = 0;
1997    app.scroll_row = 0;
1998    app.filename = None;
1999    app.dirty = false;
2000
2001    // Reset runtime/output state
2002    app.output.clear();
2003    app.tape_ptr = 0;
2004    app.tape_window_base = 0;
2005    app.tape_window = [0u8; 128];
2006
2007    set_status(app, "New File");
2008}
2009
2010// Helper: choose a new default filename in the current directory.
2011// Tries "untitled.bf", "untitled1.bf", "untitled2.bf", ...
2012fn generate_new_filename() -> io::Result<PathBuf> {
2013    let base = std::env::current_dir()?;
2014    // Start with untitled.bf
2015    let stem = "untitled";
2016    let ext = "bf";
2017
2018    let candidates = {
2019        let mut p = base.clone();
2020        p.push(format!("{stem}.{ext}"));
2021        p
2022    };
2023
2024    if !candidates.exists() {
2025        return Ok(candidates);
2026    }
2027
2028    // Try with numeric suffixes
2029    for i in 1..10_000 {
2030        let mut p = base.clone();
2031        p.push(format!("{stem}{i}.{ext}"));
2032        if !p.exists() {
2033            return Ok(p);
2034        }
2035    }
2036
2037    // If we ran out of attempts, return an error
2038    Err(io::Error::new(io::ErrorKind::AlreadyExists, "Unable to generate new filename"))
2039}
2040
2041// Helper to save to a provided filename (relative or absolute)
2042fn save_to_filename(app: &mut App, name: &str) -> io::Result<()> {
2043    let mut path = PathBuf::from(name);
2044    if path.is_relative() {
2045        path = std::env::current_dir()?.join(path);
2046    }
2047    let content = app_current_source(app);
2048    fs::write(&path, content)?;
2049    app.filename = Some(path.to_string_lossy().to_string());
2050    app.dirty = false;
2051    Ok(())
2052}
2053
2054// Compute current cell value if the pointer is within the current 128-cell window
2055fn current_cell_value(app: &App) -> Option<u8> {
2056    let base = app.tape_window_base;
2057    let end = base.saturating_add(128);
2058    if app.tape_ptr >= base && app.tape_ptr < end {
2059        let idx = app.tape_ptr - base;
2060        Some(app.tape_window[idx])
2061    } else {
2062        None
2063    }
2064}
2065
2066// Helper: set a status message
2067fn set_status(app: &mut App, status: &str) {
2068    app.status_message = Some((status.to_string(), Instant::now()));
2069}
2070
2071// Compute gutter width based on total lines. Clamp digits to avoid huge gutters.
2072fn compute_gutter_width(total_lines: usize, max_digits: usize) -> u16 {
2073    let digits = if total_lines <= 1 { 1 } else {
2074        ((total_lines as f64).log10().floor() as usize) + 1
2075    }.clamp(2, max_digits);
2076    (digits + 1) as u16 // +1 for space after number
2077}
2078
2079fn tui_has_vi_enabled() -> bool {
2080    false
2081}
2082
2083fn move_left(app: &mut App) {
2084    if app.cursor_col > 0 {
2085        app.cursor_col -= 1;
2086    } else if app.cursor_row > 0 {
2087        app.cursor_row -= 1;
2088        app.cursor_col = app.buffer[app.cursor_row].chars().count().saturating_sub(1);
2089    }
2090    ensure_cursor_visible(app);
2091}
2092
2093fn move_right(app: &mut App) {
2094    let len = app.buffer[app.cursor_row].chars().count();
2095    if app.cursor_col + 1 < len {
2096        app.cursor_col += 1;
2097    } else if app.cursor_row + 1 < app.buffer.len() {
2098        app.cursor_row += 1;
2099        app.cursor_col = 0;
2100    } else {
2101        app.cursor_col = len;
2102    }
2103    ensure_cursor_visible(app);
2104}
2105
2106fn move_up(app: &mut App) {
2107    if app.cursor_row > 0 {
2108        app.cursor_row -= 1;
2109        app.cursor_col = app.cursor_col.min(app.buffer[app.cursor_row].chars().count());
2110        ensure_cursor_visible(app);
2111    }
2112}
2113
2114fn move_down(app: &mut App) {
2115    if app.cursor_row + 1 < app.buffer.len() {
2116        app.cursor_row += 1;
2117        app.cursor_col = app.cursor_col.min(app.buffer[app.cursor_row].chars().count());
2118        ensure_cursor_visible(app);
2119    }
2120}
2121
2122fn delete_current_line(app: &mut App) {
2123    if app.buffer.len() == 1 {
2124        // Keep one empty line
2125        if !app.buffer[0].is_empty() {
2126            app.buffer[0].clear();
2127            app.cursor_col = 0;
2128            app.dirty = true;
2129        }
2130        return;
2131    }
2132    app.buffer.remove(app.cursor_row);
2133    if app.cursor_row >= app.buffer.len() {
2134        app.cursor_row = app.buffer.len() - 1;
2135    }
2136    app.cursor_col = app.buffer[app.cursor_row].chars().count().min(app.cursor_col);
2137    app.dirty = true;
2138    ensure_cursor_visible(app);
2139}