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 let timeout = Duration::from_millis(250);
942 let mut last_revision = src.revision();
943
944 if (viewport.filter_active() || viewport.grep_active()) && !viewport.dim_mode() {
949 idx.extend_to_end(src.as_ref());
950 viewport.extend_visible_lines(&idx, src.as_ref());
951 }
952
953 if viewport.follow_mode() || viewport.live_mode() {
958 src.pump();
959 viewport.extend_visible_lines(&idx, src.as_ref());
960 viewport.goto_bottom(src.as_ref(), &mut idx);
961 }
962
963 let mut needs_redraw = true;
965 let mut mode = InputMode::Normal;
966 let mut numeric_prefix: Option<usize> = None;
967 let mut marks: HashMap<char, (usize, usize)> = HashMap::new();
968 let mut previous_position: Option<(usize, usize)> = None;
969 let mut incsearch_origin: (usize, usize) = (0, 0);
972 let mut current_file_index: usize = file_set.current_index();
973 let mut transient_status: Option<String> = None;
974 let mut tag_stack = TagStack::default();
975 let mut overlay: Option<Box<dyn crate::overlay::Overlay>> = None;
976 let mut overlay_flash: Option<(&'static str, std::time::Instant)> = None;
977 let mouse_enabled = args.mouse;
978 let clipboard_enabled = args.clipboard;
979 let hscroll_shift = args.shift.unwrap_or(0);
980 let wheel_lines = args.wheel_lines.unwrap_or(3).max(1);
981
982 if let Some(tag_name) = args.tag.as_deref() {
983 let _ = refresh_tag_file(&mut tag_file);
984 if let Some(msg) = dispatch_tag_jump(
985 tag_name,
986 tag_file.as_ref(),
987 &mut tag_stack,
988 &mut file_set,
989 &mut current_file_index,
990 &args,
991 preprocessor.as_ref(),
992 record_start_regex.as_ref(),
993 &mut viewport,
994 &mut src,
995 &mut idx,
996 ) {
997 return Err(crate::error::Error::Runtime(format!("startup tag jump failed: {msg}")));
998 }
999 }
1000
1001 loop {
1002 if sigterm.load(Ordering::SeqCst) {
1003 break;
1004 }
1005
1006 if needs_redraw {
1007 if let Some(ov) = overlay.as_ref() {
1008 let w = cols;
1009 let h = viewport.body_rows() + 1;
1010 let mut ovframe = ov.render(w, h);
1011 if let Some((msg, started)) = overlay_flash {
1012 if started.elapsed() < std::time::Duration::from_millis(1500) {
1013 ovframe.status = format!("[{msg}]");
1014 } else {
1015 overlay_flash = None;
1016 }
1017 }
1018 render_overlay(&mut stdout, &ovframe, w, h)
1019 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1020 needs_redraw = false;
1021 continue;
1022 }
1023 if viewport.status_column() {
1027 let status_marks: HashMap<usize, char> = marks
1028 .iter()
1029 .filter(|(_, (fi, _))| *fi == current_file_index)
1030 .map(|(ch, (_, line))| (*line, *ch))
1031 .collect();
1032 viewport.set_status_marks(status_marks);
1033 }
1034 let mut frame = viewport.frame(src.as_ref(), &mut idx);
1035 match &mode {
1038 InputMode::SearchPrompt { direction, buffer, error } => {
1039 let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
1040 frame.status = match error {
1041 Some(e) => format!("{prefix}{buffer} [error: {e}]"),
1042 None => format!("{prefix}{buffer}"),
1043 };
1044 }
1045 InputMode::ShellPrompt { buffer, error } => {
1046 frame.status = match error {
1047 Some(e) => format!("!{buffer} [error: {e}]"),
1048 None => format!("!{buffer}"),
1049 };
1050 }
1051 InputMode::ColonPrompt { buffer, error } => {
1052 frame.status = match error {
1053 Some(e) => format!(":{buffer} [error: {e}]"),
1054 None => format!(":{buffer}"),
1055 };
1056 }
1057 InputMode::TagPrompt { buffer, error, .. } => {
1058 frame.status = match error {
1059 Some(e) => format!("tag: {buffer} [error: {e}]"),
1060 None => format!("tag: {buffer}"),
1061 };
1062 }
1063 _ => {
1064 if let Some(msg) = transient_status.take() {
1065 frame.status = msg;
1066 }
1067 }
1068 }
1069 write_frame(&mut stdout, &frame, cols, rows, truecolor)
1070 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1071 needs_redraw = false;
1072 }
1073
1074 match poll(timeout) {
1076 Ok(true) => {
1077 let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
1078 match &mut mode {
1081 InputMode::SearchPrompt { direction, buffer, error } => {
1082 if let Event::Key(KeyEvent { code, .. }) = event {
1083 match code {
1084 KeyCode::Esc => {
1085 if viewport.incsearch() {
1086 viewport.set_top(incsearch_origin.0, incsearch_origin.1);
1087 }
1088 mode = InputMode::Normal;
1089 needs_redraw = true;
1090 }
1091 KeyCode::Enter => {
1092 if viewport.incsearch() {
1093 viewport.set_top(incsearch_origin.0, incsearch_origin.1);
1094 }
1095 if buffer.is_empty() {
1096 if viewport.search_active() {
1100 let reverse = !matches!(
1101 (viewport.search_direction(), *direction),
1102 (SearchDirection::Forward, SearchDirection::Forward)
1103 | (SearchDirection::Backward, SearchDirection::Backward)
1104 );
1105 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1106 viewport.search_repeat(src.as_ref(), &mut idx, reverse);
1107 }
1108 mode = InputMode::Normal;
1109 } else {
1110 match viewport.set_search(buffer.clone(), *direction) {
1111 Ok(()) => {
1112 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1113 viewport.search_repeat(src.as_ref(), &mut idx, false);
1114 mode = InputMode::Normal;
1115 }
1116 Err(e) => { *error = Some(e); }
1117 }
1118 }
1119 needs_redraw = true;
1120 }
1121 KeyCode::Backspace => {
1122 buffer.pop();
1123 *error = None;
1124 if viewport.incsearch() {
1125 viewport.incsearch_preview(
1126 src.as_ref(), &mut idx, buffer, *direction, incsearch_origin);
1127 }
1128 needs_redraw = true;
1129 }
1130 KeyCode::Char(c) => {
1131 buffer.push(c);
1132 *error = None;
1133 if viewport.incsearch() {
1134 viewport.incsearch_preview(
1135 src.as_ref(), &mut idx, buffer, *direction, incsearch_origin);
1136 }
1137 needs_redraw = true;
1138 }
1139 _ => {}
1140 }
1141 }
1142 continue;
1143 }
1144 InputMode::OptionPrefix => {
1145 if let Event::Key(KeyEvent { code, .. }) = event {
1146 match code {
1147 KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
1148 KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
1149 KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
1150 KeyCode::Char('P') | KeyCode::Char('p') => {
1151 mode = InputMode::PrettifyPrefix;
1153 needs_redraw = true;
1154 continue;
1155 }
1156 _ => {}
1157 }
1158 }
1159 mode = InputMode::Normal;
1160 needs_redraw = true;
1161 continue;
1162 }
1163 InputMode::PrettifyPrefix => {
1164 if let Event::Key(KeyEvent { code, .. }) = event {
1165 let target: Option<PrettifyTarget> = match code {
1166 KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
1167 KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
1168 KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
1169 KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
1170 KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
1171 KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
1172 KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
1173 KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
1174 _ => None,
1175 };
1176 if let Some(t) = target {
1177 apply_prettify(
1178 src.as_ref(),
1179 &mut viewport,
1180 &mut idx,
1181 rebuild_spec,
1182 t,
1183 );
1184 last_revision = src.revision();
1185 }
1186 }
1187 mode = InputMode::Normal;
1188 needs_redraw = true;
1189 continue;
1190 }
1191 InputMode::MarkSetPending => {
1192 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1193 if is_valid_mark_name(c) {
1194 mark_set(&mut marks, c, current_file_index, viewport.top_line());
1195 }
1196 }
1197 mode = InputMode::Normal;
1198 continue;
1199 }
1200 InputMode::MarkJumpPending => {
1201 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1202 if is_valid_mark_name(c) {
1203 match mark_jump(&marks, c, current_file_index, &mut previous_position, viewport.top_line()) {
1204 Some(MarkTarget::SameFile { line }) => {
1205 let clamped = line.min(idx.line_count().saturating_sub(1));
1206 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1207 needs_redraw = true;
1208 }
1209 Some(MarkTarget::OtherFile { file_index, line }) => {
1210 if file_index < file_set.len() {
1211 file_set.set_current_index(file_index);
1212 let path = file_set.current().unwrap().to_path_buf();
1213 if let Err(e) = switch_file(
1214 &path, file_index, file_set.len(),
1215 &args, preprocessor.as_ref(),
1216 &mut viewport, &mut src, &mut idx,
1217 record_start_regex.as_ref(),
1218 ) {
1219 transient_status = Some(format!("[open: {e}]"));
1220 } else {
1221 let clamped = line.min(idx.line_count().saturating_sub(1));
1222 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1223 current_file_index = file_index;
1224 needs_redraw = true;
1225 }
1226 }
1227 }
1228 None => {}
1229 }
1230 }
1231 }
1232 mode = InputMode::Normal;
1233 continue;
1234 }
1235 InputMode::ShellPrompt { buffer, error } => {
1236 if let Event::Key(KeyEvent { code, .. }) = event {
1237 match code {
1238 KeyCode::Esc => {
1239 mode = InputMode::Normal;
1240 needs_redraw = true;
1241 }
1242 KeyCode::Enter => {
1243 if buffer.is_empty() {
1244 mode = InputMode::Normal;
1245 } else {
1246 match crate::shell::run_shell_command(buffer) {
1247 Ok(()) => {
1248 mode = InputMode::Normal;
1249 }
1250 Err(e) => {
1251 *error = Some(e.to_string());
1252 }
1253 }
1254 }
1255 needs_redraw = true;
1256 }
1257 KeyCode::Backspace => {
1258 buffer.pop();
1259 *error = None;
1260 needs_redraw = true;
1261 }
1262 KeyCode::Char(c) => {
1263 buffer.push(c);
1264 *error = None;
1265 needs_redraw = true;
1266 }
1267 _ => {}
1268 }
1269 }
1270 continue;
1271 }
1272 InputMode::CtrlXPending => {
1273 let is_ctrl_x = matches!(
1274 event,
1275 Event::Key(KeyEvent {
1276 code: KeyCode::Char('x'),
1277 modifiers: KeyModifiers::CONTROL,
1278 ..
1279 })
1280 );
1281 if is_ctrl_x {
1282 match jump_previous(&mut previous_position, current_file_index, viewport.top_line()) {
1283 Some(MarkTarget::SameFile { line }) => {
1284 let clamped = line.min(idx.line_count().saturating_sub(1));
1285 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1286 needs_redraw = true;
1287 }
1288 Some(MarkTarget::OtherFile { file_index, line }) => {
1289 if file_index < file_set.len() {
1290 file_set.set_current_index(file_index);
1291 let path = file_set.current().unwrap().to_path_buf();
1292 if let Err(e) = switch_file(
1293 &path, file_index, file_set.len(),
1294 &args, preprocessor.as_ref(),
1295 &mut viewport, &mut src, &mut idx,
1296 record_start_regex.as_ref(),
1297 ) {
1298 transient_status = Some(format!("[open: {e}]"));
1299 } else {
1300 let clamped = line.min(idx.line_count().saturating_sub(1));
1301 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1302 current_file_index = file_index;
1303 needs_redraw = true;
1304 }
1305 }
1306 }
1307 None => {}
1308 }
1309 mode = InputMode::Normal;
1310 continue;
1311 }
1312 mode = InputMode::Normal;
1314 }
1316 InputMode::ColonPrompt { buffer, error } => {
1317 if let Event::Key(KeyEvent { code, .. }) = event {
1318 match code {
1319 KeyCode::Esc => {
1320 mode = InputMode::Normal;
1321 needs_redraw = true;
1322 }
1323 KeyCode::Enter => {
1324 if buffer.is_empty() {
1325 mode = InputMode::Normal;
1326 } else {
1327 match parse_colon_command(buffer) {
1328 Ok(cmd) => {
1329 let is_tag_cmd = matches!(
1330 &cmd,
1331 ColonCommand::Tag(_)
1332 | ColonCommand::TagNext
1333 | ColonCommand::TagPrev
1334 | ColonCommand::TagSelect(_),
1335 );
1336 let reload_msg = if is_tag_cmd {
1337 refresh_tag_file(&mut tag_file)
1338 } else {
1339 None
1340 };
1341 let outcome = dispatch_colon_command(
1342 cmd,
1343 &mut file_set,
1344 &mut current_file_index,
1345 &args,
1346 preprocessor.as_ref(),
1347 record_start_regex.as_ref(),
1348 &mut viewport,
1349 &mut src,
1350 &mut idx,
1351 &mut tag_stack,
1352 tag_file.as_ref(),
1353 );
1354 match outcome {
1355 ColonOutcome::Continue(msg) => {
1356 transient_status = msg.or(reload_msg);
1357 }
1358 ColonOutcome::Quit => break,
1359 ColonOutcome::DispatchCommand(Command::OpenPicker) => {
1360 let saved = (0..file_set.len())
1361 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1362 .collect::<Vec<_>>();
1363 overlay = Some(Box::new(
1364 crate::overlay::picker::FilePicker::new(&file_set, saved)
1365 ));
1366 }
1367 ColonOutcome::DispatchCommand(Command::OpenHelp) => {
1368 let remaps = keymap.user_keys_by_command_name();
1369 overlay = Some(Box::new(
1370 crate::overlay::help::HelpOverlay::new(remaps)
1371 ));
1372 }
1373 ColonOutcome::DispatchCommand(Command::OpenTagPicker) => {
1374 if let Some(active) = tag_stack.active.as_ref() {
1375 overlay = Some(Box::new(
1376 crate::overlay::tag_picker::TagPicker::new(
1377 active.name.clone(),
1378 active.matches.clone(),
1379 active.cursor,
1380 )
1381 ));
1382 }
1383 }
1384 ColonOutcome::DispatchCommand(cmd) => {
1385 debug_assert!(false, "colon dispatcher emitted unexpected Command: {cmd:?}");
1386 }
1388 }
1389 mode = InputMode::Normal;
1390 }
1391 Err(e) => {
1392 *error = Some(e.to_string());
1393 }
1394 }
1395 }
1396 needs_redraw = true;
1397 }
1398 KeyCode::Backspace => {
1399 buffer.pop();
1400 *error = None;
1401 needs_redraw = true;
1402 }
1403 KeyCode::Char(c) => {
1404 buffer.push(c);
1405 *error = None;
1406 needs_redraw = true;
1407 }
1408 _ => {}
1409 }
1410 }
1411 continue;
1412 }
1413 InputMode::TagPrompt { buffer, error, last_tab_matches } => {
1414 if let Event::Key(KeyEvent { code, .. }) = event {
1415 match code {
1416 KeyCode::Esc => {
1417 mode = InputMode::Normal;
1418 needs_redraw = true;
1419 }
1420 KeyCode::Enter => {
1421 if buffer.is_empty() {
1422 mode = InputMode::Normal;
1423 } else {
1424 let name = buffer.clone();
1425 let reload_msg = refresh_tag_file(&mut tag_file);
1426 let msg = dispatch_tag_jump(
1427 &name,
1428 tag_file.as_ref(),
1429 &mut tag_stack,
1430 &mut file_set,
1431 &mut current_file_index,
1432 &args,
1433 preprocessor.as_ref(),
1434 record_start_regex.as_ref(),
1435 &mut viewport,
1436 &mut src,
1437 &mut idx,
1438 );
1439 transient_status = msg.or(reload_msg);
1440 mode = InputMode::Normal;
1441 }
1442 needs_redraw = true;
1443 }
1444 KeyCode::Backspace => {
1445 buffer.pop();
1446 *error = None;
1447 *last_tab_matches = None;
1448 needs_redraw = true;
1449 }
1450 KeyCode::Tab => {
1451 let _ = refresh_tag_file(&mut tag_file);
1452 let names: Vec<String> = match tag_file.as_ref() {
1453 Some(tf) => tf
1454 .names()
1455 .filter(|n| n.starts_with(buffer.as_str()))
1456 .map(String::from)
1457 .collect(),
1458 None => Vec::new(),
1459 };
1460 match (names.len(), last_tab_matches.as_ref()) {
1461 (0, _) => {
1462 *error = Some("no tags match".into());
1463 *last_tab_matches = None;
1464 }
1465 (1, _) => {
1466 *buffer = names.into_iter().next().unwrap();
1467 *error = None;
1468 *last_tab_matches = None;
1469 }
1470 (n, Some(prev)) if prev.len() == n => {
1471 *error = Some(format!("{n} matches"));
1472 }
1473 (n, _) => {
1474 let lcp = longest_common_prefix(&names);
1475 if lcp.len() > buffer.len() {
1476 *buffer = lcp;
1477 *error = None;
1478 } else {
1479 *error = Some(format!("{n} matches"));
1480 }
1481 *last_tab_matches = Some(names);
1482 }
1483 }
1484 needs_redraw = true;
1485 }
1486 KeyCode::Char(c) => {
1487 buffer.push(c);
1488 *error = None;
1489 *last_tab_matches = None;
1490 needs_redraw = true;
1491 }
1492 _ => {}
1493 }
1494 }
1495 continue;
1496 }
1497 InputMode::Normal => {}
1498 }
1499 if let crossterm::event::Event::Resize(c, r) = event {
1502 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1509 cols = c;
1510 rows = r;
1511 viewport.resize(c, r);
1512 if was_at_bottom {
1513 viewport.goto_bottom(src.as_ref(), &mut idx);
1514 }
1515 needs_redraw = true;
1516 if overlay.is_some() {
1517 continue;
1519 }
1520 }
1523 if let Some(ov) = overlay.as_mut() {
1527 let outcome = match &event {
1528 Event::Key(ke) => ov.handle_key(*ke),
1529 Event::Mouse(me) => ov.handle_mouse(*me, viewport.body_rows()),
1530 Event::Resize(_, _) => crate::overlay::OverlayOutcome::Stay,
1531 _ => crate::overlay::OverlayOutcome::Stay,
1532 };
1533 match outcome {
1534 crate::overlay::OverlayOutcome::Stay => {
1535 needs_redraw = true;
1536 continue;
1537 }
1538 crate::overlay::OverlayOutcome::Close => {
1539 overlay = None;
1540 overlay_flash = None;
1541 needs_redraw = true;
1542 continue;
1543 }
1544 crate::overlay::OverlayOutcome::CloseAnd(cmd) => {
1545 overlay = None;
1546 overlay_flash = None;
1547 if let Command::SelectFile(i) = cmd {
1548 if i < file_set.len() {
1549 file_set.set_current_index(i);
1550 if let Some(msg) = switch_to_current_file(
1551 &mut file_set, &mut current_file_index,
1552 &args, preprocessor.as_ref(),
1553 record_start_regex.as_ref(),
1554 &mut viewport, &mut src, &mut idx,
1555 ) {
1556 transient_status = Some(msg);
1557 }
1558 }
1559 } else if let Command::SelectTagMatch(idx_pick) = cmd {
1560 if let Some(active) = tag_stack.active.as_mut() {
1561 if idx_pick < active.matches.len() {
1562 active.cursor = idx_pick;
1563 let entry = active.matches[idx_pick].clone();
1564 let msg = dispatch_match(
1565 &entry,
1566 &mut file_set,
1567 &mut current_file_index,
1568 &args,
1569 preprocessor.as_ref(),
1570 record_start_regex.as_ref(),
1571 &mut viewport,
1572 &mut src,
1573 &mut idx,
1574 );
1575 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1576 if let Some(m) = msg {
1577 transient_status = Some(m);
1578 }
1579 }
1580 }
1581 }
1582 needs_redraw = true;
1583 continue;
1584 }
1585 crate::overlay::OverlayOutcome::Apply(cmd) => {
1586 if let Command::DropFileAt(target) = cmd {
1587 if file_set.len() > 1 && target < file_set.len() {
1588 let saved_cur = file_set.current_index();
1589 file_set.set_current_index(target);
1590 let _ = file_set.delete_current();
1591 if target < saved_cur {
1595 let restored = saved_cur.saturating_sub(1);
1596 file_set.set_current_index(restored);
1597 } else if target > saved_cur {
1598 file_set.set_current_index(saved_cur);
1599 }
1600 if let Some(msg) = switch_to_current_file(
1603 &mut file_set, &mut current_file_index,
1604 &args, preprocessor.as_ref(),
1605 record_start_regex.as_ref(),
1606 &mut viewport, &mut src, &mut idx,
1607 ) {
1608 transient_status = Some(msg);
1609 }
1610 if let Some(ov) = overlay.as_mut() {
1611 ov.refresh(crate::overlay::OverlayContext { file_set: &file_set });
1612 }
1613 }
1614 }
1615 needs_redraw = true;
1616 continue;
1617 }
1618 crate::overlay::OverlayOutcome::Refuse(msg) => {
1619 overlay_flash = Some((msg, std::time::Instant::now()));
1620 needs_redraw = true;
1621 continue;
1622 }
1623 }
1624 }
1625 if let crossterm::event::Event::Mouse(me) = &event {
1629 if mouse_enabled {
1630 use crossterm::event::{KeyModifiers, MouseEventKind};
1631 let hshift = me.modifiers.contains(KeyModifiers::SHIFT)
1637 && viewport.hscroll_active();
1638 match me.kind {
1639 MouseEventKind::ScrollDown if hshift => {
1640 viewport.hscroll_right_step();
1641 needs_redraw = true;
1642 }
1643 MouseEventKind::ScrollUp if hshift => {
1644 viewport.hscroll_left_step();
1645 needs_redraw = true;
1646 }
1647 MouseEventKind::ScrollDown => {
1648 viewport.scroll_lines(wheel_lines as i64, src.as_ref(), &mut idx);
1649 needs_redraw = true;
1650 }
1651 MouseEventKind::ScrollUp => {
1652 viewport.scroll_lines(-(wheel_lines as i64), src.as_ref(), &mut idx);
1653 needs_redraw = true;
1654 }
1655 MouseEventKind::ScrollLeft => {
1656 viewport.hscroll_left_step();
1657 needs_redraw = true;
1658 }
1659 MouseEventKind::ScrollRight => {
1660 viewport.hscroll_right_step();
1661 needs_redraw = true;
1662 }
1663 _ => {}
1664 }
1665 }
1666 continue;
1667 }
1668 let mut cmd: Option<Command> = None;
1672 if let InputMode::Normal = mode {
1673 if let Event::Key(ke) = &event {
1674 if let Some(target) = keymap.lookup(ke) {
1675 match target {
1676 crate::keys::BindingTarget::Shell(cmd_text) => {
1677 let cmd_text = cmd_text.clone();
1678 if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
1679 let _ = writeln!(std::io::stderr(),
1680 "[shell: {e}]");
1681 }
1682 needs_redraw = true;
1683 continue;
1684 }
1685 crate::keys::BindingTarget::Command(c) => {
1686 cmd = Some(c.clone());
1687 }
1688 }
1689 }
1690 }
1691 }
1692 let cmd = cmd.unwrap_or_else(|| translate(event));
1693 let prefix_at_cmd = numeric_prefix.take();
1696 match cmd {
1697 Command::Digit(d) => {
1698 let cur = prefix_at_cmd.unwrap_or(0);
1699 let next = cur.saturating_mul(10).saturating_add(d as usize);
1700 if next <= 99_999_999 {
1701 numeric_prefix = Some(next);
1702 } else {
1703 numeric_prefix = prefix_at_cmd;
1705 }
1706 continue;
1707 }
1708 Command::Cancel => {
1709 continue;
1711 }
1712 Command::GotoLine => {
1713 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1714 match prefix_at_cmd {
1715 Some(line) if line > 0 => {
1716 viewport.goto_line(line - 1, src.as_ref(), &mut idx);
1717 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1718 }
1719 _ => {
1720 viewport.goto_top();
1721 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1722 }
1723 }
1724 needs_redraw = true;
1725 }
1726 Command::GotoRecord => {
1727 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1728 match prefix_at_cmd {
1729 Some(rec) if rec > 0 => {
1730 viewport.goto_record(rec - 1, src.as_ref(), &mut idx);
1731 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1732 }
1733 _ => viewport.goto_bottom(src.as_ref(), &mut idx),
1734 }
1735 needs_redraw = true;
1736 }
1737 Command::GotoPercent => {
1738 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1739 match prefix_at_cmd {
1740 Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
1741 _ => viewport.goto_top(),
1742 }
1743 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1744 needs_redraw = true;
1745 }
1746 Command::Quit => break,
1747 Command::Resize(c, r) => {
1748 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1749 cols = c; rows = r;
1750 viewport.resize(c, r);
1751 if was_at_bottom {
1752 viewport.goto_bottom(src.as_ref(), &mut idx);
1753 }
1754 needs_redraw = true;
1755 }
1756 Command::ScrollLines(n) => {
1757 viewport.scroll_lines(n, src.as_ref(), &mut idx);
1758 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1759 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1760 needs_redraw = true;
1761 }
1762 Command::ScrollLogicalLines(n) => {
1763 viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
1764 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1765 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1766 needs_redraw = true;
1767 }
1768 Command::PageDown => {
1769 viewport.page_down(src.as_ref(), &mut idx);
1770 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1771 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1772 needs_redraw = true;
1773 }
1774 Command::PageUp => {
1775 viewport.page_up(src.as_ref(), &mut idx);
1776 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1777 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1778 needs_redraw = true;
1779 }
1780 Command::HalfPageDown => {
1781 viewport.half_page_down(src.as_ref(), &mut idx);
1782 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1783 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1784 needs_redraw = true;
1785 }
1786 Command::HalfPageUp => {
1787 viewport.half_page_up(src.as_ref(), &mut idx);
1788 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1789 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1790 needs_redraw = true;
1791 }
1792 Command::Refresh => {
1793 needs_redraw = true;
1794 }
1795 Command::Reload => {
1796 src.pump();
1799 if src.revision() != last_revision {
1800 rebuild_after_replace(
1801 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1802 );
1803 last_revision = src.revision();
1804 needs_redraw = true;
1805 }
1806 }
1807 Command::TogglePrettify => {
1808 apply_prettify(
1809 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1810 PrettifyTarget::Toggle,
1811 );
1812 last_revision = src.revision();
1813 needs_redraw = true;
1814 }
1815 Command::SetPrettifyMode(m) => {
1816 apply_prettify(
1817 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1818 PrettifyTarget::Mode(m),
1819 );
1820 last_revision = src.revision();
1821 needs_redraw = true;
1822 }
1823 Command::RedetectPrettify => {
1824 apply_prettify(
1825 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1826 PrettifyTarget::Auto,
1827 );
1828 last_revision = src.revision();
1829 needs_redraw = true;
1830 }
1831 Command::ToggleLineNumbers => {
1832 viewport.toggle_line_numbers();
1833 needs_redraw = true;
1834 }
1835 Command::ToggleChop => {
1836 viewport.toggle_chop();
1837 needs_redraw = true;
1838 }
1839 Command::ToggleFollow => {
1840 viewport.toggle_follow();
1841 if viewport.follow_mode() {
1842 src.pump();
1844 idx.notice_new_bytes(src.as_ref());
1845 viewport.goto_bottom(src.as_ref(), &mut idx);
1846 }
1847 needs_redraw = true;
1848 }
1849 Command::SearchForward => {
1850 incsearch_origin = (viewport.top_line(), viewport.top_row());
1851 mode = InputMode::SearchPrompt {
1852 direction: SearchDirection::Forward,
1853 buffer: String::new(),
1854 error: None,
1855 };
1856 needs_redraw = true;
1857 }
1858 Command::SearchBackward => {
1859 incsearch_origin = (viewport.top_line(), viewport.top_row());
1860 mode = InputMode::SearchPrompt {
1861 direction: SearchDirection::Backward,
1862 buffer: String::new(),
1863 error: None,
1864 };
1865 needs_redraw = true;
1866 }
1867 Command::ShellEscape => {
1868 mode = InputMode::ShellPrompt {
1869 buffer: String::new(),
1870 error: None,
1871 };
1872 needs_redraw = true;
1873 }
1874 Command::ColonPrompt => {
1875 mode = InputMode::ColonPrompt {
1876 buffer: String::new(),
1877 error: None,
1878 };
1879 needs_redraw = true;
1880 }
1881 Command::NextMatch => {
1882 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1883 if viewport.search_repeat(src.as_ref(), &mut idx, false) {
1884 needs_redraw = true;
1885 }
1886 }
1887 Command::PreviousMatch => {
1888 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1889 if viewport.search_repeat(src.as_ref(), &mut idx, true) {
1890 needs_redraw = true;
1891 }
1892 }
1893 Command::OptionPrefix => {
1894 mode = InputMode::OptionPrefix;
1895 }
1896 Command::MarkSet => {
1897 mode = InputMode::MarkSetPending;
1898 }
1899 Command::MarkJump => {
1900 mode = InputMode::MarkJumpPending;
1901 }
1902 Command::CtrlXPrefix => {
1903 mode = InputMode::CtrlXPending;
1904 }
1905 Command::JumpPrevious => {
1906 }
1909 Command::TagPrompt => {
1910 if tag_file.is_none() {
1911 transient_status = Some("[no tags file loaded]".into());
1912 needs_redraw = true;
1913 } else {
1914 mode = InputMode::TagPrompt {
1915 buffer: String::new(),
1916 error: None,
1917 last_tab_matches: None,
1918 };
1919 needs_redraw = true;
1920 }
1921 }
1922 Command::TagPop => match tag_stack.pop() {
1923 Some((file_index, line)) => {
1924 if file_index != current_file_index && file_index < file_set.len() {
1925 file_set.set_current_index(file_index);
1926 let path = file_set.current().unwrap().to_path_buf();
1927 if let Err(e) = switch_file(
1928 &path,
1929 file_index,
1930 file_set.len(),
1931 &args,
1932 preprocessor.as_ref(),
1933 &mut viewport,
1934 &mut src,
1935 &mut idx,
1936 record_start_regex.as_ref(),
1937 ) {
1938 transient_status = Some(format!("[open: {e}]"));
1939 } else {
1940 current_file_index = file_index;
1941 }
1942 }
1943 let clamped = line.min(idx.line_count().saturating_sub(1));
1944 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1945 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1946 needs_redraw = true;
1947 }
1948 None => {
1949 transient_status = Some("[tag stack empty]".into());
1950 needs_redraw = true;
1951 }
1952 },
1953 Command::OpenPicker => {
1954 let saved = (0..file_set.len())
1955 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1956 .collect::<Vec<_>>();
1957 overlay = Some(Box::new(
1958 crate::overlay::picker::FilePicker::new(&file_set, saved)
1959 ));
1960 needs_redraw = true;
1961 }
1962 Command::OpenHelp => {
1963 let remaps = keymap.user_keys_by_command_name();
1964 overlay = Some(Box::new(
1965 crate::overlay::help::HelpOverlay::new(remaps)
1966 ));
1967 needs_redraw = true;
1968 }
1969 Command::SelectFile(_)
1970 | Command::DropFileAt(_)
1971 | Command::SelectTagMatch(_)
1972 | Command::OpenTagPicker => {
1973 }
1975 Command::MouseEvent(_) => {
1976 }
1978 Command::HScrollLeft => {
1979 if hscroll_shift != 0 {
1980 viewport.hscroll_left_cols(hscroll_shift);
1981 } else {
1982 viewport.hscroll_left_half();
1983 }
1984 needs_redraw = true;
1985 }
1986 Command::HScrollRight => {
1987 if hscroll_shift != 0 {
1988 viewport.hscroll_right_cols(hscroll_shift);
1989 } else {
1990 viewport.hscroll_right_half();
1991 }
1992 needs_redraw = true;
1993 }
1994 Command::HScrollLeftStep => {
1995 viewport.hscroll_left_step();
1996 needs_redraw = true;
1997 }
1998 Command::HScrollRightStep => {
1999 viewport.hscroll_right_step();
2000 needs_redraw = true;
2001 }
2002 Command::YankLine => {
2003 let msg = yank_current_line(clipboard_enabled, &viewport, src.as_ref(), &mut idx);
2004 transient_status = Some(msg);
2005 needs_redraw = true;
2006 }
2007 Command::Noop => {}
2008 }
2009 }
2010 Ok(false) => {
2011 if viewport.live_mode() {
2013 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
2014 src.pump();
2015 if src.revision() != last_revision {
2016 rebuild_after_replace(
2017 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
2018 );
2019 if was_at_bottom {
2020 viewport.goto_bottom(src.as_ref(), &mut idx);
2021 }
2022 last_revision = src.revision();
2023 needs_redraw = true;
2024 }
2025 } else if viewport.follow_mode() {
2026 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
2027 src.pump();
2028 if src.take_rotated() {
2029 if let Some(path) = src.path().map(|p| p.to_path_buf()) {
2035 match crate::open::open_source_for_path(
2036 &path, &args, preprocessor.as_ref(),
2037 ) {
2038 Ok((new_src, _label, _err)) => {
2039 src = new_src;
2040 idx = LineIndex::new();
2041 if let Some(n) = rebuild_spec.head {
2042 idx.set_head_cap(n);
2043 }
2044 viewport.invalidate_filter_cache();
2045 idx.notice_new_bytes(src.as_ref());
2046 viewport.extend_visible_lines(&idx, src.as_ref());
2047 viewport.goto_bottom(src.as_ref(), &mut idx);
2048 viewport.flash("(F reopened)", 4);
2049 needs_redraw = true;
2050 continue;
2051 }
2052 Err(e) => {
2053 transient_status = Some(format!("[reopen failed: {e}]"));
2054 needs_redraw = true;
2055 }
2056 }
2057 }
2058 }
2059 let lines_before = idx.line_count();
2060 idx.notice_new_bytes(src.as_ref());
2061 viewport.extend_visible_lines(&idx, src.as_ref());
2062 if idx.line_count() != lines_before {
2063 needs_redraw = true;
2064 viewport.note_growth();
2065 if was_at_bottom {
2066 viewport.goto_bottom(src.as_ref(), &mut idx);
2067 }
2068 } else {
2069 viewport.tick_idle();
2070 }
2071 viewport.tick_flash();
2072 if args.exit_follow_on_close && src.is_complete() {
2078 break;
2079 }
2080 } else if !src.is_complete() {
2081 let lines_before = idx.line_count();
2084 idx.notice_new_bytes(src.as_ref());
2085 viewport.extend_visible_lines(&idx, src.as_ref());
2086 if idx.line_count() != lines_before {
2087 needs_redraw = true;
2088 }
2089 }
2090 }
2091 Err(_) => {
2092 std::thread::sleep(timeout);
2094 }
2095 }
2096 }
2097 Ok(())
2098}
2099
2100#[derive(Debug, Clone, Copy)]
2102enum PrettifyTarget {
2103 Mode(PrettifyMode),
2105 Toggle,
2107 Auto,
2109}
2110
2111fn apply_prettify(
2115 src: &dyn Source,
2116 viewport: &mut Viewport,
2117 idx: &mut LineIndex,
2118 spec: RebuildSpec,
2119 target: PrettifyTarget,
2120) {
2121 if src.prettify_mode().is_none() {
2123 return;
2124 }
2125 match target {
2126 PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
2127 PrettifyTarget::Toggle => src.toggle_prettify(),
2128 PrettifyTarget::Auto => src.redetect_prettify(),
2129 }
2130 rebuild_after_replace(src, viewport, idx, spec);
2131 viewport.set_prettify_label(src.prettify_label());
2132}
2133
2134fn rebuild_after_replace(
2140 src: &dyn Source,
2141 viewport: &mut Viewport,
2142 idx: &mut LineIndex,
2143 spec: RebuildSpec,
2144) {
2145 let new_off = match spec.tail {
2146 Some(n) => find_tail_offset(src, n),
2147 None => 0,
2148 };
2149 *idx = LineIndex::new_starting_at(new_off);
2150 if let Some(n) = spec.head {
2151 idx.set_head_cap(n);
2152 }
2153 viewport.invalidate_filter_cache();
2154 idx.notice_new_bytes(src);
2155 viewport.extend_visible_lines(idx, src);
2156 viewport.clamp_top_line(idx.line_count());
2157}
2158
2159fn to_crossterm_color(c: crate::ansi::Color, truecolor: bool) -> crossterm::style::Color {
2160 use crossterm::style::Color as CC;
2161 use crate::ansi::Color;
2162 match c {
2163 Color::Ansi(0) => CC::Black,
2164 Color::Ansi(1) => CC::DarkRed,
2165 Color::Ansi(2) => CC::DarkGreen,
2166 Color::Ansi(3) => CC::DarkYellow,
2167 Color::Ansi(4) => CC::DarkBlue,
2168 Color::Ansi(5) => CC::DarkMagenta,
2169 Color::Ansi(6) => CC::DarkCyan,
2170 Color::Ansi(7) => CC::Grey,
2171 Color::Ansi(8) => CC::DarkGrey,
2172 Color::Ansi(9) => CC::Red,
2173 Color::Ansi(10) => CC::Green,
2174 Color::Ansi(11) => CC::Yellow,
2175 Color::Ansi(12) => CC::Blue,
2176 Color::Ansi(13) => CC::Magenta,
2177 Color::Ansi(14) => CC::Cyan,
2178 Color::Ansi(15) => CC::White,
2179 Color::Ansi(_) => CC::Reset,
2180 Color::Indexed(n) => CC::AnsiValue(n),
2181 Color::Rgb(r, g, b) => {
2182 if truecolor {
2183 CC::Rgb { r, g, b }
2184 } else {
2185 CC::AnsiValue(crate::render::rgb_to_256(r, g, b))
2186 }
2187 }
2188 Color::Default => CC::Reset,
2189 }
2190}
2191
2192fn emit_style_diff<W: Write>(
2195 out: &mut W,
2196 prev: &crate::ansi::Style,
2197 next: &crate::ansi::Style,
2198 truecolor: bool,
2199) -> io::Result<()> {
2200 let intensity_changed = prev.bold != next.bold || prev.dim != next.dim;
2204
2205 let fg_changed = prev.fg != next.fg;
2209 let bg_changed = prev.bg != next.bg;
2210
2211 if (fg_changed && next.fg.is_none()) || (bg_changed && next.bg.is_none()) {
2212 out.queue(ResetColor)?;
2213 if let Some(c) = next.fg {
2215 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2216 }
2217 if let Some(c) = next.bg {
2218 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2219 }
2220 } else {
2221 if fg_changed {
2222 if let Some(c) = next.fg {
2223 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2224 }
2225 }
2226 if bg_changed {
2227 if let Some(c) = next.bg {
2228 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2229 }
2230 }
2231 }
2232
2233 if intensity_changed {
2234 if next.bold {
2235 out.queue(SetAttribute(Attribute::Bold))?;
2236 } else if next.dim {
2237 out.queue(SetAttribute(Attribute::Dim))?;
2238 } else {
2239 out.queue(SetAttribute(Attribute::NormalIntensity))?;
2240 }
2241 }
2242 if prev.italic != next.italic {
2243 out.queue(SetAttribute(if next.italic { Attribute::Italic } else { Attribute::NoItalic }))?;
2244 }
2245 if prev.underline != next.underline {
2246 out.queue(SetAttribute(if next.underline { Attribute::Underlined } else { Attribute::NoUnderline }))?;
2247 }
2248 if prev.reverse != next.reverse {
2249 out.queue(SetAttribute(if next.reverse { Attribute::Reverse } else { Attribute::NoReverse }))?;
2250 }
2251 if prev.strike != next.strike {
2252 out.queue(SetAttribute(if next.strike { Attribute::CrossedOut } else { Attribute::NotCrossedOut }))?;
2253 }
2254 Ok(())
2255}
2256
2257fn emit_hyperlink_diff<W: Write>(
2258 out: &mut W,
2259 prev: &Option<Arc<str>>,
2260 next: &Option<Arc<str>>,
2261) -> io::Result<()> {
2262 if prev == next {
2263 return Ok(());
2264 }
2265 if prev.is_some() {
2266 out.write_all(b"\x1b]8;;\x1b\\")?;
2267 }
2268 if let Some(uri) = next {
2269 out.write_all(b"\x1b]8;;")?;
2270 out.write_all(uri.as_bytes())?;
2271 out.write_all(b"\x1b\\")?;
2272 }
2273 Ok(())
2274}
2275
2276const SYNC_UPDATE_BEGIN: &[u8] = b"\x1b[?2026h";
2283const SYNC_UPDATE_END: &[u8] = b"\x1b[?2026l";
2284
2285fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16, truecolor: bool) -> io::Result<()> {
2286 out.write_all(SYNC_UPDATE_BEGIN)?;
2298
2299 out.queue(SetAttribute(Attribute::Reset))?;
2301 out.queue(ResetColor)?;
2302
2303 if let Some(blob) = &frame.image_blob {
2304 for r in 0..frame.body.len() as u16 {
2307 out.queue(MoveTo(0, r))?;
2308 out.queue(Clear(ClearType::UntilNewLine))?;
2309 }
2310 out.queue(MoveTo(0, 0))?;
2311 out.write_all(blob)?;
2312 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2314 out.queue(Clear(ClearType::UntilNewLine))?;
2315 emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2316 let mut status = frame.status.clone();
2317 if status.len() > cols as usize { status.truncate(cols as usize); }
2318 else { let pad = cols as usize - status.len(); status.push_str(&" ".repeat(pad)); }
2319 out.queue(Print(status))?;
2320 out.queue(ResetColor)?;
2321 out.queue(SetAttribute(Attribute::Reset))?;
2322 out.write_all(SYNC_UPDATE_END)?;
2323 return out.flush();
2324 }
2325
2326 for (i, row) in frame.body.iter().enumerate() {
2327 out.queue(MoveTo(0, i as u16))?;
2328 out.queue(Clear(ClearType::UntilNewLine))?;
2332 out.queue(SetAttribute(Attribute::Reset))?;
2335
2336 if let Some(Some(raw)) = frame.raw_rows.get(i) {
2341 if !raw.is_empty() {
2342 out.write_all(raw)?;
2343 }
2344 out.queue(ResetColor)?;
2346 out.queue(SetAttribute(Attribute::Reset))?;
2347 continue;
2348 }
2349
2350 let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
2351 let base_style = if matches!(row_style, RowStyle::Dim) {
2356 out.queue(SetAttribute(Attribute::Dim))?;
2357 crate::ansi::Style { dim: true, ..Default::default() }
2358 } else {
2359 crate::ansi::Style::default()
2360 };
2361 let no_highlights = Vec::new();
2362 let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
2363 write_row_with_highlights(out, row, cols, highlights, base_style, truecolor)?;
2364 }
2365 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2367 out.queue(Clear(ClearType::UntilNewLine))?;
2368 emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2369 let mut status = frame.status.clone();
2370 if status.len() > cols as usize {
2371 status.truncate(cols as usize);
2372 } else {
2373 let pad = cols as usize - status.len();
2374 status.push_str(&" ".repeat(pad));
2375 }
2376 out.queue(Print(status))?;
2377 out.queue(ResetColor)?;
2378 out.queue(SetAttribute(Attribute::Reset))?;
2379
2380 out.write_all(SYNC_UPDATE_END)?;
2383 out.flush()
2384}
2385
2386
2387fn write_row_with_highlights(
2398 out: &mut impl Write,
2399 row: &[Cell],
2400 cols: u16,
2401 highlights: &[std::ops::Range<usize>],
2402 base_style: crate::ansi::Style,
2403 truecolor: bool,
2404) -> io::Result<()> {
2405 let cols_usize = cols as usize;
2406
2407 let mut ranges: Vec<std::ops::Range<usize>> = highlights
2408 .iter()
2409 .filter_map(|r| {
2410 let s = r.start.min(cols_usize);
2411 let e = r.end.min(cols_usize);
2412 if e > s { Some(s..e) } else { None }
2413 })
2414 .collect();
2415 ranges.sort_by_key(|r| r.start);
2416
2417 let mut prev_style = base_style;
2420 let mut prev_link: Option<Arc<str>> = None;
2421
2422 let mut col = 0usize;
2423 let mut i = 0usize;
2424 while col < cols_usize && i < row.len() {
2425 let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
2426
2427 match &row[i] {
2428 Cell::Char { ch, width, style, hyperlink } => {
2429 let mut eff = *style;
2435 if in_highlight {
2436 eff.reverse = !eff.reverse;
2437 }
2438 if base_style.dim && !eff.bold {
2439 eff.dim = true;
2440 }
2441 emit_style_diff(out, &prev_style, &eff, truecolor)?;
2442 emit_hyperlink_diff(out, &prev_link, hyperlink)?;
2443 out.queue(Print(*ch))?;
2444 prev_style = eff;
2445 prev_link = hyperlink.clone();
2446 col += *width as usize;
2447 }
2448 Cell::Continuation => {
2449 }
2451 Cell::Empty => {
2452 let default = if base_style.dim {
2457 crate::ansi::Style { dim: true, ..Default::default() }
2458 } else {
2459 crate::ansi::Style::default()
2460 };
2461 emit_style_diff(out, &prev_style, &default, truecolor)?;
2462 emit_hyperlink_diff(out, &prev_link, &None)?;
2463 out.queue(Print(' '))?;
2464 prev_style = default;
2465 prev_link = None;
2466 col += 1;
2467 }
2468 }
2469 i += 1;
2470 }
2471
2472 emit_hyperlink_diff(out, &prev_link, &None)?;
2475 out.queue(ResetColor)?;
2476 out.queue(SetAttribute(Attribute::Reset))?;
2477
2478 Ok(())
2479}
2480
2481fn render_overlay(
2482 out: &mut impl Write,
2483 frame: &crate::overlay::OverlayFrame,
2484 width: u16,
2485 height: u16,
2486) -> io::Result<()> {
2487 out.write_all(SYNC_UPDATE_BEGIN)?;
2491 out.queue(SetAttribute(Attribute::Reset))?;
2492 out.queue(ResetColor)?;
2493 for row in 0..height.saturating_sub(1) {
2494 out.queue(MoveTo(0, row))?;
2495 out.queue(Clear(ClearType::UntilNewLine))?;
2496 out.queue(SetAttribute(Attribute::Reset))?;
2497 if let Some(line) = frame.body.get(row as usize) {
2498 let mut written = 0usize;
2499 for ch in line.chars() {
2500 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2501 if written + w > width as usize { break; }
2502 write!(out, "{ch}")?;
2503 written += w;
2504 }
2505 }
2506 }
2507 out.queue(MoveTo(0, height.saturating_sub(1)))?;
2508 out.queue(Clear(ClearType::UntilNewLine))?;
2509 out.queue(SetAttribute(Attribute::Reverse))?;
2510 let mut status = frame.status.clone();
2511 if status.len() > width as usize {
2513 status.truncate(width as usize);
2514 } else {
2515 let pad = width as usize - status.len();
2516 status.push_str(&" ".repeat(pad));
2517 }
2518 out.queue(Print(status))?;
2519 out.queue(ResetColor)?;
2520 out.queue(SetAttribute(Attribute::Reset))?;
2521 out.write_all(SYNC_UPDATE_END)?;
2522 out.flush()
2523}
2524
2525#[cfg(test)]
2526mod tests {
2527 use super::*;
2528
2529 #[test]
2530 fn parse_colon_n() {
2531 assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
2532 assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
2533 }
2534
2535 #[test]
2536 fn current_line_bytes_strips_trailing_newline() {
2537 use crate::line_index::LineIndex;
2538 use crate::source::MockSource;
2539 let m = MockSource::new();
2540 m.append(b"alpha\nbravo\ncharlie");
2542 let mut idx = LineIndex::new();
2543 idx.extend_to_end(&m);
2544 assert_eq!(idx.line_count(), 3);
2545 assert_eq!(current_line_bytes(&idx, &m, 0), b"alpha");
2546 assert_eq!(current_line_bytes(&idx, &m, 1), b"bravo");
2547 assert_eq!(current_line_bytes(&idx, &m, 2), b"charlie");
2549 }
2550
2551 #[test]
2552 fn write_frame_brackets_with_sync_update_and_no_full_clear() {
2553 use crate::ansi::Style;
2558 use crate::render::Cell;
2559 use crate::viewport::{Frame, RowStyle};
2560
2561 let row: Vec<Cell> = (0..3)
2562 .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
2563 .collect();
2564 let frame = Frame {
2565 body: vec![row.clone(), row],
2566 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2567 highlights: vec![Vec::new(), Vec::new()],
2568 status: "status".into(),
2569 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
2570 raw_rows: vec![None, None],
2571 image_blob: None,
2572 };
2573
2574 let mut buf: Vec<u8> = Vec::new();
2575 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2576 let s = std::str::from_utf8(&buf).expect("ascii");
2577
2578 let begin = s.find("\x1b[?2026h").expect("begin sync update");
2580 let end = s.find("\x1b[?2026l").expect("end sync update");
2581 assert!(begin < end, "begin must precede end");
2582 let first_a = s.find('a').expect("body char");
2584 assert!(begin < first_a && first_a < end, "body must be inside sync update");
2585
2586 assert!(
2589 !s.contains("\x1b[2J"),
2590 "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
2591 );
2592 assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
2593 }
2594
2595 #[test]
2596 fn write_frame_emits_image_blob_verbatim_and_skips_cell_rows() {
2597 use crate::viewport::{Frame, RowStyle};
2598 let body_rows = 3usize;
2599 let cols = 10u16;
2600 let blob = b"\x1bPqDATA\x1b\\".to_vec();
2601 let mut body = vec![vec![crate::render::Cell::Empty; cols as usize]; body_rows];
2604 body[0][0] = crate::render::Cell::Char { ch: 'Z', width: 1, style: crate::ansi::Style::default(), hyperlink: None };
2605 let frame = Frame {
2606 body,
2607 row_styles: vec![RowStyle::Normal; body_rows],
2608 highlights: vec![Vec::new(); body_rows],
2609 status: "img".to_string(),
2610 status_style: crate::ansi::Style::default(),
2611 raw_rows: vec![None; body_rows],
2612 image_blob: Some(blob.clone()),
2613 };
2614 let mut out: Vec<u8> = Vec::new();
2615 write_frame(&mut out, &frame, cols, (body_rows + 1) as u16, true).unwrap();
2616 let needle = b"\x1bPqDATA\x1b\\";
2617 assert!(out.windows(needle.len()).any(|w| w == needle), "image blob emitted verbatim");
2618 assert!(String::from_utf8_lossy(&out).contains("img"), "status still drawn");
2619 assert!(!String::from_utf8_lossy(&out).contains('Z'), "cell loop skipped: body cell not rendered");
2620 }
2621
2622 #[test]
2623 fn raw_rows_passthrough_emits_original_bytes_and_skips_continuation() {
2624 use crate::ansi::Style;
2625 use crate::render::Cell;
2626 use crate::viewport::{Frame, RowStyle};
2627
2628 let placeholder_row: Vec<Cell> = (0..3)
2630 .map(|_| Cell::Char { ch: 'X', width: 1, style: Style::default(), hyperlink: None })
2631 .collect();
2632 let frame = Frame {
2633 body: vec![placeholder_row.clone(), placeholder_row],
2634 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2635 highlights: vec![Vec::new(), Vec::new()],
2636 status: "s".into(),
2637 status_style: Style { reverse: true, ..Default::default() },
2638 raw_rows: vec![Some(b"\x1b[31mABC\x1b[0m".to_vec()), Some(Vec::new())],
2641 image_blob: None,
2642 };
2643
2644 let mut buf: Vec<u8> = Vec::new();
2645 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2646 let s = std::str::from_utf8(&buf).expect("ascii");
2647
2648 assert!(s.contains("\x1b[31mABC\x1b[0m"), "raw bytes missing in output: {s:?}");
2650 assert!(!s.contains("XXX"), "cell content leaked through despite raw passthrough: {s:?}");
2652 }
2653
2654 #[test]
2655 fn dim_row_keeps_dim_through_plain_cells_and_padding() {
2656 use crate::ansi::Style;
2661 use crate::render::Cell;
2662 let row = vec![
2663 Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
2664 Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
2665 Cell::Empty,
2666 Cell::Empty,
2667 ];
2668 let mut buf: Vec<u8> = Vec::new();
2669 let base = Style { dim: true, ..Default::default() };
2670 write_row_with_highlights(&mut buf, &row, 4, &[], base, true).unwrap();
2671 let s = String::from_utf8_lossy(&buf);
2672
2673 for needle in ['h', 'i'] {
2676 let pos = s.find(needle).expect("char printed");
2677 let before = &s[..pos];
2678 assert!(
2679 !before.contains("\x1b[22m"),
2680 "dim cleared before {needle:?}: {before:?}",
2681 );
2682 }
2683 let after_i = s.find('i').unwrap() + 1;
2686 let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
2687 let pad = &s[after_i..after_i + eor];
2688 assert!(
2689 !pad.contains("\x1b[22m"),
2690 "dim cleared in padding region: {pad:?}",
2691 );
2692 }
2693
2694 #[test]
2695 fn dim_row_yields_to_explicit_bold_cell() {
2696 use crate::ansi::Style;
2699 use crate::render::Cell;
2700 let row = vec![
2701 Cell::Char {
2702 ch: 'B',
2703 width: 1,
2704 style: Style { bold: true, ..Default::default() },
2705 hyperlink: None,
2706 },
2707 ];
2708 let mut buf: Vec<u8> = Vec::new();
2709 let base = Style { dim: true, ..Default::default() };
2710 write_row_with_highlights(&mut buf, &row, 1, &[], base, true).unwrap();
2711 let s = String::from_utf8_lossy(&buf);
2712 assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
2714 }
2715
2716 #[test]
2717 fn parse_colon_p() {
2718 assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
2719 assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
2720 }
2721
2722 #[test]
2723 fn parse_colon_e_with_path() {
2724 match parse_colon_command("e /tmp/foo.log").unwrap() {
2725 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
2726 other => panic!("expected Edit, got {other:?}"),
2727 }
2728 }
2729
2730 #[test]
2731 fn parse_colon_e_with_tilde() {
2732 std::env::set_var("HOME", "/home/user");
2733 match parse_colon_command("e ~/foo.log").unwrap() {
2734 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
2735 other => panic!("expected Edit, got {other:?}"),
2736 }
2737 }
2738
2739 #[test]
2740 fn parse_colon_e_missing_path_errors() {
2741 assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
2742 assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
2743 }
2744
2745 #[test]
2746 fn parse_colon_f_q_d_x_t() {
2747 assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
2748 assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
2749 assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
2750 assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
2751 assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
2752 }
2753
2754 #[test]
2755 fn parse_unknown_command_errors() {
2756 let err = parse_colon_command("bogus").unwrap_err();
2757 match err {
2758 ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
2759 other => panic!("expected UnknownCommand, got {other:?}"),
2760 }
2761 }
2762
2763 #[test]
2764 fn parse_handles_whitespace() {
2765 assert_eq!(parse_colon_command("n ").unwrap(), ColonCommand::Next);
2767 assert_eq!(parse_colon_command(" n").unwrap(), ColonCommand::Next);
2768 }
2769
2770 #[test]
2771 fn parse_colon_tag_with_name() {
2772 assert_eq!(
2773 parse_colon_command("tag foo").unwrap(),
2774 ColonCommand::Tag("foo".into())
2775 );
2776 }
2777
2778 #[test]
2779 fn parse_colon_tag_strips_trailing_whitespace() {
2780 assert_eq!(
2781 parse_colon_command("tag foo ").unwrap(),
2782 ColonCommand::Tag("foo".into())
2783 );
2784 }
2785
2786 #[test]
2787 fn parse_colon_tag_without_name_errors() {
2788 assert_eq!(
2789 parse_colon_command("tag").unwrap_err(),
2790 ColonParseError::TagRequiresName
2791 );
2792 assert_eq!(
2793 parse_colon_command("tag ").unwrap_err(),
2794 ColonParseError::TagRequiresName
2795 );
2796 }
2797
2798 #[test]
2799 fn parse_colon_tnext_and_tprev() {
2800 assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
2801 assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
2802 }
2803
2804 #[test]
2805 fn parse_colon_tselect_without_arg_uses_active() {
2806 assert_eq!(parse_colon_command("tselect").unwrap(), ColonCommand::TagSelect(None));
2807 }
2808
2809 #[test]
2810 fn parse_colon_tselect_with_name() {
2811 assert_eq!(
2812 parse_colon_command("tselect foo").unwrap(),
2813 ColonCommand::TagSelect(Some("foo".into())),
2814 );
2815 }
2816
2817 #[test]
2818 fn parse_colon_b_opens_picker() {
2819 assert_eq!(parse_colon_command("b").unwrap(), ColonCommand::OpenPicker);
2820 assert_eq!(parse_colon_command("buffers").unwrap(), ColonCommand::OpenPicker);
2821 }
2822
2823 #[test]
2824 fn parse_colon_help_opens_help() {
2825 assert_eq!(parse_colon_command("h").unwrap(), ColonCommand::OpenHelp);
2826 assert_eq!(parse_colon_command("help").unwrap(), ColonCommand::OpenHelp);
2827 }
2828
2829 #[test]
2830 fn parse_colon_hex_with_valid_widths() {
2831 for n in [2usize, 4, 8, 16, 32] {
2832 assert_eq!(
2833 parse_colon_command(&format!("hex {n}")).unwrap(),
2834 ColonCommand::HexGroup(n),
2835 );
2836 }
2837 }
2838
2839 #[test]
2840 fn parse_colon_hex_without_value_errors() {
2841 assert_eq!(
2842 parse_colon_command("hex").unwrap_err(),
2843 ColonParseError::HexGroupRequiresValue,
2844 );
2845 }
2846
2847 #[test]
2848 fn parse_colon_hex_with_invalid_value_errors() {
2849 match parse_colon_command("hex 3").unwrap_err() {
2850 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "3"),
2851 other => panic!("expected HexGroupInvalid, got {other:?}"),
2852 }
2853 match parse_colon_command("hex banana").unwrap_err() {
2854 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "banana"),
2855 other => panic!("expected HexGroupInvalid, got {other:?}"),
2856 }
2857 }
2858
2859 #[test]
2860 fn parse_colon_color_without_arg_cycles() {
2861 assert_eq!(parse_colon_command("color").unwrap(), ColonCommand::Color(None));
2862 }
2863
2864 #[test]
2865 fn parse_colon_color_with_named_mode() {
2866 use crate::render::AnsiMode;
2867 assert_eq!(
2868 parse_colon_command("color strict").unwrap(),
2869 ColonCommand::Color(Some(AnsiMode::Strict)),
2870 );
2871 assert_eq!(
2872 parse_colon_command("color interpret").unwrap(),
2873 ColonCommand::Color(Some(AnsiMode::Interpret)),
2874 );
2875 assert_eq!(
2876 parse_colon_command("color raw").unwrap(),
2877 ColonCommand::Color(Some(AnsiMode::Raw)),
2878 );
2879 }
2880
2881 #[test]
2882 fn parse_colon_color_with_unknown_mode_errors() {
2883 match parse_colon_command("color rainbow").unwrap_err() {
2884 ColonParseError::ColorInvalid(v) => assert_eq!(v, "rainbow"),
2885 other => panic!("expected ColorInvalid, got {other:?}"),
2886 }
2887 }
2888
2889 #[test]
2890 fn parse_colon_case_without_arg_cycles() {
2891 assert_eq!(parse_colon_command("case").unwrap(), ColonCommand::Case(None));
2892 }
2893
2894 #[test]
2895 fn parse_colon_case_with_named_mode() {
2896 use crate::viewport::CaseMode;
2897 assert_eq!(parse_colon_command("case smart").unwrap(),
2898 ColonCommand::Case(Some(CaseMode::Smart)));
2899 assert_eq!(parse_colon_command("case sensitive").unwrap(),
2900 ColonCommand::Case(Some(CaseMode::Sensitive)));
2901 assert_eq!(parse_colon_command("case insensitive").unwrap(),
2902 ColonCommand::Case(Some(CaseMode::Insensitive)));
2903 }
2904
2905 #[test]
2906 fn parse_colon_case_unknown_errors() {
2907 match parse_colon_command("case rainbow").unwrap_err() {
2908 ColonParseError::CaseInvalid(v) => assert_eq!(v, "rainbow"),
2909 other => panic!("expected CaseInvalid, got {other:?}"),
2910 }
2911 }
2912
2913 #[test]
2914 fn parse_colon_hlsearch_on_off() {
2915 assert_eq!(parse_colon_command("hlsearch").unwrap(), ColonCommand::HlSearch(true));
2916 assert_eq!(parse_colon_command("nohlsearch").unwrap(), ColonCommand::HlSearch(false));
2917 }
2918
2919 #[test]
2920 fn parse_colon_incsearch_toggle() {
2921 assert_eq!(parse_colon_command("incsearch").unwrap(), ColonCommand::IncSearch);
2922 }
2923
2924 #[test]
2925 fn lcp_empty_slice() {
2926 assert_eq!(longest_common_prefix(&[]), "");
2927 }
2928
2929 #[test]
2930 fn lcp_single_item_returns_self() {
2931 assert_eq!(longest_common_prefix(&["foo".into()]), "foo");
2932 }
2933
2934 #[test]
2935 fn lcp_finds_shared_prefix() {
2936 let v: Vec<String> = vec!["foobar".into(), "foobaz".into(), "fooqux".into()];
2937 assert_eq!(longest_common_prefix(&v), "foo");
2938 }
2939
2940 #[test]
2941 fn lcp_no_shared_prefix_returns_empty() {
2942 let v: Vec<String> = vec!["abc".into(), "xyz".into()];
2943 assert_eq!(longest_common_prefix(&v), "");
2944 }
2945
2946 #[test]
2947 fn lcp_one_item_is_prefix_of_others() {
2948 let v: Vec<String> = vec!["foo".into(), "foobar".into(), "foobaz".into()];
2949 assert_eq!(longest_common_prefix(&v), "foo");
2950 }
2951
2952 #[test]
2953 fn tag_stack_push_pop_lifo() {
2954 let mut s = TagStack::default();
2955 s.push(0, 10);
2956 s.push(1, 20);
2957 assert_eq!(s.pop(), Some((1, 20)));
2958 assert_eq!(s.pop(), Some((0, 10)));
2959 assert_eq!(s.pop(), None);
2960 }
2961
2962 #[test]
2963 fn tag_stack_pop_clears_active() {
2964 let mut s = TagStack::default();
2965 s.push(0, 10);
2966 s.set_active(
2967 "foo".into(),
2968 vec![crate::tags::TagEntry {
2969 file: std::path::PathBuf::from("/a"),
2970 address: crate::tags::TagAddress::Line(1),
2971 }],
2972 );
2973 assert!(s.active.is_some());
2974 let _ = s.pop();
2975 assert!(s.active.is_none());
2976 }
2977
2978 #[test]
2979 fn tag_stack_next_advances_then_clamps() {
2980 let mut s = TagStack::default();
2981 s.set_active(
2982 "foo".into(),
2983 vec![
2984 crate::tags::TagEntry {
2985 file: std::path::PathBuf::from("/a"),
2986 address: crate::tags::TagAddress::Line(1),
2987 },
2988 crate::tags::TagEntry {
2989 file: std::path::PathBuf::from("/b"),
2990 address: crate::tags::TagAddress::Line(2),
2991 },
2992 ],
2993 );
2994 assert_eq!(s.next(), TagStepResult::Moved(1));
2995 assert_eq!(s.next(), TagStepResult::AtBoundary);
2996 }
2997
2998 #[test]
2999 fn tag_stack_prev_clamps_at_zero() {
3000 let mut s = TagStack::default();
3001 s.set_active(
3002 "foo".into(),
3003 vec![crate::tags::TagEntry {
3004 file: std::path::PathBuf::from("/a"),
3005 address: crate::tags::TagAddress::Line(1),
3006 }],
3007 );
3008 assert_eq!(s.prev(), TagStepResult::AtBoundary);
3009 }
3010
3011 #[test]
3012 fn tag_stack_next_with_no_active_returns_no_active() {
3013 let mut s = TagStack::default();
3014 assert_eq!(s.next(), TagStepResult::NoActive);
3015 assert_eq!(s.prev(), TagStepResult::NoActive);
3016 }
3017
3018 #[test]
3019 fn tag_stack_set_active_replaces_previous_list() {
3020 let mut s = TagStack::default();
3021 s.set_active(
3022 "foo".into(),
3023 vec![crate::tags::TagEntry {
3024 file: std::path::PathBuf::from("/a"),
3025 address: crate::tags::TagAddress::Line(1),
3026 }],
3027 );
3028 s.set_active(
3029 "bar".into(),
3030 vec![
3031 crate::tags::TagEntry {
3032 file: std::path::PathBuf::from("/x"),
3033 address: crate::tags::TagAddress::Line(5),
3034 },
3035 crate::tags::TagEntry {
3036 file: std::path::PathBuf::from("/y"),
3037 address: crate::tags::TagAddress::Line(6),
3038 },
3039 ],
3040 );
3041 let active = s.active.as_ref().unwrap();
3042 assert_eq!(active.name, "bar");
3043 assert_eq!(active.matches.len(), 2);
3044 assert_eq!(active.cursor, 0);
3045 }
3046
3047 #[test]
3048 fn writer_emits_color_for_red_cell() {
3049 let cells = vec![Cell::Char {
3050 ch: 'h',
3051 width: 1,
3052 style: crate::ansi::Style {
3053 fg: Some(crate::ansi::Color::Ansi(1)),
3054 ..Default::default()
3055 },
3056 hyperlink: None,
3057 }];
3058 let mut buf: Vec<u8> = Vec::new();
3059 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3060 let s = String::from_utf8_lossy(&buf);
3061 assert!(s.contains("\x1b["), "expected ANSI escape in output: {s:?}");
3062 assert!(s.contains('h'));
3063 }
3064
3065 #[test]
3066 fn writer_emits_osc8_for_hyperlink_cell() {
3067 let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
3068 let cells = vec![Cell::Char {
3069 ch: 'c',
3070 width: 1,
3071 style: crate::ansi::Style::default(),
3072 hyperlink: Some(link),
3073 }];
3074 let mut buf: Vec<u8> = Vec::new();
3075 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3076 let s = String::from_utf8_lossy(&buf);
3077 assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
3078 }
3079}