Skip to main content

redox_tui/
lib.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::path::PathBuf;
4use std::thread;
5use std::time::Duration;
6use std::time::Instant;
7
8use redox_core::{BufferId, EditorSession};
9
10use minui::input::Clipboard;
11use minui::{ColorPair, Event, KeyKind, TerminalWindow, Window, prelude::*};
12use unicode_segmentation::UnicodeSegmentation;
13
14mod app;
15mod input;
16mod ui;
17
18use app::EditorState;
19use input::{InputAction, map_event_with_state};
20
21use crate::ui::helpers::apply_color_column;
22use ui::overlays::{
23    active_delimiter_highlights, active_scope_indent_guides, draw_delimiter_highlights,
24    draw_indent_guides,
25};
26use ui::syntax::{draw_line_with_syntax, syntax_color_for_range};
27use ui::{
28    STATUS_BAR_HEIGHT_CELLS, TextViewport, UiStyle, about_popup_inner_size,
29    build_editor_status_bar, draw_about_popup_view, draw_command_line_popup,
30    draw_explorer_popup_view, explorer_popup_inner_size, language_for_path,
31    snapshot_lines_wrapped_cached,
32};
33
34const GUTTER_CONTENT_PADDING: u16 = 1;
35const COLOR_COLUMN: usize = 79;
36
37enum LaunchTarget {
38    Empty,
39    File(PathBuf),
40    Explorer(PathBuf),
41}
42
43fn draw_buffer_view(
44    state: &mut EditorState,
45    style: UiStyle,
46    window: &mut dyn Window,
47) -> minui::Result<()> {
48    let (vw, vh) = window.get_size();
49    let popup_overlay_active = state.mode == app::EditorMode::Command
50        || state.explorer_popup().is_some()
51        || state.about_popup().is_some();
52    let background_style = if popup_overlay_active {
53        style.dimmed()
54    } else {
55        style
56    };
57    let editor_text = ColorPair::new(background_style.theme.white, background_style.theme.bg);
58    fill_background(window, vw, vh, editor_text)?;
59    let status_h: u16 = STATUS_BAR_HEIGHT_CELLS;
60    let text_h = vh.saturating_sub(status_h);
61    state.pump_active_loading(text_h as usize);
62
63    if let Some(popup) = state.explorer_popup() {
64        if let Some(background_id) = state.explorer_background_buffer_id()
65            && !state.explorer_background_is_placeholder_blank()
66        {
67            draw_buffer_snapshot_for_id(
68                state,
69                background_style,
70                background_id,
71                vw,
72                text_h,
73                editor_text,
74                window,
75            )?;
76        }
77        let (inner_w, inner_h) = explorer_popup_inner_size(vw, vh, style);
78        state.set_viewport_size(
79            inner_w as usize,
80            inner_h.saturating_add(STATUS_BAR_HEIGHT_CELLS) as usize,
81        );
82        draw_explorer_popup_view(state, style, window, popup)?;
83        return Ok(());
84    }
85
86    if let Some(popup) = state.about_popup() {
87        if let Some(background_id) = state.about_background_buffer_id() {
88            draw_buffer_snapshot_for_id(
89                state,
90                background_style,
91                background_id,
92                vw,
93                text_h,
94                editor_text,
95                window,
96            )?;
97        }
98        let (inner_w, inner_h) = about_popup_inner_size(vw, vh, style);
99        state.set_viewport_size(
100            inner_w as usize,
101            inner_h.saturating_add(STATUS_BAR_HEIGHT_CELLS) as usize,
102        );
103        draw_about_popup_view(state, style, window, popup)?;
104        hide_cursor(window);
105        return Ok(());
106    }
107
108    let active_cursor_line = state.active_cursor_pos().line;
109    let total_lines = state.session.active_buffer().len_lines().max(1);
110    let gutter_w = line_number_gutter_width(total_lines);
111    let content_x = gutter_w.saturating_add(GUTTER_CONTENT_PADDING);
112    let text_w = vw.saturating_sub(content_x);
113    state.set_viewport_size(
114        text_w as usize,
115        text_h.saturating_add(STATUS_BAR_HEIGHT_CELLS) as usize,
116    );
117    state.ensure_rain_animation(text_w, text_h, editor_text, background_style);
118
119    if let Some(animation) = state.active_rain_animation() {
120        draw_relative_line_numbers(
121            window,
122            background_style,
123            gutter_w,
124            text_h,
125            animation.first_line(),
126            active_cursor_line,
127            total_lines,
128        )?;
129        draw_gutter_padding(
130            window,
131            background_style,
132            gutter_w,
133            text_h,
134            GUTTER_CONTENT_PADDING,
135        )?;
136        animation.draw(window, 0, content_x, text_w as usize, text_h as usize)?;
137
138        let status = build_editor_status_bar(state, style);
139        status.draw(window)?;
140        if state.mode == app::EditorMode::Command {
141            draw_command_line_popup(state, style, window)?;
142            return Ok(());
143        }
144        hide_cursor(window);
145        return Ok(());
146    }
147
148    let visual_selection = state.active_visual_selection();
149    let syntax_language = language_for_path(state.session.active_meta().path.as_deref());
150    let (snapshot, spec, scroll_x, syntax_spans, delimiter_highlights, active_scope_guides) = state
151        .with_active_buffer_view_mut(|buffer, view| {
152            let (scroll_x, scroll_y) = view.cursor.viewport_scroll();
153            let viewport = TextViewport {
154                scroll_x,
155                scroll_y,
156                width: text_w,
157                height: text_h,
158            };
159            let snapshot =
160                snapshot_lines_wrapped_cached(buffer, &viewport, &mut view.grapheme_cache);
161            let spec = view
162                .cursor
163                .cursor_spec(buffer, text_w as usize, text_h as usize);
164            let syntax_spans = view.syntax_highlighter.visible_line_spans(
165                buffer,
166                syntax_language,
167                snapshot.first_line,
168                snapshot.lines.len(),
169            );
170            let tree_sitter_scope = view.syntax_highlighter.active_scope_pair(
171                buffer,
172                syntax_language,
173                view.cursor.cursor,
174            );
175            let delimiter_highlights = active_delimiter_highlights(
176                buffer,
177                view.cursor.cursor,
178                snapshot.first_line,
179                snapshot.lines.len(),
180            );
181            let active_scope_guides = active_scope_indent_guides(
182                tree_sitter_scope,
183                buffer,
184                view.cursor.cursor,
185                snapshot.first_line,
186                snapshot.lines.len(),
187                scroll_x,
188                text_w as usize,
189            );
190            (
191                snapshot,
192                spec,
193                scroll_x,
194                syntax_spans,
195                delimiter_highlights,
196                active_scope_guides,
197            )
198        });
199
200    draw_relative_line_numbers(
201        window,
202        background_style,
203        gutter_w,
204        text_h,
205        snapshot.first_line,
206        active_cursor_line,
207        total_lines,
208    )?;
209    draw_gutter_padding(
210        window,
211        background_style,
212        gutter_w,
213        text_h,
214        GUTTER_CONTENT_PADDING,
215    )?;
216
217    draw_snapshot_lines(
218        window,
219        state.session.active_buffer(),
220        &snapshot,
221        content_x,
222        scroll_x,
223        text_w as usize,
224        editor_text,
225        background_style,
226        syntax_spans.as_deref(),
227        &delimiter_highlights,
228        &active_scope_guides,
229        visual_selection,
230    )?;
231
232    // --- Status bar (bottom row) ---
233    let status = build_editor_status_bar(state, style);
234
235    status.draw(window)?;
236
237    if state.mode == app::EditorMode::Command {
238        draw_command_line_popup(state, style, window)?;
239    } else if spec.visible {
240        window.request_cursor(minui::window::CursorSpec {
241            x: spec.x.saturating_add(content_x),
242            y: spec.y,
243            visible: true,
244        });
245    }
246
247    Ok(())
248}
249
250fn draw_gutter_padding(
251    window: &mut dyn Window,
252    style: UiStyle,
253    gutter_w: u16,
254    text_h: u16,
255    padding_w: u16,
256) -> minui::Result<()> {
257    if padding_w == 0 || text_h == 0 {
258        return Ok(());
259    }
260
261    let pad = " ".repeat(padding_w as usize);
262    let color = ColorPair::new(style.theme.bg, style.theme.bg);
263    for row in 0..text_h {
264        window.write_str_colored(row, gutter_w, &pad, color)?;
265    }
266    Ok(())
267}
268
269fn line_number_gutter_width(total_lines: usize) -> u16 {
270    let digits = total_lines.max(1).ilog10() as u16 + 1;
271    // digits + separator column
272    digits.saturating_add(1)
273}
274
275fn draw_relative_line_numbers(
276    window: &mut dyn Window,
277    style: UiStyle,
278    gutter_w: u16,
279    text_h: u16,
280    first_line: usize,
281    cursor_line: usize,
282    total_lines: usize,
283) -> minui::Result<()> {
284    if gutter_w == 0 || text_h == 0 {
285        return Ok(());
286    }
287
288    let sep_x = gutter_w.saturating_sub(1);
289    let number_w = gutter_w.saturating_sub(1) as usize;
290    let relative_color = ColorPair::new(style.theme.dark_gray, style.theme.bg);
291    let current_color = ColorPair::new(style.theme.white, style.theme.bg);
292
293    for row in 0..text_h {
294        let line_idx = first_line.saturating_add(row as usize);
295        if line_idx >= total_lines {
296            continue;
297        }
298
299        let num = if line_idx == cursor_line {
300            (line_idx + 1).to_string()
301        } else {
302            line_idx.abs_diff(cursor_line).to_string()
303        };
304
305        let clipped_num = if num.chars().count() > number_w {
306            num.chars()
307                .rev()
308                .take(number_w)
309                .collect::<String>()
310                .chars()
311                .rev()
312                .collect::<String>()
313        } else {
314            num
315        };
316
317        let text = format!("{clipped_num:>number_w$}");
318
319        let color = if line_idx == cursor_line {
320            current_color
321        } else {
322            relative_color
323        };
324
325        if number_w > 0 {
326            window.write_str_colored(row, 0, &text, color)?;
327        }
328
329        window.write_str_colored(row, sep_x, "▕", color)?;
330    }
331
332    Ok(())
333}
334
335fn draw_line_with_selection(
336    window: &mut dyn Window,
337    row: u16,
338    col: u16,
339    source_line: &str,
340    scroll_x: usize,
341    width_cells: usize,
342    sel_start_char: usize,
343    sel_end_char_exclusive: usize,
344    normal_color: ColorPair,
345    selection_bg: Color,
346    color_column: Option<(usize, Color)>,
347    style: UiStyle,
348    syntax_spans: Option<&[ui::syntax::LineSyntaxSpan]>,
349    highlight_empty_line: bool,
350) -> minui::Result<()> {
351    if width_cells == 0 {
352        return Ok(());
353    }
354
355    if source_line.is_empty() {
356        if highlight_empty_line {
357            window.write_str_colored(
358                row,
359                col,
360                " ",
361                ColorPair::new(normal_color.fg, selection_bg),
362            )?;
363            if let Some((visible_col, bg)) = color_column
364                && visible_col < width_cells
365                && visible_col != 0
366            {
367                window.write_str_colored(
368                    row,
369                    col.saturating_add(visible_col as u16),
370                    " ",
371                    ColorPair::new(normal_color.fg, bg),
372                )?;
373            }
374        } else if let Some((visible_col, bg)) = color_column
375            && visible_col < width_cells
376        {
377            window.write_str_colored(
378                row,
379                col.saturating_add(visible_col as u16),
380                " ",
381                ColorPair::new(normal_color.fg, bg),
382            )?;
383        }
384        return Ok(());
385    }
386
387    let mut used_cells = 0usize;
388    let mut line_cells = 0usize;
389    let mut char_idx = 0usize;
390    let mut byte_idx = 0usize;
391
392    for g in source_line.graphemes(true) {
393        let g_width = minui::cell_width(g, minui::prelude::TabPolicy::Fixed(4)) as usize;
394        let g_chars = g.chars().count();
395        let g_bytes = g.len();
396        let start_cell = line_cells;
397        let end_cell = line_cells.saturating_add(g_width);
398        let start_char = char_idx;
399        let end_char = char_idx.saturating_add(g_chars);
400        let start_byte = byte_idx;
401        let end_byte = byte_idx.saturating_add(g_bytes);
402
403        line_cells = end_cell;
404        char_idx = end_char;
405        byte_idx = end_byte;
406
407        if end_cell <= scroll_x {
408            continue;
409        }
410        if start_cell < scroll_x {
411            continue;
412        }
413
414        if used_cells.saturating_add(g_width) > width_cells {
415            break;
416        }
417
418        let is_selected = start_char < sel_end_char_exclusive && end_char > sel_start_char;
419        let base_color = syntax_spans
420            .map(|spans| syntax_color_for_range(normal_color, style, spans, start_byte, end_byte))
421            .unwrap_or(normal_color);
422        let color = if is_selected {
423            ColorPair::new(base_color.fg, selection_bg)
424        } else {
425            apply_color_column(base_color, color_column, start_cell, end_cell)
426        };
427
428        if g == "\t" {
429            let spaces = " ".repeat(g_width.max(1));
430            window.write_str_colored(row, col.saturating_add(used_cells as u16), &spaces, color)?;
431        } else {
432            window.write_str_colored(row, col.saturating_add(used_cells as u16), g, color)?;
433        }
434        used_cells = used_cells.saturating_add(g_width);
435    }
436
437    if let Some((visible_col, bg)) = color_column
438        && visible_col < width_cells
439        && visible_col >= used_cells
440    {
441        window.write_str_colored(
442            row,
443            col.saturating_add(visible_col as u16),
444            " ",
445            ColorPair::new(normal_color.fg, bg),
446        )?;
447    }
448
449    Ok(())
450}
451
452fn fill_background(
453    window: &mut dyn Window,
454    width: u16,
455    height: u16,
456    colors: ColorPair,
457) -> minui::Result<()> {
458    if width == 0 || height == 0 {
459        return Ok(());
460    }
461
462    let row = " ".repeat(width as usize);
463    for y in 0..height {
464        window.write_str_colored(y, 0, &row, colors)?;
465    }
466    Ok(())
467}
468
469fn hide_cursor(window: &mut dyn Window) {
470    window.request_cursor(minui::window::CursorSpec {
471        x: 0,
472        y: 0,
473        visible: false,
474    });
475}
476
477fn draw_buffer_snapshot_for_id(
478    state: &mut EditorState,
479    style: UiStyle,
480    buffer_id: BufferId,
481    width: u16,
482    height: u16,
483    colors: ColorPair,
484    window: &mut dyn Window,
485) -> minui::Result<()> {
486    let syntax_language = state
487        .session
488        .meta(buffer_id)
489        .and_then(|meta| language_for_path(meta.path.as_deref()));
490    let Some((
491        snapshot,
492        cursor_line,
493        total_lines,
494        scroll_x,
495        syntax_spans,
496        delimiter_highlights,
497        active_scope_guides,
498    )) = state.with_buffer_view_mut(buffer_id, |buffer, view| {
499        let total_lines = buffer.len_lines().max(1);
500        let gutter_w = line_number_gutter_width(total_lines);
501        let content_x = gutter_w.saturating_add(GUTTER_CONTENT_PADDING);
502        let text_w = width.saturating_sub(content_x);
503        let (scroll_x, scroll_y) = view.cursor.viewport_scroll();
504        let viewport = TextViewport {
505            scroll_x,
506            scroll_y,
507            width: text_w,
508            height,
509        };
510        let snapshot = snapshot_lines_wrapped_cached(buffer, &viewport, &mut view.grapheme_cache);
511        let syntax_spans = view.syntax_highlighter.visible_line_spans(
512            buffer,
513            syntax_language,
514            snapshot.first_line,
515            snapshot.lines.len(),
516        );
517        let tree_sitter_scope =
518            view.syntax_highlighter
519                .active_scope_pair(buffer, syntax_language, view.cursor.cursor);
520        let delimiter_highlights = active_delimiter_highlights(
521            buffer,
522            view.cursor.cursor,
523            snapshot.first_line,
524            snapshot.lines.len(),
525        );
526        let active_scope_guides = active_scope_indent_guides(
527            tree_sitter_scope,
528            buffer,
529            view.cursor.cursor,
530            snapshot.first_line,
531            snapshot.lines.len(),
532            scroll_x,
533            width.saturating_sub(content_x) as usize,
534        );
535        (
536            snapshot,
537            view.cursor.cursor.line,
538            total_lines,
539            scroll_x,
540            syntax_spans,
541            delimiter_highlights,
542            active_scope_guides,
543        )
544    })
545    else {
546        return Ok(());
547    };
548
549    let gutter_w = line_number_gutter_width(total_lines);
550    let content_x = gutter_w.saturating_add(GUTTER_CONTENT_PADDING);
551    draw_relative_line_numbers(
552        window,
553        style,
554        gutter_w,
555        height,
556        snapshot.first_line,
557        cursor_line,
558        total_lines,
559    )?;
560    draw_gutter_padding(window, style, gutter_w, height, GUTTER_CONTENT_PADDING)?;
561
562    let buffer = state
563        .session
564        .buffer(buffer_id)
565        .expect("snapshot buffer must exist in session map");
566    draw_snapshot_lines(
567        window,
568        buffer,
569        &snapshot,
570        content_x,
571        scroll_x,
572        width.saturating_sub(content_x) as usize,
573        colors,
574        style,
575        syntax_spans.as_deref(),
576        &delimiter_highlights,
577        &active_scope_guides,
578        None,
579    )?;
580
581    Ok(())
582}
583
584fn draw_snapshot_lines(
585    window: &mut dyn Window,
586    buffer: &redox_core::TextBuffer,
587    snapshot: &ui::render::RenderSnapshot,
588    content_x: u16,
589    scroll_x: usize,
590    text_w: usize,
591    default_colors: ColorPair,
592    style: UiStyle,
593    syntax_spans: Option<&[Vec<ui::syntax::LineSyntaxSpan>]>,
594    delimiter_highlights: &BTreeMap<usize, Vec<usize>>,
595    active_scope_guides: &BTreeMap<usize, Vec<usize>>,
596    visual_selection: Option<(redox_core::Selection, bool)>,
597) -> minui::Result<()> {
598    let color_column = visible_color_column(scroll_x, text_w, style.theme.color_column);
599    for (row, line) in snapshot.lines.iter().enumerate() {
600        let line_idx = snapshot.first_line + row;
601        let highlighted_chars = delimiter_highlights
602            .get(&line_idx)
603            .map(Vec::as_slice)
604            .unwrap_or(&[]);
605        let visible_indent_guides = active_scope_guides
606            .get(&line_idx)
607            .map(Vec::as_slice)
608            .unwrap_or(&[]);
609        let selected_line_bg = visual_selection
610            .filter(|(selection, line_mode)| {
611                buffer
612                    .visual_selection_char_range_on_line(*selection, *line_mode, line_idx)
613                    .is_some()
614                    || (buffer.line_len_chars(line_idx) == 0
615                        && selected_empty_line(*selection, line_idx))
616            })
617            .map(|_| style.theme.selection_bg);
618        if let Some((selection, line_mode)) = visual_selection {
619            let highlight_empty_line =
620                buffer.line_len_chars(line_idx) == 0 && selected_empty_line(selection, line_idx);
621            if let Some(sel_range) =
622                buffer.visual_selection_char_range_on_line(selection, line_mode, line_idx)
623            {
624                let source_line = buffer.line_string(line_idx);
625                draw_line_with_selection(
626                    window,
627                    row as u16,
628                    content_x,
629                    &source_line,
630                    scroll_x,
631                    text_w,
632                    sel_range.start,
633                    sel_range.end,
634                    default_colors,
635                    style.theme.selection_bg,
636                    color_column,
637                    style,
638                    syntax_spans.and_then(|rows| rows.get(row).map(Vec::as_slice)),
639                    highlight_empty_line,
640                )?;
641                let source_line = buffer.line_string(line_idx);
642                draw_indent_guides(
643                    window,
644                    row as u16,
645                    content_x,
646                    visible_indent_guides,
647                    style,
648                    selected_line_bg,
649                )?;
650                draw_delimiter_highlights(
651                    window,
652                    row as u16,
653                    content_x,
654                    &source_line,
655                    scroll_x,
656                    text_w,
657                    highlighted_chars,
658                    style,
659                )?;
660                continue;
661            }
662            if highlight_empty_line {
663                draw_line_with_selection(
664                    window,
665                    row as u16,
666                    content_x,
667                    "",
668                    scroll_x,
669                    text_w,
670                    0,
671                    0,
672                    default_colors,
673                    style.theme.selection_bg,
674                    color_column,
675                    style,
676                    syntax_spans.and_then(|rows| rows.get(row).map(Vec::as_slice)),
677                    true,
678                )?;
679                continue;
680            }
681        }
682
683        if let Some(spans) = syntax_spans.and_then(|rows| rows.get(row))
684            && !spans.is_empty()
685        {
686            let source_line = buffer.line_string(line_idx);
687            draw_line_with_syntax(
688                window,
689                row as u16,
690                content_x,
691                &source_line,
692                scroll_x,
693                text_w,
694                default_colors,
695                color_column,
696                style,
697                spans,
698            )?;
699            let source_line = buffer.line_string(line_idx);
700            draw_indent_guides(
701                window,
702                row as u16,
703                content_x,
704                visible_indent_guides,
705                style,
706                selected_line_bg,
707            )?;
708            draw_delimiter_highlights(
709                window,
710                row as u16,
711                content_x,
712                &source_line,
713                scroll_x,
714                text_w,
715                highlighted_chars,
716                style,
717            )?;
718            continue;
719        }
720
721        draw_plain_line(
722            window,
723            row as u16,
724            content_x,
725            line,
726            scroll_x,
727            text_w,
728            default_colors,
729            color_column,
730        )?;
731        let source_line = buffer.line_string(line_idx);
732        draw_indent_guides(
733            window,
734            row as u16,
735            content_x,
736            visible_indent_guides,
737            style,
738            selected_line_bg,
739        )?;
740        draw_delimiter_highlights(
741            window,
742            row as u16,
743            content_x,
744            &source_line,
745            scroll_x,
746            text_w,
747            highlighted_chars,
748            style,
749        )?;
750    }
751
752    Ok(())
753}
754
755fn selected_empty_line(selection: redox_core::Selection, line_idx: usize) -> bool {
756    let (start, end) = selection.ordered();
757    line_idx >= start.line && line_idx <= end.line
758}
759
760fn draw_plain_line(
761    window: &mut dyn Window,
762    row: u16,
763    col: u16,
764    source_line: &str,
765    scroll_x: usize,
766    width_cells: usize,
767    default_colors: ColorPair,
768    color_column: Option<(usize, Color)>,
769) -> minui::Result<()> {
770    if width_cells == 0 {
771        return Ok(());
772    }
773
774    let mut used_cells = 0usize;
775    let mut line_cells = 0usize;
776
777    for g in source_line.graphemes(true) {
778        let g_width = minui::cell_width(g, minui::prelude::TabPolicy::Fixed(4)) as usize;
779        let start_cell = line_cells;
780        let end_cell = line_cells.saturating_add(g_width);
781        line_cells = end_cell;
782
783        if end_cell <= scroll_x {
784            continue;
785        }
786        if start_cell < scroll_x {
787            continue;
788        }
789        if used_cells.saturating_add(g_width) > width_cells {
790            break;
791        }
792
793        let colors = apply_color_column(default_colors, color_column, start_cell, end_cell);
794        if g == "\t" {
795            let spaces = " ".repeat(g_width.max(1));
796            window.write_str_colored(
797                row,
798                col.saturating_add(used_cells as u16),
799                &spaces,
800                colors,
801            )?;
802        } else {
803            window.write_str_colored(row, col.saturating_add(used_cells as u16), g, colors)?;
804        }
805        used_cells = used_cells.saturating_add(g_width);
806    }
807
808    if let Some((visible_col, bg)) = color_column
809        && visible_col < width_cells
810        && visible_col >= used_cells
811    {
812        window.write_str_colored(
813            row,
814            col.saturating_add(visible_col as u16),
815            " ",
816            ColorPair::new(default_colors.fg, bg),
817        )?;
818    }
819
820    Ok(())
821}
822
823fn visible_color_column(scroll_x: usize, text_w: usize, bg: Color) -> Option<(usize, Color)> {
824    if COLOR_COLUMN < scroll_x {
825        return None;
826    }
827    let visible_col = COLOR_COLUMN - scroll_x;
828    (visible_col < text_w).then_some((visible_col, bg))
829}
830
831fn parse_path_arg() -> anyhow::Result<LaunchTarget> {
832    let mut args = env::args().skip(1);
833    let Some(raw) = args.next() else {
834        return Ok(LaunchTarget::Empty);
835    };
836    let path = PathBuf::from(&raw);
837    if path.is_dir() {
838        return Ok(LaunchTarget::Explorer(path));
839    }
840    Ok(LaunchTarget::File(path))
841}
842
843fn is_cancel_event(event: &Event) -> bool {
844    matches!(event, Event::Escape)
845        || matches!(
846            event,
847            Event::KeyWithModifiers(key)
848                if matches!(key.key, KeyKind::Escape)
849                    && !key.mods.ctrl
850                    && !key.mods.alt
851                    && !key.mods.super_key
852        )
853        || matches!(
854            event,
855            Event::KeyWithModifiers(key)
856                if key.mods.ctrl
857                    && !key.mods.alt
858                    && !key.mods.super_key
859                    && matches!(key.key, KeyKind::Char('c') | KeyKind::Char('C'))
860        )
861}
862
863fn handle_editor_event(
864    state: &mut EditorState,
865    clipboard: &mut Option<Clipboard>,
866    event: Event,
867) -> bool {
868    if state.rain_is_active() {
869        if is_cancel_event(&event) {
870            state.stop_rain_animation();
871        }
872        return !state.should_quit;
873    }
874
875    if is_cancel_event(&event) && state.handle_normal_mode_escape_on_surface() {
876        return !state.should_quit;
877    }
878
879    let action = match &event {
880        Event::Paste(text) => InputAction::Paste(text.clone()),
881        _ => map_event_with_state(&mut state.input, state.mode.as_input_mode(), &event),
882    };
883
884    let (w, h) = state.viewport_size();
885    state.apply_input(action, w, h);
886    if let Some(text) = state.take_pending_system_clipboard() {
887        match clipboard.as_mut() {
888            Some(system_clipboard) => {
889                if let Err(e) = system_clipboard.copy(&text) {
890                    state.set_status(format!("clipboard copy failed: {e}"));
891                } else {
892                    state.set_status("yanked to system clipboard");
893                }
894            }
895            None => {
896                state.set_status("system clipboard unavailable");
897            }
898        }
899    }
900
901    !state.should_quit
902}
903
904pub fn run() -> minui::Result<()> {
905    let launch = parse_path_arg().expect("failed to parse launch target");
906    let launch_empty = matches!(&launch, LaunchTarget::Empty);
907    let launch_explorer_dir = match &launch {
908        LaunchTarget::Explorer(dir) => Some(dir.clone()),
909        LaunchTarget::Empty | LaunchTarget::File(_) => None,
910    };
911    let session = match launch {
912        LaunchTarget::Empty => {
913            EditorSession::open_initial_unnamed().expect("failed to open unnamed session")
914        }
915        LaunchTarget::File(path) => {
916            EditorSession::open_initial_file(path).expect("failed to open initial file")
917        }
918        LaunchTarget::Explorer(_) => {
919            EditorSession::open_initial_unnamed().expect("failed to open unnamed session")
920        }
921    };
922
923    let mut state = EditorState::new(session);
924    if let Some(dir_path) = launch_explorer_dir {
925        state
926            .open_explorer_at_path(dir_path)
927            .expect("failed to open explorer directory");
928    }
929    if launch_empty {
930        state.command_open_about();
931    }
932
933    let mut window = TerminalWindow::new()?;
934    window.set_auto_flush(false);
935    let mut clipboard = Clipboard::new().ok();
936    let style = UiStyle::default();
937
938    const MAX_EVENTS_PER_FRAME: usize = 256;
939    const ACTIVE_FRAME_BUDGET: Duration = Duration::from_millis(16);
940    const IDLE_FRAME_BUDGET: Duration = Duration::from_millis(20);
941
942    loop {
943        let frame_start = Instant::now();
944
945        for _ in 0..MAX_EVENTS_PER_FRAME {
946            match window.poll_input()? {
947                Some(event) => {
948                    if !handle_editor_event(&mut state, &mut clipboard, event) {
949                        return Ok(());
950                    }
951                }
952                None => break,
953            }
954        }
955
956        if state.rain_is_active() {
957            state.advance_rain_animation();
958        }
959
960        window.clear_cursor_request();
961        let (w, h) = window.get_size();
962        state.set_viewport_size(w as usize, h as usize);
963        window.clear_screen()?;
964        draw_buffer_view(&mut state, style, &mut window)?;
965        window.end_frame()?;
966
967        if state.should_quit {
968            return Ok(());
969        }
970
971        let frame_budget = if state.rain_is_active() {
972            ACTIVE_FRAME_BUDGET
973        } else {
974            IDLE_FRAME_BUDGET
975        };
976        let remaining = frame_budget.saturating_sub(frame_start.elapsed());
977        if !remaining.is_zero() {
978            thread::sleep(remaining);
979        }
980    }
981}