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 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1424 cols = c;
1425 rows = r;
1426 viewport.resize(c, r);
1427 if was_at_bottom {
1428 viewport.goto_bottom(src.as_ref(), &mut idx);
1429 }
1430 needs_redraw = true;
1431 if overlay.is_some() {
1432 continue;
1434 }
1435 }
1438 if let Some(ov) = overlay.as_mut() {
1442 let outcome = match &event {
1443 Event::Key(ke) => ov.handle_key(*ke),
1444 Event::Mouse(me) => ov.handle_mouse(*me, viewport.body_rows()),
1445 Event::Resize(_, _) => crate::overlay::OverlayOutcome::Stay,
1446 _ => crate::overlay::OverlayOutcome::Stay,
1447 };
1448 match outcome {
1449 crate::overlay::OverlayOutcome::Stay => {
1450 needs_redraw = true;
1451 continue;
1452 }
1453 crate::overlay::OverlayOutcome::Close => {
1454 overlay = None;
1455 overlay_flash = None;
1456 needs_redraw = true;
1457 continue;
1458 }
1459 crate::overlay::OverlayOutcome::CloseAnd(cmd) => {
1460 overlay = None;
1461 overlay_flash = None;
1462 if let Command::SelectFile(i) = cmd {
1463 if i < file_set.len() {
1464 file_set.set_current_index(i);
1465 if let Some(msg) = switch_to_current_file(
1466 &mut file_set, &mut current_file_index,
1467 &args, preprocessor.as_ref(),
1468 record_start_regex.as_ref(),
1469 &mut viewport, &mut src, &mut idx,
1470 ) {
1471 transient_status = Some(msg);
1472 }
1473 }
1474 } else if let Command::SelectTagMatch(idx_pick) = cmd {
1475 if let Some(active) = tag_stack.active.as_mut() {
1476 if idx_pick < active.matches.len() {
1477 active.cursor = idx_pick;
1478 let entry = active.matches[idx_pick].clone();
1479 let msg = dispatch_match(
1480 &entry,
1481 &mut file_set,
1482 &mut current_file_index,
1483 &args,
1484 preprocessor.as_ref(),
1485 record_start_regex.as_ref(),
1486 &mut viewport,
1487 &mut src,
1488 &mut idx,
1489 );
1490 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1491 if let Some(m) = msg {
1492 transient_status = Some(m);
1493 }
1494 }
1495 }
1496 }
1497 needs_redraw = true;
1498 continue;
1499 }
1500 crate::overlay::OverlayOutcome::Apply(cmd) => {
1501 if let Command::DropFileAt(target) = cmd {
1502 if file_set.len() > 1 && target < file_set.len() {
1503 let saved_cur = file_set.current_index();
1504 file_set.set_current_index(target);
1505 let _ = file_set.delete_current();
1506 if target < saved_cur {
1510 let restored = saved_cur.saturating_sub(1);
1511 file_set.set_current_index(restored);
1512 } else if target > saved_cur {
1513 file_set.set_current_index(saved_cur);
1514 }
1515 if let Some(msg) = switch_to_current_file(
1518 &mut file_set, &mut current_file_index,
1519 &args, preprocessor.as_ref(),
1520 record_start_regex.as_ref(),
1521 &mut viewport, &mut src, &mut idx,
1522 ) {
1523 transient_status = Some(msg);
1524 }
1525 if let Some(ov) = overlay.as_mut() {
1526 ov.refresh(crate::overlay::OverlayContext { file_set: &file_set });
1527 }
1528 }
1529 }
1530 needs_redraw = true;
1531 continue;
1532 }
1533 crate::overlay::OverlayOutcome::Refuse(msg) => {
1534 overlay_flash = Some((msg, std::time::Instant::now()));
1535 needs_redraw = true;
1536 continue;
1537 }
1538 }
1539 }
1540 if let crossterm::event::Event::Mouse(me) = &event {
1544 if mouse_enabled {
1545 use crossterm::event::MouseEventKind;
1546 match me.kind {
1547 MouseEventKind::ScrollDown => {
1548 viewport.scroll_lines(3, src.as_ref(), &mut idx);
1549 needs_redraw = true;
1550 }
1551 MouseEventKind::ScrollUp => {
1552 viewport.scroll_lines(-3, src.as_ref(), &mut idx);
1553 needs_redraw = true;
1554 }
1555 _ => {}
1556 }
1557 }
1558 continue;
1559 }
1560 let mut cmd: Option<Command> = None;
1564 if let InputMode::Normal = mode {
1565 if let Event::Key(ke) = &event {
1566 if let Some(target) = keymap.lookup(ke) {
1567 match target {
1568 crate::keys::BindingTarget::Shell(cmd_text) => {
1569 let cmd_text = cmd_text.clone();
1570 if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
1571 let _ = writeln!(std::io::stderr(),
1572 "[shell: {e}]");
1573 }
1574 needs_redraw = true;
1575 continue;
1576 }
1577 crate::keys::BindingTarget::Command(c) => {
1578 cmd = Some(c.clone());
1579 }
1580 }
1581 }
1582 }
1583 }
1584 let cmd = cmd.unwrap_or_else(|| translate(event));
1585 let prefix_at_cmd = numeric_prefix.take();
1588 match cmd {
1589 Command::Digit(d) => {
1590 let cur = prefix_at_cmd.unwrap_or(0);
1591 let next = cur.saturating_mul(10).saturating_add(d as usize);
1592 if next <= 99_999_999 {
1593 numeric_prefix = Some(next);
1594 } else {
1595 numeric_prefix = prefix_at_cmd;
1597 }
1598 continue;
1599 }
1600 Command::Cancel => {
1601 continue;
1603 }
1604 Command::GotoLine => {
1605 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1606 match prefix_at_cmd {
1607 Some(line) if line > 0 => {
1608 viewport.goto_line(line - 1, src.as_ref(), &mut idx);
1609 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1610 }
1611 _ => {
1612 viewport.goto_top();
1613 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1614 }
1615 }
1616 needs_redraw = true;
1617 }
1618 Command::GotoRecord => {
1619 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1620 match prefix_at_cmd {
1621 Some(rec) if rec > 0 => {
1622 viewport.goto_record(rec - 1, src.as_ref(), &mut idx);
1623 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1624 }
1625 _ => viewport.goto_bottom(src.as_ref(), &mut idx),
1626 }
1627 needs_redraw = true;
1628 }
1629 Command::GotoPercent => {
1630 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1631 match prefix_at_cmd {
1632 Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
1633 _ => viewport.goto_top(),
1634 }
1635 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1636 needs_redraw = true;
1637 }
1638 Command::Quit => break,
1639 Command::Resize(c, r) => {
1640 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1641 cols = c; rows = r;
1642 viewport.resize(c, r);
1643 if was_at_bottom {
1644 viewport.goto_bottom(src.as_ref(), &mut idx);
1645 }
1646 needs_redraw = true;
1647 }
1648 Command::ScrollLines(n) => {
1649 viewport.scroll_lines(n, src.as_ref(), &mut idx);
1650 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1651 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1652 needs_redraw = true;
1653 }
1654 Command::ScrollLogicalLines(n) => {
1655 viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
1656 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1657 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1658 needs_redraw = true;
1659 }
1660 Command::PageDown => {
1661 viewport.page_down(src.as_ref(), &mut idx);
1662 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1663 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1664 needs_redraw = true;
1665 }
1666 Command::PageUp => {
1667 viewport.page_up(src.as_ref(), &mut idx);
1668 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1669 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1670 needs_redraw = true;
1671 }
1672 Command::HalfPageDown => {
1673 viewport.half_page_down(src.as_ref(), &mut idx);
1674 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1675 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1676 needs_redraw = true;
1677 }
1678 Command::HalfPageUp => {
1679 viewport.half_page_up(src.as_ref(), &mut idx);
1680 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1681 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1682 needs_redraw = true;
1683 }
1684 Command::Refresh => {
1685 needs_redraw = true;
1686 }
1687 Command::Reload => {
1688 src.pump();
1691 if src.revision() != last_revision {
1692 rebuild_after_replace(
1693 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1694 );
1695 last_revision = src.revision();
1696 needs_redraw = true;
1697 }
1698 }
1699 Command::TogglePrettify => {
1700 apply_prettify(
1701 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1702 PrettifyTarget::Toggle,
1703 );
1704 last_revision = src.revision();
1705 needs_redraw = true;
1706 }
1707 Command::SetPrettifyMode(m) => {
1708 apply_prettify(
1709 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1710 PrettifyTarget::Mode(m),
1711 );
1712 last_revision = src.revision();
1713 needs_redraw = true;
1714 }
1715 Command::RedetectPrettify => {
1716 apply_prettify(
1717 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1718 PrettifyTarget::Auto,
1719 );
1720 last_revision = src.revision();
1721 needs_redraw = true;
1722 }
1723 Command::ToggleLineNumbers => {
1724 viewport.toggle_line_numbers();
1725 needs_redraw = true;
1726 }
1727 Command::ToggleChop => {
1728 viewport.toggle_chop();
1729 needs_redraw = true;
1730 }
1731 Command::ToggleFollow => {
1732 viewport.toggle_follow();
1733 if viewport.follow_mode() {
1734 src.pump();
1736 idx.notice_new_bytes(src.as_ref());
1737 viewport.goto_bottom(src.as_ref(), &mut idx);
1738 }
1739 needs_redraw = true;
1740 }
1741 Command::SearchForward => {
1742 mode = InputMode::SearchPrompt {
1743 direction: SearchDirection::Forward,
1744 buffer: String::new(),
1745 error: None,
1746 };
1747 needs_redraw = true;
1748 }
1749 Command::SearchBackward => {
1750 mode = InputMode::SearchPrompt {
1751 direction: SearchDirection::Backward,
1752 buffer: String::new(),
1753 error: None,
1754 };
1755 needs_redraw = true;
1756 }
1757 Command::ShellEscape => {
1758 mode = InputMode::ShellPrompt {
1759 buffer: String::new(),
1760 error: None,
1761 };
1762 needs_redraw = true;
1763 }
1764 Command::ColonPrompt => {
1765 mode = InputMode::ColonPrompt {
1766 buffer: String::new(),
1767 error: None,
1768 };
1769 needs_redraw = true;
1770 }
1771 Command::NextMatch => {
1772 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1773 if viewport.search_repeat(src.as_ref(), &mut idx, false) {
1774 needs_redraw = true;
1775 }
1776 }
1777 Command::PreviousMatch => {
1778 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1779 if viewport.search_repeat(src.as_ref(), &mut idx, true) {
1780 needs_redraw = true;
1781 }
1782 }
1783 Command::OptionPrefix => {
1784 mode = InputMode::OptionPrefix;
1785 }
1786 Command::MarkSet => {
1787 mode = InputMode::MarkSetPending;
1788 }
1789 Command::MarkJump => {
1790 mode = InputMode::MarkJumpPending;
1791 }
1792 Command::CtrlXPrefix => {
1793 mode = InputMode::CtrlXPending;
1794 }
1795 Command::JumpPrevious => {
1796 }
1799 Command::TagPrompt => {
1800 if tag_file.is_none() {
1801 transient_status = Some("[no tags file loaded]".into());
1802 needs_redraw = true;
1803 } else {
1804 mode = InputMode::TagPrompt {
1805 buffer: String::new(),
1806 error: None,
1807 last_tab_matches: None,
1808 };
1809 needs_redraw = true;
1810 }
1811 }
1812 Command::TagPop => match tag_stack.pop() {
1813 Some((file_index, line)) => {
1814 if file_index != current_file_index && file_index < file_set.len() {
1815 file_set.set_current_index(file_index);
1816 let path = file_set.current().unwrap().to_path_buf();
1817 if let Err(e) = switch_file(
1818 &path,
1819 file_index,
1820 file_set.len(),
1821 &args,
1822 preprocessor.as_ref(),
1823 &mut viewport,
1824 &mut src,
1825 &mut idx,
1826 record_start_regex.as_ref(),
1827 ) {
1828 transient_status = Some(format!("[open: {e}]"));
1829 } else {
1830 current_file_index = file_index;
1831 }
1832 }
1833 let clamped = line.min(idx.line_count().saturating_sub(1));
1834 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1835 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1836 needs_redraw = true;
1837 }
1838 None => {
1839 transient_status = Some("[tag stack empty]".into());
1840 needs_redraw = true;
1841 }
1842 },
1843 Command::OpenPicker => {
1844 let saved = (0..file_set.len())
1845 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1846 .collect::<Vec<_>>();
1847 overlay = Some(Box::new(
1848 crate::overlay::picker::FilePicker::new(&file_set, saved)
1849 ));
1850 needs_redraw = true;
1851 }
1852 Command::OpenHelp => {
1853 let remaps = keymap.user_keys_by_command_name();
1854 overlay = Some(Box::new(
1855 crate::overlay::help::HelpOverlay::new(remaps)
1856 ));
1857 needs_redraw = true;
1858 }
1859 Command::SelectFile(_)
1860 | Command::DropFileAt(_)
1861 | Command::SelectTagMatch(_)
1862 | Command::OpenTagPicker => {
1863 }
1865 Command::MouseEvent(_) => {
1866 }
1868 Command::Noop => {}
1869 }
1870 }
1871 Ok(false) => {
1872 if viewport.live_mode() {
1874 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1875 src.pump();
1876 if src.revision() != last_revision {
1877 rebuild_after_replace(
1878 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1879 );
1880 if was_at_bottom {
1881 viewport.goto_bottom(src.as_ref(), &mut idx);
1882 }
1883 last_revision = src.revision();
1884 needs_redraw = true;
1885 }
1886 } else if viewport.follow_mode() {
1887 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1888 src.pump();
1889 if src.take_rotated() {
1890 if let Some(path) = src.path().map(|p| p.to_path_buf()) {
1896 match crate::open::open_source_for_path(
1897 &path, &args, preprocessor.as_ref(),
1898 ) {
1899 Ok((new_src, _label, _err)) => {
1900 src = new_src;
1901 idx = LineIndex::new();
1902 if let Some(n) = rebuild_spec.head {
1903 idx.set_head_cap(n);
1904 }
1905 viewport.invalidate_filter_cache();
1906 idx.notice_new_bytes(src.as_ref());
1907 viewport.extend_visible_lines(&idx, src.as_ref());
1908 viewport.goto_bottom(src.as_ref(), &mut idx);
1909 viewport.flash("(F reopened)", 4);
1910 needs_redraw = true;
1911 continue;
1912 }
1913 Err(e) => {
1914 transient_status = Some(format!("[reopen failed: {e}]"));
1915 needs_redraw = true;
1916 }
1917 }
1918 }
1919 }
1920 let lines_before = idx.line_count();
1921 idx.notice_new_bytes(src.as_ref());
1922 viewport.extend_visible_lines(&idx, src.as_ref());
1923 if idx.line_count() != lines_before {
1924 needs_redraw = true;
1925 viewport.note_growth();
1926 if was_at_bottom {
1927 viewport.goto_bottom(src.as_ref(), &mut idx);
1928 }
1929 } else {
1930 viewport.tick_idle();
1931 }
1932 viewport.tick_flash();
1933 if args.exit_follow_on_close && src.is_complete() {
1939 break;
1940 }
1941 } else if !src.is_complete() {
1942 let lines_before = idx.line_count();
1945 idx.notice_new_bytes(src.as_ref());
1946 viewport.extend_visible_lines(&idx, src.as_ref());
1947 if idx.line_count() != lines_before {
1948 needs_redraw = true;
1949 }
1950 }
1951 }
1952 Err(_) => {
1953 std::thread::sleep(timeout);
1955 }
1956 }
1957 }
1958 Ok(())
1959}
1960
1961#[derive(Debug, Clone, Copy)]
1963enum PrettifyTarget {
1964 Mode(PrettifyMode),
1966 Toggle,
1968 Auto,
1970}
1971
1972fn apply_prettify(
1976 src: &dyn Source,
1977 viewport: &mut Viewport,
1978 idx: &mut LineIndex,
1979 spec: RebuildSpec,
1980 target: PrettifyTarget,
1981) {
1982 if src.prettify_mode().is_none() {
1984 return;
1985 }
1986 match target {
1987 PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
1988 PrettifyTarget::Toggle => src.toggle_prettify(),
1989 PrettifyTarget::Auto => src.redetect_prettify(),
1990 }
1991 rebuild_after_replace(src, viewport, idx, spec);
1992 viewport.set_prettify_label(src.prettify_label());
1993}
1994
1995fn rebuild_after_replace(
2001 src: &dyn Source,
2002 viewport: &mut Viewport,
2003 idx: &mut LineIndex,
2004 spec: RebuildSpec,
2005) {
2006 let new_off = match spec.tail {
2007 Some(n) => find_tail_offset(src, n),
2008 None => 0,
2009 };
2010 *idx = LineIndex::new_starting_at(new_off);
2011 if let Some(n) = spec.head {
2012 idx.set_head_cap(n);
2013 }
2014 viewport.invalidate_filter_cache();
2015 idx.notice_new_bytes(src);
2016 viewport.extend_visible_lines(idx, src);
2017 viewport.clamp_top_line(idx.line_count());
2018}
2019
2020fn to_crossterm_color(c: crate::ansi::Color, truecolor: bool) -> crossterm::style::Color {
2021 use crossterm::style::Color as CC;
2022 use crate::ansi::Color;
2023 match c {
2024 Color::Ansi(0) => CC::Black,
2025 Color::Ansi(1) => CC::DarkRed,
2026 Color::Ansi(2) => CC::DarkGreen,
2027 Color::Ansi(3) => CC::DarkYellow,
2028 Color::Ansi(4) => CC::DarkBlue,
2029 Color::Ansi(5) => CC::DarkMagenta,
2030 Color::Ansi(6) => CC::DarkCyan,
2031 Color::Ansi(7) => CC::Grey,
2032 Color::Ansi(8) => CC::DarkGrey,
2033 Color::Ansi(9) => CC::Red,
2034 Color::Ansi(10) => CC::Green,
2035 Color::Ansi(11) => CC::Yellow,
2036 Color::Ansi(12) => CC::Blue,
2037 Color::Ansi(13) => CC::Magenta,
2038 Color::Ansi(14) => CC::Cyan,
2039 Color::Ansi(15) => CC::White,
2040 Color::Ansi(_) => CC::Reset,
2041 Color::Indexed(n) => CC::AnsiValue(n),
2042 Color::Rgb(r, g, b) => {
2043 if truecolor {
2044 CC::Rgb { r, g, b }
2045 } else {
2046 CC::AnsiValue(crate::render::rgb_to_256(r, g, b))
2047 }
2048 }
2049 Color::Default => CC::Reset,
2050 }
2051}
2052
2053fn emit_style_diff<W: Write>(
2056 out: &mut W,
2057 prev: &crate::ansi::Style,
2058 next: &crate::ansi::Style,
2059 truecolor: bool,
2060) -> io::Result<()> {
2061 let intensity_changed = prev.bold != next.bold || prev.dim != next.dim;
2065
2066 let fg_changed = prev.fg != next.fg;
2070 let bg_changed = prev.bg != next.bg;
2071
2072 if (fg_changed && next.fg.is_none()) || (bg_changed && next.bg.is_none()) {
2073 out.queue(ResetColor)?;
2074 if let Some(c) = next.fg {
2076 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2077 }
2078 if let Some(c) = next.bg {
2079 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2080 }
2081 } else {
2082 if fg_changed {
2083 if let Some(c) = next.fg {
2084 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2085 }
2086 }
2087 if bg_changed {
2088 if let Some(c) = next.bg {
2089 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2090 }
2091 }
2092 }
2093
2094 if intensity_changed {
2095 if next.bold {
2096 out.queue(SetAttribute(Attribute::Bold))?;
2097 } else if next.dim {
2098 out.queue(SetAttribute(Attribute::Dim))?;
2099 } else {
2100 out.queue(SetAttribute(Attribute::NormalIntensity))?;
2101 }
2102 }
2103 if prev.italic != next.italic {
2104 out.queue(SetAttribute(if next.italic { Attribute::Italic } else { Attribute::NoItalic }))?;
2105 }
2106 if prev.underline != next.underline {
2107 out.queue(SetAttribute(if next.underline { Attribute::Underlined } else { Attribute::NoUnderline }))?;
2108 }
2109 if prev.reverse != next.reverse {
2110 out.queue(SetAttribute(if next.reverse { Attribute::Reverse } else { Attribute::NoReverse }))?;
2111 }
2112 if prev.strike != next.strike {
2113 out.queue(SetAttribute(if next.strike { Attribute::CrossedOut } else { Attribute::NotCrossedOut }))?;
2114 }
2115 Ok(())
2116}
2117
2118fn emit_hyperlink_diff<W: Write>(
2119 out: &mut W,
2120 prev: &Option<Arc<str>>,
2121 next: &Option<Arc<str>>,
2122) -> io::Result<()> {
2123 if prev == next {
2124 return Ok(());
2125 }
2126 if prev.is_some() {
2127 out.write_all(b"\x1b]8;;\x1b\\")?;
2128 }
2129 if let Some(uri) = next {
2130 out.write_all(b"\x1b]8;;")?;
2131 out.write_all(uri.as_bytes())?;
2132 out.write_all(b"\x1b\\")?;
2133 }
2134 Ok(())
2135}
2136
2137const SYNC_UPDATE_BEGIN: &[u8] = b"\x1b[?2026h";
2144const SYNC_UPDATE_END: &[u8] = b"\x1b[?2026l";
2145
2146fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16, truecolor: bool) -> io::Result<()> {
2147 out.write_all(SYNC_UPDATE_BEGIN)?;
2159
2160 out.queue(SetAttribute(Attribute::Reset))?;
2162 out.queue(ResetColor)?;
2163
2164 for (i, row) in frame.body.iter().enumerate() {
2165 out.queue(MoveTo(0, i as u16))?;
2166 out.queue(Clear(ClearType::UntilNewLine))?;
2170 out.queue(SetAttribute(Attribute::Reset))?;
2173
2174 if let Some(Some(raw)) = frame.raw_rows.get(i) {
2179 if !raw.is_empty() {
2180 out.write_all(raw)?;
2181 }
2182 out.queue(ResetColor)?;
2184 out.queue(SetAttribute(Attribute::Reset))?;
2185 continue;
2186 }
2187
2188 let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
2189 let base_style = if matches!(row_style, RowStyle::Dim) {
2194 out.queue(SetAttribute(Attribute::Dim))?;
2195 crate::ansi::Style { dim: true, ..Default::default() }
2196 } else {
2197 crate::ansi::Style::default()
2198 };
2199 let no_highlights = Vec::new();
2200 let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
2201 write_row_with_highlights(out, row, cols, highlights, base_style, truecolor)?;
2202 }
2203 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2205 out.queue(Clear(ClearType::UntilNewLine))?;
2206 emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2207 let mut status = frame.status.clone();
2208 if status.len() > cols as usize {
2209 status.truncate(cols as usize);
2210 } else {
2211 let pad = cols as usize - status.len();
2212 status.push_str(&" ".repeat(pad));
2213 }
2214 out.queue(Print(status))?;
2215 out.queue(ResetColor)?;
2216 out.queue(SetAttribute(Attribute::Reset))?;
2217
2218 out.write_all(SYNC_UPDATE_END)?;
2221 out.flush()
2222}
2223
2224
2225fn write_row_with_highlights(
2236 out: &mut impl Write,
2237 row: &[Cell],
2238 cols: u16,
2239 highlights: &[std::ops::Range<usize>],
2240 base_style: crate::ansi::Style,
2241 truecolor: bool,
2242) -> io::Result<()> {
2243 let cols_usize = cols as usize;
2244
2245 let mut ranges: Vec<std::ops::Range<usize>> = highlights
2246 .iter()
2247 .filter_map(|r| {
2248 let s = r.start.min(cols_usize);
2249 let e = r.end.min(cols_usize);
2250 if e > s { Some(s..e) } else { None }
2251 })
2252 .collect();
2253 ranges.sort_by_key(|r| r.start);
2254
2255 let mut prev_style = base_style;
2258 let mut prev_link: Option<Arc<str>> = None;
2259
2260 let mut col = 0usize;
2261 let mut i = 0usize;
2262 while col < cols_usize && i < row.len() {
2263 let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
2264
2265 match &row[i] {
2266 Cell::Char { ch, width, style, hyperlink } => {
2267 let mut eff = *style;
2273 if in_highlight {
2274 eff.reverse = !eff.reverse;
2275 }
2276 if base_style.dim && !eff.bold {
2277 eff.dim = true;
2278 }
2279 emit_style_diff(out, &prev_style, &eff, truecolor)?;
2280 emit_hyperlink_diff(out, &prev_link, hyperlink)?;
2281 out.queue(Print(*ch))?;
2282 prev_style = eff;
2283 prev_link = hyperlink.clone();
2284 col += *width as usize;
2285 }
2286 Cell::Continuation => {
2287 }
2289 Cell::Empty => {
2290 let default = if base_style.dim {
2295 crate::ansi::Style { dim: true, ..Default::default() }
2296 } else {
2297 crate::ansi::Style::default()
2298 };
2299 emit_style_diff(out, &prev_style, &default, truecolor)?;
2300 emit_hyperlink_diff(out, &prev_link, &None)?;
2301 out.queue(Print(' '))?;
2302 prev_style = default;
2303 prev_link = None;
2304 col += 1;
2305 }
2306 }
2307 i += 1;
2308 }
2309
2310 emit_hyperlink_diff(out, &prev_link, &None)?;
2313 out.queue(ResetColor)?;
2314 out.queue(SetAttribute(Attribute::Reset))?;
2315
2316 Ok(())
2317}
2318
2319fn render_overlay(
2320 out: &mut impl Write,
2321 frame: &crate::overlay::OverlayFrame,
2322 width: u16,
2323 height: u16,
2324) -> io::Result<()> {
2325 out.write_all(SYNC_UPDATE_BEGIN)?;
2329 out.queue(SetAttribute(Attribute::Reset))?;
2330 out.queue(ResetColor)?;
2331 for row in 0..height.saturating_sub(1) {
2332 out.queue(MoveTo(0, row))?;
2333 out.queue(Clear(ClearType::UntilNewLine))?;
2334 out.queue(SetAttribute(Attribute::Reset))?;
2335 if let Some(line) = frame.body.get(row as usize) {
2336 let mut written = 0usize;
2337 for ch in line.chars() {
2338 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2339 if written + w > width as usize { break; }
2340 write!(out, "{ch}")?;
2341 written += w;
2342 }
2343 }
2344 }
2345 out.queue(MoveTo(0, height.saturating_sub(1)))?;
2346 out.queue(Clear(ClearType::UntilNewLine))?;
2347 out.queue(SetAttribute(Attribute::Reverse))?;
2348 let mut status = frame.status.clone();
2349 if status.len() > width as usize {
2351 status.truncate(width as usize);
2352 } else {
2353 let pad = width as usize - status.len();
2354 status.push_str(&" ".repeat(pad));
2355 }
2356 out.queue(Print(status))?;
2357 out.queue(ResetColor)?;
2358 out.queue(SetAttribute(Attribute::Reset))?;
2359 out.write_all(SYNC_UPDATE_END)?;
2360 out.flush()
2361}
2362
2363#[cfg(test)]
2364mod tests {
2365 use super::*;
2366
2367 #[test]
2368 fn parse_colon_n() {
2369 assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
2370 assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
2371 }
2372
2373 #[test]
2374 fn write_frame_brackets_with_sync_update_and_no_full_clear() {
2375 use crate::ansi::Style;
2380 use crate::render::Cell;
2381 use crate::viewport::{Frame, RowStyle};
2382
2383 let row: Vec<Cell> = (0..3)
2384 .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
2385 .collect();
2386 let frame = Frame {
2387 body: vec![row.clone(), row],
2388 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2389 highlights: vec![Vec::new(), Vec::new()],
2390 status: "status".into(),
2391 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
2392 raw_rows: vec![None, None],
2393 };
2394
2395 let mut buf: Vec<u8> = Vec::new();
2396 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2397 let s = std::str::from_utf8(&buf).expect("ascii");
2398
2399 let begin = s.find("\x1b[?2026h").expect("begin sync update");
2401 let end = s.find("\x1b[?2026l").expect("end sync update");
2402 assert!(begin < end, "begin must precede end");
2403 let first_a = s.find('a').expect("body char");
2405 assert!(begin < first_a && first_a < end, "body must be inside sync update");
2406
2407 assert!(
2410 !s.contains("\x1b[2J"),
2411 "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
2412 );
2413 assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
2414 }
2415
2416 #[test]
2417 fn raw_rows_passthrough_emits_original_bytes_and_skips_continuation() {
2418 use crate::ansi::Style;
2419 use crate::render::Cell;
2420 use crate::viewport::{Frame, RowStyle};
2421
2422 let placeholder_row: Vec<Cell> = (0..3)
2424 .map(|_| Cell::Char { ch: 'X', width: 1, style: Style::default(), hyperlink: None })
2425 .collect();
2426 let frame = Frame {
2427 body: vec![placeholder_row.clone(), placeholder_row],
2428 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2429 highlights: vec![Vec::new(), Vec::new()],
2430 status: "s".into(),
2431 status_style: Style { reverse: true, ..Default::default() },
2432 raw_rows: vec![Some(b"\x1b[31mABC\x1b[0m".to_vec()), Some(Vec::new())],
2435 };
2436
2437 let mut buf: Vec<u8> = Vec::new();
2438 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2439 let s = std::str::from_utf8(&buf).expect("ascii");
2440
2441 assert!(s.contains("\x1b[31mABC\x1b[0m"), "raw bytes missing in output: {s:?}");
2443 assert!(!s.contains("XXX"), "cell content leaked through despite raw passthrough: {s:?}");
2445 }
2446
2447 #[test]
2448 fn dim_row_keeps_dim_through_plain_cells_and_padding() {
2449 use crate::ansi::Style;
2454 use crate::render::Cell;
2455 let row = vec![
2456 Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
2457 Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
2458 Cell::Empty,
2459 Cell::Empty,
2460 ];
2461 let mut buf: Vec<u8> = Vec::new();
2462 let base = Style { dim: true, ..Default::default() };
2463 write_row_with_highlights(&mut buf, &row, 4, &[], base, true).unwrap();
2464 let s = String::from_utf8_lossy(&buf);
2465
2466 for needle in ['h', 'i'] {
2469 let pos = s.find(needle).expect("char printed");
2470 let before = &s[..pos];
2471 assert!(
2472 !before.contains("\x1b[22m"),
2473 "dim cleared before {needle:?}: {before:?}",
2474 );
2475 }
2476 let after_i = s.find('i').unwrap() + 1;
2479 let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
2480 let pad = &s[after_i..after_i + eor];
2481 assert!(
2482 !pad.contains("\x1b[22m"),
2483 "dim cleared in padding region: {pad:?}",
2484 );
2485 }
2486
2487 #[test]
2488 fn dim_row_yields_to_explicit_bold_cell() {
2489 use crate::ansi::Style;
2492 use crate::render::Cell;
2493 let row = vec![
2494 Cell::Char {
2495 ch: 'B',
2496 width: 1,
2497 style: Style { bold: true, ..Default::default() },
2498 hyperlink: None,
2499 },
2500 ];
2501 let mut buf: Vec<u8> = Vec::new();
2502 let base = Style { dim: true, ..Default::default() };
2503 write_row_with_highlights(&mut buf, &row, 1, &[], base, true).unwrap();
2504 let s = String::from_utf8_lossy(&buf);
2505 assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
2507 }
2508
2509 #[test]
2510 fn parse_colon_p() {
2511 assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
2512 assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
2513 }
2514
2515 #[test]
2516 fn parse_colon_e_with_path() {
2517 match parse_colon_command("e /tmp/foo.log").unwrap() {
2518 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
2519 other => panic!("expected Edit, got {other:?}"),
2520 }
2521 }
2522
2523 #[test]
2524 fn parse_colon_e_with_tilde() {
2525 std::env::set_var("HOME", "/home/user");
2526 match parse_colon_command("e ~/foo.log").unwrap() {
2527 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
2528 other => panic!("expected Edit, got {other:?}"),
2529 }
2530 }
2531
2532 #[test]
2533 fn parse_colon_e_missing_path_errors() {
2534 assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
2535 assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
2536 }
2537
2538 #[test]
2539 fn parse_colon_f_q_d_x_t() {
2540 assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
2541 assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
2542 assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
2543 assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
2544 assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
2545 }
2546
2547 #[test]
2548 fn parse_unknown_command_errors() {
2549 let err = parse_colon_command("bogus").unwrap_err();
2550 match err {
2551 ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
2552 other => panic!("expected UnknownCommand, got {other:?}"),
2553 }
2554 }
2555
2556 #[test]
2557 fn parse_handles_whitespace() {
2558 assert_eq!(parse_colon_command("n ").unwrap(), ColonCommand::Next);
2560 assert_eq!(parse_colon_command(" n").unwrap(), ColonCommand::Next);
2561 }
2562
2563 #[test]
2564 fn parse_colon_tag_with_name() {
2565 assert_eq!(
2566 parse_colon_command("tag foo").unwrap(),
2567 ColonCommand::Tag("foo".into())
2568 );
2569 }
2570
2571 #[test]
2572 fn parse_colon_tag_strips_trailing_whitespace() {
2573 assert_eq!(
2574 parse_colon_command("tag foo ").unwrap(),
2575 ColonCommand::Tag("foo".into())
2576 );
2577 }
2578
2579 #[test]
2580 fn parse_colon_tag_without_name_errors() {
2581 assert_eq!(
2582 parse_colon_command("tag").unwrap_err(),
2583 ColonParseError::TagRequiresName
2584 );
2585 assert_eq!(
2586 parse_colon_command("tag ").unwrap_err(),
2587 ColonParseError::TagRequiresName
2588 );
2589 }
2590
2591 #[test]
2592 fn parse_colon_tnext_and_tprev() {
2593 assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
2594 assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
2595 }
2596
2597 #[test]
2598 fn parse_colon_tselect_without_arg_uses_active() {
2599 assert_eq!(parse_colon_command("tselect").unwrap(), ColonCommand::TagSelect(None));
2600 }
2601
2602 #[test]
2603 fn parse_colon_tselect_with_name() {
2604 assert_eq!(
2605 parse_colon_command("tselect foo").unwrap(),
2606 ColonCommand::TagSelect(Some("foo".into())),
2607 );
2608 }
2609
2610 #[test]
2611 fn parse_colon_b_opens_picker() {
2612 assert_eq!(parse_colon_command("b").unwrap(), ColonCommand::OpenPicker);
2613 assert_eq!(parse_colon_command("buffers").unwrap(), ColonCommand::OpenPicker);
2614 }
2615
2616 #[test]
2617 fn parse_colon_help_opens_help() {
2618 assert_eq!(parse_colon_command("h").unwrap(), ColonCommand::OpenHelp);
2619 assert_eq!(parse_colon_command("help").unwrap(), ColonCommand::OpenHelp);
2620 }
2621
2622 #[test]
2623 fn parse_colon_hex_with_valid_widths() {
2624 for n in [2usize, 4, 8, 16, 32] {
2625 assert_eq!(
2626 parse_colon_command(&format!("hex {n}")).unwrap(),
2627 ColonCommand::HexGroup(n),
2628 );
2629 }
2630 }
2631
2632 #[test]
2633 fn parse_colon_hex_without_value_errors() {
2634 assert_eq!(
2635 parse_colon_command("hex").unwrap_err(),
2636 ColonParseError::HexGroupRequiresValue,
2637 );
2638 }
2639
2640 #[test]
2641 fn parse_colon_hex_with_invalid_value_errors() {
2642 match parse_colon_command("hex 3").unwrap_err() {
2643 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "3"),
2644 other => panic!("expected HexGroupInvalid, got {other:?}"),
2645 }
2646 match parse_colon_command("hex banana").unwrap_err() {
2647 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "banana"),
2648 other => panic!("expected HexGroupInvalid, got {other:?}"),
2649 }
2650 }
2651
2652 #[test]
2653 fn parse_colon_color_without_arg_cycles() {
2654 assert_eq!(parse_colon_command("color").unwrap(), ColonCommand::Color(None));
2655 }
2656
2657 #[test]
2658 fn parse_colon_color_with_named_mode() {
2659 use crate::render::AnsiMode;
2660 assert_eq!(
2661 parse_colon_command("color strict").unwrap(),
2662 ColonCommand::Color(Some(AnsiMode::Strict)),
2663 );
2664 assert_eq!(
2665 parse_colon_command("color interpret").unwrap(),
2666 ColonCommand::Color(Some(AnsiMode::Interpret)),
2667 );
2668 assert_eq!(
2669 parse_colon_command("color raw").unwrap(),
2670 ColonCommand::Color(Some(AnsiMode::Raw)),
2671 );
2672 }
2673
2674 #[test]
2675 fn parse_colon_color_with_unknown_mode_errors() {
2676 match parse_colon_command("color rainbow").unwrap_err() {
2677 ColonParseError::ColorInvalid(v) => assert_eq!(v, "rainbow"),
2678 other => panic!("expected ColorInvalid, got {other:?}"),
2679 }
2680 }
2681
2682 #[test]
2683 fn parse_colon_case_without_arg_cycles() {
2684 assert_eq!(parse_colon_command("case").unwrap(), ColonCommand::Case(None));
2685 }
2686
2687 #[test]
2688 fn parse_colon_case_with_named_mode() {
2689 use crate::viewport::CaseMode;
2690 assert_eq!(parse_colon_command("case smart").unwrap(),
2691 ColonCommand::Case(Some(CaseMode::Smart)));
2692 assert_eq!(parse_colon_command("case sensitive").unwrap(),
2693 ColonCommand::Case(Some(CaseMode::Sensitive)));
2694 assert_eq!(parse_colon_command("case insensitive").unwrap(),
2695 ColonCommand::Case(Some(CaseMode::Insensitive)));
2696 }
2697
2698 #[test]
2699 fn parse_colon_case_unknown_errors() {
2700 match parse_colon_command("case rainbow").unwrap_err() {
2701 ColonParseError::CaseInvalid(v) => assert_eq!(v, "rainbow"),
2702 other => panic!("expected CaseInvalid, got {other:?}"),
2703 }
2704 }
2705
2706 #[test]
2707 fn parse_colon_hlsearch_on_off() {
2708 assert_eq!(parse_colon_command("hlsearch").unwrap(), ColonCommand::HlSearch(true));
2709 assert_eq!(parse_colon_command("nohlsearch").unwrap(), ColonCommand::HlSearch(false));
2710 }
2711
2712 #[test]
2713 fn lcp_empty_slice() {
2714 assert_eq!(longest_common_prefix(&[]), "");
2715 }
2716
2717 #[test]
2718 fn lcp_single_item_returns_self() {
2719 assert_eq!(longest_common_prefix(&["foo".into()]), "foo");
2720 }
2721
2722 #[test]
2723 fn lcp_finds_shared_prefix() {
2724 let v: Vec<String> = vec!["foobar".into(), "foobaz".into(), "fooqux".into()];
2725 assert_eq!(longest_common_prefix(&v), "foo");
2726 }
2727
2728 #[test]
2729 fn lcp_no_shared_prefix_returns_empty() {
2730 let v: Vec<String> = vec!["abc".into(), "xyz".into()];
2731 assert_eq!(longest_common_prefix(&v), "");
2732 }
2733
2734 #[test]
2735 fn lcp_one_item_is_prefix_of_others() {
2736 let v: Vec<String> = vec!["foo".into(), "foobar".into(), "foobaz".into()];
2737 assert_eq!(longest_common_prefix(&v), "foo");
2738 }
2739
2740 #[test]
2741 fn tag_stack_push_pop_lifo() {
2742 let mut s = TagStack::default();
2743 s.push(0, 10);
2744 s.push(1, 20);
2745 assert_eq!(s.pop(), Some((1, 20)));
2746 assert_eq!(s.pop(), Some((0, 10)));
2747 assert_eq!(s.pop(), None);
2748 }
2749
2750 #[test]
2751 fn tag_stack_pop_clears_active() {
2752 let mut s = TagStack::default();
2753 s.push(0, 10);
2754 s.set_active(
2755 "foo".into(),
2756 vec![crate::tags::TagEntry {
2757 file: std::path::PathBuf::from("/a"),
2758 address: crate::tags::TagAddress::Line(1),
2759 }],
2760 );
2761 assert!(s.active.is_some());
2762 let _ = s.pop();
2763 assert!(s.active.is_none());
2764 }
2765
2766 #[test]
2767 fn tag_stack_next_advances_then_clamps() {
2768 let mut s = TagStack::default();
2769 s.set_active(
2770 "foo".into(),
2771 vec![
2772 crate::tags::TagEntry {
2773 file: std::path::PathBuf::from("/a"),
2774 address: crate::tags::TagAddress::Line(1),
2775 },
2776 crate::tags::TagEntry {
2777 file: std::path::PathBuf::from("/b"),
2778 address: crate::tags::TagAddress::Line(2),
2779 },
2780 ],
2781 );
2782 assert_eq!(s.next(), TagStepResult::Moved(1));
2783 assert_eq!(s.next(), TagStepResult::AtBoundary);
2784 }
2785
2786 #[test]
2787 fn tag_stack_prev_clamps_at_zero() {
2788 let mut s = TagStack::default();
2789 s.set_active(
2790 "foo".into(),
2791 vec![crate::tags::TagEntry {
2792 file: std::path::PathBuf::from("/a"),
2793 address: crate::tags::TagAddress::Line(1),
2794 }],
2795 );
2796 assert_eq!(s.prev(), TagStepResult::AtBoundary);
2797 }
2798
2799 #[test]
2800 fn tag_stack_next_with_no_active_returns_no_active() {
2801 let mut s = TagStack::default();
2802 assert_eq!(s.next(), TagStepResult::NoActive);
2803 assert_eq!(s.prev(), TagStepResult::NoActive);
2804 }
2805
2806 #[test]
2807 fn tag_stack_set_active_replaces_previous_list() {
2808 let mut s = TagStack::default();
2809 s.set_active(
2810 "foo".into(),
2811 vec![crate::tags::TagEntry {
2812 file: std::path::PathBuf::from("/a"),
2813 address: crate::tags::TagAddress::Line(1),
2814 }],
2815 );
2816 s.set_active(
2817 "bar".into(),
2818 vec![
2819 crate::tags::TagEntry {
2820 file: std::path::PathBuf::from("/x"),
2821 address: crate::tags::TagAddress::Line(5),
2822 },
2823 crate::tags::TagEntry {
2824 file: std::path::PathBuf::from("/y"),
2825 address: crate::tags::TagAddress::Line(6),
2826 },
2827 ],
2828 );
2829 let active = s.active.as_ref().unwrap();
2830 assert_eq!(active.name, "bar");
2831 assert_eq!(active.matches.len(), 2);
2832 assert_eq!(active.cursor, 0);
2833 }
2834
2835 #[test]
2836 fn writer_emits_color_for_red_cell() {
2837 let cells = vec![Cell::Char {
2838 ch: 'h',
2839 width: 1,
2840 style: crate::ansi::Style {
2841 fg: Some(crate::ansi::Color::Ansi(1)),
2842 ..Default::default()
2843 },
2844 hyperlink: None,
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["), "expected ANSI escape in output: {s:?}");
2850 assert!(s.contains('h'));
2851 }
2852
2853 #[test]
2854 fn writer_emits_osc8_for_hyperlink_cell() {
2855 let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
2856 let cells = vec![Cell::Char {
2857 ch: 'c',
2858 width: 1,
2859 style: crate::ansi::Style::default(),
2860 hyperlink: Some(link),
2861 }];
2862 let mut buf: Vec<u8> = Vec::new();
2863 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
2864 let s = String::from_utf8_lossy(&buf);
2865 assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
2866 }
2867}