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 for (i, row) in frame.body.iter().enumerate() {
2304 out.queue(MoveTo(0, i as u16))?;
2305 out.queue(Clear(ClearType::UntilNewLine))?;
2309 out.queue(SetAttribute(Attribute::Reset))?;
2312
2313 if let Some(Some(raw)) = frame.raw_rows.get(i) {
2318 if !raw.is_empty() {
2319 out.write_all(raw)?;
2320 }
2321 out.queue(ResetColor)?;
2323 out.queue(SetAttribute(Attribute::Reset))?;
2324 continue;
2325 }
2326
2327 let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
2328 let base_style = if matches!(row_style, RowStyle::Dim) {
2333 out.queue(SetAttribute(Attribute::Dim))?;
2334 crate::ansi::Style { dim: true, ..Default::default() }
2335 } else {
2336 crate::ansi::Style::default()
2337 };
2338 let no_highlights = Vec::new();
2339 let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
2340 write_row_with_highlights(out, row, cols, highlights, base_style, truecolor)?;
2341 }
2342 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2344 out.queue(Clear(ClearType::UntilNewLine))?;
2345 emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2346 let mut status = frame.status.clone();
2347 if status.len() > cols as usize {
2348 status.truncate(cols as usize);
2349 } else {
2350 let pad = cols as usize - status.len();
2351 status.push_str(&" ".repeat(pad));
2352 }
2353 out.queue(Print(status))?;
2354 out.queue(ResetColor)?;
2355 out.queue(SetAttribute(Attribute::Reset))?;
2356
2357 out.write_all(SYNC_UPDATE_END)?;
2360 out.flush()
2361}
2362
2363
2364fn write_row_with_highlights(
2375 out: &mut impl Write,
2376 row: &[Cell],
2377 cols: u16,
2378 highlights: &[std::ops::Range<usize>],
2379 base_style: crate::ansi::Style,
2380 truecolor: bool,
2381) -> io::Result<()> {
2382 let cols_usize = cols as usize;
2383
2384 let mut ranges: Vec<std::ops::Range<usize>> = highlights
2385 .iter()
2386 .filter_map(|r| {
2387 let s = r.start.min(cols_usize);
2388 let e = r.end.min(cols_usize);
2389 if e > s { Some(s..e) } else { None }
2390 })
2391 .collect();
2392 ranges.sort_by_key(|r| r.start);
2393
2394 let mut prev_style = base_style;
2397 let mut prev_link: Option<Arc<str>> = None;
2398
2399 let mut col = 0usize;
2400 let mut i = 0usize;
2401 while col < cols_usize && i < row.len() {
2402 let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
2403
2404 match &row[i] {
2405 Cell::Char { ch, width, style, hyperlink } => {
2406 let mut eff = *style;
2412 if in_highlight {
2413 eff.reverse = !eff.reverse;
2414 }
2415 if base_style.dim && !eff.bold {
2416 eff.dim = true;
2417 }
2418 emit_style_diff(out, &prev_style, &eff, truecolor)?;
2419 emit_hyperlink_diff(out, &prev_link, hyperlink)?;
2420 out.queue(Print(*ch))?;
2421 prev_style = eff;
2422 prev_link = hyperlink.clone();
2423 col += *width as usize;
2424 }
2425 Cell::Continuation => {
2426 }
2428 Cell::Empty => {
2429 let default = if base_style.dim {
2434 crate::ansi::Style { dim: true, ..Default::default() }
2435 } else {
2436 crate::ansi::Style::default()
2437 };
2438 emit_style_diff(out, &prev_style, &default, truecolor)?;
2439 emit_hyperlink_diff(out, &prev_link, &None)?;
2440 out.queue(Print(' '))?;
2441 prev_style = default;
2442 prev_link = None;
2443 col += 1;
2444 }
2445 }
2446 i += 1;
2447 }
2448
2449 emit_hyperlink_diff(out, &prev_link, &None)?;
2452 out.queue(ResetColor)?;
2453 out.queue(SetAttribute(Attribute::Reset))?;
2454
2455 Ok(())
2456}
2457
2458fn render_overlay(
2459 out: &mut impl Write,
2460 frame: &crate::overlay::OverlayFrame,
2461 width: u16,
2462 height: u16,
2463) -> io::Result<()> {
2464 out.write_all(SYNC_UPDATE_BEGIN)?;
2468 out.queue(SetAttribute(Attribute::Reset))?;
2469 out.queue(ResetColor)?;
2470 for row in 0..height.saturating_sub(1) {
2471 out.queue(MoveTo(0, row))?;
2472 out.queue(Clear(ClearType::UntilNewLine))?;
2473 out.queue(SetAttribute(Attribute::Reset))?;
2474 if let Some(line) = frame.body.get(row as usize) {
2475 let mut written = 0usize;
2476 for ch in line.chars() {
2477 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2478 if written + w > width as usize { break; }
2479 write!(out, "{ch}")?;
2480 written += w;
2481 }
2482 }
2483 }
2484 out.queue(MoveTo(0, height.saturating_sub(1)))?;
2485 out.queue(Clear(ClearType::UntilNewLine))?;
2486 out.queue(SetAttribute(Attribute::Reverse))?;
2487 let mut status = frame.status.clone();
2488 if status.len() > width as usize {
2490 status.truncate(width as usize);
2491 } else {
2492 let pad = width as usize - status.len();
2493 status.push_str(&" ".repeat(pad));
2494 }
2495 out.queue(Print(status))?;
2496 out.queue(ResetColor)?;
2497 out.queue(SetAttribute(Attribute::Reset))?;
2498 out.write_all(SYNC_UPDATE_END)?;
2499 out.flush()
2500}
2501
2502#[cfg(test)]
2503mod tests {
2504 use super::*;
2505
2506 #[test]
2507 fn parse_colon_n() {
2508 assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
2509 assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
2510 }
2511
2512 #[test]
2513 fn current_line_bytes_strips_trailing_newline() {
2514 use crate::line_index::LineIndex;
2515 use crate::source::MockSource;
2516 let m = MockSource::new();
2517 m.append(b"alpha\nbravo\ncharlie");
2519 let mut idx = LineIndex::new();
2520 idx.extend_to_end(&m);
2521 assert_eq!(idx.line_count(), 3);
2522 assert_eq!(current_line_bytes(&idx, &m, 0), b"alpha");
2523 assert_eq!(current_line_bytes(&idx, &m, 1), b"bravo");
2524 assert_eq!(current_line_bytes(&idx, &m, 2), b"charlie");
2526 }
2527
2528 #[test]
2529 fn write_frame_brackets_with_sync_update_and_no_full_clear() {
2530 use crate::ansi::Style;
2535 use crate::render::Cell;
2536 use crate::viewport::{Frame, RowStyle};
2537
2538 let row: Vec<Cell> = (0..3)
2539 .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
2540 .collect();
2541 let frame = Frame {
2542 body: vec![row.clone(), row],
2543 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2544 highlights: vec![Vec::new(), Vec::new()],
2545 status: "status".into(),
2546 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
2547 raw_rows: vec![None, None],
2548 };
2549
2550 let mut buf: Vec<u8> = Vec::new();
2551 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2552 let s = std::str::from_utf8(&buf).expect("ascii");
2553
2554 let begin = s.find("\x1b[?2026h").expect("begin sync update");
2556 let end = s.find("\x1b[?2026l").expect("end sync update");
2557 assert!(begin < end, "begin must precede end");
2558 let first_a = s.find('a').expect("body char");
2560 assert!(begin < first_a && first_a < end, "body must be inside sync update");
2561
2562 assert!(
2565 !s.contains("\x1b[2J"),
2566 "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
2567 );
2568 assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
2569 }
2570
2571 #[test]
2572 fn raw_rows_passthrough_emits_original_bytes_and_skips_continuation() {
2573 use crate::ansi::Style;
2574 use crate::render::Cell;
2575 use crate::viewport::{Frame, RowStyle};
2576
2577 let placeholder_row: Vec<Cell> = (0..3)
2579 .map(|_| Cell::Char { ch: 'X', width: 1, style: Style::default(), hyperlink: None })
2580 .collect();
2581 let frame = Frame {
2582 body: vec![placeholder_row.clone(), placeholder_row],
2583 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2584 highlights: vec![Vec::new(), Vec::new()],
2585 status: "s".into(),
2586 status_style: Style { reverse: true, ..Default::default() },
2587 raw_rows: vec![Some(b"\x1b[31mABC\x1b[0m".to_vec()), Some(Vec::new())],
2590 };
2591
2592 let mut buf: Vec<u8> = Vec::new();
2593 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2594 let s = std::str::from_utf8(&buf).expect("ascii");
2595
2596 assert!(s.contains("\x1b[31mABC\x1b[0m"), "raw bytes missing in output: {s:?}");
2598 assert!(!s.contains("XXX"), "cell content leaked through despite raw passthrough: {s:?}");
2600 }
2601
2602 #[test]
2603 fn dim_row_keeps_dim_through_plain_cells_and_padding() {
2604 use crate::ansi::Style;
2609 use crate::render::Cell;
2610 let row = vec![
2611 Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
2612 Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
2613 Cell::Empty,
2614 Cell::Empty,
2615 ];
2616 let mut buf: Vec<u8> = Vec::new();
2617 let base = Style { dim: true, ..Default::default() };
2618 write_row_with_highlights(&mut buf, &row, 4, &[], base, true).unwrap();
2619 let s = String::from_utf8_lossy(&buf);
2620
2621 for needle in ['h', 'i'] {
2624 let pos = s.find(needle).expect("char printed");
2625 let before = &s[..pos];
2626 assert!(
2627 !before.contains("\x1b[22m"),
2628 "dim cleared before {needle:?}: {before:?}",
2629 );
2630 }
2631 let after_i = s.find('i').unwrap() + 1;
2634 let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
2635 let pad = &s[after_i..after_i + eor];
2636 assert!(
2637 !pad.contains("\x1b[22m"),
2638 "dim cleared in padding region: {pad:?}",
2639 );
2640 }
2641
2642 #[test]
2643 fn dim_row_yields_to_explicit_bold_cell() {
2644 use crate::ansi::Style;
2647 use crate::render::Cell;
2648 let row = vec![
2649 Cell::Char {
2650 ch: 'B',
2651 width: 1,
2652 style: Style { bold: true, ..Default::default() },
2653 hyperlink: None,
2654 },
2655 ];
2656 let mut buf: Vec<u8> = Vec::new();
2657 let base = Style { dim: true, ..Default::default() };
2658 write_row_with_highlights(&mut buf, &row, 1, &[], base, true).unwrap();
2659 let s = String::from_utf8_lossy(&buf);
2660 assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
2662 }
2663
2664 #[test]
2665 fn parse_colon_p() {
2666 assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
2667 assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
2668 }
2669
2670 #[test]
2671 fn parse_colon_e_with_path() {
2672 match parse_colon_command("e /tmp/foo.log").unwrap() {
2673 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
2674 other => panic!("expected Edit, got {other:?}"),
2675 }
2676 }
2677
2678 #[test]
2679 fn parse_colon_e_with_tilde() {
2680 std::env::set_var("HOME", "/home/user");
2681 match parse_colon_command("e ~/foo.log").unwrap() {
2682 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
2683 other => panic!("expected Edit, got {other:?}"),
2684 }
2685 }
2686
2687 #[test]
2688 fn parse_colon_e_missing_path_errors() {
2689 assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
2690 assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
2691 }
2692
2693 #[test]
2694 fn parse_colon_f_q_d_x_t() {
2695 assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
2696 assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
2697 assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
2698 assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
2699 assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
2700 }
2701
2702 #[test]
2703 fn parse_unknown_command_errors() {
2704 let err = parse_colon_command("bogus").unwrap_err();
2705 match err {
2706 ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
2707 other => panic!("expected UnknownCommand, got {other:?}"),
2708 }
2709 }
2710
2711 #[test]
2712 fn parse_handles_whitespace() {
2713 assert_eq!(parse_colon_command("n ").unwrap(), ColonCommand::Next);
2715 assert_eq!(parse_colon_command(" n").unwrap(), ColonCommand::Next);
2716 }
2717
2718 #[test]
2719 fn parse_colon_tag_with_name() {
2720 assert_eq!(
2721 parse_colon_command("tag foo").unwrap(),
2722 ColonCommand::Tag("foo".into())
2723 );
2724 }
2725
2726 #[test]
2727 fn parse_colon_tag_strips_trailing_whitespace() {
2728 assert_eq!(
2729 parse_colon_command("tag foo ").unwrap(),
2730 ColonCommand::Tag("foo".into())
2731 );
2732 }
2733
2734 #[test]
2735 fn parse_colon_tag_without_name_errors() {
2736 assert_eq!(
2737 parse_colon_command("tag").unwrap_err(),
2738 ColonParseError::TagRequiresName
2739 );
2740 assert_eq!(
2741 parse_colon_command("tag ").unwrap_err(),
2742 ColonParseError::TagRequiresName
2743 );
2744 }
2745
2746 #[test]
2747 fn parse_colon_tnext_and_tprev() {
2748 assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
2749 assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
2750 }
2751
2752 #[test]
2753 fn parse_colon_tselect_without_arg_uses_active() {
2754 assert_eq!(parse_colon_command("tselect").unwrap(), ColonCommand::TagSelect(None));
2755 }
2756
2757 #[test]
2758 fn parse_colon_tselect_with_name() {
2759 assert_eq!(
2760 parse_colon_command("tselect foo").unwrap(),
2761 ColonCommand::TagSelect(Some("foo".into())),
2762 );
2763 }
2764
2765 #[test]
2766 fn parse_colon_b_opens_picker() {
2767 assert_eq!(parse_colon_command("b").unwrap(), ColonCommand::OpenPicker);
2768 assert_eq!(parse_colon_command("buffers").unwrap(), ColonCommand::OpenPicker);
2769 }
2770
2771 #[test]
2772 fn parse_colon_help_opens_help() {
2773 assert_eq!(parse_colon_command("h").unwrap(), ColonCommand::OpenHelp);
2774 assert_eq!(parse_colon_command("help").unwrap(), ColonCommand::OpenHelp);
2775 }
2776
2777 #[test]
2778 fn parse_colon_hex_with_valid_widths() {
2779 for n in [2usize, 4, 8, 16, 32] {
2780 assert_eq!(
2781 parse_colon_command(&format!("hex {n}")).unwrap(),
2782 ColonCommand::HexGroup(n),
2783 );
2784 }
2785 }
2786
2787 #[test]
2788 fn parse_colon_hex_without_value_errors() {
2789 assert_eq!(
2790 parse_colon_command("hex").unwrap_err(),
2791 ColonParseError::HexGroupRequiresValue,
2792 );
2793 }
2794
2795 #[test]
2796 fn parse_colon_hex_with_invalid_value_errors() {
2797 match parse_colon_command("hex 3").unwrap_err() {
2798 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "3"),
2799 other => panic!("expected HexGroupInvalid, got {other:?}"),
2800 }
2801 match parse_colon_command("hex banana").unwrap_err() {
2802 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "banana"),
2803 other => panic!("expected HexGroupInvalid, got {other:?}"),
2804 }
2805 }
2806
2807 #[test]
2808 fn parse_colon_color_without_arg_cycles() {
2809 assert_eq!(parse_colon_command("color").unwrap(), ColonCommand::Color(None));
2810 }
2811
2812 #[test]
2813 fn parse_colon_color_with_named_mode() {
2814 use crate::render::AnsiMode;
2815 assert_eq!(
2816 parse_colon_command("color strict").unwrap(),
2817 ColonCommand::Color(Some(AnsiMode::Strict)),
2818 );
2819 assert_eq!(
2820 parse_colon_command("color interpret").unwrap(),
2821 ColonCommand::Color(Some(AnsiMode::Interpret)),
2822 );
2823 assert_eq!(
2824 parse_colon_command("color raw").unwrap(),
2825 ColonCommand::Color(Some(AnsiMode::Raw)),
2826 );
2827 }
2828
2829 #[test]
2830 fn parse_colon_color_with_unknown_mode_errors() {
2831 match parse_colon_command("color rainbow").unwrap_err() {
2832 ColonParseError::ColorInvalid(v) => assert_eq!(v, "rainbow"),
2833 other => panic!("expected ColorInvalid, got {other:?}"),
2834 }
2835 }
2836
2837 #[test]
2838 fn parse_colon_case_without_arg_cycles() {
2839 assert_eq!(parse_colon_command("case").unwrap(), ColonCommand::Case(None));
2840 }
2841
2842 #[test]
2843 fn parse_colon_case_with_named_mode() {
2844 use crate::viewport::CaseMode;
2845 assert_eq!(parse_colon_command("case smart").unwrap(),
2846 ColonCommand::Case(Some(CaseMode::Smart)));
2847 assert_eq!(parse_colon_command("case sensitive").unwrap(),
2848 ColonCommand::Case(Some(CaseMode::Sensitive)));
2849 assert_eq!(parse_colon_command("case insensitive").unwrap(),
2850 ColonCommand::Case(Some(CaseMode::Insensitive)));
2851 }
2852
2853 #[test]
2854 fn parse_colon_case_unknown_errors() {
2855 match parse_colon_command("case rainbow").unwrap_err() {
2856 ColonParseError::CaseInvalid(v) => assert_eq!(v, "rainbow"),
2857 other => panic!("expected CaseInvalid, got {other:?}"),
2858 }
2859 }
2860
2861 #[test]
2862 fn parse_colon_hlsearch_on_off() {
2863 assert_eq!(parse_colon_command("hlsearch").unwrap(), ColonCommand::HlSearch(true));
2864 assert_eq!(parse_colon_command("nohlsearch").unwrap(), ColonCommand::HlSearch(false));
2865 }
2866
2867 #[test]
2868 fn parse_colon_incsearch_toggle() {
2869 assert_eq!(parse_colon_command("incsearch").unwrap(), ColonCommand::IncSearch);
2870 }
2871
2872 #[test]
2873 fn lcp_empty_slice() {
2874 assert_eq!(longest_common_prefix(&[]), "");
2875 }
2876
2877 #[test]
2878 fn lcp_single_item_returns_self() {
2879 assert_eq!(longest_common_prefix(&["foo".into()]), "foo");
2880 }
2881
2882 #[test]
2883 fn lcp_finds_shared_prefix() {
2884 let v: Vec<String> = vec!["foobar".into(), "foobaz".into(), "fooqux".into()];
2885 assert_eq!(longest_common_prefix(&v), "foo");
2886 }
2887
2888 #[test]
2889 fn lcp_no_shared_prefix_returns_empty() {
2890 let v: Vec<String> = vec!["abc".into(), "xyz".into()];
2891 assert_eq!(longest_common_prefix(&v), "");
2892 }
2893
2894 #[test]
2895 fn lcp_one_item_is_prefix_of_others() {
2896 let v: Vec<String> = vec!["foo".into(), "foobar".into(), "foobaz".into()];
2897 assert_eq!(longest_common_prefix(&v), "foo");
2898 }
2899
2900 #[test]
2901 fn tag_stack_push_pop_lifo() {
2902 let mut s = TagStack::default();
2903 s.push(0, 10);
2904 s.push(1, 20);
2905 assert_eq!(s.pop(), Some((1, 20)));
2906 assert_eq!(s.pop(), Some((0, 10)));
2907 assert_eq!(s.pop(), None);
2908 }
2909
2910 #[test]
2911 fn tag_stack_pop_clears_active() {
2912 let mut s = TagStack::default();
2913 s.push(0, 10);
2914 s.set_active(
2915 "foo".into(),
2916 vec![crate::tags::TagEntry {
2917 file: std::path::PathBuf::from("/a"),
2918 address: crate::tags::TagAddress::Line(1),
2919 }],
2920 );
2921 assert!(s.active.is_some());
2922 let _ = s.pop();
2923 assert!(s.active.is_none());
2924 }
2925
2926 #[test]
2927 fn tag_stack_next_advances_then_clamps() {
2928 let mut s = TagStack::default();
2929 s.set_active(
2930 "foo".into(),
2931 vec![
2932 crate::tags::TagEntry {
2933 file: std::path::PathBuf::from("/a"),
2934 address: crate::tags::TagAddress::Line(1),
2935 },
2936 crate::tags::TagEntry {
2937 file: std::path::PathBuf::from("/b"),
2938 address: crate::tags::TagAddress::Line(2),
2939 },
2940 ],
2941 );
2942 assert_eq!(s.next(), TagStepResult::Moved(1));
2943 assert_eq!(s.next(), TagStepResult::AtBoundary);
2944 }
2945
2946 #[test]
2947 fn tag_stack_prev_clamps_at_zero() {
2948 let mut s = TagStack::default();
2949 s.set_active(
2950 "foo".into(),
2951 vec![crate::tags::TagEntry {
2952 file: std::path::PathBuf::from("/a"),
2953 address: crate::tags::TagAddress::Line(1),
2954 }],
2955 );
2956 assert_eq!(s.prev(), TagStepResult::AtBoundary);
2957 }
2958
2959 #[test]
2960 fn tag_stack_next_with_no_active_returns_no_active() {
2961 let mut s = TagStack::default();
2962 assert_eq!(s.next(), TagStepResult::NoActive);
2963 assert_eq!(s.prev(), TagStepResult::NoActive);
2964 }
2965
2966 #[test]
2967 fn tag_stack_set_active_replaces_previous_list() {
2968 let mut s = TagStack::default();
2969 s.set_active(
2970 "foo".into(),
2971 vec![crate::tags::TagEntry {
2972 file: std::path::PathBuf::from("/a"),
2973 address: crate::tags::TagAddress::Line(1),
2974 }],
2975 );
2976 s.set_active(
2977 "bar".into(),
2978 vec![
2979 crate::tags::TagEntry {
2980 file: std::path::PathBuf::from("/x"),
2981 address: crate::tags::TagAddress::Line(5),
2982 },
2983 crate::tags::TagEntry {
2984 file: std::path::PathBuf::from("/y"),
2985 address: crate::tags::TagAddress::Line(6),
2986 },
2987 ],
2988 );
2989 let active = s.active.as_ref().unwrap();
2990 assert_eq!(active.name, "bar");
2991 assert_eq!(active.matches.len(), 2);
2992 assert_eq!(active.cursor, 0);
2993 }
2994
2995 #[test]
2996 fn writer_emits_color_for_red_cell() {
2997 let cells = vec![Cell::Char {
2998 ch: 'h',
2999 width: 1,
3000 style: crate::ansi::Style {
3001 fg: Some(crate::ansi::Color::Ansi(1)),
3002 ..Default::default()
3003 },
3004 hyperlink: None,
3005 }];
3006 let mut buf: Vec<u8> = Vec::new();
3007 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3008 let s = String::from_utf8_lossy(&buf);
3009 assert!(s.contains("\x1b["), "expected ANSI escape in output: {s:?}");
3010 assert!(s.contains('h'));
3011 }
3012
3013 #[test]
3014 fn writer_emits_osc8_for_hyperlink_cell() {
3015 let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
3016 let cells = vec![Cell::Char {
3017 ch: 'c',
3018 width: 1,
3019 style: crate::ansi::Style::default(),
3020 hyperlink: Some(link),
3021 }];
3022 let mut buf: Vec<u8> = Vec::new();
3023 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3024 let s = String::from_utf8_lossy(&buf);
3025 assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
3026 }
3027}