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() {
902 src.pump();
903 viewport.extend_visible_lines(&idx, src.as_ref());
904 viewport.goto_bottom(src.as_ref(), &mut idx);
905 }
906
907 let mut needs_redraw = true;
909 let mut mode = InputMode::Normal;
910 let mut numeric_prefix: Option<usize> = None;
911 let mut marks: HashMap<char, (usize, usize)> = HashMap::new();
912 let mut previous_position: Option<(usize, usize)> = None;
913 let mut current_file_index: usize = file_set.current_index();
914 let mut transient_status: Option<String> = None;
915 let mut tag_stack = TagStack::default();
916 let mut overlay: Option<Box<dyn crate::overlay::Overlay>> = None;
917 let mut overlay_flash: Option<(&'static str, std::time::Instant)> = None;
918 let mouse_enabled = args.mouse;
919
920 if let Some(tag_name) = args.tag.as_deref() {
921 let _ = refresh_tag_file(&mut tag_file);
922 if let Some(msg) = dispatch_tag_jump(
923 tag_name,
924 tag_file.as_ref(),
925 &mut tag_stack,
926 &mut file_set,
927 &mut current_file_index,
928 &args,
929 preprocessor.as_ref(),
930 record_start_regex.as_ref(),
931 &mut viewport,
932 &mut src,
933 &mut idx,
934 ) {
935 return Err(crate::error::Error::Runtime(format!("startup tag jump failed: {msg}")));
936 }
937 }
938
939 loop {
940 if sigterm.load(Ordering::SeqCst) {
941 break;
942 }
943
944 if needs_redraw {
945 if let Some(ov) = overlay.as_ref() {
946 let w = cols;
947 let h = viewport.body_rows() + 1;
948 let mut ovframe = ov.render(w, h);
949 if let Some((msg, started)) = overlay_flash {
950 if started.elapsed() < std::time::Duration::from_millis(1500) {
951 ovframe.status = format!("[{msg}]");
952 } else {
953 overlay_flash = None;
954 }
955 }
956 render_overlay(&mut stdout, &ovframe, w, h)
957 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
958 needs_redraw = false;
959 continue;
960 }
961 let mut frame = viewport.frame(src.as_ref(), &mut idx);
962 match &mode {
965 InputMode::SearchPrompt { direction, buffer, error } => {
966 let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
967 frame.status = match error {
968 Some(e) => format!("{prefix}{buffer} [error: {e}]"),
969 None => format!("{prefix}{buffer}"),
970 };
971 }
972 InputMode::ShellPrompt { buffer, error } => {
973 frame.status = match error {
974 Some(e) => format!("!{buffer} [error: {e}]"),
975 None => format!("!{buffer}"),
976 };
977 }
978 InputMode::ColonPrompt { buffer, error } => {
979 frame.status = match error {
980 Some(e) => format!(":{buffer} [error: {e}]"),
981 None => format!(":{buffer}"),
982 };
983 }
984 InputMode::TagPrompt { buffer, error, .. } => {
985 frame.status = match error {
986 Some(e) => format!("tag: {buffer} [error: {e}]"),
987 None => format!("tag: {buffer}"),
988 };
989 }
990 _ => {
991 if let Some(msg) = transient_status.take() {
992 frame.status = msg;
993 }
994 }
995 }
996 write_frame(&mut stdout, &frame, cols, rows, truecolor)
997 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
998 needs_redraw = false;
999 }
1000
1001 match poll(timeout) {
1003 Ok(true) => {
1004 let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
1005 match &mut mode {
1008 InputMode::SearchPrompt { direction, buffer, error } => {
1009 if let Event::Key(KeyEvent { code, .. }) = event {
1010 match code {
1011 KeyCode::Esc => { mode = InputMode::Normal; needs_redraw = true; }
1012 KeyCode::Enter => {
1013 if buffer.is_empty() {
1014 if viewport.search_active() {
1018 let reverse = !matches!(
1019 (viewport.search_direction(), *direction),
1020 (SearchDirection::Forward, SearchDirection::Forward)
1021 | (SearchDirection::Backward, SearchDirection::Backward)
1022 );
1023 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1024 viewport.search_repeat(src.as_ref(), &mut idx, reverse);
1025 }
1026 mode = InputMode::Normal;
1027 } else {
1028 match viewport.set_search(buffer.clone(), *direction) {
1029 Ok(()) => {
1030 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1031 viewport.search_repeat(src.as_ref(), &mut idx, false);
1032 mode = InputMode::Normal;
1033 }
1034 Err(e) => { *error = Some(e); }
1035 }
1036 }
1037 needs_redraw = true;
1038 }
1039 KeyCode::Backspace => {
1040 buffer.pop();
1041 *error = None;
1042 needs_redraw = true;
1043 }
1044 KeyCode::Char(c) => {
1045 buffer.push(c);
1046 *error = None;
1047 needs_redraw = true;
1048 }
1049 _ => {}
1050 }
1051 }
1052 continue;
1053 }
1054 InputMode::OptionPrefix => {
1055 if let Event::Key(KeyEvent { code, .. }) = event {
1056 match code {
1057 KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
1058 KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
1059 KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
1060 KeyCode::Char('P') | KeyCode::Char('p') => {
1061 mode = InputMode::PrettifyPrefix;
1063 needs_redraw = true;
1064 continue;
1065 }
1066 _ => {}
1067 }
1068 }
1069 mode = InputMode::Normal;
1070 needs_redraw = true;
1071 continue;
1072 }
1073 InputMode::PrettifyPrefix => {
1074 if let Event::Key(KeyEvent { code, .. }) = event {
1075 let target: Option<PrettifyTarget> = match code {
1076 KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
1077 KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
1078 KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
1079 KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
1080 KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
1081 KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
1082 KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
1083 KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
1084 _ => None,
1085 };
1086 if let Some(t) = target {
1087 apply_prettify(
1088 src.as_ref(),
1089 &mut viewport,
1090 &mut idx,
1091 rebuild_spec,
1092 t,
1093 );
1094 last_revision = src.revision();
1095 }
1096 }
1097 mode = InputMode::Normal;
1098 needs_redraw = true;
1099 continue;
1100 }
1101 InputMode::MarkSetPending => {
1102 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1103 if is_valid_mark_name(c) {
1104 mark_set(&mut marks, c, current_file_index, viewport.top_line());
1105 }
1106 }
1107 mode = InputMode::Normal;
1108 continue;
1109 }
1110 InputMode::MarkJumpPending => {
1111 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1112 if is_valid_mark_name(c) {
1113 match mark_jump(&marks, c, current_file_index, &mut previous_position, viewport.top_line()) {
1114 Some(MarkTarget::SameFile { line }) => {
1115 let clamped = line.min(idx.line_count().saturating_sub(1));
1116 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1117 needs_redraw = true;
1118 }
1119 Some(MarkTarget::OtherFile { file_index, line }) => {
1120 if file_index < file_set.len() {
1121 file_set.set_current_index(file_index);
1122 let path = file_set.current().unwrap().to_path_buf();
1123 if let Err(e) = switch_file(
1124 &path, file_index, file_set.len(),
1125 &args, preprocessor.as_ref(),
1126 &mut viewport, &mut src, &mut idx,
1127 record_start_regex.as_ref(),
1128 ) {
1129 transient_status = Some(format!("[open: {e}]"));
1130 } else {
1131 let clamped = line.min(idx.line_count().saturating_sub(1));
1132 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1133 current_file_index = file_index;
1134 needs_redraw = true;
1135 }
1136 }
1137 }
1138 None => {}
1139 }
1140 }
1141 }
1142 mode = InputMode::Normal;
1143 continue;
1144 }
1145 InputMode::ShellPrompt { buffer, error } => {
1146 if let Event::Key(KeyEvent { code, .. }) = event {
1147 match code {
1148 KeyCode::Esc => {
1149 mode = InputMode::Normal;
1150 needs_redraw = true;
1151 }
1152 KeyCode::Enter => {
1153 if buffer.is_empty() {
1154 mode = InputMode::Normal;
1155 } else {
1156 match crate::shell::run_shell_command(buffer) {
1157 Ok(()) => {
1158 mode = InputMode::Normal;
1159 }
1160 Err(e) => {
1161 *error = Some(e.to_string());
1162 }
1163 }
1164 }
1165 needs_redraw = true;
1166 }
1167 KeyCode::Backspace => {
1168 buffer.pop();
1169 *error = None;
1170 needs_redraw = true;
1171 }
1172 KeyCode::Char(c) => {
1173 buffer.push(c);
1174 *error = None;
1175 needs_redraw = true;
1176 }
1177 _ => {}
1178 }
1179 }
1180 continue;
1181 }
1182 InputMode::CtrlXPending => {
1183 let is_ctrl_x = matches!(
1184 event,
1185 Event::Key(KeyEvent {
1186 code: KeyCode::Char('x'),
1187 modifiers: KeyModifiers::CONTROL,
1188 ..
1189 })
1190 );
1191 if is_ctrl_x {
1192 match jump_previous(&mut previous_position, current_file_index, viewport.top_line()) {
1193 Some(MarkTarget::SameFile { line }) => {
1194 let clamped = line.min(idx.line_count().saturating_sub(1));
1195 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1196 needs_redraw = true;
1197 }
1198 Some(MarkTarget::OtherFile { file_index, line }) => {
1199 if file_index < file_set.len() {
1200 file_set.set_current_index(file_index);
1201 let path = file_set.current().unwrap().to_path_buf();
1202 if let Err(e) = switch_file(
1203 &path, file_index, file_set.len(),
1204 &args, preprocessor.as_ref(),
1205 &mut viewport, &mut src, &mut idx,
1206 record_start_regex.as_ref(),
1207 ) {
1208 transient_status = Some(format!("[open: {e}]"));
1209 } else {
1210 let clamped = line.min(idx.line_count().saturating_sub(1));
1211 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1212 current_file_index = file_index;
1213 needs_redraw = true;
1214 }
1215 }
1216 }
1217 None => {}
1218 }
1219 mode = InputMode::Normal;
1220 continue;
1221 }
1222 mode = InputMode::Normal;
1224 }
1226 InputMode::ColonPrompt { buffer, error } => {
1227 if let Event::Key(KeyEvent { code, .. }) = event {
1228 match code {
1229 KeyCode::Esc => {
1230 mode = InputMode::Normal;
1231 needs_redraw = true;
1232 }
1233 KeyCode::Enter => {
1234 if buffer.is_empty() {
1235 mode = InputMode::Normal;
1236 } else {
1237 match parse_colon_command(buffer) {
1238 Ok(cmd) => {
1239 let is_tag_cmd = matches!(
1240 &cmd,
1241 ColonCommand::Tag(_)
1242 | ColonCommand::TagNext
1243 | ColonCommand::TagPrev
1244 | ColonCommand::TagSelect(_),
1245 );
1246 let reload_msg = if is_tag_cmd {
1247 refresh_tag_file(&mut tag_file)
1248 } else {
1249 None
1250 };
1251 let outcome = dispatch_colon_command(
1252 cmd,
1253 &mut file_set,
1254 &mut current_file_index,
1255 &args,
1256 preprocessor.as_ref(),
1257 record_start_regex.as_ref(),
1258 &mut viewport,
1259 &mut src,
1260 &mut idx,
1261 &mut tag_stack,
1262 tag_file.as_ref(),
1263 );
1264 match outcome {
1265 ColonOutcome::Continue(msg) => {
1266 transient_status = msg.or(reload_msg);
1267 }
1268 ColonOutcome::Quit => break,
1269 ColonOutcome::DispatchCommand(Command::OpenPicker) => {
1270 let saved = (0..file_set.len())
1271 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1272 .collect::<Vec<_>>();
1273 overlay = Some(Box::new(
1274 crate::overlay::picker::FilePicker::new(&file_set, saved)
1275 ));
1276 needs_redraw = true;
1277 }
1278 ColonOutcome::DispatchCommand(Command::OpenHelp) => {
1279 let remaps = keymap.user_keys_by_command_name();
1280 overlay = Some(Box::new(
1281 crate::overlay::help::HelpOverlay::new(remaps)
1282 ));
1283 needs_redraw = true;
1284 }
1285 ColonOutcome::DispatchCommand(Command::OpenTagPicker) => {
1286 if let Some(active) = tag_stack.active.as_ref() {
1287 overlay = Some(Box::new(
1288 crate::overlay::tag_picker::TagPicker::new(
1289 active.name.clone(),
1290 active.matches.clone(),
1291 active.cursor,
1292 )
1293 ));
1294 needs_redraw = true;
1295 }
1296 }
1297 ColonOutcome::DispatchCommand(cmd) => {
1298 debug_assert!(false, "colon dispatcher emitted unexpected Command: {cmd:?}");
1299 }
1301 }
1302 mode = InputMode::Normal;
1303 }
1304 Err(e) => {
1305 *error = Some(e.to_string());
1306 }
1307 }
1308 }
1309 needs_redraw = true;
1310 }
1311 KeyCode::Backspace => {
1312 buffer.pop();
1313 *error = None;
1314 needs_redraw = true;
1315 }
1316 KeyCode::Char(c) => {
1317 buffer.push(c);
1318 *error = None;
1319 needs_redraw = true;
1320 }
1321 _ => {}
1322 }
1323 }
1324 continue;
1325 }
1326 InputMode::TagPrompt { buffer, error, last_tab_matches } => {
1327 if let Event::Key(KeyEvent { code, .. }) = event {
1328 match code {
1329 KeyCode::Esc => {
1330 mode = InputMode::Normal;
1331 needs_redraw = true;
1332 }
1333 KeyCode::Enter => {
1334 if buffer.is_empty() {
1335 mode = InputMode::Normal;
1336 } else {
1337 let name = buffer.clone();
1338 let reload_msg = refresh_tag_file(&mut tag_file);
1339 let msg = dispatch_tag_jump(
1340 &name,
1341 tag_file.as_ref(),
1342 &mut tag_stack,
1343 &mut file_set,
1344 &mut current_file_index,
1345 &args,
1346 preprocessor.as_ref(),
1347 record_start_regex.as_ref(),
1348 &mut viewport,
1349 &mut src,
1350 &mut idx,
1351 );
1352 transient_status = msg.or(reload_msg);
1353 mode = InputMode::Normal;
1354 }
1355 needs_redraw = true;
1356 }
1357 KeyCode::Backspace => {
1358 buffer.pop();
1359 *error = None;
1360 *last_tab_matches = None;
1361 needs_redraw = true;
1362 }
1363 KeyCode::Tab => {
1364 let _ = refresh_tag_file(&mut tag_file);
1365 let names: Vec<String> = match tag_file.as_ref() {
1366 Some(tf) => tf
1367 .names()
1368 .filter(|n| n.starts_with(buffer.as_str()))
1369 .map(String::from)
1370 .collect(),
1371 None => Vec::new(),
1372 };
1373 match (names.len(), last_tab_matches.as_ref()) {
1374 (0, _) => {
1375 *error = Some("no tags match".into());
1376 *last_tab_matches = None;
1377 }
1378 (1, _) => {
1379 *buffer = names.into_iter().next().unwrap();
1380 *error = None;
1381 *last_tab_matches = None;
1382 }
1383 (n, Some(prev)) if prev.len() == n => {
1384 *error = Some(format!("{n} matches"));
1385 }
1386 (n, _) => {
1387 let lcp = longest_common_prefix(&names);
1388 if lcp.len() > buffer.len() {
1389 *buffer = lcp;
1390 *error = None;
1391 } else {
1392 *error = Some(format!("{n} matches"));
1393 }
1394 *last_tab_matches = Some(names);
1395 }
1396 }
1397 needs_redraw = true;
1398 }
1399 KeyCode::Char(c) => {
1400 buffer.push(c);
1401 *error = None;
1402 *last_tab_matches = None;
1403 needs_redraw = true;
1404 }
1405 _ => {}
1406 }
1407 }
1408 continue;
1409 }
1410 InputMode::Normal => {}
1411 }
1412 if let crossterm::event::Event::Resize(c, r) = event {
1415 cols = c;
1416 rows = r;
1417 viewport.resize(c, r);
1418 needs_redraw = true;
1419 if overlay.is_some() {
1420 continue;
1422 }
1423 }
1426 if let Some(ov) = overlay.as_mut() {
1430 let outcome = match &event {
1431 Event::Key(ke) => ov.handle_key(*ke),
1432 Event::Mouse(me) => ov.handle_mouse(*me, viewport.body_rows()),
1433 Event::Resize(_, _) => crate::overlay::OverlayOutcome::Stay,
1434 _ => crate::overlay::OverlayOutcome::Stay,
1435 };
1436 match outcome {
1437 crate::overlay::OverlayOutcome::Stay => {
1438 needs_redraw = true;
1439 continue;
1440 }
1441 crate::overlay::OverlayOutcome::Close => {
1442 overlay = None;
1443 overlay_flash = None;
1444 needs_redraw = true;
1445 continue;
1446 }
1447 crate::overlay::OverlayOutcome::CloseAnd(cmd) => {
1448 overlay = None;
1449 overlay_flash = None;
1450 if let Command::SelectFile(i) = cmd {
1451 if i < file_set.len() {
1452 file_set.set_current_index(i);
1453 if let Some(msg) = switch_to_current_file(
1454 &mut file_set, &mut current_file_index,
1455 &args, preprocessor.as_ref(),
1456 record_start_regex.as_ref(),
1457 &mut viewport, &mut src, &mut idx,
1458 ) {
1459 transient_status = Some(msg);
1460 }
1461 }
1462 } else if let Command::SelectTagMatch(idx_pick) = cmd {
1463 if let Some(active) = tag_stack.active.as_mut() {
1464 if idx_pick < active.matches.len() {
1465 active.cursor = idx_pick;
1466 let entry = active.matches[idx_pick].clone();
1467 let msg = dispatch_match(
1468 &entry,
1469 &mut file_set,
1470 &mut current_file_index,
1471 &args,
1472 preprocessor.as_ref(),
1473 record_start_regex.as_ref(),
1474 &mut viewport,
1475 &mut src,
1476 &mut idx,
1477 );
1478 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1479 if let Some(m) = msg {
1480 transient_status = Some(m);
1481 }
1482 }
1483 }
1484 }
1485 needs_redraw = true;
1486 continue;
1487 }
1488 crate::overlay::OverlayOutcome::Apply(cmd) => {
1489 if let Command::DropFileAt(target) = cmd {
1490 if file_set.len() > 1 && target < file_set.len() {
1491 let saved_cur = file_set.current_index();
1492 file_set.set_current_index(target);
1493 let _ = file_set.delete_current();
1494 if target < saved_cur {
1498 let restored = saved_cur.saturating_sub(1);
1499 file_set.set_current_index(restored);
1500 } else if target > saved_cur {
1501 file_set.set_current_index(saved_cur);
1502 }
1503 if let Some(msg) = switch_to_current_file(
1506 &mut file_set, &mut current_file_index,
1507 &args, preprocessor.as_ref(),
1508 record_start_regex.as_ref(),
1509 &mut viewport, &mut src, &mut idx,
1510 ) {
1511 transient_status = Some(msg);
1512 }
1513 if let Some(ov) = overlay.as_mut() {
1514 ov.refresh(crate::overlay::OverlayContext { file_set: &file_set });
1515 }
1516 }
1517 }
1518 needs_redraw = true;
1519 continue;
1520 }
1521 crate::overlay::OverlayOutcome::Refuse(msg) => {
1522 overlay_flash = Some((msg, std::time::Instant::now()));
1523 needs_redraw = true;
1524 continue;
1525 }
1526 }
1527 }
1528 if let crossterm::event::Event::Mouse(me) = &event {
1532 if mouse_enabled {
1533 use crossterm::event::MouseEventKind;
1534 match me.kind {
1535 MouseEventKind::ScrollDown => {
1536 viewport.scroll_lines(3, src.as_ref(), &mut idx);
1537 needs_redraw = true;
1538 }
1539 MouseEventKind::ScrollUp => {
1540 viewport.scroll_lines(-3, src.as_ref(), &mut idx);
1541 needs_redraw = true;
1542 }
1543 _ => {}
1544 }
1545 }
1546 continue;
1547 }
1548 let mut cmd: Option<Command> = None;
1552 if let InputMode::Normal = mode {
1553 if let Event::Key(ke) = &event {
1554 if let Some(target) = keymap.lookup(ke) {
1555 match target {
1556 crate::keys::BindingTarget::Shell(cmd_text) => {
1557 let cmd_text = cmd_text.clone();
1558 if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
1559 let _ = writeln!(std::io::stderr(),
1560 "[shell: {e}]");
1561 }
1562 needs_redraw = true;
1563 continue;
1564 }
1565 crate::keys::BindingTarget::Command(c) => {
1566 cmd = Some(c.clone());
1567 }
1568 }
1569 }
1570 }
1571 }
1572 let cmd = cmd.unwrap_or_else(|| translate(event));
1573 let prefix_at_cmd = numeric_prefix.take();
1576 match cmd {
1577 Command::Digit(d) => {
1578 let cur = prefix_at_cmd.unwrap_or(0);
1579 let next = cur.saturating_mul(10).saturating_add(d as usize);
1580 if next <= 99_999_999 {
1581 numeric_prefix = Some(next);
1582 } else {
1583 numeric_prefix = prefix_at_cmd;
1585 }
1586 continue;
1587 }
1588 Command::Cancel => {
1589 continue;
1591 }
1592 Command::GotoLine => {
1593 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1594 match prefix_at_cmd {
1595 Some(line) if line > 0 => {
1596 viewport.goto_line(line - 1, src.as_ref(), &mut idx);
1597 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1598 }
1599 _ => {
1600 viewport.goto_top();
1601 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1602 }
1603 }
1604 needs_redraw = true;
1605 }
1606 Command::GotoRecord => {
1607 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1608 match prefix_at_cmd {
1609 Some(rec) if rec > 0 => {
1610 viewport.goto_record(rec - 1, src.as_ref(), &mut idx);
1611 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1612 }
1613 _ => viewport.goto_bottom(src.as_ref(), &mut idx),
1614 }
1615 needs_redraw = true;
1616 }
1617 Command::GotoPercent => {
1618 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1619 match prefix_at_cmd {
1620 Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
1621 _ => viewport.goto_top(),
1622 }
1623 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1624 needs_redraw = true;
1625 }
1626 Command::Quit => break,
1627 Command::Resize(c, r) => {
1628 cols = c; rows = r;
1629 viewport.resize(c, r);
1630 needs_redraw = true;
1631 }
1632 Command::ScrollLines(n) => {
1633 viewport.scroll_lines(n, src.as_ref(), &mut idx);
1634 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1635 if viewport.note_motion_for_eof(n > 0, &idx) { break; }
1636 needs_redraw = true;
1637 }
1638 Command::ScrollLogicalLines(n) => {
1639 viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
1640 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1641 if viewport.note_motion_for_eof(n > 0, &idx) { break; }
1642 needs_redraw = true;
1643 }
1644 Command::PageDown => {
1645 viewport.page_down(src.as_ref(), &mut idx);
1646 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1647 if viewport.note_motion_for_eof(true, &idx) { break; }
1648 needs_redraw = true;
1649 }
1650 Command::PageUp => {
1651 viewport.page_up(src.as_ref(), &mut idx);
1652 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1653 viewport.note_motion_for_eof(false, &idx);
1654 needs_redraw = true;
1655 }
1656 Command::HalfPageDown => {
1657 viewport.half_page_down(src.as_ref(), &mut idx);
1658 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1659 if viewport.note_motion_for_eof(true, &idx) { break; }
1660 needs_redraw = true;
1661 }
1662 Command::HalfPageUp => {
1663 viewport.half_page_up(src.as_ref(), &mut idx);
1664 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1665 viewport.note_motion_for_eof(false, &idx);
1666 needs_redraw = true;
1667 }
1668 Command::Refresh => {
1669 needs_redraw = true;
1670 }
1671 Command::Reload => {
1672 src.pump();
1675 if src.revision() != last_revision {
1676 rebuild_after_replace(
1677 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1678 );
1679 last_revision = src.revision();
1680 needs_redraw = true;
1681 }
1682 }
1683 Command::TogglePrettify => {
1684 apply_prettify(
1685 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1686 PrettifyTarget::Toggle,
1687 );
1688 last_revision = src.revision();
1689 needs_redraw = true;
1690 }
1691 Command::SetPrettifyMode(m) => {
1692 apply_prettify(
1693 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1694 PrettifyTarget::Mode(m),
1695 );
1696 last_revision = src.revision();
1697 needs_redraw = true;
1698 }
1699 Command::RedetectPrettify => {
1700 apply_prettify(
1701 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1702 PrettifyTarget::Auto,
1703 );
1704 last_revision = src.revision();
1705 needs_redraw = true;
1706 }
1707 Command::ToggleLineNumbers => {
1708 viewport.toggle_line_numbers();
1709 needs_redraw = true;
1710 }
1711 Command::ToggleChop => {
1712 viewport.toggle_chop();
1713 needs_redraw = true;
1714 }
1715 Command::ToggleFollow => {
1716 viewport.toggle_follow();
1717 if viewport.follow_mode() {
1718 src.pump();
1720 idx.notice_new_bytes(src.as_ref());
1721 viewport.goto_bottom(src.as_ref(), &mut idx);
1722 }
1723 needs_redraw = true;
1724 }
1725 Command::SearchForward => {
1726 mode = InputMode::SearchPrompt {
1727 direction: SearchDirection::Forward,
1728 buffer: String::new(),
1729 error: None,
1730 };
1731 needs_redraw = true;
1732 }
1733 Command::SearchBackward => {
1734 mode = InputMode::SearchPrompt {
1735 direction: SearchDirection::Backward,
1736 buffer: String::new(),
1737 error: None,
1738 };
1739 needs_redraw = true;
1740 }
1741 Command::ShellEscape => {
1742 mode = InputMode::ShellPrompt {
1743 buffer: String::new(),
1744 error: None,
1745 };
1746 needs_redraw = true;
1747 }
1748 Command::ColonPrompt => {
1749 mode = InputMode::ColonPrompt {
1750 buffer: String::new(),
1751 error: None,
1752 };
1753 needs_redraw = true;
1754 }
1755 Command::NextMatch => {
1756 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1757 if viewport.search_repeat(src.as_ref(), &mut idx, false) {
1758 needs_redraw = true;
1759 }
1760 }
1761 Command::PreviousMatch => {
1762 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1763 if viewport.search_repeat(src.as_ref(), &mut idx, true) {
1764 needs_redraw = true;
1765 }
1766 }
1767 Command::OptionPrefix => {
1768 mode = InputMode::OptionPrefix;
1769 }
1770 Command::MarkSet => {
1771 mode = InputMode::MarkSetPending;
1772 }
1773 Command::MarkJump => {
1774 mode = InputMode::MarkJumpPending;
1775 }
1776 Command::CtrlXPrefix => {
1777 mode = InputMode::CtrlXPending;
1778 }
1779 Command::JumpPrevious => {
1780 }
1783 Command::TagPrompt => {
1784 if tag_file.is_none() {
1785 transient_status = Some("[no tags file loaded]".into());
1786 needs_redraw = true;
1787 } else {
1788 mode = InputMode::TagPrompt {
1789 buffer: String::new(),
1790 error: None,
1791 last_tab_matches: None,
1792 };
1793 needs_redraw = true;
1794 }
1795 }
1796 Command::TagPop => match tag_stack.pop() {
1797 Some((file_index, line)) => {
1798 if file_index != current_file_index && file_index < file_set.len() {
1799 file_set.set_current_index(file_index);
1800 let path = file_set.current().unwrap().to_path_buf();
1801 if let Err(e) = switch_file(
1802 &path,
1803 file_index,
1804 file_set.len(),
1805 &args,
1806 preprocessor.as_ref(),
1807 &mut viewport,
1808 &mut src,
1809 &mut idx,
1810 record_start_regex.as_ref(),
1811 ) {
1812 transient_status = Some(format!("[open: {e}]"));
1813 } else {
1814 current_file_index = file_index;
1815 }
1816 }
1817 let clamped = line.min(idx.line_count().saturating_sub(1));
1818 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1819 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1820 needs_redraw = true;
1821 }
1822 None => {
1823 transient_status = Some("[tag stack empty]".into());
1824 needs_redraw = true;
1825 }
1826 },
1827 Command::OpenPicker => {
1828 let saved = (0..file_set.len())
1829 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1830 .collect::<Vec<_>>();
1831 overlay = Some(Box::new(
1832 crate::overlay::picker::FilePicker::new(&file_set, saved)
1833 ));
1834 needs_redraw = true;
1835 }
1836 Command::OpenHelp => {
1837 let remaps = keymap.user_keys_by_command_name();
1838 overlay = Some(Box::new(
1839 crate::overlay::help::HelpOverlay::new(remaps)
1840 ));
1841 needs_redraw = true;
1842 }
1843 Command::SelectFile(_)
1844 | Command::DropFileAt(_)
1845 | Command::SelectTagMatch(_)
1846 | Command::OpenTagPicker => {
1847 }
1849 Command::MouseEvent(_) => {
1850 }
1852 Command::Noop => {}
1853 }
1854 }
1855 Ok(false) => {
1856 if viewport.live_mode() {
1858 let was_at_bottom = viewport.is_at_bottom(&idx);
1859 src.pump();
1860 if src.revision() != last_revision {
1861 rebuild_after_replace(
1862 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1863 );
1864 if was_at_bottom {
1865 viewport.goto_bottom(src.as_ref(), &mut idx);
1866 }
1867 last_revision = src.revision();
1868 needs_redraw = true;
1869 }
1870 } else if viewport.follow_mode() {
1871 let was_at_bottom = viewport.is_at_bottom(&idx);
1872 src.pump();
1873 if src.take_rotated() {
1874 if let Some(path) = src.path().map(|p| p.to_path_buf()) {
1880 match crate::open::open_source_for_path(
1881 &path, &args, preprocessor.as_ref(),
1882 ) {
1883 Ok((new_src, _label, _err)) => {
1884 src = new_src;
1885 idx = LineIndex::new();
1886 if let Some(n) = rebuild_spec.head {
1887 idx.set_head_cap(n);
1888 }
1889 viewport.invalidate_filter_cache();
1890 idx.notice_new_bytes(src.as_ref());
1891 viewport.extend_visible_lines(&idx, src.as_ref());
1892 viewport.goto_bottom(src.as_ref(), &mut idx);
1893 viewport.flash("(F reopened)", 4);
1894 needs_redraw = true;
1895 continue;
1896 }
1897 Err(e) => {
1898 transient_status = Some(format!("[reopen failed: {e}]"));
1899 needs_redraw = true;
1900 }
1901 }
1902 }
1903 }
1904 let lines_before = idx.line_count();
1905 idx.notice_new_bytes(src.as_ref());
1906 viewport.extend_visible_lines(&idx, src.as_ref());
1907 if idx.line_count() != lines_before {
1908 needs_redraw = true;
1909 viewport.note_growth();
1910 if was_at_bottom {
1911 viewport.goto_bottom(src.as_ref(), &mut idx);
1912 }
1913 } else {
1914 viewport.tick_idle();
1915 }
1916 viewport.tick_flash();
1917 if args.exit_follow_on_close && src.is_complete() {
1923 break;
1924 }
1925 } else if !src.is_complete() {
1926 let lines_before = idx.line_count();
1929 idx.notice_new_bytes(src.as_ref());
1930 viewport.extend_visible_lines(&idx, src.as_ref());
1931 if idx.line_count() != lines_before {
1932 needs_redraw = true;
1933 }
1934 }
1935 }
1936 Err(_) => {
1937 std::thread::sleep(timeout);
1939 }
1940 }
1941 }
1942 Ok(())
1943}
1944
1945#[derive(Debug, Clone, Copy)]
1947enum PrettifyTarget {
1948 Mode(PrettifyMode),
1950 Toggle,
1952 Auto,
1954}
1955
1956fn apply_prettify(
1960 src: &dyn Source,
1961 viewport: &mut Viewport,
1962 idx: &mut LineIndex,
1963 spec: RebuildSpec,
1964 target: PrettifyTarget,
1965) {
1966 if src.prettify_mode().is_none() {
1968 return;
1969 }
1970 match target {
1971 PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
1972 PrettifyTarget::Toggle => src.toggle_prettify(),
1973 PrettifyTarget::Auto => src.redetect_prettify(),
1974 }
1975 rebuild_after_replace(src, viewport, idx, spec);
1976 viewport.set_prettify_label(src.prettify_label());
1977}
1978
1979fn rebuild_after_replace(
1985 src: &dyn Source,
1986 viewport: &mut Viewport,
1987 idx: &mut LineIndex,
1988 spec: RebuildSpec,
1989) {
1990 let new_off = match spec.tail {
1991 Some(n) => find_tail_offset(src, n),
1992 None => 0,
1993 };
1994 *idx = LineIndex::new_starting_at(new_off);
1995 if let Some(n) = spec.head {
1996 idx.set_head_cap(n);
1997 }
1998 viewport.invalidate_filter_cache();
1999 idx.notice_new_bytes(src);
2000 viewport.extend_visible_lines(idx, src);
2001 viewport.clamp_top_line(idx.line_count());
2002}
2003
2004fn to_crossterm_color(c: crate::ansi::Color, truecolor: bool) -> crossterm::style::Color {
2005 use crossterm::style::Color as CC;
2006 use crate::ansi::Color;
2007 match c {
2008 Color::Ansi(0) => CC::Black,
2009 Color::Ansi(1) => CC::DarkRed,
2010 Color::Ansi(2) => CC::DarkGreen,
2011 Color::Ansi(3) => CC::DarkYellow,
2012 Color::Ansi(4) => CC::DarkBlue,
2013 Color::Ansi(5) => CC::DarkMagenta,
2014 Color::Ansi(6) => CC::DarkCyan,
2015 Color::Ansi(7) => CC::Grey,
2016 Color::Ansi(8) => CC::DarkGrey,
2017 Color::Ansi(9) => CC::Red,
2018 Color::Ansi(10) => CC::Green,
2019 Color::Ansi(11) => CC::Yellow,
2020 Color::Ansi(12) => CC::Blue,
2021 Color::Ansi(13) => CC::Magenta,
2022 Color::Ansi(14) => CC::Cyan,
2023 Color::Ansi(15) => CC::White,
2024 Color::Ansi(_) => CC::Reset,
2025 Color::Indexed(n) => CC::AnsiValue(n),
2026 Color::Rgb(r, g, b) => {
2027 if truecolor {
2028 CC::Rgb { r, g, b }
2029 } else {
2030 CC::AnsiValue(crate::render::rgb_to_256(r, g, b))
2031 }
2032 }
2033 Color::Default => CC::Reset,
2034 }
2035}
2036
2037fn emit_style_diff<W: Write>(
2040 out: &mut W,
2041 prev: &crate::ansi::Style,
2042 next: &crate::ansi::Style,
2043 truecolor: bool,
2044) -> io::Result<()> {
2045 let intensity_changed = prev.bold != next.bold || prev.dim != next.dim;
2049
2050 let fg_changed = prev.fg != next.fg;
2054 let bg_changed = prev.bg != next.bg;
2055
2056 if (fg_changed && next.fg.is_none()) || (bg_changed && next.bg.is_none()) {
2057 out.queue(ResetColor)?;
2058 if let Some(c) = next.fg {
2060 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2061 }
2062 if let Some(c) = next.bg {
2063 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2064 }
2065 } else {
2066 if fg_changed {
2067 if let Some(c) = next.fg {
2068 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2069 }
2070 }
2071 if bg_changed {
2072 if let Some(c) = next.bg {
2073 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2074 }
2075 }
2076 }
2077
2078 if intensity_changed {
2079 if next.bold {
2080 out.queue(SetAttribute(Attribute::Bold))?;
2081 } else if next.dim {
2082 out.queue(SetAttribute(Attribute::Dim))?;
2083 } else {
2084 out.queue(SetAttribute(Attribute::NormalIntensity))?;
2085 }
2086 }
2087 if prev.italic != next.italic {
2088 out.queue(SetAttribute(if next.italic { Attribute::Italic } else { Attribute::NoItalic }))?;
2089 }
2090 if prev.underline != next.underline {
2091 out.queue(SetAttribute(if next.underline { Attribute::Underlined } else { Attribute::NoUnderline }))?;
2092 }
2093 if prev.reverse != next.reverse {
2094 out.queue(SetAttribute(if next.reverse { Attribute::Reverse } else { Attribute::NoReverse }))?;
2095 }
2096 if prev.strike != next.strike {
2097 out.queue(SetAttribute(if next.strike { Attribute::CrossedOut } else { Attribute::NotCrossedOut }))?;
2098 }
2099 Ok(())
2100}
2101
2102fn emit_hyperlink_diff<W: Write>(
2103 out: &mut W,
2104 prev: &Option<Arc<str>>,
2105 next: &Option<Arc<str>>,
2106) -> io::Result<()> {
2107 if prev == next {
2108 return Ok(());
2109 }
2110 if prev.is_some() {
2111 out.write_all(b"\x1b]8;;\x1b\\")?;
2112 }
2113 if let Some(uri) = next {
2114 out.write_all(b"\x1b]8;;")?;
2115 out.write_all(uri.as_bytes())?;
2116 out.write_all(b"\x1b\\")?;
2117 }
2118 Ok(())
2119}
2120
2121const SYNC_UPDATE_BEGIN: &[u8] = b"\x1b[?2026h";
2128const SYNC_UPDATE_END: &[u8] = b"\x1b[?2026l";
2129
2130fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16, truecolor: bool) -> io::Result<()> {
2131 out.write_all(SYNC_UPDATE_BEGIN)?;
2143
2144 out.queue(SetAttribute(Attribute::Reset))?;
2146 out.queue(ResetColor)?;
2147
2148 for (i, row) in frame.body.iter().enumerate() {
2149 out.queue(MoveTo(0, i as u16))?;
2150 out.queue(Clear(ClearType::UntilNewLine))?;
2154 out.queue(SetAttribute(Attribute::Reset))?;
2157
2158 if let Some(Some(raw)) = frame.raw_rows.get(i) {
2163 if !raw.is_empty() {
2164 out.write_all(raw)?;
2165 }
2166 out.queue(ResetColor)?;
2168 out.queue(SetAttribute(Attribute::Reset))?;
2169 continue;
2170 }
2171
2172 let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
2173 let base_style = if matches!(row_style, RowStyle::Dim) {
2178 out.queue(SetAttribute(Attribute::Dim))?;
2179 crate::ansi::Style { dim: true, ..Default::default() }
2180 } else {
2181 crate::ansi::Style::default()
2182 };
2183 let no_highlights = Vec::new();
2184 let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
2185 write_row_with_highlights(out, row, cols, highlights, base_style, truecolor)?;
2186 }
2187 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2189 out.queue(Clear(ClearType::UntilNewLine))?;
2190 emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2191 let mut status = frame.status.clone();
2192 if status.len() > cols as usize {
2193 status.truncate(cols as usize);
2194 } else {
2195 let pad = cols as usize - status.len();
2196 status.push_str(&" ".repeat(pad));
2197 }
2198 out.queue(Print(status))?;
2199 out.queue(ResetColor)?;
2200 out.queue(SetAttribute(Attribute::Reset))?;
2201
2202 out.write_all(SYNC_UPDATE_END)?;
2205 out.flush()
2206}
2207
2208
2209fn write_row_with_highlights(
2220 out: &mut impl Write,
2221 row: &[Cell],
2222 cols: u16,
2223 highlights: &[std::ops::Range<usize>],
2224 base_style: crate::ansi::Style,
2225 truecolor: bool,
2226) -> io::Result<()> {
2227 let cols_usize = cols as usize;
2228
2229 let mut ranges: Vec<std::ops::Range<usize>> = highlights
2230 .iter()
2231 .filter_map(|r| {
2232 let s = r.start.min(cols_usize);
2233 let e = r.end.min(cols_usize);
2234 if e > s { Some(s..e) } else { None }
2235 })
2236 .collect();
2237 ranges.sort_by_key(|r| r.start);
2238
2239 let mut prev_style = base_style;
2242 let mut prev_link: Option<Arc<str>> = None;
2243
2244 let mut col = 0usize;
2245 let mut i = 0usize;
2246 while col < cols_usize && i < row.len() {
2247 let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
2248
2249 match &row[i] {
2250 Cell::Char { ch, width, style, hyperlink } => {
2251 let mut eff = *style;
2257 if in_highlight {
2258 eff.reverse = !eff.reverse;
2259 }
2260 if base_style.dim && !eff.bold {
2261 eff.dim = true;
2262 }
2263 emit_style_diff(out, &prev_style, &eff, truecolor)?;
2264 emit_hyperlink_diff(out, &prev_link, hyperlink)?;
2265 out.queue(Print(*ch))?;
2266 prev_style = eff;
2267 prev_link = hyperlink.clone();
2268 col += *width as usize;
2269 }
2270 Cell::Continuation => {
2271 }
2273 Cell::Empty => {
2274 let default = if base_style.dim {
2279 crate::ansi::Style { dim: true, ..Default::default() }
2280 } else {
2281 crate::ansi::Style::default()
2282 };
2283 emit_style_diff(out, &prev_style, &default, truecolor)?;
2284 emit_hyperlink_diff(out, &prev_link, &None)?;
2285 out.queue(Print(' '))?;
2286 prev_style = default;
2287 prev_link = None;
2288 col += 1;
2289 }
2290 }
2291 i += 1;
2292 }
2293
2294 emit_hyperlink_diff(out, &prev_link, &None)?;
2297 out.queue(ResetColor)?;
2298 out.queue(SetAttribute(Attribute::Reset))?;
2299
2300 Ok(())
2301}
2302
2303fn render_overlay(
2304 out: &mut impl Write,
2305 frame: &crate::overlay::OverlayFrame,
2306 width: u16,
2307 height: u16,
2308) -> io::Result<()> {
2309 out.write_all(SYNC_UPDATE_BEGIN)?;
2313 out.queue(SetAttribute(Attribute::Reset))?;
2314 out.queue(ResetColor)?;
2315 for row in 0..height.saturating_sub(1) {
2316 out.queue(MoveTo(0, row))?;
2317 out.queue(Clear(ClearType::UntilNewLine))?;
2318 out.queue(SetAttribute(Attribute::Reset))?;
2319 if let Some(line) = frame.body.get(row as usize) {
2320 let mut written = 0usize;
2321 for ch in line.chars() {
2322 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2323 if written + w > width as usize { break; }
2324 write!(out, "{ch}")?;
2325 written += w;
2326 }
2327 }
2328 }
2329 out.queue(MoveTo(0, height.saturating_sub(1)))?;
2330 out.queue(Clear(ClearType::UntilNewLine))?;
2331 out.queue(SetAttribute(Attribute::Reverse))?;
2332 let mut status = frame.status.clone();
2333 if status.len() > width as usize {
2335 status.truncate(width as usize);
2336 } else {
2337 let pad = width as usize - status.len();
2338 status.push_str(&" ".repeat(pad));
2339 }
2340 out.queue(Print(status))?;
2341 out.queue(ResetColor)?;
2342 out.queue(SetAttribute(Attribute::Reset))?;
2343 out.write_all(SYNC_UPDATE_END)?;
2344 out.flush()
2345}
2346
2347#[cfg(test)]
2348mod tests {
2349 use super::*;
2350
2351 #[test]
2352 fn parse_colon_n() {
2353 assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
2354 assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
2355 }
2356
2357 #[test]
2358 fn write_frame_brackets_with_sync_update_and_no_full_clear() {
2359 use crate::ansi::Style;
2364 use crate::render::Cell;
2365 use crate::viewport::{Frame, RowStyle};
2366
2367 let row: Vec<Cell> = (0..3)
2368 .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
2369 .collect();
2370 let frame = Frame {
2371 body: vec![row.clone(), row],
2372 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2373 highlights: vec![Vec::new(), Vec::new()],
2374 status: "status".into(),
2375 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
2376 raw_rows: vec![None, None],
2377 };
2378
2379 let mut buf: Vec<u8> = Vec::new();
2380 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2381 let s = std::str::from_utf8(&buf).expect("ascii");
2382
2383 let begin = s.find("\x1b[?2026h").expect("begin sync update");
2385 let end = s.find("\x1b[?2026l").expect("end sync update");
2386 assert!(begin < end, "begin must precede end");
2387 let first_a = s.find('a').expect("body char");
2389 assert!(begin < first_a && first_a < end, "body must be inside sync update");
2390
2391 assert!(
2394 !s.contains("\x1b[2J"),
2395 "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
2396 );
2397 assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
2398 }
2399
2400 #[test]
2401 fn raw_rows_passthrough_emits_original_bytes_and_skips_continuation() {
2402 use crate::ansi::Style;
2403 use crate::render::Cell;
2404 use crate::viewport::{Frame, RowStyle};
2405
2406 let placeholder_row: Vec<Cell> = (0..3)
2408 .map(|_| Cell::Char { ch: 'X', width: 1, style: Style::default(), hyperlink: None })
2409 .collect();
2410 let frame = Frame {
2411 body: vec![placeholder_row.clone(), placeholder_row],
2412 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2413 highlights: vec![Vec::new(), Vec::new()],
2414 status: "s".into(),
2415 status_style: Style { reverse: true, ..Default::default() },
2416 raw_rows: vec![Some(b"\x1b[31mABC\x1b[0m".to_vec()), Some(Vec::new())],
2419 };
2420
2421 let mut buf: Vec<u8> = Vec::new();
2422 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2423 let s = std::str::from_utf8(&buf).expect("ascii");
2424
2425 assert!(s.contains("\x1b[31mABC\x1b[0m"), "raw bytes missing in output: {s:?}");
2427 assert!(!s.contains("XXX"), "cell content leaked through despite raw passthrough: {s:?}");
2429 }
2430
2431 #[test]
2432 fn dim_row_keeps_dim_through_plain_cells_and_padding() {
2433 use crate::ansi::Style;
2438 use crate::render::Cell;
2439 let row = vec![
2440 Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
2441 Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
2442 Cell::Empty,
2443 Cell::Empty,
2444 ];
2445 let mut buf: Vec<u8> = Vec::new();
2446 let base = Style { dim: true, ..Default::default() };
2447 write_row_with_highlights(&mut buf, &row, 4, &[], base, true).unwrap();
2448 let s = String::from_utf8_lossy(&buf);
2449
2450 for needle in ['h', 'i'] {
2453 let pos = s.find(needle).expect("char printed");
2454 let before = &s[..pos];
2455 assert!(
2456 !before.contains("\x1b[22m"),
2457 "dim cleared before {needle:?}: {before:?}",
2458 );
2459 }
2460 let after_i = s.find('i').unwrap() + 1;
2463 let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
2464 let pad = &s[after_i..after_i + eor];
2465 assert!(
2466 !pad.contains("\x1b[22m"),
2467 "dim cleared in padding region: {pad:?}",
2468 );
2469 }
2470
2471 #[test]
2472 fn dim_row_yields_to_explicit_bold_cell() {
2473 use crate::ansi::Style;
2476 use crate::render::Cell;
2477 let row = vec![
2478 Cell::Char {
2479 ch: 'B',
2480 width: 1,
2481 style: Style { bold: true, ..Default::default() },
2482 hyperlink: None,
2483 },
2484 ];
2485 let mut buf: Vec<u8> = Vec::new();
2486 let base = Style { dim: true, ..Default::default() };
2487 write_row_with_highlights(&mut buf, &row, 1, &[], base, true).unwrap();
2488 let s = String::from_utf8_lossy(&buf);
2489 assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
2491 }
2492
2493 #[test]
2494 fn parse_colon_p() {
2495 assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
2496 assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
2497 }
2498
2499 #[test]
2500 fn parse_colon_e_with_path() {
2501 match parse_colon_command("e /tmp/foo.log").unwrap() {
2502 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
2503 other => panic!("expected Edit, got {other:?}"),
2504 }
2505 }
2506
2507 #[test]
2508 fn parse_colon_e_with_tilde() {
2509 std::env::set_var("HOME", "/home/user");
2510 match parse_colon_command("e ~/foo.log").unwrap() {
2511 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
2512 other => panic!("expected Edit, got {other:?}"),
2513 }
2514 }
2515
2516 #[test]
2517 fn parse_colon_e_missing_path_errors() {
2518 assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
2519 assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
2520 }
2521
2522 #[test]
2523 fn parse_colon_f_q_d_x_t() {
2524 assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
2525 assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
2526 assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
2527 assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
2528 assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
2529 }
2530
2531 #[test]
2532 fn parse_unknown_command_errors() {
2533 let err = parse_colon_command("bogus").unwrap_err();
2534 match err {
2535 ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
2536 other => panic!("expected UnknownCommand, got {other:?}"),
2537 }
2538 }
2539
2540 #[test]
2541 fn parse_handles_whitespace() {
2542 assert_eq!(parse_colon_command("n ").unwrap(), ColonCommand::Next);
2544 assert_eq!(parse_colon_command(" n").unwrap(), ColonCommand::Next);
2545 }
2546
2547 #[test]
2548 fn parse_colon_tag_with_name() {
2549 assert_eq!(
2550 parse_colon_command("tag foo").unwrap(),
2551 ColonCommand::Tag("foo".into())
2552 );
2553 }
2554
2555 #[test]
2556 fn parse_colon_tag_strips_trailing_whitespace() {
2557 assert_eq!(
2558 parse_colon_command("tag foo ").unwrap(),
2559 ColonCommand::Tag("foo".into())
2560 );
2561 }
2562
2563 #[test]
2564 fn parse_colon_tag_without_name_errors() {
2565 assert_eq!(
2566 parse_colon_command("tag").unwrap_err(),
2567 ColonParseError::TagRequiresName
2568 );
2569 assert_eq!(
2570 parse_colon_command("tag ").unwrap_err(),
2571 ColonParseError::TagRequiresName
2572 );
2573 }
2574
2575 #[test]
2576 fn parse_colon_tnext_and_tprev() {
2577 assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
2578 assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
2579 }
2580
2581 #[test]
2582 fn parse_colon_tselect_without_arg_uses_active() {
2583 assert_eq!(parse_colon_command("tselect").unwrap(), ColonCommand::TagSelect(None));
2584 }
2585
2586 #[test]
2587 fn parse_colon_tselect_with_name() {
2588 assert_eq!(
2589 parse_colon_command("tselect foo").unwrap(),
2590 ColonCommand::TagSelect(Some("foo".into())),
2591 );
2592 }
2593
2594 #[test]
2595 fn parse_colon_b_opens_picker() {
2596 assert_eq!(parse_colon_command("b").unwrap(), ColonCommand::OpenPicker);
2597 assert_eq!(parse_colon_command("buffers").unwrap(), ColonCommand::OpenPicker);
2598 }
2599
2600 #[test]
2601 fn parse_colon_help_opens_help() {
2602 assert_eq!(parse_colon_command("h").unwrap(), ColonCommand::OpenHelp);
2603 assert_eq!(parse_colon_command("help").unwrap(), ColonCommand::OpenHelp);
2604 }
2605
2606 #[test]
2607 fn parse_colon_hex_with_valid_widths() {
2608 for n in [2usize, 4, 8, 16, 32] {
2609 assert_eq!(
2610 parse_colon_command(&format!("hex {n}")).unwrap(),
2611 ColonCommand::HexGroup(n),
2612 );
2613 }
2614 }
2615
2616 #[test]
2617 fn parse_colon_hex_without_value_errors() {
2618 assert_eq!(
2619 parse_colon_command("hex").unwrap_err(),
2620 ColonParseError::HexGroupRequiresValue,
2621 );
2622 }
2623
2624 #[test]
2625 fn parse_colon_hex_with_invalid_value_errors() {
2626 match parse_colon_command("hex 3").unwrap_err() {
2627 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "3"),
2628 other => panic!("expected HexGroupInvalid, got {other:?}"),
2629 }
2630 match parse_colon_command("hex banana").unwrap_err() {
2631 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "banana"),
2632 other => panic!("expected HexGroupInvalid, got {other:?}"),
2633 }
2634 }
2635
2636 #[test]
2637 fn parse_colon_color_without_arg_cycles() {
2638 assert_eq!(parse_colon_command("color").unwrap(), ColonCommand::Color(None));
2639 }
2640
2641 #[test]
2642 fn parse_colon_color_with_named_mode() {
2643 use crate::render::AnsiMode;
2644 assert_eq!(
2645 parse_colon_command("color strict").unwrap(),
2646 ColonCommand::Color(Some(AnsiMode::Strict)),
2647 );
2648 assert_eq!(
2649 parse_colon_command("color interpret").unwrap(),
2650 ColonCommand::Color(Some(AnsiMode::Interpret)),
2651 );
2652 assert_eq!(
2653 parse_colon_command("color raw").unwrap(),
2654 ColonCommand::Color(Some(AnsiMode::Raw)),
2655 );
2656 }
2657
2658 #[test]
2659 fn parse_colon_color_with_unknown_mode_errors() {
2660 match parse_colon_command("color rainbow").unwrap_err() {
2661 ColonParseError::ColorInvalid(v) => assert_eq!(v, "rainbow"),
2662 other => panic!("expected ColorInvalid, got {other:?}"),
2663 }
2664 }
2665
2666 #[test]
2667 fn parse_colon_case_without_arg_cycles() {
2668 assert_eq!(parse_colon_command("case").unwrap(), ColonCommand::Case(None));
2669 }
2670
2671 #[test]
2672 fn parse_colon_case_with_named_mode() {
2673 use crate::viewport::CaseMode;
2674 assert_eq!(parse_colon_command("case smart").unwrap(),
2675 ColonCommand::Case(Some(CaseMode::Smart)));
2676 assert_eq!(parse_colon_command("case sensitive").unwrap(),
2677 ColonCommand::Case(Some(CaseMode::Sensitive)));
2678 assert_eq!(parse_colon_command("case insensitive").unwrap(),
2679 ColonCommand::Case(Some(CaseMode::Insensitive)));
2680 }
2681
2682 #[test]
2683 fn parse_colon_case_unknown_errors() {
2684 match parse_colon_command("case rainbow").unwrap_err() {
2685 ColonParseError::CaseInvalid(v) => assert_eq!(v, "rainbow"),
2686 other => panic!("expected CaseInvalid, got {other:?}"),
2687 }
2688 }
2689
2690 #[test]
2691 fn parse_colon_hlsearch_on_off() {
2692 assert_eq!(parse_colon_command("hlsearch").unwrap(), ColonCommand::HlSearch(true));
2693 assert_eq!(parse_colon_command("nohlsearch").unwrap(), ColonCommand::HlSearch(false));
2694 }
2695
2696 #[test]
2697 fn lcp_empty_slice() {
2698 assert_eq!(longest_common_prefix(&[]), "");
2699 }
2700
2701 #[test]
2702 fn lcp_single_item_returns_self() {
2703 assert_eq!(longest_common_prefix(&["foo".into()]), "foo");
2704 }
2705
2706 #[test]
2707 fn lcp_finds_shared_prefix() {
2708 let v: Vec<String> = vec!["foobar".into(), "foobaz".into(), "fooqux".into()];
2709 assert_eq!(longest_common_prefix(&v), "foo");
2710 }
2711
2712 #[test]
2713 fn lcp_no_shared_prefix_returns_empty() {
2714 let v: Vec<String> = vec!["abc".into(), "xyz".into()];
2715 assert_eq!(longest_common_prefix(&v), "");
2716 }
2717
2718 #[test]
2719 fn lcp_one_item_is_prefix_of_others() {
2720 let v: Vec<String> = vec!["foo".into(), "foobar".into(), "foobaz".into()];
2721 assert_eq!(longest_common_prefix(&v), "foo");
2722 }
2723
2724 #[test]
2725 fn tag_stack_push_pop_lifo() {
2726 let mut s = TagStack::default();
2727 s.push(0, 10);
2728 s.push(1, 20);
2729 assert_eq!(s.pop(), Some((1, 20)));
2730 assert_eq!(s.pop(), Some((0, 10)));
2731 assert_eq!(s.pop(), None);
2732 }
2733
2734 #[test]
2735 fn tag_stack_pop_clears_active() {
2736 let mut s = TagStack::default();
2737 s.push(0, 10);
2738 s.set_active(
2739 "foo".into(),
2740 vec![crate::tags::TagEntry {
2741 file: std::path::PathBuf::from("/a"),
2742 address: crate::tags::TagAddress::Line(1),
2743 }],
2744 );
2745 assert!(s.active.is_some());
2746 let _ = s.pop();
2747 assert!(s.active.is_none());
2748 }
2749
2750 #[test]
2751 fn tag_stack_next_advances_then_clamps() {
2752 let mut s = TagStack::default();
2753 s.set_active(
2754 "foo".into(),
2755 vec![
2756 crate::tags::TagEntry {
2757 file: std::path::PathBuf::from("/a"),
2758 address: crate::tags::TagAddress::Line(1),
2759 },
2760 crate::tags::TagEntry {
2761 file: std::path::PathBuf::from("/b"),
2762 address: crate::tags::TagAddress::Line(2),
2763 },
2764 ],
2765 );
2766 assert_eq!(s.next(), TagStepResult::Moved(1));
2767 assert_eq!(s.next(), TagStepResult::AtBoundary);
2768 }
2769
2770 #[test]
2771 fn tag_stack_prev_clamps_at_zero() {
2772 let mut s = TagStack::default();
2773 s.set_active(
2774 "foo".into(),
2775 vec![crate::tags::TagEntry {
2776 file: std::path::PathBuf::from("/a"),
2777 address: crate::tags::TagAddress::Line(1),
2778 }],
2779 );
2780 assert_eq!(s.prev(), TagStepResult::AtBoundary);
2781 }
2782
2783 #[test]
2784 fn tag_stack_next_with_no_active_returns_no_active() {
2785 let mut s = TagStack::default();
2786 assert_eq!(s.next(), TagStepResult::NoActive);
2787 assert_eq!(s.prev(), TagStepResult::NoActive);
2788 }
2789
2790 #[test]
2791 fn tag_stack_set_active_replaces_previous_list() {
2792 let mut s = TagStack::default();
2793 s.set_active(
2794 "foo".into(),
2795 vec![crate::tags::TagEntry {
2796 file: std::path::PathBuf::from("/a"),
2797 address: crate::tags::TagAddress::Line(1),
2798 }],
2799 );
2800 s.set_active(
2801 "bar".into(),
2802 vec![
2803 crate::tags::TagEntry {
2804 file: std::path::PathBuf::from("/x"),
2805 address: crate::tags::TagAddress::Line(5),
2806 },
2807 crate::tags::TagEntry {
2808 file: std::path::PathBuf::from("/y"),
2809 address: crate::tags::TagAddress::Line(6),
2810 },
2811 ],
2812 );
2813 let active = s.active.as_ref().unwrap();
2814 assert_eq!(active.name, "bar");
2815 assert_eq!(active.matches.len(), 2);
2816 assert_eq!(active.cursor, 0);
2817 }
2818
2819 #[test]
2820 fn writer_emits_color_for_red_cell() {
2821 let cells = vec![Cell::Char {
2822 ch: 'h',
2823 width: 1,
2824 style: crate::ansi::Style {
2825 fg: Some(crate::ansi::Color::Ansi(1)),
2826 ..Default::default()
2827 },
2828 hyperlink: None,
2829 }];
2830 let mut buf: Vec<u8> = Vec::new();
2831 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
2832 let s = String::from_utf8_lossy(&buf);
2833 assert!(s.contains("\x1b["), "expected ANSI escape in output: {s:?}");
2834 assert!(s.contains('h'));
2835 }
2836
2837 #[test]
2838 fn writer_emits_osc8_for_hyperlink_cell() {
2839 let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
2840 let cells = vec![Cell::Char {
2841 ch: 'c',
2842 width: 1,
2843 style: crate::ansi::Style::default(),
2844 hyperlink: Some(link),
2845 }];
2846 let mut buf: Vec<u8> = Vec::new();
2847 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
2848 let s = String::from_utf8_lossy(&buf);
2849 assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
2850 }
2851}