1use std::collections::HashMap;
2use std::io::{self, Write};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::time::Duration;
6
7use crossterm::cursor::MoveTo;
8use crossterm::event::{poll, read, Event, KeyCode, KeyEvent, KeyModifiers};
9use crossterm::style::{Print, ResetColor, SetAttribute, SetForegroundColor, SetBackgroundColor, Attribute};
10use crossterm::terminal::{Clear, ClearType, size};
11use crossterm::QueueableCommand;
12
13use crate::error::Result;
14use crate::input::{translate, Command};
15use crate::marks::{mark_set, mark_jump, jump_previous, update_prev_position, is_valid_mark_name, MarkTarget};
16use crate::line_index::LineIndex;
17use crate::prettify::PrettifyMode;
18use crate::render::Cell;
19use crate::source::{find_tail_offset, Source};
20use crate::viewport::{Frame, RowStyle, SearchDirection, Viewport};
21
22#[derive(Default, Clone, Copy)]
26pub struct RebuildSpec {
27 pub head: Option<usize>,
28 pub tail: Option<usize>,
29}
30
31#[derive(Debug, Clone)]
33enum InputMode {
34 Normal,
35 OptionPrefix,
37 PrettifyPrefix,
40 SearchPrompt {
43 direction: SearchDirection,
44 buffer: String,
45 error: Option<String>,
48 },
49 ShellPrompt { buffer: String, error: Option<String> },
52 MarkSetPending,
54 MarkJumpPending,
56 CtrlXPending,
58 ColonPrompt { buffer: String, error: Option<String> },
61 TagPrompt {
68 buffer: String,
69 error: Option<String>,
70 last_tab_matches: Option<Vec<String>>,
71 },
72}
73
74#[derive(Debug, Clone, PartialEq)]
75enum ColonCommand {
76 Next,
77 Prev,
78 Edit(std::path::PathBuf),
79 ShowFile,
80 Quit,
81 Delete,
82 First,
83 Last,
84 Tag(String),
85 TagNext,
86 TagPrev,
87 TagSelect(Option<String>),
90 OpenPicker,
91 OpenHelp,
92 HexGroup(usize),
94 Color(Option<crate::render::AnsiMode>),
96 Case(Option<crate::viewport::CaseMode>),
99 HlSearch(bool),
102 Header(usize, usize),
104}
105
106#[derive(Debug, Clone, PartialEq)]
107enum ColonParseError {
108 UnknownCommand(String),
109 MissingPath,
110 TagRequiresName,
111 HexGroupRequiresValue,
112 HexGroupInvalid(String),
113 ColorInvalid(String),
114 CaseInvalid(String),
115 HeaderInvalid(String),
116}
117
118impl std::fmt::Display for ColonParseError {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 match self {
121 ColonParseError::UnknownCommand(t) => write!(f, "unknown command: :{t}"),
122 ColonParseError::MissingPath => write!(f, ":e requires a path"),
123 ColonParseError::TagRequiresName => write!(f, ":tag requires a name"),
124 ColonParseError::HexGroupRequiresValue => {
125 write!(f, ":hex requires N (one of 2, 4, 8, 16, 32)")
126 }
127 ColonParseError::HexGroupInvalid(v) => {
128 write!(f, ":hex N must be one of 2, 4, 8, 16, 32 (got {v})")
129 }
130 ColonParseError::ColorInvalid(v) => {
131 write!(f, ":color mode must be strict, interpret, or raw (got {v})")
132 }
133 ColonParseError::CaseInvalid(v) => {
134 write!(f, ":case mode must be sensitive, smart, or insensitive (got {v})")
135 }
136 ColonParseError::HeaderInvalid(v) => {
137 write!(f, ":header expects `L` or `L C` (got {v})")
138 }
139 }
140 }
141}
142
143fn parse_colon_command(buf: &str) -> std::result::Result<ColonCommand, ColonParseError> {
144 let buf = buf.trim();
145 if buf.is_empty() {
146 return Err(ColonParseError::UnknownCommand(String::new()));
147 }
148 let mut parts = buf.splitn(2, char::is_whitespace);
149 let cmd = parts.next().unwrap();
150 let rest = parts.next().unwrap_or("").trim();
151 match cmd {
152 "n" | "next" => Ok(ColonCommand::Next),
153 "p" | "prev" => Ok(ColonCommand::Prev),
154 "e" | "edit" => {
155 if rest.is_empty() {
156 Err(ColonParseError::MissingPath)
157 } else {
158 let expanded = if let Some(stripped) = rest.strip_prefix("~/") {
160 if let Some(home) = std::env::var_os("HOME") {
161 let mut p = std::path::PathBuf::from(home);
162 p.push(stripped);
163 p
164 } else {
165 std::path::PathBuf::from(rest)
166 }
167 } else {
168 std::path::PathBuf::from(rest)
169 };
170 Ok(ColonCommand::Edit(expanded))
171 }
172 }
173 "f" => Ok(ColonCommand::ShowFile),
174 "q" | "quit" => Ok(ColonCommand::Quit),
175 "d" | "delete" => Ok(ColonCommand::Delete),
176 "x" | "first" => Ok(ColonCommand::First),
177 "t" | "last" => Ok(ColonCommand::Last),
178 "tag" => {
179 if rest.is_empty() {
180 Err(ColonParseError::TagRequiresName)
181 } else {
182 Ok(ColonCommand::Tag(rest.to_string()))
183 }
184 }
185 "tnext" => Ok(ColonCommand::TagNext),
186 "tprev" => Ok(ColonCommand::TagPrev),
187 "tselect" => {
188 if rest.is_empty() {
189 Ok(ColonCommand::TagSelect(None))
190 } else {
191 Ok(ColonCommand::TagSelect(Some(rest.to_string())))
192 }
193 }
194 "b" | "buffers" => Ok(ColonCommand::OpenPicker),
195 "h" | "help" => Ok(ColonCommand::OpenHelp),
196 "hex" => {
197 if rest.is_empty() {
198 Err(ColonParseError::HexGroupRequiresValue)
199 } else {
200 match rest.parse::<usize>() {
201 Ok(n) if matches!(n, 2 | 4 | 8 | 16 | 32) => Ok(ColonCommand::HexGroup(n)),
202 _ => Err(ColonParseError::HexGroupInvalid(rest.to_string())),
203 }
204 }
205 }
206 "color" => {
207 if rest.is_empty() {
208 Ok(ColonCommand::Color(None))
209 } else {
210 match rest {
211 "strict" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Strict))),
212 "interpret" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Interpret))),
213 "raw" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Raw))),
214 other => Err(ColonParseError::ColorInvalid(other.to_string())),
215 }
216 }
217 }
218 "hlsearch" => Ok(ColonCommand::HlSearch(true)),
219 "nohlsearch" => Ok(ColonCommand::HlSearch(false)),
220 "header" => {
221 let parts: Vec<&str> = rest.split_whitespace().collect();
222 match parts.as_slice() {
223 [l] => {
224 let n: usize = l.parse()
225 .map_err(|_| ColonParseError::HeaderInvalid(l.to_string()))?;
226 Ok(ColonCommand::Header(n, 0))
227 }
228 [l, c] => {
229 let nl: usize = l.parse()
230 .map_err(|_| ColonParseError::HeaderInvalid(l.to_string()))?;
231 let nc: usize = c.parse()
232 .map_err(|_| ColonParseError::HeaderInvalid(c.to_string()))?;
233 Ok(ColonCommand::Header(nl, nc))
234 }
235 _ => Err(ColonParseError::HeaderInvalid(rest.to_string())),
236 }
237 }
238 "case" => {
239 if rest.is_empty() {
240 Ok(ColonCommand::Case(None))
241 } else {
242 match rest {
243 "sensitive" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Sensitive))),
244 "smart" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Smart))),
245 "insensitive" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Insensitive))),
246 other => Err(ColonParseError::CaseInvalid(other.to_string())),
247 }
248 }
249 }
250 other => Err(ColonParseError::UnknownCommand(other.to_string())),
251 }
252}
253
254enum ColonOutcome {
255 Continue(Option<String>), Quit,
257 DispatchCommand(Command),
261}
262
263#[derive(Debug, Default)]
264struct TagStack {
265 history: Vec<(usize, usize)>,
268 active: Option<ActiveMatches>,
271}
272
273#[derive(Debug, Clone)]
274struct ActiveMatches {
275 name: String,
276 matches: Vec<crate::tags::TagEntry>,
277 cursor: usize,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
281enum TagStepResult {
282 Moved(usize),
284 AtBoundary,
286 NoActive,
288}
289
290impl TagStack {
291 fn push(&mut self, file_index: usize, top_line: usize) {
292 self.history.push((file_index, top_line));
293 }
294
295 fn pop(&mut self) -> Option<(usize, usize)> {
296 let popped = self.history.pop();
297 if popped.is_some() {
298 self.active = None;
299 }
300 popped
301 }
302
303 fn set_active(&mut self, name: String, matches: Vec<crate::tags::TagEntry>) {
304 self.active = Some(ActiveMatches {
305 name,
306 matches,
307 cursor: 0,
308 });
309 }
310
311 fn next(&mut self) -> TagStepResult {
312 let Some(a) = &mut self.active else {
313 return TagStepResult::NoActive;
314 };
315 if a.cursor + 1 >= a.matches.len() {
316 TagStepResult::AtBoundary
317 } else {
318 a.cursor += 1;
319 TagStepResult::Moved(a.cursor)
320 }
321 }
322
323 fn prev(&mut self) -> TagStepResult {
324 let Some(a) = &mut self.active else {
325 return TagStepResult::NoActive;
326 };
327 if a.cursor == 0 {
328 TagStepResult::AtBoundary
329 } else {
330 a.cursor -= 1;
331 TagStepResult::Moved(a.cursor)
332 }
333 }
334}
335
336fn refresh_tag_file(tag_file: &mut Option<crate::tags::TagFile>) -> Option<String> {
342 match tag_file.as_mut()?.reload_if_changed() {
343 Ok(true) => Some("[tags reloaded]".into()),
344 _ => None,
345 }
346}
347
348fn longest_common_prefix(items: &[String]) -> String {
352 let mut iter = items.iter();
353 let Some(first) = iter.next() else { return String::new() };
354 let mut prefix = first.clone();
355 for s in iter {
356 while !s.starts_with(&prefix) {
357 prefix.pop();
358 if prefix.is_empty() {
359 return prefix;
360 }
361 }
362 }
363 prefix
364}
365
366#[allow(clippy::too_many_arguments)]
369fn dispatch_tag_jump(
370 name: &str,
371 tag_file: Option<&crate::tags::TagFile>,
372 tag_stack: &mut TagStack,
373 file_set: &mut crate::file_set::FileSet,
374 current_file_index: &mut usize,
375 args: &crate::cli::Args,
376 preprocessor: Option<&crate::preprocess::Preprocessor>,
377 record_start_regex: Option<®ex::bytes::Regex>,
378 viewport: &mut crate::viewport::Viewport,
379 src: &mut Box<dyn crate::source::Source>,
380 idx: &mut crate::line_index::LineIndex,
381) -> Option<String> {
382 let Some(tf) = tag_file else {
383 return Some("[no tags file loaded]".into());
384 };
385 let matches = tf.lookup(name);
386 if matches.is_empty() {
387 return Some(format!("[tag not found: {name}]"));
388 }
389 let matches: Vec<crate::tags::TagEntry> = matches.to_vec();
390 tag_stack.push(*current_file_index, viewport.top_line());
391 tag_stack.set_active(name.to_string(), matches.clone());
392 let msg = dispatch_match(
393 &matches[0],
394 file_set,
395 current_file_index,
396 args,
397 preprocessor,
398 record_start_regex,
399 viewport,
400 src,
401 idx,
402 );
403 update_viewport_tag_indicator(tag_stack, viewport);
404 msg
405}
406
407#[allow(clippy::too_many_arguments)]
408fn dispatch_match(
409 entry: &crate::tags::TagEntry,
410 file_set: &mut crate::file_set::FileSet,
411 current_file_index: &mut usize,
412 args: &crate::cli::Args,
413 preprocessor: Option<&crate::preprocess::Preprocessor>,
414 record_start_regex: Option<®ex::bytes::Regex>,
415 viewport: &mut crate::viewport::Viewport,
416 src: &mut Box<dyn crate::source::Source>,
417 idx: &mut crate::line_index::LineIndex,
418) -> Option<String> {
419 let target_file = entry.file.as_path();
420 let already_current = file_set
421 .current()
422 .map(|p| p == target_file)
423 .unwrap_or(false);
424
425 if !already_current {
426 let existing_idx = (0..file_set.len()).find(|i| {
427 file_set
428 .nth(*i)
429 .map(|p| p == target_file)
430 .unwrap_or(false)
431 });
432 match existing_idx {
433 Some(i) => {
434 file_set.set_current_index(i);
435 }
436 None => {
437 file_set.append_and_switch(target_file.to_path_buf());
438 }
439 }
440 let path = file_set.current().unwrap().to_path_buf();
441 if let Err(e) = switch_file(
442 &path,
443 file_set.current_index(),
444 file_set.len(),
445 args,
446 preprocessor,
447 viewport,
448 src,
449 idx,
450 record_start_regex,
451 ) {
452 return Some(format!("[open: {e}]"));
453 }
454 *current_file_index = file_set.current_index();
455 }
456
457 let (line, hint) = match resolve_tag_address(&entry.address, src.as_ref(), idx, 0) {
458 AddressResult::Line(l) => (l, None),
459 AddressResult::NotFound => (0, Some("[tag pattern not found]".into())),
460 AddressResult::Unsupported(raw) => (
461 0,
462 Some(format!("[tag address not supported: {raw}]")),
463 ),
464 };
465
466 let clamped = line.min(idx.line_count().saturating_sub(1));
467 viewport.goto_line(clamped, src.as_ref(), idx);
468 hint
469}
470
471enum AddressResult {
472 Line(usize),
473 NotFound,
474 Unsupported(String),
475}
476
477fn resolve_tag_address(
481 addr: &crate::tags::TagAddress,
482 src: &dyn crate::source::Source,
483 idx: &mut crate::line_index::LineIndex,
484 from_line: usize,
485) -> AddressResult {
486 match addr {
487 crate::tags::TagAddress::Line(n) => AddressResult::Line(n.saturating_sub(1)),
488 crate::tags::TagAddress::Pattern(p) => {
489 let re_src = crate::tags::pattern_to_regex(p);
490 let re = match regex::bytes::Regex::new(&re_src) {
491 Ok(r) => r,
492 Err(_) => return AddressResult::NotFound,
493 };
494 match find_pattern_line(src, idx, &re, from_line) {
495 Some(l) => AddressResult::Line(l),
496 None => AddressResult::NotFound,
497 }
498 }
499 crate::tags::TagAddress::Chained(parts) => {
500 let mut here = from_line;
501 for step in parts {
502 match resolve_tag_address(step, src, idx, here) {
503 AddressResult::Line(l) => here = l + 1,
504 other => return other,
505 }
506 }
507 AddressResult::Line(here.saturating_sub(1).max(0))
510 }
511 crate::tags::TagAddress::Unsupported(raw) => {
512 AddressResult::Unsupported(raw.clone())
513 }
514 }
515}
516
517fn find_pattern_line(
518 src: &dyn crate::source::Source,
519 idx: &mut crate::line_index::LineIndex,
520 re: ®ex::bytes::Regex,
521 from_line: usize,
522) -> Option<usize> {
523 idx.extend_to_end(src);
524 for line_no in from_line..idx.line_count() {
525 let bytes = idx.line_bytes_stripped(line_no, src);
526 if re.is_match(&bytes) {
527 return Some(line_no);
528 }
529 }
530 None
531}
532
533fn update_viewport_tag_indicator(stack: &TagStack, viewport: &mut crate::viewport::Viewport) {
534 viewport.set_tag_active(stack.active.as_ref().map(|a| {
535 (a.name.clone(), a.cursor + 1, a.matches.len())
536 }));
537}
538
539#[allow(clippy::too_many_arguments)]
543fn switch_to_current_file(
544 file_set: &mut crate::file_set::FileSet,
545 current_file_index: &mut usize,
546 args: &crate::cli::Args,
547 preprocessor: Option<&crate::preprocess::Preprocessor>,
548 record_start_regex: Option<®ex::bytes::Regex>,
549 viewport: &mut crate::viewport::Viewport,
550 src: &mut Box<dyn crate::source::Source>,
551 idx: &mut crate::line_index::LineIndex,
552) -> Option<String> {
553 let path = match file_set.current() {
554 Some(p) => p.to_path_buf(),
555 None => return Some("[empty file set]".into()),
556 };
557 let new_idx_val = file_set.current_index();
558 match switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
559 Ok(()) => {
560 *current_file_index = new_idx_val;
561 None
562 }
563 Err(e) => Some(format!("[open: {e}]")),
564 }
565}
566
567#[allow(clippy::too_many_arguments)]
568fn switch_file(
569 new_path: &std::path::Path,
570 new_file_index: usize,
571 total_files: usize,
572 args: &crate::cli::Args,
573 preprocessor: Option<&crate::preprocess::Preprocessor>,
574 viewport: &mut crate::viewport::Viewport,
575 src: &mut Box<dyn crate::source::Source>,
576 idx: &mut crate::line_index::LineIndex,
577 record_start_regex: Option<®ex::bytes::Regex>,
578) -> crate::error::Result<()> {
579 let (new_src, new_label, new_failure) =
580 crate::open::open_source_for_path(new_path, args, preprocessor)?;
581
582 *src = new_src;
583 let mut new_idx = crate::line_index::LineIndex::new();
584 if let Some(re) = record_start_regex {
585 new_idx.set_record_start(re.clone());
586 }
587 *idx = new_idx;
588
589 viewport.set_source_label(new_label);
590 viewport.set_file_index(new_file_index, total_files);
591 viewport.set_preprocess_failure(new_failure);
592 viewport.goto_top();
593
594 Ok(())
595}
596
597#[allow(clippy::too_many_arguments)]
598fn dispatch_colon_command(
599 cmd: ColonCommand,
600 file_set: &mut crate::file_set::FileSet,
601 current_file_index: &mut usize,
602 args: &crate::cli::Args,
603 preprocessor: Option<&crate::preprocess::Preprocessor>,
604 record_start_regex: Option<®ex::bytes::Regex>,
605 viewport: &mut crate::viewport::Viewport,
606 src: &mut Box<dyn crate::source::Source>,
607 idx: &mut crate::line_index::LineIndex,
608 tag_stack: &mut TagStack,
609 tag_file: Option<&crate::tags::TagFile>,
610) -> ColonOutcome {
611 match cmd {
612 ColonCommand::Next => {
613 match file_set.next() {
614 Ok(path) => {
615 let path = path.to_path_buf();
616 let new_idx_val = file_set.current_index();
617 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
618 ColonOutcome::Continue(Some(format!("[open: {e}]")))
619 } else {
620 *current_file_index = new_idx_val;
621 ColonOutcome::Continue(None)
622 }
623 }
624 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
625 }
626 }
627 ColonCommand::Prev => {
628 match file_set.prev() {
629 Ok(path) => {
630 let path = path.to_path_buf();
631 let new_idx_val = file_set.current_index();
632 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
633 ColonOutcome::Continue(Some(format!("[open: {e}]")))
634 } else {
635 *current_file_index = new_idx_val;
636 ColonOutcome::Continue(None)
637 }
638 }
639 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
640 }
641 }
642 ColonCommand::Edit(path) => {
643 match crate::open::open_source_for_path(&path, args, preprocessor) {
645 Ok(_) => {
646 let final_path = file_set.append_and_switch(path.clone()).to_path_buf();
648 let new_idx_val = file_set.current_index();
649 if let Err(e) = switch_file(&final_path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
650 ColonOutcome::Continue(Some(format!("[open: {e}]")))
651 } else {
652 *current_file_index = new_idx_val;
653 ColonOutcome::Continue(None)
654 }
655 }
656 Err(e) => ColonOutcome::Continue(Some(format!("[open: {}: {e}]", path.display()))),
657 }
658 }
659 ColonCommand::ShowFile => {
660 let label = viewport.source_label_clone();
661 let cur = file_set.current_index() + 1;
662 let total = file_set.len();
663 let top = viewport.top_line() + 1;
664 let total_lines = idx.line_count();
665 let msg = if total > 1 {
666 format!("{label} (file {cur}/{total}): line {top}/{total_lines}")
667 } else {
668 format!("{label}: line {top}/{total_lines}")
669 };
670 ColonOutcome::Continue(Some(msg))
671 }
672 ColonCommand::Quit => ColonOutcome::Quit,
673 ColonCommand::Delete => {
674 match file_set.delete_current() {
675 Ok(path) => {
676 let path = path.to_path_buf();
677 let new_idx_val = file_set.current_index();
678 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
679 ColonOutcome::Continue(Some(format!("[open: {e}]")))
680 } else {
681 *current_file_index = new_idx_val;
682 ColonOutcome::Continue(None)
683 }
684 }
685 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
686 }
687 }
688 ColonCommand::First => {
689 if file_set.current_index() == 0 {
690 ColonOutcome::Continue(None) } else if let Some(path) = file_set.first() {
692 let path = path.to_path_buf();
693 let new_idx_val = file_set.current_index();
694 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
695 ColonOutcome::Continue(Some(format!("[open: {e}]")))
696 } else {
697 *current_file_index = new_idx_val;
698 ColonOutcome::Continue(None)
699 }
700 } else {
701 ColonOutcome::Continue(None)
702 }
703 }
704 ColonCommand::Last => {
705 if file_set.current_index() + 1 == file_set.len() {
706 ColonOutcome::Continue(None)
707 } else if let Some(path) = file_set.last() {
708 let path = path.to_path_buf();
709 let new_idx_val = file_set.current_index();
710 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
711 ColonOutcome::Continue(Some(format!("[open: {e}]")))
712 } else {
713 *current_file_index = new_idx_val;
714 ColonOutcome::Continue(None)
715 }
716 } else {
717 ColonOutcome::Continue(None)
718 }
719 }
720 ColonCommand::Tag(name) => {
721 match dispatch_tag_jump(
722 &name,
723 tag_file,
724 tag_stack,
725 file_set,
726 current_file_index,
727 args,
728 preprocessor,
729 record_start_regex,
730 viewport,
731 src,
732 idx,
733 ) {
734 Some(msg) => ColonOutcome::Continue(Some(msg)),
735 None => ColonOutcome::Continue(None),
736 }
737 }
738 ColonCommand::TagNext => match tag_stack.next() {
739 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
740 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[no more matches]".into())),
741 TagStepResult::Moved(cur) => {
742 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
743 let msg = dispatch_match(
744 &entry,
745 file_set,
746 current_file_index,
747 args,
748 preprocessor,
749 record_start_regex,
750 viewport,
751 src,
752 idx,
753 );
754 update_viewport_tag_indicator(tag_stack, viewport);
755 ColonOutcome::Continue(msg)
756 }
757 },
758 ColonCommand::TagSelect(name) => {
759 let prepared = match name {
760 Some(n) => {
761 let tf = match tag_file {
762 Some(t) => t,
763 None => {
764 return ColonOutcome::Continue(Some(
765 "[no tags file loaded]".into(),
766 ))
767 }
768 };
769 let matches: Vec<crate::tags::TagEntry> = tf.lookup(&n).to_vec();
770 if matches.is_empty() {
771 return ColonOutcome::Continue(Some(
772 format!("[no matches for `{n}`]"),
773 ));
774 }
775 tag_stack.set_active(n, matches);
776 true
777 }
778 None => tag_stack.active.is_some(),
779 };
780 if prepared {
781 ColonOutcome::DispatchCommand(Command::OpenTagPicker)
782 } else {
783 ColonOutcome::Continue(Some("[no active tag]".into()))
784 }
785 }
786 ColonCommand::TagPrev => match tag_stack.prev() {
787 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
788 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[at first match]".into())),
789 TagStepResult::Moved(cur) => {
790 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
791 let msg = dispatch_match(
792 &entry,
793 file_set,
794 current_file_index,
795 args,
796 preprocessor,
797 record_start_regex,
798 viewport,
799 src,
800 idx,
801 );
802 update_viewport_tag_indicator(tag_stack, viewport);
803 ColonOutcome::Continue(msg)
804 }
805 },
806 ColonCommand::OpenPicker => ColonOutcome::DispatchCommand(Command::OpenPicker),
809 ColonCommand::OpenHelp => ColonOutcome::DispatchCommand(Command::OpenHelp),
810 ColonCommand::HexGroup(hex_chars) => {
811 if !viewport.hex_mode() {
812 return ColonOutcome::Continue(Some(
813 "[:hex requires --hex mode]".into(),
814 ));
815 }
816 let bpg = crate::hex::hex_chars_to_bytes_per_group(hex_chars).unwrap();
818 viewport.set_hex_group_size(bpg);
819 ColonOutcome::Continue(Some(format!("[hex group: {hex_chars} chars]")))
820 }
821 ColonCommand::Color(mode) => {
822 use crate::render::AnsiMode;
823 let next = mode.unwrap_or_else(|| match viewport.ansi_mode() {
824 AnsiMode::Strict => AnsiMode::Interpret,
825 AnsiMode::Interpret => AnsiMode::Raw,
826 AnsiMode::Raw => AnsiMode::Strict,
827 });
828 viewport.set_ansi_mode(next);
829 let label = match next {
830 AnsiMode::Strict => "strict",
831 AnsiMode::Interpret => "interpret",
832 AnsiMode::Raw => "raw",
833 };
834 ColonOutcome::Continue(Some(format!("[color: {label}]")))
835 }
836 ColonCommand::Header(l, c) => {
837 viewport.set_header(l, c);
838 ColonOutcome::Continue(Some(format!("[header: {l} rows, {c} cols]")))
839 }
840 ColonCommand::HlSearch(on) => {
841 viewport.set_hilite_search(on);
842 let msg = if on { "[hlsearch on]" } else { "[hlsearch off]" };
843 ColonOutcome::Continue(Some(msg.into()))
844 }
845 ColonCommand::Case(mode) => {
846 use crate::viewport::CaseMode;
847 let next = mode.unwrap_or_else(|| match viewport.case_mode() {
848 CaseMode::Sensitive => CaseMode::Smart,
849 CaseMode::Smart => CaseMode::Insensitive,
850 CaseMode::Insensitive => CaseMode::Sensitive,
851 });
852 viewport.set_case_mode(next);
853 let label = match next {
854 CaseMode::Sensitive => "sensitive",
855 CaseMode::Smart => "smart",
856 CaseMode::Insensitive => "insensitive",
857 };
858 ColonOutcome::Continue(Some(format!("[case: {label}]")))
859 }
860 }
861}
862
863#[allow(clippy::too_many_arguments, clippy::collapsible_match)]
864pub fn run(
865 mut src: Box<dyn Source>,
866 mut viewport: Viewport,
867 mut idx: LineIndex,
868 sigterm: Arc<AtomicBool>,
869 rebuild_spec: RebuildSpec,
870 keymap: crate::keys::KeyMap,
871 mut file_set: crate::file_set::FileSet,
872 record_start_regex: Option<regex::bytes::Regex>,
873 args: crate::cli::Args,
874 preprocessor: Option<crate::preprocess::Preprocessor>,
875 mut tag_file: Option<crate::tags::TagFile>,
876) -> Result<()> {
877 let (mut cols, mut rows) = size().unwrap_or((80, 24));
878 viewport.resize(cols, rows);
879
880 let truecolor = match args.truecolor.as_str() {
881 "always" => true,
882 "never" => false,
883 _ => crate::render::TrueColor::Auto.resolve(),
884 };
885
886 let mut stdout = io::stdout();
887 let timeout = Duration::from_millis(250);
888 let mut last_revision = src.revision();
889
890 if (viewport.filter_active() || viewport.grep_active()) && !viewport.dim_mode() {
895 idx.extend_to_end(src.as_ref());
896 viewport.extend_visible_lines(&idx, src.as_ref());
897 }
898
899 if viewport.follow_mode() || viewport.live_mode() {
904 src.pump();
905 viewport.extend_visible_lines(&idx, src.as_ref());
906 viewport.goto_bottom(src.as_ref(), &mut idx);
907 }
908
909 let mut needs_redraw = true;
911 let mut mode = InputMode::Normal;
912 let mut numeric_prefix: Option<usize> = None;
913 let mut marks: HashMap<char, (usize, usize)> = HashMap::new();
914 let mut previous_position: Option<(usize, usize)> = None;
915 let mut current_file_index: usize = file_set.current_index();
916 let mut transient_status: Option<String> = None;
917 let mut tag_stack = TagStack::default();
918 let mut overlay: Option<Box<dyn crate::overlay::Overlay>> = None;
919 let mut overlay_flash: Option<(&'static str, std::time::Instant)> = None;
920 let mouse_enabled = args.mouse;
921
922 if let Some(tag_name) = args.tag.as_deref() {
923 let _ = refresh_tag_file(&mut tag_file);
924 if let Some(msg) = dispatch_tag_jump(
925 tag_name,
926 tag_file.as_ref(),
927 &mut tag_stack,
928 &mut file_set,
929 &mut current_file_index,
930 &args,
931 preprocessor.as_ref(),
932 record_start_regex.as_ref(),
933 &mut viewport,
934 &mut src,
935 &mut idx,
936 ) {
937 return Err(crate::error::Error::Runtime(format!("startup tag jump failed: {msg}")));
938 }
939 }
940
941 loop {
942 if sigterm.load(Ordering::SeqCst) {
943 break;
944 }
945
946 if needs_redraw {
947 if let Some(ov) = overlay.as_ref() {
948 let w = cols;
949 let h = viewport.body_rows() + 1;
950 let mut ovframe = ov.render(w, h);
951 if let Some((msg, started)) = overlay_flash {
952 if started.elapsed() < std::time::Duration::from_millis(1500) {
953 ovframe.status = format!("[{msg}]");
954 } else {
955 overlay_flash = None;
956 }
957 }
958 render_overlay(&mut stdout, &ovframe, w, h)
959 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
960 needs_redraw = false;
961 continue;
962 }
963 let mut frame = viewport.frame(src.as_ref(), &mut idx);
964 match &mode {
967 InputMode::SearchPrompt { direction, buffer, error } => {
968 let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
969 frame.status = match error {
970 Some(e) => format!("{prefix}{buffer} [error: {e}]"),
971 None => format!("{prefix}{buffer}"),
972 };
973 }
974 InputMode::ShellPrompt { buffer, error } => {
975 frame.status = match error {
976 Some(e) => format!("!{buffer} [error: {e}]"),
977 None => format!("!{buffer}"),
978 };
979 }
980 InputMode::ColonPrompt { buffer, error } => {
981 frame.status = match error {
982 Some(e) => format!(":{buffer} [error: {e}]"),
983 None => format!(":{buffer}"),
984 };
985 }
986 InputMode::TagPrompt { buffer, error, .. } => {
987 frame.status = match error {
988 Some(e) => format!("tag: {buffer} [error: {e}]"),
989 None => format!("tag: {buffer}"),
990 };
991 }
992 _ => {
993 if let Some(msg) = transient_status.take() {
994 frame.status = msg;
995 }
996 }
997 }
998 write_frame(&mut stdout, &frame, cols, rows, truecolor)
999 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1000 needs_redraw = false;
1001 }
1002
1003 match poll(timeout) {
1005 Ok(true) => {
1006 let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
1007 match &mut mode {
1010 InputMode::SearchPrompt { direction, buffer, error } => {
1011 if let Event::Key(KeyEvent { code, .. }) = event {
1012 match code {
1013 KeyCode::Esc => { mode = InputMode::Normal; needs_redraw = true; }
1014 KeyCode::Enter => {
1015 if buffer.is_empty() {
1016 if viewport.search_active() {
1020 let reverse = !matches!(
1021 (viewport.search_direction(), *direction),
1022 (SearchDirection::Forward, SearchDirection::Forward)
1023 | (SearchDirection::Backward, SearchDirection::Backward)
1024 );
1025 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1026 viewport.search_repeat(src.as_ref(), &mut idx, reverse);
1027 }
1028 mode = InputMode::Normal;
1029 } else {
1030 match viewport.set_search(buffer.clone(), *direction) {
1031 Ok(()) => {
1032 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1033 viewport.search_repeat(src.as_ref(), &mut idx, false);
1034 mode = InputMode::Normal;
1035 }
1036 Err(e) => { *error = Some(e); }
1037 }
1038 }
1039 needs_redraw = true;
1040 }
1041 KeyCode::Backspace => {
1042 buffer.pop();
1043 *error = None;
1044 needs_redraw = true;
1045 }
1046 KeyCode::Char(c) => {
1047 buffer.push(c);
1048 *error = None;
1049 needs_redraw = true;
1050 }
1051 _ => {}
1052 }
1053 }
1054 continue;
1055 }
1056 InputMode::OptionPrefix => {
1057 if let Event::Key(KeyEvent { code, .. }) = event {
1058 match code {
1059 KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
1060 KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
1061 KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
1062 KeyCode::Char('P') | KeyCode::Char('p') => {
1063 mode = InputMode::PrettifyPrefix;
1065 needs_redraw = true;
1066 continue;
1067 }
1068 _ => {}
1069 }
1070 }
1071 mode = InputMode::Normal;
1072 needs_redraw = true;
1073 continue;
1074 }
1075 InputMode::PrettifyPrefix => {
1076 if let Event::Key(KeyEvent { code, .. }) = event {
1077 let target: Option<PrettifyTarget> = match code {
1078 KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
1079 KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
1080 KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
1081 KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
1082 KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
1083 KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
1084 KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
1085 KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
1086 _ => None,
1087 };
1088 if let Some(t) = target {
1089 apply_prettify(
1090 src.as_ref(),
1091 &mut viewport,
1092 &mut idx,
1093 rebuild_spec,
1094 t,
1095 );
1096 last_revision = src.revision();
1097 }
1098 }
1099 mode = InputMode::Normal;
1100 needs_redraw = true;
1101 continue;
1102 }
1103 InputMode::MarkSetPending => {
1104 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1105 if is_valid_mark_name(c) {
1106 mark_set(&mut marks, c, current_file_index, viewport.top_line());
1107 }
1108 }
1109 mode = InputMode::Normal;
1110 continue;
1111 }
1112 InputMode::MarkJumpPending => {
1113 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1114 if is_valid_mark_name(c) {
1115 match mark_jump(&marks, c, current_file_index, &mut previous_position, viewport.top_line()) {
1116 Some(MarkTarget::SameFile { line }) => {
1117 let clamped = line.min(idx.line_count().saturating_sub(1));
1118 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1119 needs_redraw = true;
1120 }
1121 Some(MarkTarget::OtherFile { file_index, line }) => {
1122 if file_index < file_set.len() {
1123 file_set.set_current_index(file_index);
1124 let path = file_set.current().unwrap().to_path_buf();
1125 if let Err(e) = switch_file(
1126 &path, file_index, file_set.len(),
1127 &args, preprocessor.as_ref(),
1128 &mut viewport, &mut src, &mut idx,
1129 record_start_regex.as_ref(),
1130 ) {
1131 transient_status = Some(format!("[open: {e}]"));
1132 } else {
1133 let clamped = line.min(idx.line_count().saturating_sub(1));
1134 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1135 current_file_index = file_index;
1136 needs_redraw = true;
1137 }
1138 }
1139 }
1140 None => {}
1141 }
1142 }
1143 }
1144 mode = InputMode::Normal;
1145 continue;
1146 }
1147 InputMode::ShellPrompt { buffer, error } => {
1148 if let Event::Key(KeyEvent { code, .. }) = event {
1149 match code {
1150 KeyCode::Esc => {
1151 mode = InputMode::Normal;
1152 needs_redraw = true;
1153 }
1154 KeyCode::Enter => {
1155 if buffer.is_empty() {
1156 mode = InputMode::Normal;
1157 } else {
1158 match crate::shell::run_shell_command(buffer) {
1159 Ok(()) => {
1160 mode = InputMode::Normal;
1161 }
1162 Err(e) => {
1163 *error = Some(e.to_string());
1164 }
1165 }
1166 }
1167 needs_redraw = true;
1168 }
1169 KeyCode::Backspace => {
1170 buffer.pop();
1171 *error = None;
1172 needs_redraw = true;
1173 }
1174 KeyCode::Char(c) => {
1175 buffer.push(c);
1176 *error = None;
1177 needs_redraw = true;
1178 }
1179 _ => {}
1180 }
1181 }
1182 continue;
1183 }
1184 InputMode::CtrlXPending => {
1185 let is_ctrl_x = matches!(
1186 event,
1187 Event::Key(KeyEvent {
1188 code: KeyCode::Char('x'),
1189 modifiers: KeyModifiers::CONTROL,
1190 ..
1191 })
1192 );
1193 if is_ctrl_x {
1194 match jump_previous(&mut previous_position, current_file_index, viewport.top_line()) {
1195 Some(MarkTarget::SameFile { line }) => {
1196 let clamped = line.min(idx.line_count().saturating_sub(1));
1197 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1198 needs_redraw = true;
1199 }
1200 Some(MarkTarget::OtherFile { file_index, line }) => {
1201 if file_index < file_set.len() {
1202 file_set.set_current_index(file_index);
1203 let path = file_set.current().unwrap().to_path_buf();
1204 if let Err(e) = switch_file(
1205 &path, file_index, file_set.len(),
1206 &args, preprocessor.as_ref(),
1207 &mut viewport, &mut src, &mut idx,
1208 record_start_regex.as_ref(),
1209 ) {
1210 transient_status = Some(format!("[open: {e}]"));
1211 } else {
1212 let clamped = line.min(idx.line_count().saturating_sub(1));
1213 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1214 current_file_index = file_index;
1215 needs_redraw = true;
1216 }
1217 }
1218 }
1219 None => {}
1220 }
1221 mode = InputMode::Normal;
1222 continue;
1223 }
1224 mode = InputMode::Normal;
1226 }
1228 InputMode::ColonPrompt { buffer, error } => {
1229 if let Event::Key(KeyEvent { code, .. }) = event {
1230 match code {
1231 KeyCode::Esc => {
1232 mode = InputMode::Normal;
1233 needs_redraw = true;
1234 }
1235 KeyCode::Enter => {
1236 if buffer.is_empty() {
1237 mode = InputMode::Normal;
1238 } else {
1239 match parse_colon_command(buffer) {
1240 Ok(cmd) => {
1241 let is_tag_cmd = matches!(
1242 &cmd,
1243 ColonCommand::Tag(_)
1244 | ColonCommand::TagNext
1245 | ColonCommand::TagPrev
1246 | ColonCommand::TagSelect(_),
1247 );
1248 let reload_msg = if is_tag_cmd {
1249 refresh_tag_file(&mut tag_file)
1250 } else {
1251 None
1252 };
1253 let outcome = dispatch_colon_command(
1254 cmd,
1255 &mut file_set,
1256 &mut current_file_index,
1257 &args,
1258 preprocessor.as_ref(),
1259 record_start_regex.as_ref(),
1260 &mut viewport,
1261 &mut src,
1262 &mut idx,
1263 &mut tag_stack,
1264 tag_file.as_ref(),
1265 );
1266 match outcome {
1267 ColonOutcome::Continue(msg) => {
1268 transient_status = msg.or(reload_msg);
1269 }
1270 ColonOutcome::Quit => break,
1271 ColonOutcome::DispatchCommand(Command::OpenPicker) => {
1272 let saved = (0..file_set.len())
1273 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1274 .collect::<Vec<_>>();
1275 overlay = Some(Box::new(
1276 crate::overlay::picker::FilePicker::new(&file_set, saved)
1277 ));
1278 needs_redraw = true;
1279 }
1280 ColonOutcome::DispatchCommand(Command::OpenHelp) => {
1281 let remaps = keymap.user_keys_by_command_name();
1282 overlay = Some(Box::new(
1283 crate::overlay::help::HelpOverlay::new(remaps)
1284 ));
1285 needs_redraw = true;
1286 }
1287 ColonOutcome::DispatchCommand(Command::OpenTagPicker) => {
1288 if let Some(active) = tag_stack.active.as_ref() {
1289 overlay = Some(Box::new(
1290 crate::overlay::tag_picker::TagPicker::new(
1291 active.name.clone(),
1292 active.matches.clone(),
1293 active.cursor,
1294 )
1295 ));
1296 needs_redraw = true;
1297 }
1298 }
1299 ColonOutcome::DispatchCommand(cmd) => {
1300 debug_assert!(false, "colon dispatcher emitted unexpected Command: {cmd:?}");
1301 }
1303 }
1304 mode = InputMode::Normal;
1305 }
1306 Err(e) => {
1307 *error = Some(e.to_string());
1308 }
1309 }
1310 }
1311 needs_redraw = true;
1312 }
1313 KeyCode::Backspace => {
1314 buffer.pop();
1315 *error = None;
1316 needs_redraw = true;
1317 }
1318 KeyCode::Char(c) => {
1319 buffer.push(c);
1320 *error = None;
1321 needs_redraw = true;
1322 }
1323 _ => {}
1324 }
1325 }
1326 continue;
1327 }
1328 InputMode::TagPrompt { buffer, error, last_tab_matches } => {
1329 if let Event::Key(KeyEvent { code, .. }) = event {
1330 match code {
1331 KeyCode::Esc => {
1332 mode = InputMode::Normal;
1333 needs_redraw = true;
1334 }
1335 KeyCode::Enter => {
1336 if buffer.is_empty() {
1337 mode = InputMode::Normal;
1338 } else {
1339 let name = buffer.clone();
1340 let reload_msg = refresh_tag_file(&mut tag_file);
1341 let msg = dispatch_tag_jump(
1342 &name,
1343 tag_file.as_ref(),
1344 &mut tag_stack,
1345 &mut file_set,
1346 &mut current_file_index,
1347 &args,
1348 preprocessor.as_ref(),
1349 record_start_regex.as_ref(),
1350 &mut viewport,
1351 &mut src,
1352 &mut idx,
1353 );
1354 transient_status = msg.or(reload_msg);
1355 mode = InputMode::Normal;
1356 }
1357 needs_redraw = true;
1358 }
1359 KeyCode::Backspace => {
1360 buffer.pop();
1361 *error = None;
1362 *last_tab_matches = None;
1363 needs_redraw = true;
1364 }
1365 KeyCode::Tab => {
1366 let _ = refresh_tag_file(&mut tag_file);
1367 let names: Vec<String> = match tag_file.as_ref() {
1368 Some(tf) => tf
1369 .names()
1370 .filter(|n| n.starts_with(buffer.as_str()))
1371 .map(String::from)
1372 .collect(),
1373 None => Vec::new(),
1374 };
1375 match (names.len(), last_tab_matches.as_ref()) {
1376 (0, _) => {
1377 *error = Some("no tags match".into());
1378 *last_tab_matches = None;
1379 }
1380 (1, _) => {
1381 *buffer = names.into_iter().next().unwrap();
1382 *error = None;
1383 *last_tab_matches = None;
1384 }
1385 (n, Some(prev)) if prev.len() == n => {
1386 *error = Some(format!("{n} matches"));
1387 }
1388 (n, _) => {
1389 let lcp = longest_common_prefix(&names);
1390 if lcp.len() > buffer.len() {
1391 *buffer = lcp;
1392 *error = None;
1393 } else {
1394 *error = Some(format!("{n} matches"));
1395 }
1396 *last_tab_matches = Some(names);
1397 }
1398 }
1399 needs_redraw = true;
1400 }
1401 KeyCode::Char(c) => {
1402 buffer.push(c);
1403 *error = None;
1404 *last_tab_matches = None;
1405 needs_redraw = true;
1406 }
1407 _ => {}
1408 }
1409 }
1410 continue;
1411 }
1412 InputMode::Normal => {}
1413 }
1414 if let crossterm::event::Event::Resize(c, r) = event {
1417 cols = c;
1418 rows = r;
1419 viewport.resize(c, r);
1420 needs_redraw = true;
1421 if overlay.is_some() {
1422 continue;
1424 }
1425 }
1428 if let Some(ov) = overlay.as_mut() {
1432 let outcome = match &event {
1433 Event::Key(ke) => ov.handle_key(*ke),
1434 Event::Mouse(me) => ov.handle_mouse(*me, viewport.body_rows()),
1435 Event::Resize(_, _) => crate::overlay::OverlayOutcome::Stay,
1436 _ => crate::overlay::OverlayOutcome::Stay,
1437 };
1438 match outcome {
1439 crate::overlay::OverlayOutcome::Stay => {
1440 needs_redraw = true;
1441 continue;
1442 }
1443 crate::overlay::OverlayOutcome::Close => {
1444 overlay = None;
1445 overlay_flash = None;
1446 needs_redraw = true;
1447 continue;
1448 }
1449 crate::overlay::OverlayOutcome::CloseAnd(cmd) => {
1450 overlay = None;
1451 overlay_flash = None;
1452 if let Command::SelectFile(i) = cmd {
1453 if i < file_set.len() {
1454 file_set.set_current_index(i);
1455 if let Some(msg) = switch_to_current_file(
1456 &mut file_set, &mut current_file_index,
1457 &args, preprocessor.as_ref(),
1458 record_start_regex.as_ref(),
1459 &mut viewport, &mut src, &mut idx,
1460 ) {
1461 transient_status = Some(msg);
1462 }
1463 }
1464 } else if let Command::SelectTagMatch(idx_pick) = cmd {
1465 if let Some(active) = tag_stack.active.as_mut() {
1466 if idx_pick < active.matches.len() {
1467 active.cursor = idx_pick;
1468 let entry = active.matches[idx_pick].clone();
1469 let msg = dispatch_match(
1470 &entry,
1471 &mut file_set,
1472 &mut current_file_index,
1473 &args,
1474 preprocessor.as_ref(),
1475 record_start_regex.as_ref(),
1476 &mut viewport,
1477 &mut src,
1478 &mut idx,
1479 );
1480 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1481 if let Some(m) = msg {
1482 transient_status = Some(m);
1483 }
1484 }
1485 }
1486 }
1487 needs_redraw = true;
1488 continue;
1489 }
1490 crate::overlay::OverlayOutcome::Apply(cmd) => {
1491 if let Command::DropFileAt(target) = cmd {
1492 if file_set.len() > 1 && target < file_set.len() {
1493 let saved_cur = file_set.current_index();
1494 file_set.set_current_index(target);
1495 let _ = file_set.delete_current();
1496 if target < saved_cur {
1500 let restored = saved_cur.saturating_sub(1);
1501 file_set.set_current_index(restored);
1502 } else if target > saved_cur {
1503 file_set.set_current_index(saved_cur);
1504 }
1505 if let Some(msg) = switch_to_current_file(
1508 &mut file_set, &mut current_file_index,
1509 &args, preprocessor.as_ref(),
1510 record_start_regex.as_ref(),
1511 &mut viewport, &mut src, &mut idx,
1512 ) {
1513 transient_status = Some(msg);
1514 }
1515 if let Some(ov) = overlay.as_mut() {
1516 ov.refresh(crate::overlay::OverlayContext { file_set: &file_set });
1517 }
1518 }
1519 }
1520 needs_redraw = true;
1521 continue;
1522 }
1523 crate::overlay::OverlayOutcome::Refuse(msg) => {
1524 overlay_flash = Some((msg, std::time::Instant::now()));
1525 needs_redraw = true;
1526 continue;
1527 }
1528 }
1529 }
1530 if let crossterm::event::Event::Mouse(me) = &event {
1534 if mouse_enabled {
1535 use crossterm::event::MouseEventKind;
1536 match me.kind {
1537 MouseEventKind::ScrollDown => {
1538 viewport.scroll_lines(3, src.as_ref(), &mut idx);
1539 needs_redraw = true;
1540 }
1541 MouseEventKind::ScrollUp => {
1542 viewport.scroll_lines(-3, src.as_ref(), &mut idx);
1543 needs_redraw = true;
1544 }
1545 _ => {}
1546 }
1547 }
1548 continue;
1549 }
1550 let mut cmd: Option<Command> = None;
1554 if let InputMode::Normal = mode {
1555 if let Event::Key(ke) = &event {
1556 if let Some(target) = keymap.lookup(ke) {
1557 match target {
1558 crate::keys::BindingTarget::Shell(cmd_text) => {
1559 let cmd_text = cmd_text.clone();
1560 if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
1561 let _ = writeln!(std::io::stderr(),
1562 "[shell: {e}]");
1563 }
1564 needs_redraw = true;
1565 continue;
1566 }
1567 crate::keys::BindingTarget::Command(c) => {
1568 cmd = Some(c.clone());
1569 }
1570 }
1571 }
1572 }
1573 }
1574 let cmd = cmd.unwrap_or_else(|| translate(event));
1575 let prefix_at_cmd = numeric_prefix.take();
1578 match cmd {
1579 Command::Digit(d) => {
1580 let cur = prefix_at_cmd.unwrap_or(0);
1581 let next = cur.saturating_mul(10).saturating_add(d as usize);
1582 if next <= 99_999_999 {
1583 numeric_prefix = Some(next);
1584 } else {
1585 numeric_prefix = prefix_at_cmd;
1587 }
1588 continue;
1589 }
1590 Command::Cancel => {
1591 continue;
1593 }
1594 Command::GotoLine => {
1595 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1596 match prefix_at_cmd {
1597 Some(line) if line > 0 => {
1598 viewport.goto_line(line - 1, src.as_ref(), &mut idx);
1599 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1600 }
1601 _ => {
1602 viewport.goto_top();
1603 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1604 }
1605 }
1606 needs_redraw = true;
1607 }
1608 Command::GotoRecord => {
1609 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1610 match prefix_at_cmd {
1611 Some(rec) if rec > 0 => {
1612 viewport.goto_record(rec - 1, src.as_ref(), &mut idx);
1613 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1614 }
1615 _ => viewport.goto_bottom(src.as_ref(), &mut idx),
1616 }
1617 needs_redraw = true;
1618 }
1619 Command::GotoPercent => {
1620 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1621 match prefix_at_cmd {
1622 Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
1623 _ => viewport.goto_top(),
1624 }
1625 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1626 needs_redraw = true;
1627 }
1628 Command::Quit => break,
1629 Command::Resize(c, r) => {
1630 cols = c; rows = r;
1631 viewport.resize(c, r);
1632 needs_redraw = true;
1633 }
1634 Command::ScrollLines(n) => {
1635 viewport.scroll_lines(n, src.as_ref(), &mut idx);
1636 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1637 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1638 needs_redraw = true;
1639 }
1640 Command::ScrollLogicalLines(n) => {
1641 viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
1642 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1643 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1644 needs_redraw = true;
1645 }
1646 Command::PageDown => {
1647 viewport.page_down(src.as_ref(), &mut idx);
1648 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1649 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1650 needs_redraw = true;
1651 }
1652 Command::PageUp => {
1653 viewport.page_up(src.as_ref(), &mut idx);
1654 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1655 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1656 needs_redraw = true;
1657 }
1658 Command::HalfPageDown => {
1659 viewport.half_page_down(src.as_ref(), &mut idx);
1660 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1661 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1662 needs_redraw = true;
1663 }
1664 Command::HalfPageUp => {
1665 viewport.half_page_up(src.as_ref(), &mut idx);
1666 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1667 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1668 needs_redraw = true;
1669 }
1670 Command::Refresh => {
1671 needs_redraw = true;
1672 }
1673 Command::Reload => {
1674 src.pump();
1677 if src.revision() != last_revision {
1678 rebuild_after_replace(
1679 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1680 );
1681 last_revision = src.revision();
1682 needs_redraw = true;
1683 }
1684 }
1685 Command::TogglePrettify => {
1686 apply_prettify(
1687 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1688 PrettifyTarget::Toggle,
1689 );
1690 last_revision = src.revision();
1691 needs_redraw = true;
1692 }
1693 Command::SetPrettifyMode(m) => {
1694 apply_prettify(
1695 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1696 PrettifyTarget::Mode(m),
1697 );
1698 last_revision = src.revision();
1699 needs_redraw = true;
1700 }
1701 Command::RedetectPrettify => {
1702 apply_prettify(
1703 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1704 PrettifyTarget::Auto,
1705 );
1706 last_revision = src.revision();
1707 needs_redraw = true;
1708 }
1709 Command::ToggleLineNumbers => {
1710 viewport.toggle_line_numbers();
1711 needs_redraw = true;
1712 }
1713 Command::ToggleChop => {
1714 viewport.toggle_chop();
1715 needs_redraw = true;
1716 }
1717 Command::ToggleFollow => {
1718 viewport.toggle_follow();
1719 if viewport.follow_mode() {
1720 src.pump();
1722 idx.notice_new_bytes(src.as_ref());
1723 viewport.goto_bottom(src.as_ref(), &mut idx);
1724 }
1725 needs_redraw = true;
1726 }
1727 Command::SearchForward => {
1728 mode = InputMode::SearchPrompt {
1729 direction: SearchDirection::Forward,
1730 buffer: String::new(),
1731 error: None,
1732 };
1733 needs_redraw = true;
1734 }
1735 Command::SearchBackward => {
1736 mode = InputMode::SearchPrompt {
1737 direction: SearchDirection::Backward,
1738 buffer: String::new(),
1739 error: None,
1740 };
1741 needs_redraw = true;
1742 }
1743 Command::ShellEscape => {
1744 mode = InputMode::ShellPrompt {
1745 buffer: String::new(),
1746 error: None,
1747 };
1748 needs_redraw = true;
1749 }
1750 Command::ColonPrompt => {
1751 mode = InputMode::ColonPrompt {
1752 buffer: String::new(),
1753 error: None,
1754 };
1755 needs_redraw = true;
1756 }
1757 Command::NextMatch => {
1758 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1759 if viewport.search_repeat(src.as_ref(), &mut idx, false) {
1760 needs_redraw = true;
1761 }
1762 }
1763 Command::PreviousMatch => {
1764 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1765 if viewport.search_repeat(src.as_ref(), &mut idx, true) {
1766 needs_redraw = true;
1767 }
1768 }
1769 Command::OptionPrefix => {
1770 mode = InputMode::OptionPrefix;
1771 }
1772 Command::MarkSet => {
1773 mode = InputMode::MarkSetPending;
1774 }
1775 Command::MarkJump => {
1776 mode = InputMode::MarkJumpPending;
1777 }
1778 Command::CtrlXPrefix => {
1779 mode = InputMode::CtrlXPending;
1780 }
1781 Command::JumpPrevious => {
1782 }
1785 Command::TagPrompt => {
1786 if tag_file.is_none() {
1787 transient_status = Some("[no tags file loaded]".into());
1788 needs_redraw = true;
1789 } else {
1790 mode = InputMode::TagPrompt {
1791 buffer: String::new(),
1792 error: None,
1793 last_tab_matches: None,
1794 };
1795 needs_redraw = true;
1796 }
1797 }
1798 Command::TagPop => match tag_stack.pop() {
1799 Some((file_index, line)) => {
1800 if file_index != current_file_index && file_index < file_set.len() {
1801 file_set.set_current_index(file_index);
1802 let path = file_set.current().unwrap().to_path_buf();
1803 if let Err(e) = switch_file(
1804 &path,
1805 file_index,
1806 file_set.len(),
1807 &args,
1808 preprocessor.as_ref(),
1809 &mut viewport,
1810 &mut src,
1811 &mut idx,
1812 record_start_regex.as_ref(),
1813 ) {
1814 transient_status = Some(format!("[open: {e}]"));
1815 } else {
1816 current_file_index = file_index;
1817 }
1818 }
1819 let clamped = line.min(idx.line_count().saturating_sub(1));
1820 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1821 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1822 needs_redraw = true;
1823 }
1824 None => {
1825 transient_status = Some("[tag stack empty]".into());
1826 needs_redraw = true;
1827 }
1828 },
1829 Command::OpenPicker => {
1830 let saved = (0..file_set.len())
1831 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1832 .collect::<Vec<_>>();
1833 overlay = Some(Box::new(
1834 crate::overlay::picker::FilePicker::new(&file_set, saved)
1835 ));
1836 needs_redraw = true;
1837 }
1838 Command::OpenHelp => {
1839 let remaps = keymap.user_keys_by_command_name();
1840 overlay = Some(Box::new(
1841 crate::overlay::help::HelpOverlay::new(remaps)
1842 ));
1843 needs_redraw = true;
1844 }
1845 Command::SelectFile(_)
1846 | Command::DropFileAt(_)
1847 | Command::SelectTagMatch(_)
1848 | Command::OpenTagPicker => {
1849 }
1851 Command::MouseEvent(_) => {
1852 }
1854 Command::Noop => {}
1855 }
1856 }
1857 Ok(false) => {
1858 if viewport.live_mode() {
1860 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1861 src.pump();
1862 if src.revision() != last_revision {
1863 rebuild_after_replace(
1864 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1865 );
1866 if was_at_bottom {
1867 viewport.goto_bottom(src.as_ref(), &mut idx);
1868 }
1869 last_revision = src.revision();
1870 needs_redraw = true;
1871 }
1872 } else if viewport.follow_mode() {
1873 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1874 src.pump();
1875 if src.take_rotated() {
1876 if let Some(path) = src.path().map(|p| p.to_path_buf()) {
1882 match crate::open::open_source_for_path(
1883 &path, &args, preprocessor.as_ref(),
1884 ) {
1885 Ok((new_src, _label, _err)) => {
1886 src = new_src;
1887 idx = LineIndex::new();
1888 if let Some(n) = rebuild_spec.head {
1889 idx.set_head_cap(n);
1890 }
1891 viewport.invalidate_filter_cache();
1892 idx.notice_new_bytes(src.as_ref());
1893 viewport.extend_visible_lines(&idx, src.as_ref());
1894 viewport.goto_bottom(src.as_ref(), &mut idx);
1895 viewport.flash("(F reopened)", 4);
1896 needs_redraw = true;
1897 continue;
1898 }
1899 Err(e) => {
1900 transient_status = Some(format!("[reopen failed: {e}]"));
1901 needs_redraw = true;
1902 }
1903 }
1904 }
1905 }
1906 let lines_before = idx.line_count();
1907 idx.notice_new_bytes(src.as_ref());
1908 viewport.extend_visible_lines(&idx, src.as_ref());
1909 if idx.line_count() != lines_before {
1910 needs_redraw = true;
1911 viewport.note_growth();
1912 if was_at_bottom {
1913 viewport.goto_bottom(src.as_ref(), &mut idx);
1914 }
1915 } else {
1916 viewport.tick_idle();
1917 }
1918 viewport.tick_flash();
1919 if args.exit_follow_on_close && src.is_complete() {
1925 break;
1926 }
1927 } else if !src.is_complete() {
1928 let lines_before = idx.line_count();
1931 idx.notice_new_bytes(src.as_ref());
1932 viewport.extend_visible_lines(&idx, src.as_ref());
1933 if idx.line_count() != lines_before {
1934 needs_redraw = true;
1935 }
1936 }
1937 }
1938 Err(_) => {
1939 std::thread::sleep(timeout);
1941 }
1942 }
1943 }
1944 Ok(())
1945}
1946
1947#[derive(Debug, Clone, Copy)]
1949enum PrettifyTarget {
1950 Mode(PrettifyMode),
1952 Toggle,
1954 Auto,
1956}
1957
1958fn apply_prettify(
1962 src: &dyn Source,
1963 viewport: &mut Viewport,
1964 idx: &mut LineIndex,
1965 spec: RebuildSpec,
1966 target: PrettifyTarget,
1967) {
1968 if src.prettify_mode().is_none() {
1970 return;
1971 }
1972 match target {
1973 PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
1974 PrettifyTarget::Toggle => src.toggle_prettify(),
1975 PrettifyTarget::Auto => src.redetect_prettify(),
1976 }
1977 rebuild_after_replace(src, viewport, idx, spec);
1978 viewport.set_prettify_label(src.prettify_label());
1979}
1980
1981fn rebuild_after_replace(
1987 src: &dyn Source,
1988 viewport: &mut Viewport,
1989 idx: &mut LineIndex,
1990 spec: RebuildSpec,
1991) {
1992 let new_off = match spec.tail {
1993 Some(n) => find_tail_offset(src, n),
1994 None => 0,
1995 };
1996 *idx = LineIndex::new_starting_at(new_off);
1997 if let Some(n) = spec.head {
1998 idx.set_head_cap(n);
1999 }
2000 viewport.invalidate_filter_cache();
2001 idx.notice_new_bytes(src);
2002 viewport.extend_visible_lines(idx, src);
2003 viewport.clamp_top_line(idx.line_count());
2004}
2005
2006fn to_crossterm_color(c: crate::ansi::Color, truecolor: bool) -> crossterm::style::Color {
2007 use crossterm::style::Color as CC;
2008 use crate::ansi::Color;
2009 match c {
2010 Color::Ansi(0) => CC::Black,
2011 Color::Ansi(1) => CC::DarkRed,
2012 Color::Ansi(2) => CC::DarkGreen,
2013 Color::Ansi(3) => CC::DarkYellow,
2014 Color::Ansi(4) => CC::DarkBlue,
2015 Color::Ansi(5) => CC::DarkMagenta,
2016 Color::Ansi(6) => CC::DarkCyan,
2017 Color::Ansi(7) => CC::Grey,
2018 Color::Ansi(8) => CC::DarkGrey,
2019 Color::Ansi(9) => CC::Red,
2020 Color::Ansi(10) => CC::Green,
2021 Color::Ansi(11) => CC::Yellow,
2022 Color::Ansi(12) => CC::Blue,
2023 Color::Ansi(13) => CC::Magenta,
2024 Color::Ansi(14) => CC::Cyan,
2025 Color::Ansi(15) => CC::White,
2026 Color::Ansi(_) => CC::Reset,
2027 Color::Indexed(n) => CC::AnsiValue(n),
2028 Color::Rgb(r, g, b) => {
2029 if truecolor {
2030 CC::Rgb { r, g, b }
2031 } else {
2032 CC::AnsiValue(crate::render::rgb_to_256(r, g, b))
2033 }
2034 }
2035 Color::Default => CC::Reset,
2036 }
2037}
2038
2039fn emit_style_diff<W: Write>(
2042 out: &mut W,
2043 prev: &crate::ansi::Style,
2044 next: &crate::ansi::Style,
2045 truecolor: bool,
2046) -> io::Result<()> {
2047 let intensity_changed = prev.bold != next.bold || prev.dim != next.dim;
2051
2052 let fg_changed = prev.fg != next.fg;
2056 let bg_changed = prev.bg != next.bg;
2057
2058 if (fg_changed && next.fg.is_none()) || (bg_changed && next.bg.is_none()) {
2059 out.queue(ResetColor)?;
2060 if let Some(c) = next.fg {
2062 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2063 }
2064 if let Some(c) = next.bg {
2065 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2066 }
2067 } else {
2068 if fg_changed {
2069 if let Some(c) = next.fg {
2070 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2071 }
2072 }
2073 if bg_changed {
2074 if let Some(c) = next.bg {
2075 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2076 }
2077 }
2078 }
2079
2080 if intensity_changed {
2081 if next.bold {
2082 out.queue(SetAttribute(Attribute::Bold))?;
2083 } else if next.dim {
2084 out.queue(SetAttribute(Attribute::Dim))?;
2085 } else {
2086 out.queue(SetAttribute(Attribute::NormalIntensity))?;
2087 }
2088 }
2089 if prev.italic != next.italic {
2090 out.queue(SetAttribute(if next.italic { Attribute::Italic } else { Attribute::NoItalic }))?;
2091 }
2092 if prev.underline != next.underline {
2093 out.queue(SetAttribute(if next.underline { Attribute::Underlined } else { Attribute::NoUnderline }))?;
2094 }
2095 if prev.reverse != next.reverse {
2096 out.queue(SetAttribute(if next.reverse { Attribute::Reverse } else { Attribute::NoReverse }))?;
2097 }
2098 if prev.strike != next.strike {
2099 out.queue(SetAttribute(if next.strike { Attribute::CrossedOut } else { Attribute::NotCrossedOut }))?;
2100 }
2101 Ok(())
2102}
2103
2104fn emit_hyperlink_diff<W: Write>(
2105 out: &mut W,
2106 prev: &Option<Arc<str>>,
2107 next: &Option<Arc<str>>,
2108) -> io::Result<()> {
2109 if prev == next {
2110 return Ok(());
2111 }
2112 if prev.is_some() {
2113 out.write_all(b"\x1b]8;;\x1b\\")?;
2114 }
2115 if let Some(uri) = next {
2116 out.write_all(b"\x1b]8;;")?;
2117 out.write_all(uri.as_bytes())?;
2118 out.write_all(b"\x1b\\")?;
2119 }
2120 Ok(())
2121}
2122
2123const SYNC_UPDATE_BEGIN: &[u8] = b"\x1b[?2026h";
2130const SYNC_UPDATE_END: &[u8] = b"\x1b[?2026l";
2131
2132fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16, truecolor: bool) -> io::Result<()> {
2133 out.write_all(SYNC_UPDATE_BEGIN)?;
2145
2146 out.queue(SetAttribute(Attribute::Reset))?;
2148 out.queue(ResetColor)?;
2149
2150 for (i, row) in frame.body.iter().enumerate() {
2151 out.queue(MoveTo(0, i as u16))?;
2152 out.queue(Clear(ClearType::UntilNewLine))?;
2156 out.queue(SetAttribute(Attribute::Reset))?;
2159
2160 if let Some(Some(raw)) = frame.raw_rows.get(i) {
2165 if !raw.is_empty() {
2166 out.write_all(raw)?;
2167 }
2168 out.queue(ResetColor)?;
2170 out.queue(SetAttribute(Attribute::Reset))?;
2171 continue;
2172 }
2173
2174 let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
2175 let base_style = if matches!(row_style, RowStyle::Dim) {
2180 out.queue(SetAttribute(Attribute::Dim))?;
2181 crate::ansi::Style { dim: true, ..Default::default() }
2182 } else {
2183 crate::ansi::Style::default()
2184 };
2185 let no_highlights = Vec::new();
2186 let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
2187 write_row_with_highlights(out, row, cols, highlights, base_style, truecolor)?;
2188 }
2189 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2191 out.queue(Clear(ClearType::UntilNewLine))?;
2192 emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2193 let mut status = frame.status.clone();
2194 if status.len() > cols as usize {
2195 status.truncate(cols as usize);
2196 } else {
2197 let pad = cols as usize - status.len();
2198 status.push_str(&" ".repeat(pad));
2199 }
2200 out.queue(Print(status))?;
2201 out.queue(ResetColor)?;
2202 out.queue(SetAttribute(Attribute::Reset))?;
2203
2204 out.write_all(SYNC_UPDATE_END)?;
2207 out.flush()
2208}
2209
2210
2211fn write_row_with_highlights(
2222 out: &mut impl Write,
2223 row: &[Cell],
2224 cols: u16,
2225 highlights: &[std::ops::Range<usize>],
2226 base_style: crate::ansi::Style,
2227 truecolor: bool,
2228) -> io::Result<()> {
2229 let cols_usize = cols as usize;
2230
2231 let mut ranges: Vec<std::ops::Range<usize>> = highlights
2232 .iter()
2233 .filter_map(|r| {
2234 let s = r.start.min(cols_usize);
2235 let e = r.end.min(cols_usize);
2236 if e > s { Some(s..e) } else { None }
2237 })
2238 .collect();
2239 ranges.sort_by_key(|r| r.start);
2240
2241 let mut prev_style = base_style;
2244 let mut prev_link: Option<Arc<str>> = None;
2245
2246 let mut col = 0usize;
2247 let mut i = 0usize;
2248 while col < cols_usize && i < row.len() {
2249 let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
2250
2251 match &row[i] {
2252 Cell::Char { ch, width, style, hyperlink } => {
2253 let mut eff = *style;
2259 if in_highlight {
2260 eff.reverse = !eff.reverse;
2261 }
2262 if base_style.dim && !eff.bold {
2263 eff.dim = true;
2264 }
2265 emit_style_diff(out, &prev_style, &eff, truecolor)?;
2266 emit_hyperlink_diff(out, &prev_link, hyperlink)?;
2267 out.queue(Print(*ch))?;
2268 prev_style = eff;
2269 prev_link = hyperlink.clone();
2270 col += *width as usize;
2271 }
2272 Cell::Continuation => {
2273 }
2275 Cell::Empty => {
2276 let default = if base_style.dim {
2281 crate::ansi::Style { dim: true, ..Default::default() }
2282 } else {
2283 crate::ansi::Style::default()
2284 };
2285 emit_style_diff(out, &prev_style, &default, truecolor)?;
2286 emit_hyperlink_diff(out, &prev_link, &None)?;
2287 out.queue(Print(' '))?;
2288 prev_style = default;
2289 prev_link = None;
2290 col += 1;
2291 }
2292 }
2293 i += 1;
2294 }
2295
2296 emit_hyperlink_diff(out, &prev_link, &None)?;
2299 out.queue(ResetColor)?;
2300 out.queue(SetAttribute(Attribute::Reset))?;
2301
2302 Ok(())
2303}
2304
2305fn render_overlay(
2306 out: &mut impl Write,
2307 frame: &crate::overlay::OverlayFrame,
2308 width: u16,
2309 height: u16,
2310) -> io::Result<()> {
2311 out.write_all(SYNC_UPDATE_BEGIN)?;
2315 out.queue(SetAttribute(Attribute::Reset))?;
2316 out.queue(ResetColor)?;
2317 for row in 0..height.saturating_sub(1) {
2318 out.queue(MoveTo(0, row))?;
2319 out.queue(Clear(ClearType::UntilNewLine))?;
2320 out.queue(SetAttribute(Attribute::Reset))?;
2321 if let Some(line) = frame.body.get(row as usize) {
2322 let mut written = 0usize;
2323 for ch in line.chars() {
2324 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2325 if written + w > width as usize { break; }
2326 write!(out, "{ch}")?;
2327 written += w;
2328 }
2329 }
2330 }
2331 out.queue(MoveTo(0, height.saturating_sub(1)))?;
2332 out.queue(Clear(ClearType::UntilNewLine))?;
2333 out.queue(SetAttribute(Attribute::Reverse))?;
2334 let mut status = frame.status.clone();
2335 if status.len() > width as usize {
2337 status.truncate(width as usize);
2338 } else {
2339 let pad = width as usize - status.len();
2340 status.push_str(&" ".repeat(pad));
2341 }
2342 out.queue(Print(status))?;
2343 out.queue(ResetColor)?;
2344 out.queue(SetAttribute(Attribute::Reset))?;
2345 out.write_all(SYNC_UPDATE_END)?;
2346 out.flush()
2347}
2348
2349#[cfg(test)]
2350mod tests {
2351 use super::*;
2352
2353 #[test]
2354 fn parse_colon_n() {
2355 assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
2356 assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
2357 }
2358
2359 #[test]
2360 fn write_frame_brackets_with_sync_update_and_no_full_clear() {
2361 use crate::ansi::Style;
2366 use crate::render::Cell;
2367 use crate::viewport::{Frame, RowStyle};
2368
2369 let row: Vec<Cell> = (0..3)
2370 .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
2371 .collect();
2372 let frame = Frame {
2373 body: vec![row.clone(), row],
2374 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2375 highlights: vec![Vec::new(), Vec::new()],
2376 status: "status".into(),
2377 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
2378 raw_rows: vec![None, None],
2379 };
2380
2381 let mut buf: Vec<u8> = Vec::new();
2382 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2383 let s = std::str::from_utf8(&buf).expect("ascii");
2384
2385 let begin = s.find("\x1b[?2026h").expect("begin sync update");
2387 let end = s.find("\x1b[?2026l").expect("end sync update");
2388 assert!(begin < end, "begin must precede end");
2389 let first_a = s.find('a').expect("body char");
2391 assert!(begin < first_a && first_a < end, "body must be inside sync update");
2392
2393 assert!(
2396 !s.contains("\x1b[2J"),
2397 "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
2398 );
2399 assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
2400 }
2401
2402 #[test]
2403 fn raw_rows_passthrough_emits_original_bytes_and_skips_continuation() {
2404 use crate::ansi::Style;
2405 use crate::render::Cell;
2406 use crate::viewport::{Frame, RowStyle};
2407
2408 let placeholder_row: Vec<Cell> = (0..3)
2410 .map(|_| Cell::Char { ch: 'X', width: 1, style: Style::default(), hyperlink: None })
2411 .collect();
2412 let frame = Frame {
2413 body: vec![placeholder_row.clone(), placeholder_row],
2414 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2415 highlights: vec![Vec::new(), Vec::new()],
2416 status: "s".into(),
2417 status_style: Style { reverse: true, ..Default::default() },
2418 raw_rows: vec![Some(b"\x1b[31mABC\x1b[0m".to_vec()), Some(Vec::new())],
2421 };
2422
2423 let mut buf: Vec<u8> = Vec::new();
2424 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2425 let s = std::str::from_utf8(&buf).expect("ascii");
2426
2427 assert!(s.contains("\x1b[31mABC\x1b[0m"), "raw bytes missing in output: {s:?}");
2429 assert!(!s.contains("XXX"), "cell content leaked through despite raw passthrough: {s:?}");
2431 }
2432
2433 #[test]
2434 fn dim_row_keeps_dim_through_plain_cells_and_padding() {
2435 use crate::ansi::Style;
2440 use crate::render::Cell;
2441 let row = vec![
2442 Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
2443 Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
2444 Cell::Empty,
2445 Cell::Empty,
2446 ];
2447 let mut buf: Vec<u8> = Vec::new();
2448 let base = Style { dim: true, ..Default::default() };
2449 write_row_with_highlights(&mut buf, &row, 4, &[], base, true).unwrap();
2450 let s = String::from_utf8_lossy(&buf);
2451
2452 for needle in ['h', 'i'] {
2455 let pos = s.find(needle).expect("char printed");
2456 let before = &s[..pos];
2457 assert!(
2458 !before.contains("\x1b[22m"),
2459 "dim cleared before {needle:?}: {before:?}",
2460 );
2461 }
2462 let after_i = s.find('i').unwrap() + 1;
2465 let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
2466 let pad = &s[after_i..after_i + eor];
2467 assert!(
2468 !pad.contains("\x1b[22m"),
2469 "dim cleared in padding region: {pad:?}",
2470 );
2471 }
2472
2473 #[test]
2474 fn dim_row_yields_to_explicit_bold_cell() {
2475 use crate::ansi::Style;
2478 use crate::render::Cell;
2479 let row = vec![
2480 Cell::Char {
2481 ch: 'B',
2482 width: 1,
2483 style: Style { bold: true, ..Default::default() },
2484 hyperlink: None,
2485 },
2486 ];
2487 let mut buf: Vec<u8> = Vec::new();
2488 let base = Style { dim: true, ..Default::default() };
2489 write_row_with_highlights(&mut buf, &row, 1, &[], base, true).unwrap();
2490 let s = String::from_utf8_lossy(&buf);
2491 assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
2493 }
2494
2495 #[test]
2496 fn parse_colon_p() {
2497 assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
2498 assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
2499 }
2500
2501 #[test]
2502 fn parse_colon_e_with_path() {
2503 match parse_colon_command("e /tmp/foo.log").unwrap() {
2504 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
2505 other => panic!("expected Edit, got {other:?}"),
2506 }
2507 }
2508
2509 #[test]
2510 fn parse_colon_e_with_tilde() {
2511 std::env::set_var("HOME", "/home/user");
2512 match parse_colon_command("e ~/foo.log").unwrap() {
2513 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
2514 other => panic!("expected Edit, got {other:?}"),
2515 }
2516 }
2517
2518 #[test]
2519 fn parse_colon_e_missing_path_errors() {
2520 assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
2521 assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
2522 }
2523
2524 #[test]
2525 fn parse_colon_f_q_d_x_t() {
2526 assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
2527 assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
2528 assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
2529 assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
2530 assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
2531 }
2532
2533 #[test]
2534 fn parse_unknown_command_errors() {
2535 let err = parse_colon_command("bogus").unwrap_err();
2536 match err {
2537 ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
2538 other => panic!("expected UnknownCommand, got {other:?}"),
2539 }
2540 }
2541
2542 #[test]
2543 fn parse_handles_whitespace() {
2544 assert_eq!(parse_colon_command("n ").unwrap(), ColonCommand::Next);
2546 assert_eq!(parse_colon_command(" n").unwrap(), ColonCommand::Next);
2547 }
2548
2549 #[test]
2550 fn parse_colon_tag_with_name() {
2551 assert_eq!(
2552 parse_colon_command("tag foo").unwrap(),
2553 ColonCommand::Tag("foo".into())
2554 );
2555 }
2556
2557 #[test]
2558 fn parse_colon_tag_strips_trailing_whitespace() {
2559 assert_eq!(
2560 parse_colon_command("tag foo ").unwrap(),
2561 ColonCommand::Tag("foo".into())
2562 );
2563 }
2564
2565 #[test]
2566 fn parse_colon_tag_without_name_errors() {
2567 assert_eq!(
2568 parse_colon_command("tag").unwrap_err(),
2569 ColonParseError::TagRequiresName
2570 );
2571 assert_eq!(
2572 parse_colon_command("tag ").unwrap_err(),
2573 ColonParseError::TagRequiresName
2574 );
2575 }
2576
2577 #[test]
2578 fn parse_colon_tnext_and_tprev() {
2579 assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
2580 assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
2581 }
2582
2583 #[test]
2584 fn parse_colon_tselect_without_arg_uses_active() {
2585 assert_eq!(parse_colon_command("tselect").unwrap(), ColonCommand::TagSelect(None));
2586 }
2587
2588 #[test]
2589 fn parse_colon_tselect_with_name() {
2590 assert_eq!(
2591 parse_colon_command("tselect foo").unwrap(),
2592 ColonCommand::TagSelect(Some("foo".into())),
2593 );
2594 }
2595
2596 #[test]
2597 fn parse_colon_b_opens_picker() {
2598 assert_eq!(parse_colon_command("b").unwrap(), ColonCommand::OpenPicker);
2599 assert_eq!(parse_colon_command("buffers").unwrap(), ColonCommand::OpenPicker);
2600 }
2601
2602 #[test]
2603 fn parse_colon_help_opens_help() {
2604 assert_eq!(parse_colon_command("h").unwrap(), ColonCommand::OpenHelp);
2605 assert_eq!(parse_colon_command("help").unwrap(), ColonCommand::OpenHelp);
2606 }
2607
2608 #[test]
2609 fn parse_colon_hex_with_valid_widths() {
2610 for n in [2usize, 4, 8, 16, 32] {
2611 assert_eq!(
2612 parse_colon_command(&format!("hex {n}")).unwrap(),
2613 ColonCommand::HexGroup(n),
2614 );
2615 }
2616 }
2617
2618 #[test]
2619 fn parse_colon_hex_without_value_errors() {
2620 assert_eq!(
2621 parse_colon_command("hex").unwrap_err(),
2622 ColonParseError::HexGroupRequiresValue,
2623 );
2624 }
2625
2626 #[test]
2627 fn parse_colon_hex_with_invalid_value_errors() {
2628 match parse_colon_command("hex 3").unwrap_err() {
2629 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "3"),
2630 other => panic!("expected HexGroupInvalid, got {other:?}"),
2631 }
2632 match parse_colon_command("hex banana").unwrap_err() {
2633 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "banana"),
2634 other => panic!("expected HexGroupInvalid, got {other:?}"),
2635 }
2636 }
2637
2638 #[test]
2639 fn parse_colon_color_without_arg_cycles() {
2640 assert_eq!(parse_colon_command("color").unwrap(), ColonCommand::Color(None));
2641 }
2642
2643 #[test]
2644 fn parse_colon_color_with_named_mode() {
2645 use crate::render::AnsiMode;
2646 assert_eq!(
2647 parse_colon_command("color strict").unwrap(),
2648 ColonCommand::Color(Some(AnsiMode::Strict)),
2649 );
2650 assert_eq!(
2651 parse_colon_command("color interpret").unwrap(),
2652 ColonCommand::Color(Some(AnsiMode::Interpret)),
2653 );
2654 assert_eq!(
2655 parse_colon_command("color raw").unwrap(),
2656 ColonCommand::Color(Some(AnsiMode::Raw)),
2657 );
2658 }
2659
2660 #[test]
2661 fn parse_colon_color_with_unknown_mode_errors() {
2662 match parse_colon_command("color rainbow").unwrap_err() {
2663 ColonParseError::ColorInvalid(v) => assert_eq!(v, "rainbow"),
2664 other => panic!("expected ColorInvalid, got {other:?}"),
2665 }
2666 }
2667
2668 #[test]
2669 fn parse_colon_case_without_arg_cycles() {
2670 assert_eq!(parse_colon_command("case").unwrap(), ColonCommand::Case(None));
2671 }
2672
2673 #[test]
2674 fn parse_colon_case_with_named_mode() {
2675 use crate::viewport::CaseMode;
2676 assert_eq!(parse_colon_command("case smart").unwrap(),
2677 ColonCommand::Case(Some(CaseMode::Smart)));
2678 assert_eq!(parse_colon_command("case sensitive").unwrap(),
2679 ColonCommand::Case(Some(CaseMode::Sensitive)));
2680 assert_eq!(parse_colon_command("case insensitive").unwrap(),
2681 ColonCommand::Case(Some(CaseMode::Insensitive)));
2682 }
2683
2684 #[test]
2685 fn parse_colon_case_unknown_errors() {
2686 match parse_colon_command("case rainbow").unwrap_err() {
2687 ColonParseError::CaseInvalid(v) => assert_eq!(v, "rainbow"),
2688 other => panic!("expected CaseInvalid, got {other:?}"),
2689 }
2690 }
2691
2692 #[test]
2693 fn parse_colon_hlsearch_on_off() {
2694 assert_eq!(parse_colon_command("hlsearch").unwrap(), ColonCommand::HlSearch(true));
2695 assert_eq!(parse_colon_command("nohlsearch").unwrap(), ColonCommand::HlSearch(false));
2696 }
2697
2698 #[test]
2699 fn lcp_empty_slice() {
2700 assert_eq!(longest_common_prefix(&[]), "");
2701 }
2702
2703 #[test]
2704 fn lcp_single_item_returns_self() {
2705 assert_eq!(longest_common_prefix(&["foo".into()]), "foo");
2706 }
2707
2708 #[test]
2709 fn lcp_finds_shared_prefix() {
2710 let v: Vec<String> = vec!["foobar".into(), "foobaz".into(), "fooqux".into()];
2711 assert_eq!(longest_common_prefix(&v), "foo");
2712 }
2713
2714 #[test]
2715 fn lcp_no_shared_prefix_returns_empty() {
2716 let v: Vec<String> = vec!["abc".into(), "xyz".into()];
2717 assert_eq!(longest_common_prefix(&v), "");
2718 }
2719
2720 #[test]
2721 fn lcp_one_item_is_prefix_of_others() {
2722 let v: Vec<String> = vec!["foo".into(), "foobar".into(), "foobaz".into()];
2723 assert_eq!(longest_common_prefix(&v), "foo");
2724 }
2725
2726 #[test]
2727 fn tag_stack_push_pop_lifo() {
2728 let mut s = TagStack::default();
2729 s.push(0, 10);
2730 s.push(1, 20);
2731 assert_eq!(s.pop(), Some((1, 20)));
2732 assert_eq!(s.pop(), Some((0, 10)));
2733 assert_eq!(s.pop(), None);
2734 }
2735
2736 #[test]
2737 fn tag_stack_pop_clears_active() {
2738 let mut s = TagStack::default();
2739 s.push(0, 10);
2740 s.set_active(
2741 "foo".into(),
2742 vec![crate::tags::TagEntry {
2743 file: std::path::PathBuf::from("/a"),
2744 address: crate::tags::TagAddress::Line(1),
2745 }],
2746 );
2747 assert!(s.active.is_some());
2748 let _ = s.pop();
2749 assert!(s.active.is_none());
2750 }
2751
2752 #[test]
2753 fn tag_stack_next_advances_then_clamps() {
2754 let mut s = TagStack::default();
2755 s.set_active(
2756 "foo".into(),
2757 vec![
2758 crate::tags::TagEntry {
2759 file: std::path::PathBuf::from("/a"),
2760 address: crate::tags::TagAddress::Line(1),
2761 },
2762 crate::tags::TagEntry {
2763 file: std::path::PathBuf::from("/b"),
2764 address: crate::tags::TagAddress::Line(2),
2765 },
2766 ],
2767 );
2768 assert_eq!(s.next(), TagStepResult::Moved(1));
2769 assert_eq!(s.next(), TagStepResult::AtBoundary);
2770 }
2771
2772 #[test]
2773 fn tag_stack_prev_clamps_at_zero() {
2774 let mut s = TagStack::default();
2775 s.set_active(
2776 "foo".into(),
2777 vec![crate::tags::TagEntry {
2778 file: std::path::PathBuf::from("/a"),
2779 address: crate::tags::TagAddress::Line(1),
2780 }],
2781 );
2782 assert_eq!(s.prev(), TagStepResult::AtBoundary);
2783 }
2784
2785 #[test]
2786 fn tag_stack_next_with_no_active_returns_no_active() {
2787 let mut s = TagStack::default();
2788 assert_eq!(s.next(), TagStepResult::NoActive);
2789 assert_eq!(s.prev(), TagStepResult::NoActive);
2790 }
2791
2792 #[test]
2793 fn tag_stack_set_active_replaces_previous_list() {
2794 let mut s = TagStack::default();
2795 s.set_active(
2796 "foo".into(),
2797 vec![crate::tags::TagEntry {
2798 file: std::path::PathBuf::from("/a"),
2799 address: crate::tags::TagAddress::Line(1),
2800 }],
2801 );
2802 s.set_active(
2803 "bar".into(),
2804 vec![
2805 crate::tags::TagEntry {
2806 file: std::path::PathBuf::from("/x"),
2807 address: crate::tags::TagAddress::Line(5),
2808 },
2809 crate::tags::TagEntry {
2810 file: std::path::PathBuf::from("/y"),
2811 address: crate::tags::TagAddress::Line(6),
2812 },
2813 ],
2814 );
2815 let active = s.active.as_ref().unwrap();
2816 assert_eq!(active.name, "bar");
2817 assert_eq!(active.matches.len(), 2);
2818 assert_eq!(active.cursor, 0);
2819 }
2820
2821 #[test]
2822 fn writer_emits_color_for_red_cell() {
2823 let cells = vec![Cell::Char {
2824 ch: 'h',
2825 width: 1,
2826 style: crate::ansi::Style {
2827 fg: Some(crate::ansi::Color::Ansi(1)),
2828 ..Default::default()
2829 },
2830 hyperlink: None,
2831 }];
2832 let mut buf: Vec<u8> = Vec::new();
2833 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
2834 let s = String::from_utf8_lossy(&buf);
2835 assert!(s.contains("\x1b["), "expected ANSI escape in output: {s:?}");
2836 assert!(s.contains('h'));
2837 }
2838
2839 #[test]
2840 fn writer_emits_osc8_for_hyperlink_cell() {
2841 let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
2842 let cells = vec![Cell::Char {
2843 ch: 'c',
2844 width: 1,
2845 style: crate::ansi::Style::default(),
2846 hyperlink: Some(link),
2847 }];
2848 let mut buf: Vec<u8> = Vec::new();
2849 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
2850 let s = String::from_utf8_lossy(&buf);
2851 assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
2852 }
2853}