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 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.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}