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 IncSearch,
105 Header(usize, usize),
107 Yank,
110}
111
112#[derive(Debug, Clone, PartialEq)]
113enum ColonParseError {
114 UnknownCommand(String),
115 MissingPath,
116 TagRequiresName,
117 HexGroupRequiresValue,
118 HexGroupInvalid(String),
119 ColorInvalid(String),
120 CaseInvalid(String),
121 HeaderInvalid(String),
122}
123
124impl std::fmt::Display for ColonParseError {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 match self {
127 ColonParseError::UnknownCommand(t) => write!(f, "unknown command: :{t}"),
128 ColonParseError::MissingPath => write!(f, ":e requires a path"),
129 ColonParseError::TagRequiresName => write!(f, ":tag requires a name"),
130 ColonParseError::HexGroupRequiresValue => {
131 write!(f, ":hex requires N (one of 2, 4, 8, 16, 32)")
132 }
133 ColonParseError::HexGroupInvalid(v) => {
134 write!(f, ":hex N must be one of 2, 4, 8, 16, 32 (got {v})")
135 }
136 ColonParseError::ColorInvalid(v) => {
137 write!(f, ":color mode must be strict, interpret, or raw (got {v})")
138 }
139 ColonParseError::CaseInvalid(v) => {
140 write!(f, ":case mode must be sensitive, smart, or insensitive (got {v})")
141 }
142 ColonParseError::HeaderInvalid(v) => {
143 write!(f, ":header expects `L` or `L C` (got {v})")
144 }
145 }
146 }
147}
148
149fn parse_colon_command(buf: &str) -> std::result::Result<ColonCommand, ColonParseError> {
150 let buf = buf.trim();
151 if buf.is_empty() {
152 return Err(ColonParseError::UnknownCommand(String::new()));
153 }
154 let mut parts = buf.splitn(2, char::is_whitespace);
155 let cmd = parts.next().unwrap();
156 let rest = parts.next().unwrap_or("").trim();
157 match cmd {
158 "n" | "next" => Ok(ColonCommand::Next),
159 "p" | "prev" => Ok(ColonCommand::Prev),
160 "e" | "edit" => {
161 if rest.is_empty() {
162 Err(ColonParseError::MissingPath)
163 } else {
164 let expanded = if let Some(stripped) = rest.strip_prefix("~/") {
166 if let Some(home) = std::env::var_os("HOME") {
167 let mut p = std::path::PathBuf::from(home);
168 p.push(stripped);
169 p
170 } else {
171 std::path::PathBuf::from(rest)
172 }
173 } else {
174 std::path::PathBuf::from(rest)
175 };
176 Ok(ColonCommand::Edit(expanded))
177 }
178 }
179 "f" => Ok(ColonCommand::ShowFile),
180 "q" | "quit" => Ok(ColonCommand::Quit),
181 "d" | "delete" => Ok(ColonCommand::Delete),
182 "x" | "first" => Ok(ColonCommand::First),
183 "t" | "last" => Ok(ColonCommand::Last),
184 "tag" => {
185 if rest.is_empty() {
186 Err(ColonParseError::TagRequiresName)
187 } else {
188 Ok(ColonCommand::Tag(rest.to_string()))
189 }
190 }
191 "tnext" => Ok(ColonCommand::TagNext),
192 "tprev" => Ok(ColonCommand::TagPrev),
193 "tselect" => {
194 if rest.is_empty() {
195 Ok(ColonCommand::TagSelect(None))
196 } else {
197 Ok(ColonCommand::TagSelect(Some(rest.to_string())))
198 }
199 }
200 "b" | "buffers" => Ok(ColonCommand::OpenPicker),
201 "h" | "help" => Ok(ColonCommand::OpenHelp),
202 "hex" => {
203 if rest.is_empty() {
204 Err(ColonParseError::HexGroupRequiresValue)
205 } else {
206 match rest.parse::<usize>() {
207 Ok(n) if matches!(n, 2 | 4 | 8 | 16 | 32) => Ok(ColonCommand::HexGroup(n)),
208 _ => Err(ColonParseError::HexGroupInvalid(rest.to_string())),
209 }
210 }
211 }
212 "color" => {
213 if rest.is_empty() {
214 Ok(ColonCommand::Color(None))
215 } else {
216 match rest {
217 "strict" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Strict))),
218 "interpret" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Interpret))),
219 "raw" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Raw))),
220 other => Err(ColonParseError::ColorInvalid(other.to_string())),
221 }
222 }
223 }
224 "hlsearch" => Ok(ColonCommand::HlSearch(true)),
225 "nohlsearch" => Ok(ColonCommand::HlSearch(false)),
226 "incsearch" => Ok(ColonCommand::IncSearch),
227 "yank" => Ok(ColonCommand::Yank),
228 "header" => {
229 let parts: Vec<&str> = rest.split_whitespace().collect();
230 match parts.as_slice() {
231 [l] => {
232 let n: usize = l.parse()
233 .map_err(|_| ColonParseError::HeaderInvalid(l.to_string()))?;
234 Ok(ColonCommand::Header(n, 0))
235 }
236 [l, c] => {
237 let nl: usize = l.parse()
238 .map_err(|_| ColonParseError::HeaderInvalid(l.to_string()))?;
239 let nc: usize = c.parse()
240 .map_err(|_| ColonParseError::HeaderInvalid(c.to_string()))?;
241 Ok(ColonCommand::Header(nl, nc))
242 }
243 _ => Err(ColonParseError::HeaderInvalid(rest.to_string())),
244 }
245 }
246 "case" => {
247 if rest.is_empty() {
248 Ok(ColonCommand::Case(None))
249 } else {
250 match rest {
251 "sensitive" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Sensitive))),
252 "smart" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Smart))),
253 "insensitive" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Insensitive))),
254 other => Err(ColonParseError::CaseInvalid(other.to_string())),
255 }
256 }
257 }
258 other => Err(ColonParseError::UnknownCommand(other.to_string())),
259 }
260}
261
262enum ColonOutcome {
263 Continue(Option<String>), Quit,
265 DispatchCommand(Command),
269}
270
271#[derive(Debug, Default)]
272struct TagStack {
273 history: Vec<(usize, usize)>,
276 active: Option<ActiveMatches>,
279}
280
281#[derive(Debug, Clone)]
282struct ActiveMatches {
283 name: String,
284 matches: Vec<crate::tags::TagEntry>,
285 cursor: usize,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
289enum TagStepResult {
290 Moved(usize),
292 AtBoundary,
294 NoActive,
296}
297
298impl TagStack {
299 fn push(&mut self, file_index: usize, top_line: usize) {
300 self.history.push((file_index, top_line));
301 }
302
303 fn pop(&mut self) -> Option<(usize, usize)> {
304 let popped = self.history.pop();
305 if popped.is_some() {
306 self.active = None;
307 }
308 popped
309 }
310
311 fn set_active(&mut self, name: String, matches: Vec<crate::tags::TagEntry>) {
312 self.active = Some(ActiveMatches {
313 name,
314 matches,
315 cursor: 0,
316 });
317 }
318
319 fn next(&mut self) -> TagStepResult {
320 let Some(a) = &mut self.active else {
321 return TagStepResult::NoActive;
322 };
323 if a.cursor + 1 >= a.matches.len() {
324 TagStepResult::AtBoundary
325 } else {
326 a.cursor += 1;
327 TagStepResult::Moved(a.cursor)
328 }
329 }
330
331 fn prev(&mut self) -> TagStepResult {
332 let Some(a) = &mut self.active else {
333 return TagStepResult::NoActive;
334 };
335 if a.cursor == 0 {
336 TagStepResult::AtBoundary
337 } else {
338 a.cursor -= 1;
339 TagStepResult::Moved(a.cursor)
340 }
341 }
342}
343
344fn refresh_tag_file(tag_file: &mut Option<crate::tags::TagFile>) -> Option<String> {
350 match tag_file.as_mut()?.reload_if_changed() {
351 Ok(true) => Some("[tags reloaded]".into()),
352 _ => None,
353 }
354}
355
356fn longest_common_prefix(items: &[String]) -> String {
360 let mut iter = items.iter();
361 let Some(first) = iter.next() else { return String::new() };
362 let mut prefix = first.clone();
363 for s in iter {
364 while !s.starts_with(&prefix) {
365 prefix.pop();
366 if prefix.is_empty() {
367 return prefix;
368 }
369 }
370 }
371 prefix
372}
373
374#[allow(clippy::too_many_arguments)]
377fn dispatch_tag_jump(
378 name: &str,
379 tag_file: Option<&crate::tags::TagFile>,
380 tag_stack: &mut TagStack,
381 file_set: &mut crate::file_set::FileSet,
382 current_file_index: &mut usize,
383 args: &crate::cli::Args,
384 preprocessor: Option<&crate::preprocess::Preprocessor>,
385 record_start_regex: Option<®ex::bytes::Regex>,
386 viewport: &mut crate::viewport::Viewport,
387 src: &mut Box<dyn crate::source::Source>,
388 idx: &mut crate::line_index::LineIndex,
389) -> Option<String> {
390 let Some(tf) = tag_file else {
391 return Some("[no tags file loaded]".into());
392 };
393 let matches = tf.lookup(name);
394 if matches.is_empty() {
395 return Some(format!("[tag not found: {name}]"));
396 }
397 let matches: Vec<crate::tags::TagEntry> = matches.to_vec();
398 tag_stack.push(*current_file_index, viewport.top_line());
399 tag_stack.set_active(name.to_string(), matches.clone());
400 let msg = dispatch_match(
401 &matches[0],
402 file_set,
403 current_file_index,
404 args,
405 preprocessor,
406 record_start_regex,
407 viewport,
408 src,
409 idx,
410 );
411 update_viewport_tag_indicator(tag_stack, viewport);
412 msg
413}
414
415#[allow(clippy::too_many_arguments)]
416fn dispatch_match(
417 entry: &crate::tags::TagEntry,
418 file_set: &mut crate::file_set::FileSet,
419 current_file_index: &mut usize,
420 args: &crate::cli::Args,
421 preprocessor: Option<&crate::preprocess::Preprocessor>,
422 record_start_regex: Option<®ex::bytes::Regex>,
423 viewport: &mut crate::viewport::Viewport,
424 src: &mut Box<dyn crate::source::Source>,
425 idx: &mut crate::line_index::LineIndex,
426) -> Option<String> {
427 let target_file = entry.file.as_path();
428 let already_current = file_set
429 .current()
430 .map(|p| p == target_file)
431 .unwrap_or(false);
432
433 if !already_current {
434 let existing_idx = (0..file_set.len()).find(|i| {
435 file_set
436 .nth(*i)
437 .map(|p| p == target_file)
438 .unwrap_or(false)
439 });
440 match existing_idx {
441 Some(i) => {
442 file_set.set_current_index(i);
443 }
444 None => {
445 file_set.append_and_switch(target_file.to_path_buf());
446 }
447 }
448 let path = file_set.current().unwrap().to_path_buf();
449 if let Err(e) = switch_file(
450 &path,
451 file_set.current_index(),
452 file_set.len(),
453 args,
454 preprocessor,
455 viewport,
456 src,
457 idx,
458 record_start_regex,
459 ) {
460 return Some(format!("[open: {e}]"));
461 }
462 *current_file_index = file_set.current_index();
463 }
464
465 let (line, hint) = match resolve_tag_address(&entry.address, src.as_ref(), idx, 0) {
466 AddressResult::Line(l) => (l, None),
467 AddressResult::NotFound => (0, Some("[tag pattern not found]".into())),
468 AddressResult::Unsupported(raw) => (
469 0,
470 Some(format!("[tag address not supported: {raw}]")),
471 ),
472 };
473
474 let clamped = line.min(idx.line_count().saturating_sub(1));
475 viewport.goto_line(clamped, src.as_ref(), idx);
476 hint
477}
478
479enum AddressResult {
480 Line(usize),
481 NotFound,
482 Unsupported(String),
483}
484
485fn resolve_tag_address(
489 addr: &crate::tags::TagAddress,
490 src: &dyn crate::source::Source,
491 idx: &mut crate::line_index::LineIndex,
492 from_line: usize,
493) -> AddressResult {
494 match addr {
495 crate::tags::TagAddress::Line(n) => AddressResult::Line(n.saturating_sub(1)),
496 crate::tags::TagAddress::Pattern(p) => {
497 let re_src = crate::tags::pattern_to_regex(p);
498 let re = match regex::bytes::Regex::new(&re_src) {
499 Ok(r) => r,
500 Err(_) => return AddressResult::NotFound,
501 };
502 match find_pattern_line(src, idx, &re, from_line) {
503 Some(l) => AddressResult::Line(l),
504 None => AddressResult::NotFound,
505 }
506 }
507 crate::tags::TagAddress::Chained(parts) => {
508 let mut here = from_line;
509 for step in parts {
510 match resolve_tag_address(step, src, idx, here) {
511 AddressResult::Line(l) => here = l + 1,
512 other => return other,
513 }
514 }
515 AddressResult::Line(here.saturating_sub(1))
518 }
519 crate::tags::TagAddress::Unsupported(raw) => {
520 AddressResult::Unsupported(raw.clone())
521 }
522 }
523}
524
525fn find_pattern_line(
526 src: &dyn crate::source::Source,
527 idx: &mut crate::line_index::LineIndex,
528 re: ®ex::bytes::Regex,
529 from_line: usize,
530) -> Option<usize> {
531 idx.extend_to_end(src);
532 for line_no in from_line..idx.line_count() {
533 let bytes = idx.line_bytes_stripped(line_no, src);
534 if re.is_match(&bytes) {
535 return Some(line_no);
536 }
537 }
538 None
539}
540
541fn update_viewport_tag_indicator(stack: &TagStack, viewport: &mut crate::viewport::Viewport) {
542 viewport.set_tag_active(stack.active.as_ref().map(|a| {
543 (a.name.clone(), a.cursor + 1, a.matches.len())
544 }));
545}
546
547#[allow(clippy::too_many_arguments)]
551fn switch_to_current_file(
552 file_set: &mut crate::file_set::FileSet,
553 current_file_index: &mut usize,
554 args: &crate::cli::Args,
555 preprocessor: Option<&crate::preprocess::Preprocessor>,
556 record_start_regex: Option<®ex::bytes::Regex>,
557 viewport: &mut crate::viewport::Viewport,
558 src: &mut Box<dyn crate::source::Source>,
559 idx: &mut crate::line_index::LineIndex,
560) -> Option<String> {
561 let path = match file_set.current() {
562 Some(p) => p.to_path_buf(),
563 None => return Some("[empty file set]".into()),
564 };
565 let new_idx_val = file_set.current_index();
566 match switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
567 Ok(()) => {
568 *current_file_index = new_idx_val;
569 None
570 }
571 Err(e) => Some(format!("[open: {e}]")),
572 }
573}
574
575#[allow(clippy::too_many_arguments)]
576fn switch_file(
577 new_path: &std::path::Path,
578 new_file_index: usize,
579 total_files: usize,
580 args: &crate::cli::Args,
581 preprocessor: Option<&crate::preprocess::Preprocessor>,
582 viewport: &mut crate::viewport::Viewport,
583 src: &mut Box<dyn crate::source::Source>,
584 idx: &mut crate::line_index::LineIndex,
585 record_start_regex: Option<®ex::bytes::Regex>,
586) -> crate::error::Result<()> {
587 let (new_src, new_label, new_failure) =
588 crate::open::open_source_for_path(new_path, args, preprocessor)?;
589
590 *src = new_src;
591 let mut new_idx = crate::line_index::LineIndex::new();
592 if let Some(re) = record_start_regex {
593 new_idx.set_record_start(re.clone());
594 }
595 *idx = new_idx;
596
597 viewport.set_source_label(new_label);
598 viewport.set_file_index(new_file_index, total_files);
599 viewport.set_preprocess_failure(new_failure);
600 viewport.goto_top();
601 viewport.reset_hscroll(); Ok(())
604}
605
606fn yank_current_line(
611 clipboard_enabled: bool,
612 viewport: &crate::viewport::Viewport,
613 src: &dyn crate::source::Source,
614 idx: &mut crate::line_index::LineIndex,
615) -> String {
616 if !clipboard_enabled {
617 return "[clipboard not enabled (pass --clipboard)]".to_string();
618 }
619 if idx.line_count() == 0 {
620 return "[nothing to copy]".to_string();
621 }
622 let line = viewport.top_line();
623 let bytes = current_line_bytes(idx, src, line);
624 match crate::clipboard::write(&bytes) {
625 Ok(()) => format!("[copied {} bytes]", bytes.len()),
626 Err(e) => format!("[{e}]"),
627 }
628}
629
630fn current_line_bytes(
634 idx: &crate::line_index::LineIndex,
635 src: &dyn crate::source::Source,
636 line: usize,
637) -> Vec<u8> {
638 let range = idx.line_range(line, src);
639 src.bytes(range).into_owned()
640}
641
642#[allow(clippy::too_many_arguments)]
643fn dispatch_colon_command(
644 cmd: ColonCommand,
645 file_set: &mut crate::file_set::FileSet,
646 current_file_index: &mut usize,
647 args: &crate::cli::Args,
648 preprocessor: Option<&crate::preprocess::Preprocessor>,
649 record_start_regex: Option<®ex::bytes::Regex>,
650 viewport: &mut crate::viewport::Viewport,
651 src: &mut Box<dyn crate::source::Source>,
652 idx: &mut crate::line_index::LineIndex,
653 tag_stack: &mut TagStack,
654 tag_file: Option<&crate::tags::TagFile>,
655) -> ColonOutcome {
656 match cmd {
657 ColonCommand::Next => {
658 match file_set.next() {
659 Ok(path) => {
660 let path = path.to_path_buf();
661 let new_idx_val = file_set.current_index();
662 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
663 ColonOutcome::Continue(Some(format!("[open: {e}]")))
664 } else {
665 *current_file_index = new_idx_val;
666 ColonOutcome::Continue(None)
667 }
668 }
669 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
670 }
671 }
672 ColonCommand::Prev => {
673 match file_set.prev() {
674 Ok(path) => {
675 let path = path.to_path_buf();
676 let new_idx_val = file_set.current_index();
677 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
678 ColonOutcome::Continue(Some(format!("[open: {e}]")))
679 } else {
680 *current_file_index = new_idx_val;
681 ColonOutcome::Continue(None)
682 }
683 }
684 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
685 }
686 }
687 ColonCommand::Edit(path) => {
688 match crate::open::open_source_for_path(&path, args, preprocessor) {
690 Ok(_) => {
691 let final_path = file_set.append_and_switch(path.clone()).to_path_buf();
693 let new_idx_val = file_set.current_index();
694 if let Err(e) = switch_file(&final_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 }
701 Err(e) => ColonOutcome::Continue(Some(format!("[open: {}: {e}]", path.display()))),
702 }
703 }
704 ColonCommand::ShowFile => {
705 let label = viewport.source_label_clone();
706 let cur = file_set.current_index() + 1;
707 let total = file_set.len();
708 let top = viewport.top_line() + 1;
709 let total_lines = idx.line_count();
710 let msg = if total > 1 {
711 format!("{label} (file {cur}/{total}): line {top}/{total_lines}")
712 } else {
713 format!("{label}: line {top}/{total_lines}")
714 };
715 ColonOutcome::Continue(Some(msg))
716 }
717 ColonCommand::Quit => ColonOutcome::Quit,
718 ColonCommand::Delete => {
719 match file_set.delete_current() {
720 Ok(path) => {
721 let path = path.to_path_buf();
722 let new_idx_val = file_set.current_index();
723 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
724 ColonOutcome::Continue(Some(format!("[open: {e}]")))
725 } else {
726 *current_file_index = new_idx_val;
727 ColonOutcome::Continue(None)
728 }
729 }
730 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
731 }
732 }
733 ColonCommand::First => {
734 if file_set.current_index() == 0 {
735 ColonOutcome::Continue(None) } else if let Some(path) = file_set.first() {
737 let path = path.to_path_buf();
738 let new_idx_val = file_set.current_index();
739 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
740 ColonOutcome::Continue(Some(format!("[open: {e}]")))
741 } else {
742 *current_file_index = new_idx_val;
743 ColonOutcome::Continue(None)
744 }
745 } else {
746 ColonOutcome::Continue(None)
747 }
748 }
749 ColonCommand::Last => {
750 if file_set.current_index() + 1 == file_set.len() {
751 ColonOutcome::Continue(None)
752 } else if let Some(path) = file_set.last() {
753 let path = path.to_path_buf();
754 let new_idx_val = file_set.current_index();
755 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
756 ColonOutcome::Continue(Some(format!("[open: {e}]")))
757 } else {
758 *current_file_index = new_idx_val;
759 ColonOutcome::Continue(None)
760 }
761 } else {
762 ColonOutcome::Continue(None)
763 }
764 }
765 ColonCommand::Tag(name) => {
766 match dispatch_tag_jump(
767 &name,
768 tag_file,
769 tag_stack,
770 file_set,
771 current_file_index,
772 args,
773 preprocessor,
774 record_start_regex,
775 viewport,
776 src,
777 idx,
778 ) {
779 Some(msg) => ColonOutcome::Continue(Some(msg)),
780 None => ColonOutcome::Continue(None),
781 }
782 }
783 ColonCommand::TagNext => match tag_stack.next() {
784 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
785 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[no more matches]".into())),
786 TagStepResult::Moved(cur) => {
787 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
788 let msg = dispatch_match(
789 &entry,
790 file_set,
791 current_file_index,
792 args,
793 preprocessor,
794 record_start_regex,
795 viewport,
796 src,
797 idx,
798 );
799 update_viewport_tag_indicator(tag_stack, viewport);
800 ColonOutcome::Continue(msg)
801 }
802 },
803 ColonCommand::TagSelect(name) => {
804 let prepared = match name {
805 Some(n) => {
806 let tf = match tag_file {
807 Some(t) => t,
808 None => {
809 return ColonOutcome::Continue(Some(
810 "[no tags file loaded]".into(),
811 ))
812 }
813 };
814 let matches: Vec<crate::tags::TagEntry> = tf.lookup(&n).to_vec();
815 if matches.is_empty() {
816 return ColonOutcome::Continue(Some(
817 format!("[no matches for `{n}`]"),
818 ));
819 }
820 tag_stack.set_active(n, matches);
821 true
822 }
823 None => tag_stack.active.is_some(),
824 };
825 if prepared {
826 ColonOutcome::DispatchCommand(Command::OpenTagPicker)
827 } else {
828 ColonOutcome::Continue(Some("[no active tag]".into()))
829 }
830 }
831 ColonCommand::TagPrev => match tag_stack.prev() {
832 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
833 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[at first match]".into())),
834 TagStepResult::Moved(cur) => {
835 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
836 let msg = dispatch_match(
837 &entry,
838 file_set,
839 current_file_index,
840 args,
841 preprocessor,
842 record_start_regex,
843 viewport,
844 src,
845 idx,
846 );
847 update_viewport_tag_indicator(tag_stack, viewport);
848 ColonOutcome::Continue(msg)
849 }
850 },
851 ColonCommand::OpenPicker => ColonOutcome::DispatchCommand(Command::OpenPicker),
854 ColonCommand::OpenHelp => ColonOutcome::DispatchCommand(Command::OpenHelp),
855 ColonCommand::HexGroup(hex_chars) => {
856 if !viewport.hex_mode() {
857 return ColonOutcome::Continue(Some(
858 "[:hex requires --hex mode]".into(),
859 ));
860 }
861 let bpg = crate::hex::hex_chars_to_bytes_per_group(hex_chars).unwrap();
863 viewport.set_hex_group_size(bpg);
864 ColonOutcome::Continue(Some(format!("[hex group: {hex_chars} chars]")))
865 }
866 ColonCommand::Color(mode) => {
867 use crate::render::AnsiMode;
868 let next = mode.unwrap_or_else(|| match viewport.ansi_mode() {
869 AnsiMode::Strict => AnsiMode::Interpret,
870 AnsiMode::Interpret => AnsiMode::Raw,
871 AnsiMode::Raw => AnsiMode::Strict,
872 });
873 viewport.set_ansi_mode(next);
874 let label = match next {
875 AnsiMode::Strict => "strict",
876 AnsiMode::Interpret => "interpret",
877 AnsiMode::Raw => "raw",
878 };
879 ColonOutcome::Continue(Some(format!("[color: {label}]")))
880 }
881 ColonCommand::Header(l, c) => {
882 viewport.set_header(l, c);
883 ColonOutcome::Continue(Some(format!("[header: {l} rows, {c} cols]")))
884 }
885 ColonCommand::HlSearch(on) => {
886 viewport.set_hilite_search(on);
887 let msg = if on { "[hlsearch on]" } else { "[hlsearch off]" };
888 ColonOutcome::Continue(Some(msg.into()))
889 }
890 ColonCommand::IncSearch => {
891 let on = !viewport.incsearch();
892 viewport.set_incsearch(on);
893 let msg = if on { "[incsearch on]" } else { "[incsearch off]" };
894 ColonOutcome::Continue(Some(msg.into()))
895 }
896 ColonCommand::Yank => {
897 ColonOutcome::Continue(Some(yank_current_line(args.clipboard, viewport, src.as_ref(), idx)))
898 }
899 ColonCommand::Case(mode) => {
900 use crate::viewport::CaseMode;
901 let next = mode.unwrap_or_else(|| match viewport.case_mode() {
902 CaseMode::Sensitive => CaseMode::Smart,
903 CaseMode::Smart => CaseMode::Insensitive,
904 CaseMode::Insensitive => CaseMode::Sensitive,
905 });
906 viewport.set_case_mode(next);
907 let label = match next {
908 CaseMode::Sensitive => "sensitive",
909 CaseMode::Smart => "smart",
910 CaseMode::Insensitive => "insensitive",
911 };
912 ColonOutcome::Continue(Some(format!("[case: {label}]")))
913 }
914 }
915}
916
917#[allow(clippy::too_many_arguments, clippy::collapsible_match)]
918pub fn run(
919 mut src: Box<dyn Source>,
920 mut viewport: Viewport,
921 mut idx: LineIndex,
922 sigterm: Arc<AtomicBool>,
923 rebuild_spec: RebuildSpec,
924 keymap: crate::keys::KeyMap,
925 mut file_set: crate::file_set::FileSet,
926 record_start_regex: Option<regex::bytes::Regex>,
927 args: crate::cli::Args,
928 preprocessor: Option<crate::preprocess::Preprocessor>,
929 mut tag_file: Option<crate::tags::TagFile>,
930) -> Result<()> {
931 let (mut cols, mut rows) = size().unwrap_or((80, 24));
932 viewport.resize(cols, rows);
933
934 let truecolor = match args.truecolor.as_str() {
935 "always" => true,
936 "never" => false,
937 _ => crate::render::TrueColor::Auto.resolve(),
938 };
939
940 let mut stdout = io::stdout();
941 const BASE_POLL: Duration = Duration::from_millis(250);
942 #[cfg(feature = "image")]
943 let mut last_tick = std::time::Instant::now();
944 let mut last_revision = src.revision();
945
946 if (viewport.filter_active() || viewport.grep_active()) && !viewport.dim_mode() {
951 idx.extend_to_end(src.as_ref());
952 viewport.extend_visible_lines(&idx, src.as_ref());
953 }
954
955 if viewport.follow_mode() || viewport.live_mode() {
960 src.pump();
961 viewport.extend_visible_lines(&idx, src.as_ref());
962 viewport.goto_bottom(src.as_ref(), &mut idx);
963 }
964
965 let mut needs_redraw = true;
967 let mut mode = InputMode::Normal;
968 let mut numeric_prefix: Option<usize> = None;
969 let mut marks: HashMap<char, (usize, usize)> = HashMap::new();
970 let mut previous_position: Option<(usize, usize)> = None;
971 let mut incsearch_origin: (usize, usize) = (0, 0);
974 let mut current_file_index: usize = file_set.current_index();
975 let mut transient_status: Option<String> = None;
976 let mut tag_stack = TagStack::default();
977 let mut overlay: Option<Box<dyn crate::overlay::Overlay>> = None;
978 let mut overlay_flash: Option<(&'static str, std::time::Instant)> = None;
979 let mouse_enabled = args.mouse;
980 let clipboard_enabled = args.clipboard;
981 let hscroll_shift = args.shift.unwrap_or(0);
982 let wheel_lines = args.wheel_lines.unwrap_or(3).max(1);
983
984 if let Some(tag_name) = args.tag.as_deref() {
985 let _ = refresh_tag_file(&mut tag_file);
986 if let Some(msg) = dispatch_tag_jump(
987 tag_name,
988 tag_file.as_ref(),
989 &mut tag_stack,
990 &mut file_set,
991 &mut current_file_index,
992 &args,
993 preprocessor.as_ref(),
994 record_start_regex.as_ref(),
995 &mut viewport,
996 &mut src,
997 &mut idx,
998 ) {
999 return Err(crate::error::Error::Runtime(format!("startup tag jump failed: {msg}")));
1000 }
1001 }
1002
1003 loop {
1004 if sigterm.load(Ordering::SeqCst) {
1005 break;
1006 }
1007
1008 if needs_redraw {
1009 if let Some(ov) = overlay.as_ref() {
1010 let w = cols;
1011 let h = viewport.body_rows() + 1;
1012 let mut ovframe = ov.render(w, h);
1013 if let Some((msg, started)) = overlay_flash {
1014 if started.elapsed() < std::time::Duration::from_millis(1500) {
1015 ovframe.status = format!("[{msg}]");
1016 } else {
1017 overlay_flash = None;
1018 }
1019 }
1020 render_overlay(&mut stdout, &ovframe, w, h)
1021 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1022 needs_redraw = false;
1023 continue;
1024 }
1025 if viewport.status_column() {
1029 let status_marks: HashMap<usize, char> = marks
1030 .iter()
1031 .filter(|(_, (fi, _))| *fi == current_file_index)
1032 .map(|(ch, (_, line))| (*line, *ch))
1033 .collect();
1034 viewport.set_status_marks(status_marks);
1035 }
1036 let mut frame = viewport.frame(src.as_ref(), &mut idx);
1037 match &mode {
1040 InputMode::SearchPrompt { direction, buffer, error } => {
1041 let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
1042 frame.status = match error {
1043 Some(e) => format!("{prefix}{buffer} [error: {e}]"),
1044 None => format!("{prefix}{buffer}"),
1045 };
1046 }
1047 InputMode::ShellPrompt { buffer, error } => {
1048 frame.status = match error {
1049 Some(e) => format!("!{buffer} [error: {e}]"),
1050 None => format!("!{buffer}"),
1051 };
1052 }
1053 InputMode::ColonPrompt { buffer, error } => {
1054 frame.status = match error {
1055 Some(e) => format!(":{buffer} [error: {e}]"),
1056 None => format!(":{buffer}"),
1057 };
1058 }
1059 InputMode::TagPrompt { buffer, error, .. } => {
1060 frame.status = match error {
1061 Some(e) => format!("tag: {buffer} [error: {e}]"),
1062 None => format!("tag: {buffer}"),
1063 };
1064 }
1065 _ => {
1066 if let Some(msg) = transient_status.take() {
1067 frame.status = msg;
1068 }
1069 }
1070 }
1071 write_frame(&mut stdout, &frame, cols, rows, truecolor)
1072 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1073 needs_redraw = false;
1074 }
1075
1076 #[cfg(feature = "image")]
1080 let timeout = viewport.anim_deadline()
1081 .map(|d| d.min(BASE_POLL))
1082 .unwrap_or(BASE_POLL);
1083 #[cfg(not(feature = "image"))]
1084 let timeout = BASE_POLL;
1085 match poll(timeout) {
1086 Ok(true) => {
1087 let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
1088 match &mut mode {
1091 InputMode::SearchPrompt { direction, buffer, error } => {
1092 if let Event::Key(KeyEvent { code, .. }) = event {
1093 match code {
1094 KeyCode::Esc => {
1095 if viewport.incsearch() {
1096 viewport.set_top(incsearch_origin.0, incsearch_origin.1);
1097 }
1098 mode = InputMode::Normal;
1099 needs_redraw = true;
1100 }
1101 KeyCode::Enter => {
1102 if viewport.incsearch() {
1103 viewport.set_top(incsearch_origin.0, incsearch_origin.1);
1104 }
1105 if buffer.is_empty() {
1106 if viewport.search_active() {
1110 let reverse = !matches!(
1111 (viewport.search_direction(), *direction),
1112 (SearchDirection::Forward, SearchDirection::Forward)
1113 | (SearchDirection::Backward, SearchDirection::Backward)
1114 );
1115 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1116 viewport.search_repeat(src.as_ref(), &mut idx, reverse);
1117 }
1118 mode = InputMode::Normal;
1119 } else {
1120 match viewport.set_search(buffer.clone(), *direction) {
1121 Ok(()) => {
1122 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1123 viewport.search_repeat(src.as_ref(), &mut idx, false);
1124 mode = InputMode::Normal;
1125 }
1126 Err(e) => { *error = Some(e); }
1127 }
1128 }
1129 needs_redraw = true;
1130 }
1131 KeyCode::Backspace => {
1132 buffer.pop();
1133 *error = None;
1134 if viewport.incsearch() {
1135 viewport.incsearch_preview(
1136 src.as_ref(), &mut idx, buffer, *direction, incsearch_origin);
1137 }
1138 needs_redraw = true;
1139 }
1140 KeyCode::Char(c) => {
1141 buffer.push(c);
1142 *error = None;
1143 if viewport.incsearch() {
1144 viewport.incsearch_preview(
1145 src.as_ref(), &mut idx, buffer, *direction, incsearch_origin);
1146 }
1147 needs_redraw = true;
1148 }
1149 _ => {}
1150 }
1151 }
1152 continue;
1153 }
1154 InputMode::OptionPrefix => {
1155 if let Event::Key(KeyEvent { code, .. }) = event {
1156 match code {
1157 KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
1158 KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
1159 KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
1160 KeyCode::Char('P') | KeyCode::Char('p') => {
1161 mode = InputMode::PrettifyPrefix;
1163 needs_redraw = true;
1164 continue;
1165 }
1166 _ => {}
1167 }
1168 }
1169 mode = InputMode::Normal;
1170 needs_redraw = true;
1171 continue;
1172 }
1173 InputMode::PrettifyPrefix => {
1174 if let Event::Key(KeyEvent { code, .. }) = event {
1175 let target: Option<PrettifyTarget> = match code {
1176 KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
1177 KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
1178 KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
1179 KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
1180 KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
1181 KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
1182 KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
1183 KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
1184 _ => None,
1185 };
1186 if let Some(t) = target {
1187 apply_prettify(
1188 src.as_ref(),
1189 &mut viewport,
1190 &mut idx,
1191 rebuild_spec,
1192 t,
1193 );
1194 last_revision = src.revision();
1195 }
1196 }
1197 mode = InputMode::Normal;
1198 needs_redraw = true;
1199 continue;
1200 }
1201 InputMode::MarkSetPending => {
1202 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1203 if is_valid_mark_name(c) {
1204 mark_set(&mut marks, c, current_file_index, viewport.top_line());
1205 }
1206 }
1207 mode = InputMode::Normal;
1208 continue;
1209 }
1210 InputMode::MarkJumpPending => {
1211 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1212 if is_valid_mark_name(c) {
1213 match mark_jump(&marks, c, current_file_index, &mut previous_position, viewport.top_line()) {
1214 Some(MarkTarget::SameFile { line }) => {
1215 let clamped = line.min(idx.line_count().saturating_sub(1));
1216 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1217 needs_redraw = true;
1218 }
1219 Some(MarkTarget::OtherFile { file_index, line }) => {
1220 if file_index < file_set.len() {
1221 file_set.set_current_index(file_index);
1222 let path = file_set.current().unwrap().to_path_buf();
1223 if let Err(e) = switch_file(
1224 &path, file_index, file_set.len(),
1225 &args, preprocessor.as_ref(),
1226 &mut viewport, &mut src, &mut idx,
1227 record_start_regex.as_ref(),
1228 ) {
1229 transient_status = Some(format!("[open: {e}]"));
1230 } else {
1231 let clamped = line.min(idx.line_count().saturating_sub(1));
1232 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1233 current_file_index = file_index;
1234 needs_redraw = true;
1235 }
1236 }
1237 }
1238 None => {}
1239 }
1240 }
1241 }
1242 mode = InputMode::Normal;
1243 continue;
1244 }
1245 InputMode::ShellPrompt { buffer, error } => {
1246 if let Event::Key(KeyEvent { code, .. }) = event {
1247 match code {
1248 KeyCode::Esc => {
1249 mode = InputMode::Normal;
1250 needs_redraw = true;
1251 }
1252 KeyCode::Enter => {
1253 if buffer.is_empty() {
1254 mode = InputMode::Normal;
1255 } else {
1256 match crate::shell::run_shell_command(buffer) {
1257 Ok(()) => {
1258 mode = InputMode::Normal;
1259 }
1260 Err(e) => {
1261 *error = Some(e.to_string());
1262 }
1263 }
1264 }
1265 needs_redraw = true;
1266 }
1267 KeyCode::Backspace => {
1268 buffer.pop();
1269 *error = None;
1270 needs_redraw = true;
1271 }
1272 KeyCode::Char(c) => {
1273 buffer.push(c);
1274 *error = None;
1275 needs_redraw = true;
1276 }
1277 _ => {}
1278 }
1279 }
1280 continue;
1281 }
1282 InputMode::CtrlXPending => {
1283 let is_ctrl_x = matches!(
1284 event,
1285 Event::Key(KeyEvent {
1286 code: KeyCode::Char('x'),
1287 modifiers: KeyModifiers::CONTROL,
1288 ..
1289 })
1290 );
1291 if is_ctrl_x {
1292 match jump_previous(&mut previous_position, current_file_index, viewport.top_line()) {
1293 Some(MarkTarget::SameFile { line }) => {
1294 let clamped = line.min(idx.line_count().saturating_sub(1));
1295 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1296 needs_redraw = true;
1297 }
1298 Some(MarkTarget::OtherFile { file_index, line }) => {
1299 if file_index < file_set.len() {
1300 file_set.set_current_index(file_index);
1301 let path = file_set.current().unwrap().to_path_buf();
1302 if let Err(e) = switch_file(
1303 &path, file_index, file_set.len(),
1304 &args, preprocessor.as_ref(),
1305 &mut viewport, &mut src, &mut idx,
1306 record_start_regex.as_ref(),
1307 ) {
1308 transient_status = Some(format!("[open: {e}]"));
1309 } else {
1310 let clamped = line.min(idx.line_count().saturating_sub(1));
1311 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1312 current_file_index = file_index;
1313 needs_redraw = true;
1314 }
1315 }
1316 }
1317 None => {}
1318 }
1319 mode = InputMode::Normal;
1320 continue;
1321 }
1322 mode = InputMode::Normal;
1324 }
1326 InputMode::ColonPrompt { buffer, error } => {
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 match parse_colon_command(buffer) {
1338 Ok(cmd) => {
1339 let is_tag_cmd = matches!(
1340 &cmd,
1341 ColonCommand::Tag(_)
1342 | ColonCommand::TagNext
1343 | ColonCommand::TagPrev
1344 | ColonCommand::TagSelect(_),
1345 );
1346 let reload_msg = if is_tag_cmd {
1347 refresh_tag_file(&mut tag_file)
1348 } else {
1349 None
1350 };
1351 let outcome = dispatch_colon_command(
1352 cmd,
1353 &mut file_set,
1354 &mut current_file_index,
1355 &args,
1356 preprocessor.as_ref(),
1357 record_start_regex.as_ref(),
1358 &mut viewport,
1359 &mut src,
1360 &mut idx,
1361 &mut tag_stack,
1362 tag_file.as_ref(),
1363 );
1364 match outcome {
1365 ColonOutcome::Continue(msg) => {
1366 transient_status = msg.or(reload_msg);
1367 }
1368 ColonOutcome::Quit => break,
1369 ColonOutcome::DispatchCommand(Command::OpenPicker) => {
1370 let saved = (0..file_set.len())
1371 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1372 .collect::<Vec<_>>();
1373 overlay = Some(Box::new(
1374 crate::overlay::picker::FilePicker::new(&file_set, saved)
1375 ));
1376 }
1377 ColonOutcome::DispatchCommand(Command::OpenHelp) => {
1378 let remaps = keymap.user_keys_by_command_name();
1379 overlay = Some(Box::new(
1380 crate::overlay::help::HelpOverlay::new(remaps)
1381 ));
1382 }
1383 ColonOutcome::DispatchCommand(Command::OpenTagPicker) => {
1384 if let Some(active) = tag_stack.active.as_ref() {
1385 overlay = Some(Box::new(
1386 crate::overlay::tag_picker::TagPicker::new(
1387 active.name.clone(),
1388 active.matches.clone(),
1389 active.cursor,
1390 )
1391 ));
1392 }
1393 }
1394 ColonOutcome::DispatchCommand(cmd) => {
1395 debug_assert!(false, "colon dispatcher emitted unexpected Command: {cmd:?}");
1396 }
1398 }
1399 mode = InputMode::Normal;
1400 }
1401 Err(e) => {
1402 *error = Some(e.to_string());
1403 }
1404 }
1405 }
1406 needs_redraw = true;
1407 }
1408 KeyCode::Backspace => {
1409 buffer.pop();
1410 *error = None;
1411 needs_redraw = true;
1412 }
1413 KeyCode::Char(c) => {
1414 buffer.push(c);
1415 *error = None;
1416 needs_redraw = true;
1417 }
1418 _ => {}
1419 }
1420 }
1421 continue;
1422 }
1423 InputMode::TagPrompt { buffer, error, last_tab_matches } => {
1424 if let Event::Key(KeyEvent { code, .. }) = event {
1425 match code {
1426 KeyCode::Esc => {
1427 mode = InputMode::Normal;
1428 needs_redraw = true;
1429 }
1430 KeyCode::Enter => {
1431 if buffer.is_empty() {
1432 mode = InputMode::Normal;
1433 } else {
1434 let name = buffer.clone();
1435 let reload_msg = refresh_tag_file(&mut tag_file);
1436 let msg = dispatch_tag_jump(
1437 &name,
1438 tag_file.as_ref(),
1439 &mut tag_stack,
1440 &mut file_set,
1441 &mut current_file_index,
1442 &args,
1443 preprocessor.as_ref(),
1444 record_start_regex.as_ref(),
1445 &mut viewport,
1446 &mut src,
1447 &mut idx,
1448 );
1449 transient_status = msg.or(reload_msg);
1450 mode = InputMode::Normal;
1451 }
1452 needs_redraw = true;
1453 }
1454 KeyCode::Backspace => {
1455 buffer.pop();
1456 *error = None;
1457 *last_tab_matches = None;
1458 needs_redraw = true;
1459 }
1460 KeyCode::Tab => {
1461 let _ = refresh_tag_file(&mut tag_file);
1462 let names: Vec<String> = match tag_file.as_ref() {
1463 Some(tf) => tf
1464 .names()
1465 .filter(|n| n.starts_with(buffer.as_str()))
1466 .map(String::from)
1467 .collect(),
1468 None => Vec::new(),
1469 };
1470 match (names.len(), last_tab_matches.as_ref()) {
1471 (0, _) => {
1472 *error = Some("no tags match".into());
1473 *last_tab_matches = None;
1474 }
1475 (1, _) => {
1476 *buffer = names.into_iter().next().unwrap();
1477 *error = None;
1478 *last_tab_matches = None;
1479 }
1480 (n, Some(prev)) if prev.len() == n => {
1481 *error = Some(format!("{n} matches"));
1482 }
1483 (n, _) => {
1484 let lcp = longest_common_prefix(&names);
1485 if lcp.len() > buffer.len() {
1486 *buffer = lcp;
1487 *error = None;
1488 } else {
1489 *error = Some(format!("{n} matches"));
1490 }
1491 *last_tab_matches = Some(names);
1492 }
1493 }
1494 needs_redraw = true;
1495 }
1496 KeyCode::Char(c) => {
1497 buffer.push(c);
1498 *error = None;
1499 *last_tab_matches = None;
1500 needs_redraw = true;
1501 }
1502 _ => {}
1503 }
1504 }
1505 continue;
1506 }
1507 InputMode::Normal => {}
1508 }
1509 if let crossterm::event::Event::Resize(c, r) = event {
1512 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1519 cols = c;
1520 rows = r;
1521 viewport.resize(c, r);
1522 if was_at_bottom {
1523 viewport.goto_bottom(src.as_ref(), &mut idx);
1524 }
1525 needs_redraw = true;
1526 if overlay.is_some() {
1527 continue;
1529 }
1530 }
1533 if let Some(ov) = overlay.as_mut() {
1537 let outcome = match &event {
1538 Event::Key(ke) => ov.handle_key(*ke),
1539 Event::Mouse(me) => ov.handle_mouse(*me, viewport.body_rows()),
1540 Event::Resize(_, _) => crate::overlay::OverlayOutcome::Stay,
1541 _ => crate::overlay::OverlayOutcome::Stay,
1542 };
1543 match outcome {
1544 crate::overlay::OverlayOutcome::Stay => {
1545 needs_redraw = true;
1546 continue;
1547 }
1548 crate::overlay::OverlayOutcome::Close => {
1549 overlay = None;
1550 overlay_flash = None;
1551 needs_redraw = true;
1552 continue;
1553 }
1554 crate::overlay::OverlayOutcome::CloseAnd(cmd) => {
1555 overlay = None;
1556 overlay_flash = None;
1557 if let Command::SelectFile(i) = cmd {
1558 if i < file_set.len() {
1559 file_set.set_current_index(i);
1560 if let Some(msg) = switch_to_current_file(
1561 &mut file_set, &mut current_file_index,
1562 &args, preprocessor.as_ref(),
1563 record_start_regex.as_ref(),
1564 &mut viewport, &mut src, &mut idx,
1565 ) {
1566 transient_status = Some(msg);
1567 }
1568 }
1569 } else if let Command::SelectTagMatch(idx_pick) = cmd {
1570 if let Some(active) = tag_stack.active.as_mut() {
1571 if idx_pick < active.matches.len() {
1572 active.cursor = idx_pick;
1573 let entry = active.matches[idx_pick].clone();
1574 let msg = dispatch_match(
1575 &entry,
1576 &mut file_set,
1577 &mut current_file_index,
1578 &args,
1579 preprocessor.as_ref(),
1580 record_start_regex.as_ref(),
1581 &mut viewport,
1582 &mut src,
1583 &mut idx,
1584 );
1585 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1586 if let Some(m) = msg {
1587 transient_status = Some(m);
1588 }
1589 }
1590 }
1591 }
1592 needs_redraw = true;
1593 continue;
1594 }
1595 crate::overlay::OverlayOutcome::Apply(cmd) => {
1596 if let Command::DropFileAt(target) = cmd {
1597 if file_set.len() > 1 && target < file_set.len() {
1598 let saved_cur = file_set.current_index();
1599 file_set.set_current_index(target);
1600 let _ = file_set.delete_current();
1601 if target < saved_cur {
1605 let restored = saved_cur.saturating_sub(1);
1606 file_set.set_current_index(restored);
1607 } else if target > saved_cur {
1608 file_set.set_current_index(saved_cur);
1609 }
1610 if let Some(msg) = switch_to_current_file(
1613 &mut file_set, &mut current_file_index,
1614 &args, preprocessor.as_ref(),
1615 record_start_regex.as_ref(),
1616 &mut viewport, &mut src, &mut idx,
1617 ) {
1618 transient_status = Some(msg);
1619 }
1620 if let Some(ov) = overlay.as_mut() {
1621 ov.refresh(crate::overlay::OverlayContext { file_set: &file_set });
1622 }
1623 }
1624 }
1625 needs_redraw = true;
1626 continue;
1627 }
1628 crate::overlay::OverlayOutcome::Refuse(msg) => {
1629 overlay_flash = Some((msg, std::time::Instant::now()));
1630 needs_redraw = true;
1631 continue;
1632 }
1633 }
1634 }
1635 if let crossterm::event::Event::Mouse(me) = &event {
1639 if mouse_enabled {
1640 use crossterm::event::{KeyModifiers, MouseEventKind};
1641 let hshift = me.modifiers.contains(KeyModifiers::SHIFT)
1647 && viewport.hscroll_active();
1648 match me.kind {
1649 MouseEventKind::ScrollDown if hshift => {
1650 viewport.hscroll_right_step();
1651 needs_redraw = true;
1652 }
1653 MouseEventKind::ScrollUp if hshift => {
1654 viewport.hscroll_left_step();
1655 needs_redraw = true;
1656 }
1657 MouseEventKind::ScrollDown => {
1658 viewport.scroll_lines(wheel_lines as i64, src.as_ref(), &mut idx);
1659 needs_redraw = true;
1660 }
1661 MouseEventKind::ScrollUp => {
1662 viewport.scroll_lines(-(wheel_lines as i64), src.as_ref(), &mut idx);
1663 needs_redraw = true;
1664 }
1665 MouseEventKind::ScrollLeft => {
1666 viewport.hscroll_left_step();
1667 needs_redraw = true;
1668 }
1669 MouseEventKind::ScrollRight => {
1670 viewport.hscroll_right_step();
1671 needs_redraw = true;
1672 }
1673 _ => {}
1674 }
1675 }
1676 continue;
1677 }
1678 let mut cmd: Option<Command> = None;
1682 if let InputMode::Normal = mode {
1683 if let Event::Key(ke) = &event {
1684 if let Some(target) = keymap.lookup(ke) {
1685 match target {
1686 crate::keys::BindingTarget::Shell(cmd_text) => {
1687 let cmd_text = cmd_text.clone();
1688 if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
1689 let _ = writeln!(std::io::stderr(),
1690 "[shell: {e}]");
1691 }
1692 needs_redraw = true;
1693 continue;
1694 }
1695 crate::keys::BindingTarget::Command(c) => {
1696 cmd = Some(c.clone());
1697 }
1698 }
1699 }
1700 }
1701 }
1702 let cmd = cmd.unwrap_or_else(|| translate(event));
1703 let prefix_at_cmd = numeric_prefix.take();
1706 match cmd {
1707 Command::Digit(d) => {
1708 let cur = prefix_at_cmd.unwrap_or(0);
1709 let next = cur.saturating_mul(10).saturating_add(d as usize);
1710 if next <= 99_999_999 {
1711 numeric_prefix = Some(next);
1712 } else {
1713 numeric_prefix = prefix_at_cmd;
1715 }
1716 continue;
1717 }
1718 Command::Cancel => {
1719 continue;
1721 }
1722 Command::GotoLine => {
1723 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1724 match prefix_at_cmd {
1725 Some(line) if line > 0 => {
1726 viewport.goto_line(line - 1, src.as_ref(), &mut idx);
1727 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1728 }
1729 _ => {
1730 viewport.goto_top();
1731 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1732 }
1733 }
1734 needs_redraw = true;
1735 }
1736 Command::GotoRecord => {
1737 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1738 match prefix_at_cmd {
1739 Some(rec) if rec > 0 => {
1740 viewport.goto_record(rec - 1, src.as_ref(), &mut idx);
1741 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1742 }
1743 _ => viewport.goto_bottom(src.as_ref(), &mut idx),
1744 }
1745 needs_redraw = true;
1746 }
1747 Command::GotoPercent => {
1748 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1749 match prefix_at_cmd {
1750 Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
1751 _ => viewport.goto_top(),
1752 }
1753 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1754 needs_redraw = true;
1755 }
1756 Command::Quit => break,
1757 Command::Resize(c, r) => {
1758 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1759 cols = c; rows = r;
1760 viewport.resize(c, r);
1761 if was_at_bottom {
1762 viewport.goto_bottom(src.as_ref(), &mut idx);
1763 }
1764 needs_redraw = true;
1765 }
1766 Command::ScrollLines(n) => {
1767 viewport.scroll_lines(n, src.as_ref(), &mut idx);
1768 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1769 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1770 needs_redraw = true;
1771 }
1772 Command::ScrollLogicalLines(n) => {
1773 viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
1774 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1775 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1776 needs_redraw = true;
1777 }
1778 Command::PageDown => {
1779 viewport.page_down(src.as_ref(), &mut idx);
1780 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1781 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1782 needs_redraw = true;
1783 }
1784 Command::PageUp => {
1785 viewport.page_up(src.as_ref(), &mut idx);
1786 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1787 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1788 needs_redraw = true;
1789 }
1790 Command::HalfPageDown => {
1791 viewport.half_page_down(src.as_ref(), &mut idx);
1792 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1793 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1794 needs_redraw = true;
1795 }
1796 Command::HalfPageUp => {
1797 viewport.half_page_up(src.as_ref(), &mut idx);
1798 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1799 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1800 needs_redraw = true;
1801 }
1802 Command::Refresh => {
1803 needs_redraw = true;
1804 }
1805 Command::Reload => {
1806 src.pump();
1809 if src.revision() != last_revision {
1810 rebuild_after_replace(
1811 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1812 );
1813 last_revision = src.revision();
1814 needs_redraw = true;
1815 }
1816 }
1817 Command::TogglePrettify => {
1818 apply_prettify(
1819 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1820 PrettifyTarget::Toggle,
1821 );
1822 last_revision = src.revision();
1823 needs_redraw = true;
1824 }
1825 Command::SetPrettifyMode(m) => {
1826 apply_prettify(
1827 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1828 PrettifyTarget::Mode(m),
1829 );
1830 last_revision = src.revision();
1831 needs_redraw = true;
1832 }
1833 Command::RedetectPrettify => {
1834 apply_prettify(
1835 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1836 PrettifyTarget::Auto,
1837 );
1838 last_revision = src.revision();
1839 needs_redraw = true;
1840 }
1841 Command::ToggleLineNumbers => {
1842 viewport.toggle_line_numbers();
1843 needs_redraw = true;
1844 }
1845 Command::ToggleChop => {
1846 viewport.toggle_chop();
1847 needs_redraw = true;
1848 }
1849 Command::ToggleFollow => {
1850 viewport.toggle_follow();
1851 if viewport.follow_mode() {
1852 src.pump();
1854 idx.notice_new_bytes(src.as_ref());
1855 viewport.goto_bottom(src.as_ref(), &mut idx);
1856 }
1857 needs_redraw = true;
1858 }
1859 Command::SearchForward => {
1860 incsearch_origin = (viewport.top_line(), viewport.top_row());
1861 mode = InputMode::SearchPrompt {
1862 direction: SearchDirection::Forward,
1863 buffer: String::new(),
1864 error: None,
1865 };
1866 needs_redraw = true;
1867 }
1868 Command::SearchBackward => {
1869 incsearch_origin = (viewport.top_line(), viewport.top_row());
1870 mode = InputMode::SearchPrompt {
1871 direction: SearchDirection::Backward,
1872 buffer: String::new(),
1873 error: None,
1874 };
1875 needs_redraw = true;
1876 }
1877 Command::ShellEscape => {
1878 mode = InputMode::ShellPrompt {
1879 buffer: String::new(),
1880 error: None,
1881 };
1882 needs_redraw = true;
1883 }
1884 Command::ColonPrompt => {
1885 mode = InputMode::ColonPrompt {
1886 buffer: String::new(),
1887 error: None,
1888 };
1889 needs_redraw = true;
1890 }
1891 Command::NextMatch => {
1892 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1893 if viewport.search_repeat(src.as_ref(), &mut idx, false) {
1894 needs_redraw = true;
1895 }
1896 }
1897 Command::PreviousMatch => {
1898 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1899 if viewport.search_repeat(src.as_ref(), &mut idx, true) {
1900 needs_redraw = true;
1901 }
1902 }
1903 Command::OptionPrefix => {
1904 mode = InputMode::OptionPrefix;
1905 }
1906 Command::MarkSet => {
1907 mode = InputMode::MarkSetPending;
1908 }
1909 Command::MarkJump => {
1910 mode = InputMode::MarkJumpPending;
1911 }
1912 Command::CtrlXPrefix => {
1913 mode = InputMode::CtrlXPending;
1914 }
1915 Command::JumpPrevious => {
1916 }
1919 Command::TagPrompt => {
1920 if tag_file.is_none() {
1921 transient_status = Some("[no tags file loaded]".into());
1922 needs_redraw = true;
1923 } else {
1924 mode = InputMode::TagPrompt {
1925 buffer: String::new(),
1926 error: None,
1927 last_tab_matches: None,
1928 };
1929 needs_redraw = true;
1930 }
1931 }
1932 Command::TagPop => match tag_stack.pop() {
1933 Some((file_index, line)) => {
1934 if file_index != current_file_index && file_index < file_set.len() {
1935 file_set.set_current_index(file_index);
1936 let path = file_set.current().unwrap().to_path_buf();
1937 if let Err(e) = switch_file(
1938 &path,
1939 file_index,
1940 file_set.len(),
1941 &args,
1942 preprocessor.as_ref(),
1943 &mut viewport,
1944 &mut src,
1945 &mut idx,
1946 record_start_regex.as_ref(),
1947 ) {
1948 transient_status = Some(format!("[open: {e}]"));
1949 } else {
1950 current_file_index = file_index;
1951 }
1952 }
1953 let clamped = line.min(idx.line_count().saturating_sub(1));
1954 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1955 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1956 needs_redraw = true;
1957 }
1958 None => {
1959 transient_status = Some("[tag stack empty]".into());
1960 needs_redraw = true;
1961 }
1962 },
1963 Command::OpenPicker => {
1964 let saved = (0..file_set.len())
1965 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1966 .collect::<Vec<_>>();
1967 overlay = Some(Box::new(
1968 crate::overlay::picker::FilePicker::new(&file_set, saved)
1969 ));
1970 needs_redraw = true;
1971 }
1972 Command::OpenHelp => {
1973 let remaps = keymap.user_keys_by_command_name();
1974 overlay = Some(Box::new(
1975 crate::overlay::help::HelpOverlay::new(remaps)
1976 ));
1977 needs_redraw = true;
1978 }
1979 Command::SelectFile(_)
1980 | Command::DropFileAt(_)
1981 | Command::SelectTagMatch(_)
1982 | Command::OpenTagPicker => {
1983 }
1985 Command::MouseEvent(_) => {
1986 }
1988 Command::HScrollLeft => {
1989 if hscroll_shift != 0 {
1990 viewport.hscroll_left_cols(hscroll_shift);
1991 } else {
1992 viewport.hscroll_left_half();
1993 }
1994 needs_redraw = true;
1995 }
1996 Command::HScrollRight => {
1997 if hscroll_shift != 0 {
1998 viewport.hscroll_right_cols(hscroll_shift);
1999 } else {
2000 viewport.hscroll_right_half();
2001 }
2002 needs_redraw = true;
2003 }
2004 Command::HScrollLeftStep => {
2005 viewport.hscroll_left_step();
2006 needs_redraw = true;
2007 }
2008 Command::HScrollRightStep => {
2009 viewport.hscroll_right_step();
2010 needs_redraw = true;
2011 }
2012 Command::YankLine => {
2013 let msg = yank_current_line(clipboard_enabled, &viewport, src.as_ref(), &mut idx);
2014 transient_status = Some(msg);
2015 needs_redraw = true;
2016 }
2017 Command::AnimPause => {
2018 #[cfg(feature = "image")]
2019 viewport.anim_toggle_pause();
2020 needs_redraw = true;
2021 }
2022 Command::AnimStepForward => {
2023 #[cfg(feature = "image")]
2024 viewport.anim_step(1);
2025 needs_redraw = true;
2026 }
2027 Command::AnimStepBack => {
2028 #[cfg(feature = "image")]
2029 viewport.anim_step(-1);
2030 needs_redraw = true;
2031 }
2032 Command::AnimRestart => {
2033 #[cfg(feature = "image")]
2034 viewport.anim_restart();
2035 needs_redraw = true;
2036 }
2037 Command::Noop => {}
2038 }
2039 #[cfg(feature = "image")]
2043 {
2044 last_tick = std::time::Instant::now();
2045 }
2046 }
2047 Ok(false) => {
2048 #[cfg(feature = "image")]
2052 {
2053 let dt = last_tick.elapsed();
2054 last_tick = std::time::Instant::now();
2055 if viewport.tick(dt) { needs_redraw = true; }
2056 }
2057 if viewport.live_mode() {
2059 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
2060 src.pump();
2061 if src.revision() != last_revision {
2062 rebuild_after_replace(
2063 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
2064 );
2065 if was_at_bottom {
2066 viewport.goto_bottom(src.as_ref(), &mut idx);
2067 }
2068 last_revision = src.revision();
2069 needs_redraw = true;
2070 }
2071 } else if viewport.follow_mode() {
2072 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
2073 src.pump();
2074 if src.take_rotated() {
2075 if let Some(path) = src.path().map(|p| p.to_path_buf()) {
2081 match crate::open::open_source_for_path(
2082 &path, &args, preprocessor.as_ref(),
2083 ) {
2084 Ok((new_src, _label, _err)) => {
2085 src = new_src;
2086 idx = LineIndex::new();
2087 if let Some(n) = rebuild_spec.head {
2088 idx.set_head_cap(n);
2089 }
2090 viewport.invalidate_filter_cache();
2091 idx.notice_new_bytes(src.as_ref());
2092 viewport.extend_visible_lines(&idx, src.as_ref());
2093 viewport.goto_bottom(src.as_ref(), &mut idx);
2094 viewport.flash("(F reopened)", 4);
2095 needs_redraw = true;
2096 continue;
2097 }
2098 Err(e) => {
2099 transient_status = Some(format!("[reopen failed: {e}]"));
2100 needs_redraw = true;
2101 }
2102 }
2103 }
2104 }
2105 let lines_before = idx.line_count();
2106 idx.notice_new_bytes(src.as_ref());
2107 viewport.extend_visible_lines(&idx, src.as_ref());
2108 if idx.line_count() != lines_before {
2109 needs_redraw = true;
2110 viewport.note_growth();
2111 if was_at_bottom {
2112 viewport.goto_bottom(src.as_ref(), &mut idx);
2113 }
2114 } else {
2115 viewport.tick_idle();
2116 }
2117 viewport.tick_flash();
2118 if args.exit_follow_on_close && src.is_complete() {
2124 break;
2125 }
2126 } else if !src.is_complete() {
2127 let lines_before = idx.line_count();
2130 idx.notice_new_bytes(src.as_ref());
2131 viewport.extend_visible_lines(&idx, src.as_ref());
2132 if idx.line_count() != lines_before {
2133 needs_redraw = true;
2134 }
2135 }
2136 }
2137 Err(_) => {
2138 std::thread::sleep(timeout);
2140 }
2141 }
2142 }
2143 Ok(())
2144}
2145
2146#[derive(Debug, Clone, Copy)]
2148enum PrettifyTarget {
2149 Mode(PrettifyMode),
2151 Toggle,
2153 Auto,
2155}
2156
2157fn apply_prettify(
2161 src: &dyn Source,
2162 viewport: &mut Viewport,
2163 idx: &mut LineIndex,
2164 spec: RebuildSpec,
2165 target: PrettifyTarget,
2166) {
2167 if src.prettify_mode().is_none() {
2169 return;
2170 }
2171 match target {
2172 PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
2173 PrettifyTarget::Toggle => src.toggle_prettify(),
2174 PrettifyTarget::Auto => src.redetect_prettify(),
2175 }
2176 rebuild_after_replace(src, viewport, idx, spec);
2177 viewport.set_prettify_label(src.prettify_label());
2178}
2179
2180fn rebuild_after_replace(
2186 src: &dyn Source,
2187 viewport: &mut Viewport,
2188 idx: &mut LineIndex,
2189 spec: RebuildSpec,
2190) {
2191 let new_off = match spec.tail {
2192 Some(n) => find_tail_offset(src, n),
2193 None => 0,
2194 };
2195 *idx = LineIndex::new_starting_at(new_off);
2196 if let Some(n) = spec.head {
2197 idx.set_head_cap(n);
2198 }
2199 viewport.invalidate_filter_cache();
2200 idx.notice_new_bytes(src);
2201 viewport.extend_visible_lines(idx, src);
2202 viewport.clamp_top_line(idx.line_count());
2203}
2204
2205fn to_crossterm_color(c: crate::ansi::Color, truecolor: bool) -> crossterm::style::Color {
2206 use crossterm::style::Color as CC;
2207 use crate::ansi::Color;
2208 match c {
2209 Color::Ansi(0) => CC::Black,
2210 Color::Ansi(1) => CC::DarkRed,
2211 Color::Ansi(2) => CC::DarkGreen,
2212 Color::Ansi(3) => CC::DarkYellow,
2213 Color::Ansi(4) => CC::DarkBlue,
2214 Color::Ansi(5) => CC::DarkMagenta,
2215 Color::Ansi(6) => CC::DarkCyan,
2216 Color::Ansi(7) => CC::Grey,
2217 Color::Ansi(8) => CC::DarkGrey,
2218 Color::Ansi(9) => CC::Red,
2219 Color::Ansi(10) => CC::Green,
2220 Color::Ansi(11) => CC::Yellow,
2221 Color::Ansi(12) => CC::Blue,
2222 Color::Ansi(13) => CC::Magenta,
2223 Color::Ansi(14) => CC::Cyan,
2224 Color::Ansi(15) => CC::White,
2225 Color::Ansi(_) => CC::Reset,
2226 Color::Indexed(n) => CC::AnsiValue(n),
2227 Color::Rgb(r, g, b) => {
2228 if truecolor {
2229 CC::Rgb { r, g, b }
2230 } else {
2231 CC::AnsiValue(crate::render::rgb_to_256(r, g, b))
2232 }
2233 }
2234 Color::Default => CC::Reset,
2235 }
2236}
2237
2238fn emit_style_diff<W: Write>(
2241 out: &mut W,
2242 prev: &crate::ansi::Style,
2243 next: &crate::ansi::Style,
2244 truecolor: bool,
2245) -> io::Result<()> {
2246 let intensity_changed = prev.bold != next.bold || prev.dim != next.dim;
2250
2251 let fg_changed = prev.fg != next.fg;
2255 let bg_changed = prev.bg != next.bg;
2256
2257 if (fg_changed && next.fg.is_none()) || (bg_changed && next.bg.is_none()) {
2258 out.queue(ResetColor)?;
2259 if let Some(c) = next.fg {
2261 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2262 }
2263 if let Some(c) = next.bg {
2264 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2265 }
2266 } else {
2267 if fg_changed {
2268 if let Some(c) = next.fg {
2269 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2270 }
2271 }
2272 if bg_changed {
2273 if let Some(c) = next.bg {
2274 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2275 }
2276 }
2277 }
2278
2279 if intensity_changed {
2280 if next.bold {
2281 out.queue(SetAttribute(Attribute::Bold))?;
2282 } else if next.dim {
2283 out.queue(SetAttribute(Attribute::Dim))?;
2284 } else {
2285 out.queue(SetAttribute(Attribute::NormalIntensity))?;
2286 }
2287 }
2288 if prev.italic != next.italic {
2289 out.queue(SetAttribute(if next.italic { Attribute::Italic } else { Attribute::NoItalic }))?;
2290 }
2291 if prev.underline != next.underline {
2292 out.queue(SetAttribute(if next.underline { Attribute::Underlined } else { Attribute::NoUnderline }))?;
2293 }
2294 if prev.reverse != next.reverse {
2295 out.queue(SetAttribute(if next.reverse { Attribute::Reverse } else { Attribute::NoReverse }))?;
2296 }
2297 if prev.strike != next.strike {
2298 out.queue(SetAttribute(if next.strike { Attribute::CrossedOut } else { Attribute::NotCrossedOut }))?;
2299 }
2300 Ok(())
2301}
2302
2303fn emit_hyperlink_diff<W: Write>(
2304 out: &mut W,
2305 prev: &Option<Arc<str>>,
2306 next: &Option<Arc<str>>,
2307) -> io::Result<()> {
2308 if prev == next {
2309 return Ok(());
2310 }
2311 if prev.is_some() {
2312 out.write_all(b"\x1b]8;;\x1b\\")?;
2313 }
2314 if let Some(uri) = next {
2315 out.write_all(b"\x1b]8;;")?;
2316 out.write_all(uri.as_bytes())?;
2317 out.write_all(b"\x1b\\")?;
2318 }
2319 Ok(())
2320}
2321
2322const SYNC_UPDATE_BEGIN: &[u8] = b"\x1b[?2026h";
2329const SYNC_UPDATE_END: &[u8] = b"\x1b[?2026l";
2330
2331fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16, truecolor: bool) -> io::Result<()> {
2332 out.write_all(SYNC_UPDATE_BEGIN)?;
2344
2345 out.queue(SetAttribute(Attribute::Reset))?;
2347 out.queue(ResetColor)?;
2348
2349 if let Some(blob) = &frame.image_blob {
2350 for r in 0..frame.body.len() as u16 {
2353 out.queue(MoveTo(0, r))?;
2354 out.queue(Clear(ClearType::UntilNewLine))?;
2355 }
2356 out.queue(MoveTo(0, 0))?;
2357 out.write_all(blob)?;
2358 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2360 out.queue(Clear(ClearType::UntilNewLine))?;
2361 emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2362 let mut status = frame.status.clone();
2363 if status.len() > cols as usize { status.truncate(cols as usize); }
2364 else { let pad = cols as usize - status.len(); status.push_str(&" ".repeat(pad)); }
2365 out.queue(Print(status))?;
2366 out.queue(ResetColor)?;
2367 out.queue(SetAttribute(Attribute::Reset))?;
2368 out.write_all(SYNC_UPDATE_END)?;
2369 return out.flush();
2370 }
2371
2372 for (i, row) in frame.body.iter().enumerate() {
2373 out.queue(MoveTo(0, i as u16))?;
2374 out.queue(Clear(ClearType::UntilNewLine))?;
2378 out.queue(SetAttribute(Attribute::Reset))?;
2381
2382 if let Some(Some(raw)) = frame.raw_rows.get(i) {
2387 if !raw.is_empty() {
2388 out.write_all(raw)?;
2389 }
2390 out.queue(ResetColor)?;
2392 out.queue(SetAttribute(Attribute::Reset))?;
2393 continue;
2394 }
2395
2396 let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
2397 let base_style = if matches!(row_style, RowStyle::Dim) {
2402 out.queue(SetAttribute(Attribute::Dim))?;
2403 crate::ansi::Style { dim: true, ..Default::default() }
2404 } else {
2405 crate::ansi::Style::default()
2406 };
2407 let no_highlights = Vec::new();
2408 let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
2409 write_row_with_highlights(out, row, cols, highlights, base_style, truecolor)?;
2410 }
2411 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2413 out.queue(Clear(ClearType::UntilNewLine))?;
2414 emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2415 let mut status = frame.status.clone();
2416 if status.len() > cols as usize {
2417 status.truncate(cols as usize);
2418 } else {
2419 let pad = cols as usize - status.len();
2420 status.push_str(&" ".repeat(pad));
2421 }
2422 out.queue(Print(status))?;
2423 out.queue(ResetColor)?;
2424 out.queue(SetAttribute(Attribute::Reset))?;
2425
2426 out.write_all(SYNC_UPDATE_END)?;
2429 out.flush()
2430}
2431
2432
2433fn write_row_with_highlights(
2444 out: &mut impl Write,
2445 row: &[Cell],
2446 cols: u16,
2447 highlights: &[std::ops::Range<usize>],
2448 base_style: crate::ansi::Style,
2449 truecolor: bool,
2450) -> io::Result<()> {
2451 let cols_usize = cols as usize;
2452
2453 let mut ranges: Vec<std::ops::Range<usize>> = highlights
2454 .iter()
2455 .filter_map(|r| {
2456 let s = r.start.min(cols_usize);
2457 let e = r.end.min(cols_usize);
2458 if e > s { Some(s..e) } else { None }
2459 })
2460 .collect();
2461 ranges.sort_by_key(|r| r.start);
2462
2463 let mut prev_style = base_style;
2466 let mut prev_link: Option<Arc<str>> = None;
2467
2468 let mut col = 0usize;
2469 let mut i = 0usize;
2470 while col < cols_usize && i < row.len() {
2471 let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
2472
2473 match &row[i] {
2474 Cell::Char { ch, width, style, hyperlink } => {
2475 let mut eff = *style;
2481 if in_highlight {
2482 eff.reverse = !eff.reverse;
2483 }
2484 if base_style.dim && !eff.bold {
2485 eff.dim = true;
2486 }
2487 emit_style_diff(out, &prev_style, &eff, truecolor)?;
2488 emit_hyperlink_diff(out, &prev_link, hyperlink)?;
2489 out.queue(Print(*ch))?;
2490 prev_style = eff;
2491 prev_link = hyperlink.clone();
2492 col += *width as usize;
2493 }
2494 Cell::Continuation => {
2495 }
2497 Cell::Empty => {
2498 let default = if base_style.dim {
2503 crate::ansi::Style { dim: true, ..Default::default() }
2504 } else {
2505 crate::ansi::Style::default()
2506 };
2507 emit_style_diff(out, &prev_style, &default, truecolor)?;
2508 emit_hyperlink_diff(out, &prev_link, &None)?;
2509 out.queue(Print(' '))?;
2510 prev_style = default;
2511 prev_link = None;
2512 col += 1;
2513 }
2514 }
2515 i += 1;
2516 }
2517
2518 emit_hyperlink_diff(out, &prev_link, &None)?;
2521 out.queue(ResetColor)?;
2522 out.queue(SetAttribute(Attribute::Reset))?;
2523
2524 Ok(())
2525}
2526
2527fn render_overlay(
2528 out: &mut impl Write,
2529 frame: &crate::overlay::OverlayFrame,
2530 width: u16,
2531 height: u16,
2532) -> io::Result<()> {
2533 out.write_all(SYNC_UPDATE_BEGIN)?;
2537 out.queue(SetAttribute(Attribute::Reset))?;
2538 out.queue(ResetColor)?;
2539 for row in 0..height.saturating_sub(1) {
2540 out.queue(MoveTo(0, row))?;
2541 out.queue(Clear(ClearType::UntilNewLine))?;
2542 out.queue(SetAttribute(Attribute::Reset))?;
2543 if let Some(line) = frame.body.get(row as usize) {
2544 let mut written = 0usize;
2545 for ch in line.chars() {
2546 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2547 if written + w > width as usize { break; }
2548 write!(out, "{ch}")?;
2549 written += w;
2550 }
2551 }
2552 }
2553 out.queue(MoveTo(0, height.saturating_sub(1)))?;
2554 out.queue(Clear(ClearType::UntilNewLine))?;
2555 out.queue(SetAttribute(Attribute::Reverse))?;
2556 let mut status = frame.status.clone();
2557 if status.len() > width as usize {
2559 status.truncate(width as usize);
2560 } else {
2561 let pad = width as usize - status.len();
2562 status.push_str(&" ".repeat(pad));
2563 }
2564 out.queue(Print(status))?;
2565 out.queue(ResetColor)?;
2566 out.queue(SetAttribute(Attribute::Reset))?;
2567 out.write_all(SYNC_UPDATE_END)?;
2568 out.flush()
2569}
2570
2571#[cfg(test)]
2572mod tests {
2573 use super::*;
2574
2575 #[test]
2576 fn parse_colon_n() {
2577 assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
2578 assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
2579 }
2580
2581 #[test]
2582 fn current_line_bytes_strips_trailing_newline() {
2583 use crate::line_index::LineIndex;
2584 use crate::source::MockSource;
2585 let m = MockSource::new();
2586 m.append(b"alpha\nbravo\ncharlie");
2588 let mut idx = LineIndex::new();
2589 idx.extend_to_end(&m);
2590 assert_eq!(idx.line_count(), 3);
2591 assert_eq!(current_line_bytes(&idx, &m, 0), b"alpha");
2592 assert_eq!(current_line_bytes(&idx, &m, 1), b"bravo");
2593 assert_eq!(current_line_bytes(&idx, &m, 2), b"charlie");
2595 }
2596
2597 #[test]
2598 fn write_frame_brackets_with_sync_update_and_no_full_clear() {
2599 use crate::ansi::Style;
2604 use crate::render::Cell;
2605 use crate::viewport::{Frame, RowStyle};
2606
2607 let row: Vec<Cell> = (0..3)
2608 .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
2609 .collect();
2610 let frame = Frame {
2611 body: vec![row.clone(), row],
2612 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2613 highlights: vec![Vec::new(), Vec::new()],
2614 status: "status".into(),
2615 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
2616 raw_rows: vec![None, None],
2617 image_blob: None,
2618 };
2619
2620 let mut buf: Vec<u8> = Vec::new();
2621 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2622 let s = std::str::from_utf8(&buf).expect("ascii");
2623
2624 let begin = s.find("\x1b[?2026h").expect("begin sync update");
2626 let end = s.find("\x1b[?2026l").expect("end sync update");
2627 assert!(begin < end, "begin must precede end");
2628 let first_a = s.find('a').expect("body char");
2630 assert!(begin < first_a && first_a < end, "body must be inside sync update");
2631
2632 assert!(
2635 !s.contains("\x1b[2J"),
2636 "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
2637 );
2638 assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
2639 }
2640
2641 #[test]
2642 fn write_frame_emits_image_blob_verbatim_and_skips_cell_rows() {
2643 use crate::viewport::{Frame, RowStyle};
2644 let body_rows = 3usize;
2645 let cols = 10u16;
2646 let blob = b"\x1bPqDATA\x1b\\".to_vec();
2647 let mut body = vec![vec![crate::render::Cell::Empty; cols as usize]; body_rows];
2650 body[0][0] = crate::render::Cell::Char { ch: 'Z', width: 1, style: crate::ansi::Style::default(), hyperlink: None };
2651 let frame = Frame {
2652 body,
2653 row_styles: vec![RowStyle::Normal; body_rows],
2654 highlights: vec![Vec::new(); body_rows],
2655 status: "img".to_string(),
2656 status_style: crate::ansi::Style::default(),
2657 raw_rows: vec![None; body_rows],
2658 image_blob: Some(blob.clone()),
2659 };
2660 let mut out: Vec<u8> = Vec::new();
2661 write_frame(&mut out, &frame, cols, (body_rows + 1) as u16, true).unwrap();
2662 let needle = b"\x1bPqDATA\x1b\\";
2663 assert!(out.windows(needle.len()).any(|w| w == needle), "image blob emitted verbatim");
2664 assert!(String::from_utf8_lossy(&out).contains("img"), "status still drawn");
2665 assert!(!String::from_utf8_lossy(&out).contains('Z'), "cell loop skipped: body cell not rendered");
2666 }
2667
2668 #[test]
2669 fn raw_rows_passthrough_emits_original_bytes_and_skips_continuation() {
2670 use crate::ansi::Style;
2671 use crate::render::Cell;
2672 use crate::viewport::{Frame, RowStyle};
2673
2674 let placeholder_row: Vec<Cell> = (0..3)
2676 .map(|_| Cell::Char { ch: 'X', width: 1, style: Style::default(), hyperlink: None })
2677 .collect();
2678 let frame = Frame {
2679 body: vec![placeholder_row.clone(), placeholder_row],
2680 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2681 highlights: vec![Vec::new(), Vec::new()],
2682 status: "s".into(),
2683 status_style: Style { reverse: true, ..Default::default() },
2684 raw_rows: vec![Some(b"\x1b[31mABC\x1b[0m".to_vec()), Some(Vec::new())],
2687 image_blob: None,
2688 };
2689
2690 let mut buf: Vec<u8> = Vec::new();
2691 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2692 let s = std::str::from_utf8(&buf).expect("ascii");
2693
2694 assert!(s.contains("\x1b[31mABC\x1b[0m"), "raw bytes missing in output: {s:?}");
2696 assert!(!s.contains("XXX"), "cell content leaked through despite raw passthrough: {s:?}");
2698 }
2699
2700 #[test]
2701 fn dim_row_keeps_dim_through_plain_cells_and_padding() {
2702 use crate::ansi::Style;
2707 use crate::render::Cell;
2708 let row = vec![
2709 Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
2710 Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
2711 Cell::Empty,
2712 Cell::Empty,
2713 ];
2714 let mut buf: Vec<u8> = Vec::new();
2715 let base = Style { dim: true, ..Default::default() };
2716 write_row_with_highlights(&mut buf, &row, 4, &[], base, true).unwrap();
2717 let s = String::from_utf8_lossy(&buf);
2718
2719 for needle in ['h', 'i'] {
2722 let pos = s.find(needle).expect("char printed");
2723 let before = &s[..pos];
2724 assert!(
2725 !before.contains("\x1b[22m"),
2726 "dim cleared before {needle:?}: {before:?}",
2727 );
2728 }
2729 let after_i = s.find('i').unwrap() + 1;
2732 let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
2733 let pad = &s[after_i..after_i + eor];
2734 assert!(
2735 !pad.contains("\x1b[22m"),
2736 "dim cleared in padding region: {pad:?}",
2737 );
2738 }
2739
2740 #[test]
2741 fn dim_row_yields_to_explicit_bold_cell() {
2742 use crate::ansi::Style;
2745 use crate::render::Cell;
2746 let row = vec![
2747 Cell::Char {
2748 ch: 'B',
2749 width: 1,
2750 style: Style { bold: true, ..Default::default() },
2751 hyperlink: None,
2752 },
2753 ];
2754 let mut buf: Vec<u8> = Vec::new();
2755 let base = Style { dim: true, ..Default::default() };
2756 write_row_with_highlights(&mut buf, &row, 1, &[], base, true).unwrap();
2757 let s = String::from_utf8_lossy(&buf);
2758 assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
2760 }
2761
2762 #[test]
2763 fn parse_colon_p() {
2764 assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
2765 assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
2766 }
2767
2768 #[test]
2769 fn parse_colon_e_with_path() {
2770 match parse_colon_command("e /tmp/foo.log").unwrap() {
2771 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
2772 other => panic!("expected Edit, got {other:?}"),
2773 }
2774 }
2775
2776 #[test]
2777 fn parse_colon_e_with_tilde() {
2778 std::env::set_var("HOME", "/home/user");
2779 match parse_colon_command("e ~/foo.log").unwrap() {
2780 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
2781 other => panic!("expected Edit, got {other:?}"),
2782 }
2783 }
2784
2785 #[test]
2786 fn parse_colon_e_missing_path_errors() {
2787 assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
2788 assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
2789 }
2790
2791 #[test]
2792 fn parse_colon_f_q_d_x_t() {
2793 assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
2794 assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
2795 assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
2796 assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
2797 assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
2798 }
2799
2800 #[test]
2801 fn parse_unknown_command_errors() {
2802 let err = parse_colon_command("bogus").unwrap_err();
2803 match err {
2804 ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
2805 other => panic!("expected UnknownCommand, got {other:?}"),
2806 }
2807 }
2808
2809 #[test]
2810 fn parse_handles_whitespace() {
2811 assert_eq!(parse_colon_command("n ").unwrap(), ColonCommand::Next);
2813 assert_eq!(parse_colon_command(" n").unwrap(), ColonCommand::Next);
2814 }
2815
2816 #[test]
2817 fn parse_colon_tag_with_name() {
2818 assert_eq!(
2819 parse_colon_command("tag foo").unwrap(),
2820 ColonCommand::Tag("foo".into())
2821 );
2822 }
2823
2824 #[test]
2825 fn parse_colon_tag_strips_trailing_whitespace() {
2826 assert_eq!(
2827 parse_colon_command("tag foo ").unwrap(),
2828 ColonCommand::Tag("foo".into())
2829 );
2830 }
2831
2832 #[test]
2833 fn parse_colon_tag_without_name_errors() {
2834 assert_eq!(
2835 parse_colon_command("tag").unwrap_err(),
2836 ColonParseError::TagRequiresName
2837 );
2838 assert_eq!(
2839 parse_colon_command("tag ").unwrap_err(),
2840 ColonParseError::TagRequiresName
2841 );
2842 }
2843
2844 #[test]
2845 fn parse_colon_tnext_and_tprev() {
2846 assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
2847 assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
2848 }
2849
2850 #[test]
2851 fn parse_colon_tselect_without_arg_uses_active() {
2852 assert_eq!(parse_colon_command("tselect").unwrap(), ColonCommand::TagSelect(None));
2853 }
2854
2855 #[test]
2856 fn parse_colon_tselect_with_name() {
2857 assert_eq!(
2858 parse_colon_command("tselect foo").unwrap(),
2859 ColonCommand::TagSelect(Some("foo".into())),
2860 );
2861 }
2862
2863 #[test]
2864 fn parse_colon_b_opens_picker() {
2865 assert_eq!(parse_colon_command("b").unwrap(), ColonCommand::OpenPicker);
2866 assert_eq!(parse_colon_command("buffers").unwrap(), ColonCommand::OpenPicker);
2867 }
2868
2869 #[test]
2870 fn parse_colon_help_opens_help() {
2871 assert_eq!(parse_colon_command("h").unwrap(), ColonCommand::OpenHelp);
2872 assert_eq!(parse_colon_command("help").unwrap(), ColonCommand::OpenHelp);
2873 }
2874
2875 #[test]
2876 fn parse_colon_hex_with_valid_widths() {
2877 for n in [2usize, 4, 8, 16, 32] {
2878 assert_eq!(
2879 parse_colon_command(&format!("hex {n}")).unwrap(),
2880 ColonCommand::HexGroup(n),
2881 );
2882 }
2883 }
2884
2885 #[test]
2886 fn parse_colon_hex_without_value_errors() {
2887 assert_eq!(
2888 parse_colon_command("hex").unwrap_err(),
2889 ColonParseError::HexGroupRequiresValue,
2890 );
2891 }
2892
2893 #[test]
2894 fn parse_colon_hex_with_invalid_value_errors() {
2895 match parse_colon_command("hex 3").unwrap_err() {
2896 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "3"),
2897 other => panic!("expected HexGroupInvalid, got {other:?}"),
2898 }
2899 match parse_colon_command("hex banana").unwrap_err() {
2900 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "banana"),
2901 other => panic!("expected HexGroupInvalid, got {other:?}"),
2902 }
2903 }
2904
2905 #[test]
2906 fn parse_colon_color_without_arg_cycles() {
2907 assert_eq!(parse_colon_command("color").unwrap(), ColonCommand::Color(None));
2908 }
2909
2910 #[test]
2911 fn parse_colon_color_with_named_mode() {
2912 use crate::render::AnsiMode;
2913 assert_eq!(
2914 parse_colon_command("color strict").unwrap(),
2915 ColonCommand::Color(Some(AnsiMode::Strict)),
2916 );
2917 assert_eq!(
2918 parse_colon_command("color interpret").unwrap(),
2919 ColonCommand::Color(Some(AnsiMode::Interpret)),
2920 );
2921 assert_eq!(
2922 parse_colon_command("color raw").unwrap(),
2923 ColonCommand::Color(Some(AnsiMode::Raw)),
2924 );
2925 }
2926
2927 #[test]
2928 fn parse_colon_color_with_unknown_mode_errors() {
2929 match parse_colon_command("color rainbow").unwrap_err() {
2930 ColonParseError::ColorInvalid(v) => assert_eq!(v, "rainbow"),
2931 other => panic!("expected ColorInvalid, got {other:?}"),
2932 }
2933 }
2934
2935 #[test]
2936 fn parse_colon_case_without_arg_cycles() {
2937 assert_eq!(parse_colon_command("case").unwrap(), ColonCommand::Case(None));
2938 }
2939
2940 #[test]
2941 fn parse_colon_case_with_named_mode() {
2942 use crate::viewport::CaseMode;
2943 assert_eq!(parse_colon_command("case smart").unwrap(),
2944 ColonCommand::Case(Some(CaseMode::Smart)));
2945 assert_eq!(parse_colon_command("case sensitive").unwrap(),
2946 ColonCommand::Case(Some(CaseMode::Sensitive)));
2947 assert_eq!(parse_colon_command("case insensitive").unwrap(),
2948 ColonCommand::Case(Some(CaseMode::Insensitive)));
2949 }
2950
2951 #[test]
2952 fn parse_colon_case_unknown_errors() {
2953 match parse_colon_command("case rainbow").unwrap_err() {
2954 ColonParseError::CaseInvalid(v) => assert_eq!(v, "rainbow"),
2955 other => panic!("expected CaseInvalid, got {other:?}"),
2956 }
2957 }
2958
2959 #[test]
2960 fn parse_colon_hlsearch_on_off() {
2961 assert_eq!(parse_colon_command("hlsearch").unwrap(), ColonCommand::HlSearch(true));
2962 assert_eq!(parse_colon_command("nohlsearch").unwrap(), ColonCommand::HlSearch(false));
2963 }
2964
2965 #[test]
2966 fn parse_colon_incsearch_toggle() {
2967 assert_eq!(parse_colon_command("incsearch").unwrap(), ColonCommand::IncSearch);
2968 }
2969
2970 #[test]
2971 fn lcp_empty_slice() {
2972 assert_eq!(longest_common_prefix(&[]), "");
2973 }
2974
2975 #[test]
2976 fn lcp_single_item_returns_self() {
2977 assert_eq!(longest_common_prefix(&["foo".into()]), "foo");
2978 }
2979
2980 #[test]
2981 fn lcp_finds_shared_prefix() {
2982 let v: Vec<String> = vec!["foobar".into(), "foobaz".into(), "fooqux".into()];
2983 assert_eq!(longest_common_prefix(&v), "foo");
2984 }
2985
2986 #[test]
2987 fn lcp_no_shared_prefix_returns_empty() {
2988 let v: Vec<String> = vec!["abc".into(), "xyz".into()];
2989 assert_eq!(longest_common_prefix(&v), "");
2990 }
2991
2992 #[test]
2993 fn lcp_one_item_is_prefix_of_others() {
2994 let v: Vec<String> = vec!["foo".into(), "foobar".into(), "foobaz".into()];
2995 assert_eq!(longest_common_prefix(&v), "foo");
2996 }
2997
2998 #[test]
2999 fn tag_stack_push_pop_lifo() {
3000 let mut s = TagStack::default();
3001 s.push(0, 10);
3002 s.push(1, 20);
3003 assert_eq!(s.pop(), Some((1, 20)));
3004 assert_eq!(s.pop(), Some((0, 10)));
3005 assert_eq!(s.pop(), None);
3006 }
3007
3008 #[test]
3009 fn tag_stack_pop_clears_active() {
3010 let mut s = TagStack::default();
3011 s.push(0, 10);
3012 s.set_active(
3013 "foo".into(),
3014 vec![crate::tags::TagEntry {
3015 file: std::path::PathBuf::from("/a"),
3016 address: crate::tags::TagAddress::Line(1),
3017 }],
3018 );
3019 assert!(s.active.is_some());
3020 let _ = s.pop();
3021 assert!(s.active.is_none());
3022 }
3023
3024 #[test]
3025 fn tag_stack_next_advances_then_clamps() {
3026 let mut s = TagStack::default();
3027 s.set_active(
3028 "foo".into(),
3029 vec![
3030 crate::tags::TagEntry {
3031 file: std::path::PathBuf::from("/a"),
3032 address: crate::tags::TagAddress::Line(1),
3033 },
3034 crate::tags::TagEntry {
3035 file: std::path::PathBuf::from("/b"),
3036 address: crate::tags::TagAddress::Line(2),
3037 },
3038 ],
3039 );
3040 assert_eq!(s.next(), TagStepResult::Moved(1));
3041 assert_eq!(s.next(), TagStepResult::AtBoundary);
3042 }
3043
3044 #[test]
3045 fn tag_stack_prev_clamps_at_zero() {
3046 let mut s = TagStack::default();
3047 s.set_active(
3048 "foo".into(),
3049 vec![crate::tags::TagEntry {
3050 file: std::path::PathBuf::from("/a"),
3051 address: crate::tags::TagAddress::Line(1),
3052 }],
3053 );
3054 assert_eq!(s.prev(), TagStepResult::AtBoundary);
3055 }
3056
3057 #[test]
3058 fn tag_stack_next_with_no_active_returns_no_active() {
3059 let mut s = TagStack::default();
3060 assert_eq!(s.next(), TagStepResult::NoActive);
3061 assert_eq!(s.prev(), TagStepResult::NoActive);
3062 }
3063
3064 #[test]
3065 fn tag_stack_set_active_replaces_previous_list() {
3066 let mut s = TagStack::default();
3067 s.set_active(
3068 "foo".into(),
3069 vec![crate::tags::TagEntry {
3070 file: std::path::PathBuf::from("/a"),
3071 address: crate::tags::TagAddress::Line(1),
3072 }],
3073 );
3074 s.set_active(
3075 "bar".into(),
3076 vec![
3077 crate::tags::TagEntry {
3078 file: std::path::PathBuf::from("/x"),
3079 address: crate::tags::TagAddress::Line(5),
3080 },
3081 crate::tags::TagEntry {
3082 file: std::path::PathBuf::from("/y"),
3083 address: crate::tags::TagAddress::Line(6),
3084 },
3085 ],
3086 );
3087 let active = s.active.as_ref().unwrap();
3088 assert_eq!(active.name, "bar");
3089 assert_eq!(active.matches.len(), 2);
3090 assert_eq!(active.cursor, 0);
3091 }
3092
3093 #[test]
3094 fn writer_emits_color_for_red_cell() {
3095 let cells = vec![Cell::Char {
3096 ch: 'h',
3097 width: 1,
3098 style: crate::ansi::Style {
3099 fg: Some(crate::ansi::Color::Ansi(1)),
3100 ..Default::default()
3101 },
3102 hyperlink: None,
3103 }];
3104 let mut buf: Vec<u8> = Vec::new();
3105 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3106 let s = String::from_utf8_lossy(&buf);
3107 assert!(s.contains("\x1b["), "expected ANSI escape in output: {s:?}");
3108 assert!(s.contains('h'));
3109 }
3110
3111 #[test]
3112 fn writer_emits_osc8_for_hyperlink_cell() {
3113 let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
3114 let cells = vec![Cell::Char {
3115 ch: 'c',
3116 width: 1,
3117 style: crate::ansi::Style::default(),
3118 hyperlink: Some(link),
3119 }];
3120 let mut buf: Vec<u8> = Vec::new();
3121 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3122 let s = String::from_utf8_lossy(&buf);
3123 assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
3124 }
3125}