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 VSplit(Option<String>),
114 Only,
116}
117
118#[derive(Debug, Clone, PartialEq)]
119enum ColonParseError {
120 UnknownCommand(String),
121 MissingPath,
122 TagRequiresName,
123 HexGroupRequiresValue,
124 HexGroupInvalid(String),
125 ColorInvalid(String),
126 CaseInvalid(String),
127 HeaderInvalid(String),
128}
129
130impl std::fmt::Display for ColonParseError {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 match self {
133 ColonParseError::UnknownCommand(t) => write!(f, "unknown command: :{t}"),
134 ColonParseError::MissingPath => write!(f, ":e requires a path"),
135 ColonParseError::TagRequiresName => write!(f, ":tag requires a name"),
136 ColonParseError::HexGroupRequiresValue => {
137 write!(f, ":hex requires N (one of 2, 4, 8, 16, 32)")
138 }
139 ColonParseError::HexGroupInvalid(v) => {
140 write!(f, ":hex N must be one of 2, 4, 8, 16, 32 (got {v})")
141 }
142 ColonParseError::ColorInvalid(v) => {
143 write!(f, ":color mode must be strict, interpret, or raw (got {v})")
144 }
145 ColonParseError::CaseInvalid(v) => {
146 write!(f, ":case mode must be sensitive, smart, or insensitive (got {v})")
147 }
148 ColonParseError::HeaderInvalid(v) => {
149 write!(f, ":header expects `L` or `L C` (got {v})")
150 }
151 }
152 }
153}
154
155fn parse_colon_command(buf: &str) -> std::result::Result<ColonCommand, ColonParseError> {
156 let buf = buf.trim();
157 if buf.is_empty() {
158 return Err(ColonParseError::UnknownCommand(String::new()));
159 }
160 let mut parts = buf.splitn(2, char::is_whitespace);
161 let cmd = parts.next().unwrap();
162 let rest = parts.next().unwrap_or("").trim();
163 match cmd {
164 "n" | "next" => Ok(ColonCommand::Next),
165 "p" | "prev" => Ok(ColonCommand::Prev),
166 "e" | "edit" => {
167 if rest.is_empty() {
168 Err(ColonParseError::MissingPath)
169 } else {
170 Ok(ColonCommand::Edit(expand_tilde(rest)))
171 }
172 }
173 "f" => Ok(ColonCommand::ShowFile),
174 "q" | "quit" => Ok(ColonCommand::Quit),
175 "d" | "delete" => Ok(ColonCommand::Delete),
176 "x" | "first" => Ok(ColonCommand::First),
177 "t" | "last" => Ok(ColonCommand::Last),
178 "tag" => {
179 if rest.is_empty() {
180 Err(ColonParseError::TagRequiresName)
181 } else {
182 Ok(ColonCommand::Tag(rest.to_string()))
183 }
184 }
185 "tnext" => Ok(ColonCommand::TagNext),
186 "tprev" => Ok(ColonCommand::TagPrev),
187 "tselect" => {
188 if rest.is_empty() {
189 Ok(ColonCommand::TagSelect(None))
190 } else {
191 Ok(ColonCommand::TagSelect(Some(rest.to_string())))
192 }
193 }
194 "b" | "buffers" => Ok(ColonCommand::OpenPicker),
195 "h" | "help" => Ok(ColonCommand::OpenHelp),
196 "hex" => {
197 if rest.is_empty() {
198 Err(ColonParseError::HexGroupRequiresValue)
199 } else {
200 match rest.parse::<usize>() {
201 Ok(n) if matches!(n, 2 | 4 | 8 | 16 | 32) => Ok(ColonCommand::HexGroup(n)),
202 _ => Err(ColonParseError::HexGroupInvalid(rest.to_string())),
203 }
204 }
205 }
206 "color" => {
207 if rest.is_empty() {
208 Ok(ColonCommand::Color(None))
209 } else {
210 match rest {
211 "strict" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Strict))),
212 "interpret" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Interpret))),
213 "raw" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Raw))),
214 other => Err(ColonParseError::ColorInvalid(other.to_string())),
215 }
216 }
217 }
218 "vsplit" | "split" => {
219 if rest.is_empty() {
220 Ok(ColonCommand::VSplit(None))
221 } else {
222 Ok(ColonCommand::VSplit(Some(rest.to_string())))
223 }
224 }
225 "only" | "close" => Ok(ColonCommand::Only),
226 "hlsearch" => Ok(ColonCommand::HlSearch(true)),
227 "nohlsearch" => Ok(ColonCommand::HlSearch(false)),
228 "incsearch" => Ok(ColonCommand::IncSearch),
229 "yank" => Ok(ColonCommand::Yank),
230 "header" => {
231 let parts: Vec<&str> = rest.split_whitespace().collect();
232 match parts.as_slice() {
233 [l] => {
234 let n: usize = l.parse()
235 .map_err(|_| ColonParseError::HeaderInvalid(l.to_string()))?;
236 Ok(ColonCommand::Header(n, 0))
237 }
238 [l, c] => {
239 let nl: usize = l.parse()
240 .map_err(|_| ColonParseError::HeaderInvalid(l.to_string()))?;
241 let nc: usize = c.parse()
242 .map_err(|_| ColonParseError::HeaderInvalid(c.to_string()))?;
243 Ok(ColonCommand::Header(nl, nc))
244 }
245 _ => Err(ColonParseError::HeaderInvalid(rest.to_string())),
246 }
247 }
248 "case" => {
249 if rest.is_empty() {
250 Ok(ColonCommand::Case(None))
251 } else {
252 match rest {
253 "sensitive" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Sensitive))),
254 "smart" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Smart))),
255 "insensitive" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Insensitive))),
256 other => Err(ColonParseError::CaseInvalid(other.to_string())),
257 }
258 }
259 }
260 other => Err(ColonParseError::UnknownCommand(other.to_string())),
261 }
262}
263
264enum ColonOutcome {
265 Continue(Option<String>), Quit,
267 DispatchCommand(Command),
271}
272
273#[derive(Debug, Default)]
274struct TagStack {
275 history: Vec<(usize, usize)>,
278 active: Option<ActiveMatches>,
281}
282
283#[derive(Debug, Clone)]
284struct ActiveMatches {
285 name: String,
286 matches: Vec<crate::tags::TagEntry>,
287 cursor: usize,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
291enum TagStepResult {
292 Moved(usize),
294 AtBoundary,
296 NoActive,
298}
299
300impl TagStack {
301 fn push(&mut self, file_index: usize, top_line: usize) {
302 self.history.push((file_index, top_line));
303 }
304
305 fn pop(&mut self) -> Option<(usize, usize)> {
306 let popped = self.history.pop();
307 if popped.is_some() {
308 self.active = None;
309 }
310 popped
311 }
312
313 fn set_active(&mut self, name: String, matches: Vec<crate::tags::TagEntry>) {
314 self.active = Some(ActiveMatches {
315 name,
316 matches,
317 cursor: 0,
318 });
319 }
320
321 fn next(&mut self) -> TagStepResult {
322 let Some(a) = &mut self.active else {
323 return TagStepResult::NoActive;
324 };
325 if a.cursor + 1 >= a.matches.len() {
326 TagStepResult::AtBoundary
327 } else {
328 a.cursor += 1;
329 TagStepResult::Moved(a.cursor)
330 }
331 }
332
333 fn prev(&mut self) -> TagStepResult {
334 let Some(a) = &mut self.active else {
335 return TagStepResult::NoActive;
336 };
337 if a.cursor == 0 {
338 TagStepResult::AtBoundary
339 } else {
340 a.cursor -= 1;
341 TagStepResult::Moved(a.cursor)
342 }
343 }
344}
345
346fn refresh_tag_file(tag_file: &mut Option<crate::tags::TagFile>) -> Option<String> {
352 match tag_file.as_mut()?.reload_if_changed() {
353 Ok(true) => Some("[tags reloaded]".into()),
354 _ => None,
355 }
356}
357
358fn longest_common_prefix(items: &[String]) -> String {
362 let mut iter = items.iter();
363 let Some(first) = iter.next() else { return String::new() };
364 let mut prefix = first.clone();
365 for s in iter {
366 while !s.starts_with(&prefix) {
367 prefix.pop();
368 if prefix.is_empty() {
369 return prefix;
370 }
371 }
372 }
373 prefix
374}
375
376#[allow(clippy::too_many_arguments)]
379fn dispatch_tag_jump(
380 name: &str,
381 tag_file: Option<&crate::tags::TagFile>,
382 tag_stack: &mut TagStack,
383 file_set: &mut crate::file_set::FileSet,
384 current_file_index: &mut usize,
385 args: &crate::cli::Args,
386 preprocessor: Option<&crate::preprocess::Preprocessor>,
387 record_start_regex: Option<®ex::bytes::Regex>,
388 viewport: &mut crate::viewport::Viewport,
389 src: &mut Box<dyn crate::source::Source>,
390 idx: &mut crate::line_index::LineIndex,
391) -> Option<String> {
392 let Some(tf) = tag_file else {
393 return Some("[no tags file loaded]".into());
394 };
395 let matches = tf.lookup(name);
396 if matches.is_empty() {
397 return Some(format!("[tag not found: {name}]"));
398 }
399 let matches: Vec<crate::tags::TagEntry> = matches.to_vec();
400 tag_stack.push(*current_file_index, viewport.top_line());
401 tag_stack.set_active(name.to_string(), matches.clone());
402 let msg = dispatch_match(
403 &matches[0],
404 file_set,
405 current_file_index,
406 args,
407 preprocessor,
408 record_start_regex,
409 viewport,
410 src,
411 idx,
412 );
413 update_viewport_tag_indicator(tag_stack, viewport);
414 msg
415}
416
417#[allow(clippy::too_many_arguments)]
418fn dispatch_match(
419 entry: &crate::tags::TagEntry,
420 file_set: &mut crate::file_set::FileSet,
421 current_file_index: &mut usize,
422 args: &crate::cli::Args,
423 preprocessor: Option<&crate::preprocess::Preprocessor>,
424 record_start_regex: Option<®ex::bytes::Regex>,
425 viewport: &mut crate::viewport::Viewport,
426 src: &mut Box<dyn crate::source::Source>,
427 idx: &mut crate::line_index::LineIndex,
428) -> Option<String> {
429 let target_file = entry.file.as_path();
430 let already_current = file_set
431 .current()
432 .map(|p| p == target_file)
433 .unwrap_or(false);
434
435 if !already_current {
436 let existing_idx = (0..file_set.len()).find(|i| {
437 file_set
438 .nth(*i)
439 .map(|p| p == target_file)
440 .unwrap_or(false)
441 });
442 match existing_idx {
443 Some(i) => {
444 file_set.set_current_index(i);
445 }
446 None => {
447 file_set.append_and_switch(target_file.to_path_buf());
448 }
449 }
450 let path = file_set.current().unwrap().to_path_buf();
451 if let Err(e) = switch_file(
452 &path,
453 file_set.current_index(),
454 file_set.len(),
455 args,
456 preprocessor,
457 viewport,
458 src,
459 idx,
460 record_start_regex,
461 ) {
462 return Some(format!("[open: {e}]"));
463 }
464 *current_file_index = file_set.current_index();
465 }
466
467 let (line, hint) = match resolve_tag_address(&entry.address, src.as_ref(), idx, 0) {
468 AddressResult::Line(l) => (l, None),
469 AddressResult::NotFound => (0, Some("[tag pattern not found]".into())),
470 AddressResult::Unsupported(raw) => (
471 0,
472 Some(format!("[tag address not supported: {raw}]")),
473 ),
474 };
475
476 let clamped = line.min(idx.line_count().saturating_sub(1));
477 viewport.goto_line(clamped, src.as_ref(), idx);
478 hint
479}
480
481enum AddressResult {
482 Line(usize),
483 NotFound,
484 Unsupported(String),
485}
486
487fn resolve_tag_address(
491 addr: &crate::tags::TagAddress,
492 src: &dyn crate::source::Source,
493 idx: &mut crate::line_index::LineIndex,
494 from_line: usize,
495) -> AddressResult {
496 match addr {
497 crate::tags::TagAddress::Line(n) => AddressResult::Line(n.saturating_sub(1)),
498 crate::tags::TagAddress::Pattern(p) => {
499 let re_src = crate::tags::pattern_to_regex(p);
500 let re = match regex::bytes::Regex::new(&re_src) {
501 Ok(r) => r,
502 Err(_) => return AddressResult::NotFound,
503 };
504 match find_pattern_line(src, idx, &re, from_line) {
505 Some(l) => AddressResult::Line(l),
506 None => AddressResult::NotFound,
507 }
508 }
509 crate::tags::TagAddress::Chained(parts) => {
510 let mut here = from_line;
511 for step in parts {
512 match resolve_tag_address(step, src, idx, here) {
513 AddressResult::Line(l) => here = l + 1,
514 other => return other,
515 }
516 }
517 AddressResult::Line(here.saturating_sub(1))
520 }
521 crate::tags::TagAddress::Unsupported(raw) => {
522 AddressResult::Unsupported(raw.clone())
523 }
524 }
525}
526
527fn find_pattern_line(
528 src: &dyn crate::source::Source,
529 idx: &mut crate::line_index::LineIndex,
530 re: ®ex::bytes::Regex,
531 from_line: usize,
532) -> Option<usize> {
533 idx.extend_to_end(src);
534 for line_no in from_line..idx.line_count() {
535 let bytes = idx.line_bytes_stripped(line_no, src);
536 if re.is_match(&bytes) {
537 return Some(line_no);
538 }
539 }
540 None
541}
542
543fn update_viewport_tag_indicator(stack: &TagStack, viewport: &mut crate::viewport::Viewport) {
544 viewport.set_tag_active(stack.active.as_ref().map(|a| {
545 (a.name.clone(), a.cursor + 1, a.matches.len())
546 }));
547}
548
549#[allow(clippy::too_many_arguments)]
553fn switch_to_current_file(
554 file_set: &mut crate::file_set::FileSet,
555 current_file_index: &mut usize,
556 args: &crate::cli::Args,
557 preprocessor: Option<&crate::preprocess::Preprocessor>,
558 record_start_regex: Option<®ex::bytes::Regex>,
559 viewport: &mut crate::viewport::Viewport,
560 src: &mut Box<dyn crate::source::Source>,
561 idx: &mut crate::line_index::LineIndex,
562) -> Option<String> {
563 let path = match file_set.current() {
564 Some(p) => p.to_path_buf(),
565 None => return Some("[empty file set]".into()),
566 };
567 let new_idx_val = file_set.current_index();
568 match switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
569 Ok(()) => {
570 *current_file_index = new_idx_val;
571 None
572 }
573 Err(e) => Some(format!("[open: {e}]")),
574 }
575}
576
577#[allow(clippy::too_many_arguments)]
578fn switch_file(
579 new_path: &std::path::Path,
580 new_file_index: usize,
581 total_files: usize,
582 args: &crate::cli::Args,
583 preprocessor: Option<&crate::preprocess::Preprocessor>,
584 viewport: &mut crate::viewport::Viewport,
585 src: &mut Box<dyn crate::source::Source>,
586 idx: &mut crate::line_index::LineIndex,
587 record_start_regex: Option<®ex::bytes::Regex>,
588) -> crate::error::Result<()> {
589 let (new_src, new_label, new_failure) =
590 crate::open::open_source_for_path(new_path, args, preprocessor)?;
591
592 *src = new_src;
593 let mut new_idx = crate::line_index::LineIndex::new();
594 if let Some(re) = record_start_regex {
595 new_idx.set_record_start(re.clone());
596 }
597 *idx = new_idx;
598
599 viewport.set_source_label(new_label);
600 viewport.set_file_index(new_file_index, total_files);
601 viewport.set_preprocess_failure(new_failure);
602 viewport.goto_top();
603 viewport.reset_hscroll(); Ok(())
606}
607
608fn yank_current_line(
613 clipboard_enabled: bool,
614 viewport: &crate::viewport::Viewport,
615 src: &dyn crate::source::Source,
616 idx: &mut crate::line_index::LineIndex,
617) -> String {
618 if !clipboard_enabled {
619 return "[clipboard not enabled (pass --clipboard)]".to_string();
620 }
621 if idx.line_count() == 0 {
622 return "[nothing to copy]".to_string();
623 }
624 let line = viewport.top_line();
625 let bytes = current_line_bytes(idx, src, line);
626 match crate::clipboard::write(&bytes) {
627 Ok(()) => format!("[copied {} bytes]", bytes.len()),
628 Err(e) => format!("[{e}]"),
629 }
630}
631
632fn current_line_bytes(
636 idx: &crate::line_index::LineIndex,
637 src: &dyn crate::source::Source,
638 line: usize,
639) -> Vec<u8> {
640 let range = idx.line_range(line, src);
641 src.bytes(range).into_owned()
642}
643
644#[allow(clippy::too_many_arguments)]
645fn dispatch_colon_command(
646 cmd: ColonCommand,
647 file_set: &mut crate::file_set::FileSet,
648 current_file_index: &mut usize,
649 args: &crate::cli::Args,
650 preprocessor: Option<&crate::preprocess::Preprocessor>,
651 record_start_regex: Option<®ex::bytes::Regex>,
652 viewport: &mut crate::viewport::Viewport,
653 src: &mut Box<dyn crate::source::Source>,
654 idx: &mut crate::line_index::LineIndex,
655 tag_stack: &mut TagStack,
656 tag_file: Option<&crate::tags::TagFile>,
657) -> ColonOutcome {
658 match cmd {
659 ColonCommand::Next => {
660 match file_set.next() {
661 Ok(path) => {
662 let path = path.to_path_buf();
663 let new_idx_val = file_set.current_index();
664 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
665 ColonOutcome::Continue(Some(format!("[open: {e}]")))
666 } else {
667 *current_file_index = new_idx_val;
668 ColonOutcome::Continue(None)
669 }
670 }
671 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
672 }
673 }
674 ColonCommand::Prev => {
675 match file_set.prev() {
676 Ok(path) => {
677 let path = path.to_path_buf();
678 let new_idx_val = file_set.current_index();
679 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
680 ColonOutcome::Continue(Some(format!("[open: {e}]")))
681 } else {
682 *current_file_index = new_idx_val;
683 ColonOutcome::Continue(None)
684 }
685 }
686 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
687 }
688 }
689 ColonCommand::Edit(path) => {
690 match crate::open::open_source_for_path(&path, args, preprocessor) {
692 Ok(_) => {
693 let final_path = file_set.append_and_switch(path.clone()).to_path_buf();
695 let new_idx_val = file_set.current_index();
696 if let Err(e) = switch_file(&final_path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
697 ColonOutcome::Continue(Some(format!("[open: {e}]")))
698 } else {
699 *current_file_index = new_idx_val;
700 ColonOutcome::Continue(None)
701 }
702 }
703 Err(e) => ColonOutcome::Continue(Some(format!("[open: {}: {e}]", path.display()))),
704 }
705 }
706 ColonCommand::ShowFile => {
707 let label = viewport.source_label_clone();
708 let cur = file_set.current_index() + 1;
709 let total = file_set.len();
710 let top = viewport.top_line() + 1;
711 let total_lines = idx.line_count();
712 let msg = if total > 1 {
713 format!("{label} (file {cur}/{total}): line {top}/{total_lines}")
714 } else {
715 format!("{label}: line {top}/{total_lines}")
716 };
717 ColonOutcome::Continue(Some(msg))
718 }
719 ColonCommand::Quit => ColonOutcome::Quit,
720 ColonCommand::Delete => {
721 match file_set.delete_current() {
722 Ok(path) => {
723 let path = path.to_path_buf();
724 let new_idx_val = file_set.current_index();
725 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
726 ColonOutcome::Continue(Some(format!("[open: {e}]")))
727 } else {
728 *current_file_index = new_idx_val;
729 ColonOutcome::Continue(None)
730 }
731 }
732 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
733 }
734 }
735 ColonCommand::First => {
736 if file_set.current_index() == 0 {
737 ColonOutcome::Continue(None) } else if let Some(path) = file_set.first() {
739 let path = path.to_path_buf();
740 let new_idx_val = file_set.current_index();
741 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
742 ColonOutcome::Continue(Some(format!("[open: {e}]")))
743 } else {
744 *current_file_index = new_idx_val;
745 ColonOutcome::Continue(None)
746 }
747 } else {
748 ColonOutcome::Continue(None)
749 }
750 }
751 ColonCommand::Last => {
752 if file_set.current_index() + 1 == file_set.len() {
753 ColonOutcome::Continue(None)
754 } else if let Some(path) = file_set.last() {
755 let path = path.to_path_buf();
756 let new_idx_val = file_set.current_index();
757 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
758 ColonOutcome::Continue(Some(format!("[open: {e}]")))
759 } else {
760 *current_file_index = new_idx_val;
761 ColonOutcome::Continue(None)
762 }
763 } else {
764 ColonOutcome::Continue(None)
765 }
766 }
767 ColonCommand::Tag(name) => {
768 match dispatch_tag_jump(
769 &name,
770 tag_file,
771 tag_stack,
772 file_set,
773 current_file_index,
774 args,
775 preprocessor,
776 record_start_regex,
777 viewport,
778 src,
779 idx,
780 ) {
781 Some(msg) => ColonOutcome::Continue(Some(msg)),
782 None => ColonOutcome::Continue(None),
783 }
784 }
785 ColonCommand::TagNext => match tag_stack.next() {
786 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
787 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[no more matches]".into())),
788 TagStepResult::Moved(cur) => {
789 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
790 let msg = dispatch_match(
791 &entry,
792 file_set,
793 current_file_index,
794 args,
795 preprocessor,
796 record_start_regex,
797 viewport,
798 src,
799 idx,
800 );
801 update_viewport_tag_indicator(tag_stack, viewport);
802 ColonOutcome::Continue(msg)
803 }
804 },
805 ColonCommand::TagSelect(name) => {
806 let prepared = match name {
807 Some(n) => {
808 let tf = match tag_file {
809 Some(t) => t,
810 None => {
811 return ColonOutcome::Continue(Some(
812 "[no tags file loaded]".into(),
813 ))
814 }
815 };
816 let matches: Vec<crate::tags::TagEntry> = tf.lookup(&n).to_vec();
817 if matches.is_empty() {
818 return ColonOutcome::Continue(Some(
819 format!("[no matches for `{n}`]"),
820 ));
821 }
822 tag_stack.set_active(n, matches);
823 true
824 }
825 None => tag_stack.active.is_some(),
826 };
827 if prepared {
828 ColonOutcome::DispatchCommand(Command::OpenTagPicker)
829 } else {
830 ColonOutcome::Continue(Some("[no active tag]".into()))
831 }
832 }
833 ColonCommand::TagPrev => match tag_stack.prev() {
834 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
835 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[at first match]".into())),
836 TagStepResult::Moved(cur) => {
837 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
838 let msg = dispatch_match(
839 &entry,
840 file_set,
841 current_file_index,
842 args,
843 preprocessor,
844 record_start_regex,
845 viewport,
846 src,
847 idx,
848 );
849 update_viewport_tag_indicator(tag_stack, viewport);
850 ColonOutcome::Continue(msg)
851 }
852 },
853 ColonCommand::OpenPicker => ColonOutcome::DispatchCommand(Command::OpenPicker),
856 ColonCommand::OpenHelp => ColonOutcome::DispatchCommand(Command::OpenHelp),
857 ColonCommand::HexGroup(hex_chars) => {
858 if !viewport.hex_mode() {
859 return ColonOutcome::Continue(Some(
860 "[:hex requires --hex mode]".into(),
861 ));
862 }
863 let bpg = crate::hex::hex_chars_to_bytes_per_group(hex_chars).unwrap();
865 viewport.set_hex_group_size(bpg);
866 ColonOutcome::Continue(Some(format!("[hex group: {hex_chars} chars]")))
867 }
868 ColonCommand::Color(mode) => {
869 use crate::render::AnsiMode;
870 let next = mode.unwrap_or_else(|| match viewport.ansi_mode() {
871 AnsiMode::Strict => AnsiMode::Interpret,
872 AnsiMode::Interpret => AnsiMode::Raw,
873 AnsiMode::Raw => AnsiMode::Strict,
874 });
875 viewport.set_ansi_mode(next);
876 let label = match next {
877 AnsiMode::Strict => "strict",
878 AnsiMode::Interpret => "interpret",
879 AnsiMode::Raw => "raw",
880 };
881 ColonOutcome::Continue(Some(format!("[color: {label}]")))
882 }
883 ColonCommand::Header(l, c) => {
884 viewport.set_header(l, c);
885 ColonOutcome::Continue(Some(format!("[header: {l} rows, {c} cols]")))
886 }
887 ColonCommand::HlSearch(on) => {
888 viewport.set_hilite_search(on);
889 let msg = if on { "[hlsearch on]" } else { "[hlsearch off]" };
890 ColonOutcome::Continue(Some(msg.into()))
891 }
892 ColonCommand::IncSearch => {
893 let on = !viewport.incsearch();
894 viewport.set_incsearch(on);
895 let msg = if on { "[incsearch on]" } else { "[incsearch off]" };
896 ColonOutcome::Continue(Some(msg.into()))
897 }
898 ColonCommand::Yank => {
899 ColonOutcome::Continue(Some(yank_current_line(args.clipboard, viewport, src.as_ref(), idx)))
900 }
901 ColonCommand::Case(mode) => {
902 use crate::viewport::CaseMode;
903 let next = mode.unwrap_or_else(|| match viewport.case_mode() {
904 CaseMode::Sensitive => CaseMode::Smart,
905 CaseMode::Smart => CaseMode::Insensitive,
906 CaseMode::Insensitive => CaseMode::Sensitive,
907 });
908 viewport.set_case_mode(next);
909 let label = match next {
910 CaseMode::Sensitive => "sensitive",
911 CaseMode::Smart => "smart",
912 CaseMode::Insensitive => "insensitive",
913 };
914 ColonOutcome::Continue(Some(format!("[case: {label}]")))
915 }
916 ColonCommand::VSplit(_) | ColonCommand::Only => {
920 unreachable!("split commands are handled in the run() event loop")
921 }
922 }
923}
924
925fn force_cell_mode(vp: &mut Viewport) {
928 #[cfg(feature = "image")]
929 vp.set_image_protocol(crate::viewport::ImageProtocol::Ascii, None);
930 vp.set_ansi_mode_cells();
931}
932
933fn other_pane_init(
936 second: Option<crate::pane::Pane>,
937 focused_vp: &mut Viewport,
938 cols: u16,
939 rows: u16,
940) -> Option<crate::pane::Pane> {
941 let mut other = second?;
942 let (lw, rw) = crate::pane::split_widths(cols);
943 if rw == 0 {
944 focused_vp.resize(cols, rows);
945 } else {
946 focused_vp.resize(lw, rows);
947 other.viewport.resize(rw, rows);
948 }
949 force_cell_mode(focused_vp);
950 force_cell_mode(&mut other.viewport);
951 Some(other)
952}
953
954fn resize_split_aware(
958 focused_vp: &mut Viewport,
959 other_pane: &mut Option<crate::pane::Pane>,
960 cols: u16,
961 rows: u16,
962 focused_left: bool,
963) {
964 if let Some(other) = other_pane.as_mut() {
965 let (lw, rw) = crate::pane::split_widths(cols);
966 if rw == 0 {
967 focused_vp.resize(cols, rows);
968 } else {
969 let (fw, ow) = if focused_left { (lw, rw) } else { (rw, lw) };
970 focused_vp.resize(fw, rows);
971 other.viewport.resize(ow, rows);
972 }
973 } else {
974 focused_vp.resize(cols, rows);
975 }
976}
977
978fn expand_tilde(arg: &str) -> std::path::PathBuf {
981 if let Some(stripped) = arg.strip_prefix("~/") {
982 if let Some(home) = std::env::var_os("HOME") {
983 let mut p = std::path::PathBuf::from(home);
984 p.push(stripped);
985 return p;
986 }
987 }
988 std::path::PathBuf::from(arg)
989}
990
991pub fn apply_pane_display_config(viewport: &mut Viewport, args: &crate::cli::Args) {
1000 if args.line_numbers { viewport.toggle_line_numbers(); }
1001 if args.chop { viewport.toggle_chop(); }
1002 viewport.opts.tab_width = args.tab_width;
1003 viewport.set_follow_mode(args.follow);
1004 viewport.set_live_mode(args.live);
1005 if args.hex {
1006 viewport.set_hex_mode(true);
1007 if let Some(bpg) = crate::hex::hex_chars_to_bytes_per_group(args.hex_group) {
1008 viewport.set_hex_group_size(bpg);
1009 }
1010 }
1011 viewport.set_squeeze_blanks(args.squeeze_blanks);
1012 viewport.set_status_column(args.status_column);
1013 viewport.opts.rscroll_char = args.rscroll.chars().next();
1014 viewport.opts.word_wrap = args.word_wrap;
1015 viewport.set_page_size(args.window);
1016 viewport.set_file_index(0, 1);
1017}
1018
1019#[allow(clippy::too_many_arguments)]
1024fn build_runtime_pane(
1025 path_arg: Option<&str>,
1026 focused_path: Option<&std::path::Path>,
1027 focused_top: usize,
1028 cols: u16,
1029 rows: u16,
1030 args: &crate::cli::Args,
1031 ansi_mode: crate::render::AnsiMode,
1032 preprocessor: Option<&crate::preprocess::Preprocessor>,
1033 record_start_regex: Option<®ex::bytes::Regex>,
1034) -> crate::error::Result<crate::pane::Pane> {
1035 let (path, dup_top): (std::path::PathBuf, Option<usize>) = match path_arg {
1037 Some(arg) => (expand_tilde(arg), None),
1038 None => match focused_path {
1039 Some(p) => (p.to_path_buf(), Some(focused_top)),
1040 None => {
1041 return Err(crate::error::Error::Runtime(
1042 "can't duplicate stdin; give a file".to_string(),
1043 ));
1044 }
1045 },
1046 };
1047
1048 let (src, label, preprocess_failure) =
1049 crate::open::open_source_for_path(&path, args, preprocessor)?;
1050
1051 let mut idx = crate::line_index::LineIndex::new();
1052 if let Some(re) = record_start_regex {
1053 idx.set_record_start(re.clone());
1054 }
1055
1056 let mut viewport = Viewport::new(cols, rows, label);
1057 apply_pane_display_config(&mut viewport, args);
1058 viewport.set_ansi_mode(ansi_mode);
1059 viewport.set_preprocess_failure(preprocess_failure);
1060 if let Some(top) = dup_top {
1061 viewport.goto_line(top, src.as_ref(), &mut idx);
1062 }
1063
1064 Ok(crate::pane::Pane {
1065 last_revision: src.revision(),
1066 #[cfg(feature = "image")]
1067 last_tick: std::time::Instant::now(),
1068 src,
1069 idx,
1070 viewport,
1071 })
1072}
1073
1074#[allow(clippy::too_many_arguments, clippy::collapsible_match)]
1075pub fn run(
1076 mut src: Box<dyn Source>,
1077 mut viewport: Viewport,
1078 mut idx: LineIndex,
1079 sigterm: Arc<AtomicBool>,
1080 rebuild_spec: RebuildSpec,
1081 keymap: crate::keys::KeyMap,
1082 mut file_set: crate::file_set::FileSet,
1083 record_start_regex: Option<regex::bytes::Regex>,
1084 args: crate::cli::Args,
1085 preprocessor: Option<crate::preprocess::Preprocessor>,
1086 mut tag_file: Option<crate::tags::TagFile>,
1087 second_pane: Option<crate::pane::Pane>,
1088 #[cfg(feature = "image")]
1089 startup_image_protocol: (crate::viewport::ImageProtocol, Option<(u16, u16)>),
1090) -> Result<()> {
1091 let (mut cols, mut rows) = size().unwrap_or((80, 24));
1092 viewport.resize(cols, rows);
1093
1094 let truecolor = match args.truecolor.as_str() {
1095 "always" => true,
1096 "never" => false,
1097 _ => crate::render::TrueColor::Auto.resolve(),
1098 };
1099
1100 let mut stdout = io::stdout();
1101 const BASE_POLL: Duration = Duration::from_millis(250);
1102 #[cfg(feature = "image")]
1103 let mut last_tick = std::time::Instant::now();
1104 let mut last_revision = src.revision();
1105
1106 let mut other_pane = other_pane_init(second_pane, &mut viewport, cols, rows);
1107 let mut focused_left = true;
1111
1112 if (viewport.filter_active() || viewport.grep_active()) && !viewport.dim_mode() {
1117 idx.extend_to_end(src.as_ref());
1118 viewport.extend_visible_lines(&idx, src.as_ref());
1119 }
1120
1121 if viewport.follow_mode() || viewport.live_mode() {
1126 src.pump();
1127 viewport.extend_visible_lines(&idx, src.as_ref());
1128 viewport.goto_bottom(src.as_ref(), &mut idx);
1129 }
1130
1131 let mut needs_redraw = true;
1133 let mut mode = InputMode::Normal;
1134 let mut numeric_prefix: Option<usize> = None;
1135 let mut marks: HashMap<char, (usize, usize)> = HashMap::new();
1136 let mut previous_position: Option<(usize, usize)> = None;
1137 let mut incsearch_origin: (usize, usize) = (0, 0);
1140 let mut current_file_index: usize = file_set.current_index();
1141 let mut transient_status: Option<String> = None;
1142 let mut tag_stack = TagStack::default();
1143 let mut overlay: Option<Box<dyn crate::overlay::Overlay>> = None;
1144 let mut overlay_flash: Option<(&'static str, std::time::Instant)> = None;
1145 let mouse_enabled = args.mouse;
1146 let clipboard_enabled = args.clipboard;
1147 let hscroll_shift = args.shift.unwrap_or(0);
1148 let wheel_lines = args.wheel_lines.unwrap_or(3).max(1);
1149
1150 if let Some(tag_name) = args.tag.as_deref() {
1151 let _ = refresh_tag_file(&mut tag_file);
1152 if let Some(msg) = dispatch_tag_jump(
1153 tag_name,
1154 tag_file.as_ref(),
1155 &mut tag_stack,
1156 &mut file_set,
1157 &mut current_file_index,
1158 &args,
1159 preprocessor.as_ref(),
1160 record_start_regex.as_ref(),
1161 &mut viewport,
1162 &mut src,
1163 &mut idx,
1164 ) {
1165 return Err(crate::error::Error::Runtime(format!("startup tag jump failed: {msg}")));
1166 }
1167 }
1168
1169 loop {
1170 if sigterm.load(Ordering::SeqCst) {
1171 break;
1172 }
1173
1174 if needs_redraw {
1175 if let Some(ov) = overlay.as_ref() {
1176 let w = cols;
1177 let h = viewport.body_rows() + 1;
1178 let mut ovframe = ov.render(w, h);
1179 if let Some((msg, started)) = overlay_flash {
1180 if started.elapsed() < std::time::Duration::from_millis(1500) {
1181 ovframe.status = format!("[{msg}]");
1182 } else {
1183 overlay_flash = None;
1184 }
1185 }
1186 render_overlay(&mut stdout, &ovframe, w, h)
1187 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1188 needs_redraw = false;
1189 continue;
1190 }
1191 if viewport.status_column() {
1195 let status_marks: HashMap<usize, char> = marks
1196 .iter()
1197 .filter(|(_, (fi, _))| *fi == current_file_index)
1198 .map(|(ch, (_, line))| (*line, *ch))
1199 .collect();
1200 viewport.set_status_marks(status_marks);
1201 }
1202 let mut frame = if let Some(other) = other_pane.as_mut() {
1203 let (lw, rw) = crate::pane::split_widths(cols);
1204 if rw == 0 {
1205 viewport.frame(src.as_ref(), &mut idx)
1206 } else {
1207 let ffr = viewport.frame(src.as_ref(), &mut idx);
1208 let ofr = other.viewport.frame(other.src.as_ref(), &mut other.idx);
1209 let (left_fr, right_fr) = if focused_left {
1213 (&ffr, &ofr)
1214 } else {
1215 (&ofr, &ffr)
1216 };
1217 crate::pane::compose_split(left_fr, right_fr, lw, cols, focused_left)
1218 }
1219 } else {
1220 viewport.frame(src.as_ref(), &mut idx)
1221 };
1222 match &mode {
1225 InputMode::SearchPrompt { direction, buffer, error } => {
1226 let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
1227 frame.status = match error {
1228 Some(e) => format!("{prefix}{buffer} [error: {e}]"),
1229 None => format!("{prefix}{buffer}"),
1230 };
1231 }
1232 InputMode::ShellPrompt { buffer, error } => {
1233 frame.status = match error {
1234 Some(e) => format!("!{buffer} [error: {e}]"),
1235 None => format!("!{buffer}"),
1236 };
1237 }
1238 InputMode::ColonPrompt { buffer, error } => {
1239 frame.status = match error {
1240 Some(e) => format!(":{buffer} [error: {e}]"),
1241 None => format!(":{buffer}"),
1242 };
1243 }
1244 InputMode::TagPrompt { buffer, error, .. } => {
1245 frame.status = match error {
1246 Some(e) => format!("tag: {buffer} [error: {e}]"),
1247 None => format!("tag: {buffer}"),
1248 };
1249 }
1250 _ => {
1251 if let Some(msg) = transient_status.take() {
1252 frame.status = msg;
1253 }
1254 }
1255 }
1256 write_frame(&mut stdout, &frame, cols, rows, truecolor)
1257 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1258 needs_redraw = false;
1259 }
1260
1261 #[cfg(feature = "image")]
1265 let timeout = {
1266 let mut d = viewport.anim_deadline();
1267 if let Some(other) = other_pane.as_ref() {
1268 d = match (d, other.viewport.anim_deadline()) {
1269 (Some(a), Some(b)) => Some(a.min(b)),
1270 (a, b) => a.or(b),
1271 };
1272 }
1273 d.map(|x| x.min(BASE_POLL)).unwrap_or(BASE_POLL)
1274 };
1275 #[cfg(not(feature = "image"))]
1276 let timeout = BASE_POLL;
1277 match poll(timeout) {
1278 Ok(true) => {
1279 let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
1280 match &mut mode {
1283 InputMode::SearchPrompt { direction, buffer, error } => {
1284 if let Event::Key(KeyEvent { code, .. }) = event {
1285 match code {
1286 KeyCode::Esc => {
1287 if viewport.incsearch() {
1288 viewport.set_top(incsearch_origin.0, incsearch_origin.1);
1289 }
1290 mode = InputMode::Normal;
1291 needs_redraw = true;
1292 }
1293 KeyCode::Enter => {
1294 if viewport.incsearch() {
1295 viewport.set_top(incsearch_origin.0, incsearch_origin.1);
1296 }
1297 if buffer.is_empty() {
1298 if viewport.search_active() {
1302 let reverse = !matches!(
1303 (viewport.search_direction(), *direction),
1304 (SearchDirection::Forward, SearchDirection::Forward)
1305 | (SearchDirection::Backward, SearchDirection::Backward)
1306 );
1307 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1308 viewport.search_repeat(src.as_ref(), &mut idx, reverse);
1309 }
1310 mode = InputMode::Normal;
1311 } else {
1312 match viewport.set_search(buffer.clone(), *direction) {
1313 Ok(()) => {
1314 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1315 viewport.search_repeat(src.as_ref(), &mut idx, false);
1316 mode = InputMode::Normal;
1317 }
1318 Err(e) => { *error = Some(e); }
1319 }
1320 }
1321 needs_redraw = true;
1322 }
1323 KeyCode::Backspace => {
1324 buffer.pop();
1325 *error = None;
1326 if viewport.incsearch() {
1327 viewport.incsearch_preview(
1328 src.as_ref(), &mut idx, buffer, *direction, incsearch_origin);
1329 }
1330 needs_redraw = true;
1331 }
1332 KeyCode::Char(c) => {
1333 buffer.push(c);
1334 *error = None;
1335 if viewport.incsearch() {
1336 viewport.incsearch_preview(
1337 src.as_ref(), &mut idx, buffer, *direction, incsearch_origin);
1338 }
1339 needs_redraw = true;
1340 }
1341 _ => {}
1342 }
1343 }
1344 continue;
1345 }
1346 InputMode::OptionPrefix => {
1347 if let Event::Key(KeyEvent { code, .. }) = event {
1348 match code {
1349 KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
1350 KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
1351 KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
1352 KeyCode::Char('P') | KeyCode::Char('p') => {
1353 mode = InputMode::PrettifyPrefix;
1355 needs_redraw = true;
1356 continue;
1357 }
1358 _ => {}
1359 }
1360 }
1361 mode = InputMode::Normal;
1362 needs_redraw = true;
1363 continue;
1364 }
1365 InputMode::PrettifyPrefix => {
1366 if let Event::Key(KeyEvent { code, .. }) = event {
1367 let target: Option<PrettifyTarget> = match code {
1368 KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
1369 KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
1370 KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
1371 KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
1372 KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
1373 KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
1374 KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
1375 KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
1376 _ => None,
1377 };
1378 if let Some(t) = target {
1379 apply_prettify(
1380 src.as_ref(),
1381 &mut viewport,
1382 &mut idx,
1383 rebuild_spec,
1384 t,
1385 );
1386 last_revision = src.revision();
1387 }
1388 }
1389 mode = InputMode::Normal;
1390 needs_redraw = true;
1391 continue;
1392 }
1393 InputMode::MarkSetPending => {
1394 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1395 if is_valid_mark_name(c) {
1396 mark_set(&mut marks, c, current_file_index, viewport.top_line());
1397 }
1398 }
1399 mode = InputMode::Normal;
1400 continue;
1401 }
1402 InputMode::MarkJumpPending => {
1403 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1404 if is_valid_mark_name(c) {
1405 match mark_jump(&marks, c, current_file_index, &mut previous_position, viewport.top_line()) {
1406 Some(MarkTarget::SameFile { line }) => {
1407 let clamped = line.min(idx.line_count().saturating_sub(1));
1408 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1409 needs_redraw = true;
1410 }
1411 Some(MarkTarget::OtherFile { file_index, line }) => {
1412 if file_index < file_set.len() {
1413 file_set.set_current_index(file_index);
1414 let path = file_set.current().unwrap().to_path_buf();
1415 if let Err(e) = switch_file(
1416 &path, file_index, file_set.len(),
1417 &args, preprocessor.as_ref(),
1418 &mut viewport, &mut src, &mut idx,
1419 record_start_regex.as_ref(),
1420 ) {
1421 transient_status = Some(format!("[open: {e}]"));
1422 } else {
1423 let clamped = line.min(idx.line_count().saturating_sub(1));
1424 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1425 current_file_index = file_index;
1426 needs_redraw = true;
1427 }
1428 }
1429 }
1430 None => {}
1431 }
1432 }
1433 }
1434 mode = InputMode::Normal;
1435 continue;
1436 }
1437 InputMode::ShellPrompt { buffer, error } => {
1438 if let Event::Key(KeyEvent { code, .. }) = event {
1439 match code {
1440 KeyCode::Esc => {
1441 mode = InputMode::Normal;
1442 needs_redraw = true;
1443 }
1444 KeyCode::Enter => {
1445 if buffer.is_empty() {
1446 mode = InputMode::Normal;
1447 } else {
1448 match crate::shell::run_shell_command(buffer) {
1449 Ok(()) => {
1450 mode = InputMode::Normal;
1451 }
1452 Err(e) => {
1453 *error = Some(e.to_string());
1454 }
1455 }
1456 }
1457 needs_redraw = true;
1458 }
1459 KeyCode::Backspace => {
1460 buffer.pop();
1461 *error = None;
1462 needs_redraw = true;
1463 }
1464 KeyCode::Char(c) => {
1465 buffer.push(c);
1466 *error = None;
1467 needs_redraw = true;
1468 }
1469 _ => {}
1470 }
1471 }
1472 continue;
1473 }
1474 InputMode::CtrlXPending => {
1475 let is_ctrl_x = matches!(
1476 event,
1477 Event::Key(KeyEvent {
1478 code: KeyCode::Char('x'),
1479 modifiers: KeyModifiers::CONTROL,
1480 ..
1481 })
1482 );
1483 if is_ctrl_x {
1484 match jump_previous(&mut previous_position, current_file_index, viewport.top_line()) {
1485 Some(MarkTarget::SameFile { line }) => {
1486 let clamped = line.min(idx.line_count().saturating_sub(1));
1487 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1488 needs_redraw = true;
1489 }
1490 Some(MarkTarget::OtherFile { file_index, line }) => {
1491 if file_index < file_set.len() {
1492 file_set.set_current_index(file_index);
1493 let path = file_set.current().unwrap().to_path_buf();
1494 if let Err(e) = switch_file(
1495 &path, file_index, file_set.len(),
1496 &args, preprocessor.as_ref(),
1497 &mut viewport, &mut src, &mut idx,
1498 record_start_regex.as_ref(),
1499 ) {
1500 transient_status = Some(format!("[open: {e}]"));
1501 } else {
1502 let clamped = line.min(idx.line_count().saturating_sub(1));
1503 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1504 current_file_index = file_index;
1505 needs_redraw = true;
1506 }
1507 }
1508 }
1509 None => {}
1510 }
1511 mode = InputMode::Normal;
1512 continue;
1513 }
1514 mode = InputMode::Normal;
1516 }
1518 InputMode::ColonPrompt { buffer, error } => {
1519 if let Event::Key(KeyEvent { code, .. }) = event {
1520 match code {
1521 KeyCode::Esc => {
1522 mode = InputMode::Normal;
1523 needs_redraw = true;
1524 }
1525 KeyCode::Enter => {
1526 if buffer.is_empty() {
1527 mode = InputMode::Normal;
1528 } else {
1529 match parse_colon_command(buffer) {
1530 Ok(ColonCommand::VSplit(path_arg)) => {
1531 if other_pane.is_some() {
1532 viewport.flash("already split (`:only` first)", 30);
1533 } else {
1534 let (lw, rw) = crate::pane::split_widths(cols);
1535 if rw == 0 {
1536 viewport.flash("terminal too narrow to split", 30);
1537 } else {
1538 let focused_path = file_set.current().map(|p| p.to_path_buf());
1539 let focused_ansi = viewport.ansi_mode();
1540 let built = build_runtime_pane(
1541 path_arg.as_deref(),
1542 focused_path.as_deref(),
1543 viewport.top_line(),
1544 rw,
1545 rows,
1546 &args,
1547 focused_ansi,
1548 preprocessor.as_ref(),
1549 record_start_regex.as_ref(),
1550 );
1551 match built {
1552 Ok(mut pane) => {
1553 force_cell_mode(&mut viewport);
1554 force_cell_mode(&mut pane.viewport);
1555 focused_left = true;
1556 viewport.resize(lw, rows);
1557 pane.viewport.resize(rw, rows);
1558 other_pane = Some(pane);
1559 }
1560 Err(e) => viewport.flash(format!("vsplit: {e}"), 40),
1561 }
1562 }
1563 }
1564 mode = InputMode::Normal;
1565 }
1566 Ok(ColonCommand::Only) => {
1567 if other_pane.take().is_some() {
1568 viewport.resize(cols, rows);
1569 #[cfg(feature = "image")]
1570 {
1571 let (proto, cell_px) = startup_image_protocol;
1572 viewport.set_image_protocol(proto, cell_px);
1573 }
1574 focused_left = true;
1575 }
1576 mode = InputMode::Normal;
1577 }
1578 Ok(cmd) => {
1579 let is_tag_cmd = matches!(
1580 &cmd,
1581 ColonCommand::Tag(_)
1582 | ColonCommand::TagNext
1583 | ColonCommand::TagPrev
1584 | ColonCommand::TagSelect(_),
1585 );
1586 let reload_msg = if is_tag_cmd {
1587 refresh_tag_file(&mut tag_file)
1588 } else {
1589 None
1590 };
1591 let outcome = dispatch_colon_command(
1592 cmd,
1593 &mut file_set,
1594 &mut current_file_index,
1595 &args,
1596 preprocessor.as_ref(),
1597 record_start_regex.as_ref(),
1598 &mut viewport,
1599 &mut src,
1600 &mut idx,
1601 &mut tag_stack,
1602 tag_file.as_ref(),
1603 );
1604 match outcome {
1605 ColonOutcome::Continue(msg) => {
1606 transient_status = msg.or(reload_msg);
1607 }
1608 ColonOutcome::Quit => break,
1609 ColonOutcome::DispatchCommand(Command::OpenPicker) => {
1610 let saved = (0..file_set.len())
1611 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1612 .collect::<Vec<_>>();
1613 overlay = Some(Box::new(
1614 crate::overlay::picker::FilePicker::new(&file_set, saved)
1615 ));
1616 }
1617 ColonOutcome::DispatchCommand(Command::OpenHelp) => {
1618 let remaps = keymap.user_keys_by_command_name();
1619 overlay = Some(Box::new(
1620 crate::overlay::help::HelpOverlay::new(remaps)
1621 ));
1622 }
1623 ColonOutcome::DispatchCommand(Command::OpenTagPicker) => {
1624 if let Some(active) = tag_stack.active.as_ref() {
1625 overlay = Some(Box::new(
1626 crate::overlay::tag_picker::TagPicker::new(
1627 active.name.clone(),
1628 active.matches.clone(),
1629 active.cursor,
1630 )
1631 ));
1632 }
1633 }
1634 ColonOutcome::DispatchCommand(cmd) => {
1635 debug_assert!(false, "colon dispatcher emitted unexpected Command: {cmd:?}");
1636 }
1638 }
1639 mode = InputMode::Normal;
1640 }
1641 Err(e) => {
1642 *error = Some(e.to_string());
1643 }
1644 }
1645 }
1646 needs_redraw = true;
1647 }
1648 KeyCode::Backspace => {
1649 buffer.pop();
1650 *error = None;
1651 needs_redraw = true;
1652 }
1653 KeyCode::Char(c) => {
1654 buffer.push(c);
1655 *error = None;
1656 needs_redraw = true;
1657 }
1658 _ => {}
1659 }
1660 }
1661 continue;
1662 }
1663 InputMode::TagPrompt { buffer, error, last_tab_matches } => {
1664 if let Event::Key(KeyEvent { code, .. }) = event {
1665 match code {
1666 KeyCode::Esc => {
1667 mode = InputMode::Normal;
1668 needs_redraw = true;
1669 }
1670 KeyCode::Enter => {
1671 if buffer.is_empty() {
1672 mode = InputMode::Normal;
1673 } else {
1674 let name = buffer.clone();
1675 let reload_msg = refresh_tag_file(&mut tag_file);
1676 let msg = dispatch_tag_jump(
1677 &name,
1678 tag_file.as_ref(),
1679 &mut tag_stack,
1680 &mut file_set,
1681 &mut current_file_index,
1682 &args,
1683 preprocessor.as_ref(),
1684 record_start_regex.as_ref(),
1685 &mut viewport,
1686 &mut src,
1687 &mut idx,
1688 );
1689 transient_status = msg.or(reload_msg);
1690 mode = InputMode::Normal;
1691 }
1692 needs_redraw = true;
1693 }
1694 KeyCode::Backspace => {
1695 buffer.pop();
1696 *error = None;
1697 *last_tab_matches = None;
1698 needs_redraw = true;
1699 }
1700 KeyCode::Tab => {
1701 let _ = refresh_tag_file(&mut tag_file);
1702 let names: Vec<String> = match tag_file.as_ref() {
1703 Some(tf) => tf
1704 .names()
1705 .filter(|n| n.starts_with(buffer.as_str()))
1706 .map(String::from)
1707 .collect(),
1708 None => Vec::new(),
1709 };
1710 match (names.len(), last_tab_matches.as_ref()) {
1711 (0, _) => {
1712 *error = Some("no tags match".into());
1713 *last_tab_matches = None;
1714 }
1715 (1, _) => {
1716 *buffer = names.into_iter().next().unwrap();
1717 *error = None;
1718 *last_tab_matches = None;
1719 }
1720 (n, Some(prev)) if prev.len() == n => {
1721 *error = Some(format!("{n} matches"));
1722 }
1723 (n, _) => {
1724 let lcp = longest_common_prefix(&names);
1725 if lcp.len() > buffer.len() {
1726 *buffer = lcp;
1727 *error = None;
1728 } else {
1729 *error = Some(format!("{n} matches"));
1730 }
1731 *last_tab_matches = Some(names);
1732 }
1733 }
1734 needs_redraw = true;
1735 }
1736 KeyCode::Char(c) => {
1737 buffer.push(c);
1738 *error = None;
1739 *last_tab_matches = None;
1740 needs_redraw = true;
1741 }
1742 _ => {}
1743 }
1744 }
1745 continue;
1746 }
1747 InputMode::Normal => {}
1748 }
1749 if let crossterm::event::Event::Resize(c, r) = event {
1752 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1759 cols = c;
1760 rows = r;
1761 resize_split_aware(&mut viewport, &mut other_pane, cols, rows, focused_left);
1762 if was_at_bottom {
1763 viewport.goto_bottom(src.as_ref(), &mut idx);
1764 }
1765 needs_redraw = true;
1766 if overlay.is_some() {
1767 continue;
1769 }
1770 }
1773 if let Some(ov) = overlay.as_mut() {
1777 let outcome = match &event {
1778 Event::Key(ke) => ov.handle_key(*ke),
1779 Event::Mouse(me) => ov.handle_mouse(*me, viewport.body_rows()),
1780 Event::Resize(_, _) => crate::overlay::OverlayOutcome::Stay,
1781 _ => crate::overlay::OverlayOutcome::Stay,
1782 };
1783 match outcome {
1784 crate::overlay::OverlayOutcome::Stay => {
1785 needs_redraw = true;
1786 continue;
1787 }
1788 crate::overlay::OverlayOutcome::Close => {
1789 overlay = None;
1790 overlay_flash = None;
1791 needs_redraw = true;
1792 continue;
1793 }
1794 crate::overlay::OverlayOutcome::CloseAnd(cmd) => {
1795 overlay = None;
1796 overlay_flash = None;
1797 if let Command::SelectFile(i) = cmd {
1798 if i < file_set.len() {
1799 file_set.set_current_index(i);
1800 if let Some(msg) = switch_to_current_file(
1801 &mut file_set, &mut current_file_index,
1802 &args, preprocessor.as_ref(),
1803 record_start_regex.as_ref(),
1804 &mut viewport, &mut src, &mut idx,
1805 ) {
1806 transient_status = Some(msg);
1807 }
1808 }
1809 } else if let Command::SelectTagMatch(idx_pick) = cmd {
1810 if let Some(active) = tag_stack.active.as_mut() {
1811 if idx_pick < active.matches.len() {
1812 active.cursor = idx_pick;
1813 let entry = active.matches[idx_pick].clone();
1814 let msg = dispatch_match(
1815 &entry,
1816 &mut file_set,
1817 &mut current_file_index,
1818 &args,
1819 preprocessor.as_ref(),
1820 record_start_regex.as_ref(),
1821 &mut viewport,
1822 &mut src,
1823 &mut idx,
1824 );
1825 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1826 if let Some(m) = msg {
1827 transient_status = Some(m);
1828 }
1829 }
1830 }
1831 }
1832 needs_redraw = true;
1833 continue;
1834 }
1835 crate::overlay::OverlayOutcome::Apply(cmd) => {
1836 if let Command::DropFileAt(target) = cmd {
1837 if file_set.len() > 1 && target < file_set.len() {
1838 let saved_cur = file_set.current_index();
1839 file_set.set_current_index(target);
1840 let _ = file_set.delete_current();
1841 if target < saved_cur {
1845 let restored = saved_cur.saturating_sub(1);
1846 file_set.set_current_index(restored);
1847 } else if target > saved_cur {
1848 file_set.set_current_index(saved_cur);
1849 }
1850 if let Some(msg) = switch_to_current_file(
1853 &mut file_set, &mut current_file_index,
1854 &args, preprocessor.as_ref(),
1855 record_start_regex.as_ref(),
1856 &mut viewport, &mut src, &mut idx,
1857 ) {
1858 transient_status = Some(msg);
1859 }
1860 if let Some(ov) = overlay.as_mut() {
1861 ov.refresh(crate::overlay::OverlayContext { file_set: &file_set });
1862 }
1863 }
1864 }
1865 needs_redraw = true;
1866 continue;
1867 }
1868 crate::overlay::OverlayOutcome::Refuse(msg) => {
1869 overlay_flash = Some((msg, std::time::Instant::now()));
1870 needs_redraw = true;
1871 continue;
1872 }
1873 }
1874 }
1875 if let crossterm::event::Event::Mouse(me) = &event {
1879 if mouse_enabled {
1880 use crossterm::event::{KeyModifiers, MouseEventKind};
1881 let hshift = me.modifiers.contains(KeyModifiers::SHIFT)
1887 && viewport.hscroll_active();
1888 match me.kind {
1889 MouseEventKind::ScrollDown if hshift => {
1890 viewport.hscroll_right_step();
1891 needs_redraw = true;
1892 }
1893 MouseEventKind::ScrollUp if hshift => {
1894 viewport.hscroll_left_step();
1895 needs_redraw = true;
1896 }
1897 MouseEventKind::ScrollDown => {
1898 viewport.scroll_lines(wheel_lines as i64, src.as_ref(), &mut idx);
1899 needs_redraw = true;
1900 }
1901 MouseEventKind::ScrollUp => {
1902 viewport.scroll_lines(-(wheel_lines as i64), src.as_ref(), &mut idx);
1903 needs_redraw = true;
1904 }
1905 MouseEventKind::ScrollLeft => {
1906 viewport.hscroll_left_step();
1907 needs_redraw = true;
1908 }
1909 MouseEventKind::ScrollRight => {
1910 viewport.hscroll_right_step();
1911 needs_redraw = true;
1912 }
1913 _ => {}
1914 }
1915 }
1916 continue;
1917 }
1918 let mut cmd: Option<Command> = None;
1922 if let InputMode::Normal = mode {
1923 if let Event::Key(ke) = &event {
1924 if let Some(target) = keymap.lookup(ke) {
1925 match target {
1926 crate::keys::BindingTarget::Shell(cmd_text) => {
1927 let cmd_text = cmd_text.clone();
1928 if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
1929 let _ = writeln!(std::io::stderr(),
1930 "[shell: {e}]");
1931 }
1932 needs_redraw = true;
1933 continue;
1934 }
1935 crate::keys::BindingTarget::Command(c) => {
1936 cmd = Some(c.clone());
1937 }
1938 }
1939 }
1940 }
1941 }
1942 let cmd = cmd.unwrap_or_else(|| translate(event));
1943 let prefix_at_cmd = numeric_prefix.take();
1946 match cmd {
1947 Command::Digit(d) => {
1948 let cur = prefix_at_cmd.unwrap_or(0);
1949 let next = cur.saturating_mul(10).saturating_add(d as usize);
1950 if next <= 99_999_999 {
1951 numeric_prefix = Some(next);
1952 } else {
1953 numeric_prefix = prefix_at_cmd;
1955 }
1956 continue;
1957 }
1958 Command::Cancel => {
1959 continue;
1961 }
1962 Command::GotoLine => {
1963 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1964 match prefix_at_cmd {
1965 Some(line) if line > 0 => {
1966 viewport.goto_line(line - 1, src.as_ref(), &mut idx);
1967 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1968 }
1969 _ => {
1970 viewport.goto_top();
1971 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1972 }
1973 }
1974 needs_redraw = true;
1975 }
1976 Command::GotoRecord => {
1977 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1978 match prefix_at_cmd {
1979 Some(rec) if rec > 0 => {
1980 viewport.goto_record(rec - 1, src.as_ref(), &mut idx);
1981 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1982 }
1983 _ => viewport.goto_bottom(src.as_ref(), &mut idx),
1984 }
1985 needs_redraw = true;
1986 }
1987 Command::GotoPercent => {
1988 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1989 match prefix_at_cmd {
1990 Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
1991 _ => viewport.goto_top(),
1992 }
1993 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1994 needs_redraw = true;
1995 }
1996 Command::Quit => break,
1997 Command::Resize(c, r) => {
1998 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1999 cols = c; rows = r;
2000 resize_split_aware(&mut viewport, &mut other_pane, cols, rows, focused_left);
2001 if was_at_bottom {
2002 viewport.goto_bottom(src.as_ref(), &mut idx);
2003 }
2004 needs_redraw = true;
2005 }
2006 Command::ScrollLines(n) => {
2007 viewport.scroll_lines(n, src.as_ref(), &mut idx);
2008 viewport.suspend_follow_if(args.follow_suspend_on_motion);
2009 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
2010 needs_redraw = true;
2011 }
2012 Command::ScrollLogicalLines(n) => {
2013 viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
2014 viewport.suspend_follow_if(args.follow_suspend_on_motion);
2015 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
2016 needs_redraw = true;
2017 }
2018 Command::PageDown => {
2019 viewport.page_down(src.as_ref(), &mut idx);
2020 viewport.suspend_follow_if(args.follow_suspend_on_motion);
2021 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
2022 needs_redraw = true;
2023 }
2024 Command::PageUp => {
2025 viewport.page_up(src.as_ref(), &mut idx);
2026 viewport.suspend_follow_if(args.follow_suspend_on_motion);
2027 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
2028 needs_redraw = true;
2029 }
2030 Command::HalfPageDown => {
2031 viewport.half_page_down(src.as_ref(), &mut idx);
2032 viewport.suspend_follow_if(args.follow_suspend_on_motion);
2033 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
2034 needs_redraw = true;
2035 }
2036 Command::HalfPageUp => {
2037 viewport.half_page_up(src.as_ref(), &mut idx);
2038 viewport.suspend_follow_if(args.follow_suspend_on_motion);
2039 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
2040 needs_redraw = true;
2041 }
2042 Command::FocusOtherPane => {
2043 if let Some(other) = other_pane.as_mut() {
2044 std::mem::swap(&mut src, &mut other.src);
2045 std::mem::swap(&mut idx, &mut other.idx);
2046 std::mem::swap(&mut viewport, &mut other.viewport);
2047 std::mem::swap(&mut last_revision, &mut other.last_revision);
2048 #[cfg(feature = "image")]
2049 std::mem::swap(&mut last_tick, &mut other.last_tick);
2050 focused_left = !focused_left;
2051 resize_split_aware(&mut viewport, &mut other_pane, cols, rows, focused_left);
2056 needs_redraw = true;
2057 }
2058 }
2059 Command::Refresh => {
2060 needs_redraw = true;
2061 }
2062 Command::Reload => {
2063 src.pump();
2066 if src.revision() != last_revision {
2067 rebuild_after_replace(
2068 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
2069 );
2070 last_revision = src.revision();
2071 needs_redraw = true;
2072 }
2073 }
2074 Command::TogglePrettify => {
2075 apply_prettify(
2076 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
2077 PrettifyTarget::Toggle,
2078 );
2079 last_revision = src.revision();
2080 needs_redraw = true;
2081 }
2082 Command::SetPrettifyMode(m) => {
2083 apply_prettify(
2084 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
2085 PrettifyTarget::Mode(m),
2086 );
2087 last_revision = src.revision();
2088 needs_redraw = true;
2089 }
2090 Command::RedetectPrettify => {
2091 apply_prettify(
2092 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
2093 PrettifyTarget::Auto,
2094 );
2095 last_revision = src.revision();
2096 needs_redraw = true;
2097 }
2098 Command::ToggleLineNumbers => {
2099 viewport.toggle_line_numbers();
2100 needs_redraw = true;
2101 }
2102 Command::ToggleChop => {
2103 viewport.toggle_chop();
2104 needs_redraw = true;
2105 }
2106 Command::ToggleFollow => {
2107 viewport.toggle_follow();
2108 if viewport.follow_mode() {
2109 src.pump();
2111 idx.notice_new_bytes(src.as_ref());
2112 viewport.goto_bottom(src.as_ref(), &mut idx);
2113 }
2114 needs_redraw = true;
2115 }
2116 Command::SearchForward => {
2117 incsearch_origin = (viewport.top_line(), viewport.top_row());
2118 mode = InputMode::SearchPrompt {
2119 direction: SearchDirection::Forward,
2120 buffer: String::new(),
2121 error: None,
2122 };
2123 needs_redraw = true;
2124 }
2125 Command::SearchBackward => {
2126 incsearch_origin = (viewport.top_line(), viewport.top_row());
2127 mode = InputMode::SearchPrompt {
2128 direction: SearchDirection::Backward,
2129 buffer: String::new(),
2130 error: None,
2131 };
2132 needs_redraw = true;
2133 }
2134 Command::ShellEscape => {
2135 mode = InputMode::ShellPrompt {
2136 buffer: String::new(),
2137 error: None,
2138 };
2139 needs_redraw = true;
2140 }
2141 Command::ColonPrompt => {
2142 mode = InputMode::ColonPrompt {
2143 buffer: String::new(),
2144 error: None,
2145 };
2146 needs_redraw = true;
2147 }
2148 Command::NextMatch => {
2149 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
2150 if viewport.search_repeat(src.as_ref(), &mut idx, false) {
2151 needs_redraw = true;
2152 }
2153 }
2154 Command::PreviousMatch => {
2155 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
2156 if viewport.search_repeat(src.as_ref(), &mut idx, true) {
2157 needs_redraw = true;
2158 }
2159 }
2160 Command::OptionPrefix => {
2161 mode = InputMode::OptionPrefix;
2162 }
2163 Command::MarkSet => {
2164 mode = InputMode::MarkSetPending;
2165 }
2166 Command::MarkJump => {
2167 mode = InputMode::MarkJumpPending;
2168 }
2169 Command::CtrlXPrefix => {
2170 mode = InputMode::CtrlXPending;
2171 }
2172 Command::JumpPrevious => {
2173 }
2176 Command::TagPrompt => {
2177 if tag_file.is_none() {
2178 transient_status = Some("[no tags file loaded]".into());
2179 needs_redraw = true;
2180 } else {
2181 mode = InputMode::TagPrompt {
2182 buffer: String::new(),
2183 error: None,
2184 last_tab_matches: None,
2185 };
2186 needs_redraw = true;
2187 }
2188 }
2189 Command::TagPop => match tag_stack.pop() {
2190 Some((file_index, line)) => {
2191 if file_index != current_file_index && file_index < file_set.len() {
2192 file_set.set_current_index(file_index);
2193 let path = file_set.current().unwrap().to_path_buf();
2194 if let Err(e) = switch_file(
2195 &path,
2196 file_index,
2197 file_set.len(),
2198 &args,
2199 preprocessor.as_ref(),
2200 &mut viewport,
2201 &mut src,
2202 &mut idx,
2203 record_start_regex.as_ref(),
2204 ) {
2205 transient_status = Some(format!("[open: {e}]"));
2206 } else {
2207 current_file_index = file_index;
2208 }
2209 }
2210 let clamped = line.min(idx.line_count().saturating_sub(1));
2211 viewport.goto_line(clamped, src.as_ref(), &mut idx);
2212 update_viewport_tag_indicator(&tag_stack, &mut viewport);
2213 needs_redraw = true;
2214 }
2215 None => {
2216 transient_status = Some("[tag stack empty]".into());
2217 needs_redraw = true;
2218 }
2219 },
2220 Command::OpenPicker => {
2221 let saved = (0..file_set.len())
2222 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
2223 .collect::<Vec<_>>();
2224 overlay = Some(Box::new(
2225 crate::overlay::picker::FilePicker::new(&file_set, saved)
2226 ));
2227 needs_redraw = true;
2228 }
2229 Command::OpenHelp => {
2230 let remaps = keymap.user_keys_by_command_name();
2231 overlay = Some(Box::new(
2232 crate::overlay::help::HelpOverlay::new(remaps)
2233 ));
2234 needs_redraw = true;
2235 }
2236 Command::SelectFile(_)
2237 | Command::DropFileAt(_)
2238 | Command::SelectTagMatch(_)
2239 | Command::OpenTagPicker => {
2240 }
2242 Command::MouseEvent(_) => {
2243 }
2245 Command::HScrollLeft => {
2246 if hscroll_shift != 0 {
2247 viewport.hscroll_left_cols(hscroll_shift);
2248 } else {
2249 viewport.hscroll_left_half();
2250 }
2251 needs_redraw = true;
2252 }
2253 Command::HScrollRight => {
2254 if hscroll_shift != 0 {
2255 viewport.hscroll_right_cols(hscroll_shift);
2256 } else {
2257 viewport.hscroll_right_half();
2258 }
2259 needs_redraw = true;
2260 }
2261 Command::HScrollLeftStep => {
2262 viewport.hscroll_left_step();
2263 needs_redraw = true;
2264 }
2265 Command::HScrollRightStep => {
2266 viewport.hscroll_right_step();
2267 needs_redraw = true;
2268 }
2269 Command::YankLine => {
2270 let msg = yank_current_line(clipboard_enabled, &viewport, src.as_ref(), &mut idx);
2271 transient_status = Some(msg);
2272 needs_redraw = true;
2273 }
2274 Command::AnimPause => {
2275 #[cfg(feature = "image")]
2276 viewport.anim_toggle_pause();
2277 needs_redraw = true;
2278 }
2279 Command::AnimStepForward => {
2280 #[cfg(feature = "image")]
2281 viewport.anim_step(1);
2282 needs_redraw = true;
2283 }
2284 Command::AnimStepBack => {
2285 #[cfg(feature = "image")]
2286 viewport.anim_step(-1);
2287 needs_redraw = true;
2288 }
2289 Command::AnimRestart => {
2290 #[cfg(feature = "image")]
2291 viewport.anim_restart();
2292 needs_redraw = true;
2293 }
2294 Command::Noop => {}
2295 }
2296 #[cfg(feature = "image")]
2300 {
2301 last_tick = std::time::Instant::now();
2302 }
2303 #[cfg(feature = "image")]
2304 if let Some(other) = other_pane.as_mut() {
2305 other.last_tick = std::time::Instant::now();
2306 }
2307 }
2308 Ok(false) => {
2309 #[cfg(feature = "image")]
2313 {
2314 let dt = last_tick.elapsed();
2315 last_tick = std::time::Instant::now();
2316 if viewport.tick(dt) { needs_redraw = true; }
2317 }
2318 #[cfg(feature = "image")]
2319 if let Some(other) = other_pane.as_mut() {
2320 let odt = other.last_tick.elapsed();
2321 other.last_tick = std::time::Instant::now();
2322 if other.viewport.tick(odt) { needs_redraw = true; }
2323 }
2324 if viewport.live_mode() {
2326 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
2327 src.pump();
2328 if src.revision() != last_revision {
2329 rebuild_after_replace(
2330 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
2331 );
2332 if was_at_bottom {
2333 viewport.goto_bottom(src.as_ref(), &mut idx);
2334 }
2335 last_revision = src.revision();
2336 needs_redraw = true;
2337 }
2338 } else if viewport.follow_mode() {
2339 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
2340 src.pump();
2341 if src.take_rotated() {
2342 if let Some(path) = src.path().map(|p| p.to_path_buf()) {
2348 match crate::open::open_source_for_path(
2349 &path, &args, preprocessor.as_ref(),
2350 ) {
2351 Ok((new_src, _label, _err)) => {
2352 src = new_src;
2353 idx = LineIndex::new();
2354 if let Some(n) = rebuild_spec.head {
2355 idx.set_head_cap(n);
2356 }
2357 viewport.invalidate_filter_cache();
2358 idx.notice_new_bytes(src.as_ref());
2359 viewport.extend_visible_lines(&idx, src.as_ref());
2360 viewport.goto_bottom(src.as_ref(), &mut idx);
2361 viewport.flash("(F reopened)", 4);
2362 needs_redraw = true;
2363 continue;
2364 }
2365 Err(e) => {
2366 transient_status = Some(format!("[reopen failed: {e}]"));
2367 needs_redraw = true;
2368 }
2369 }
2370 }
2371 }
2372 let lines_before = idx.line_count();
2373 idx.notice_new_bytes(src.as_ref());
2374 viewport.extend_visible_lines(&idx, src.as_ref());
2375 if idx.line_count() != lines_before {
2376 needs_redraw = true;
2377 viewport.note_growth();
2378 if was_at_bottom {
2379 viewport.goto_bottom(src.as_ref(), &mut idx);
2380 }
2381 } else {
2382 viewport.tick_idle();
2383 }
2384 viewport.tick_flash();
2385 if args.exit_follow_on_close && src.is_complete() {
2391 break;
2392 }
2393 } else if !src.is_complete() {
2394 let lines_before = idx.line_count();
2397 idx.notice_new_bytes(src.as_ref());
2398 viewport.extend_visible_lines(&idx, src.as_ref());
2399 if idx.line_count() != lines_before {
2400 needs_redraw = true;
2401 }
2402 }
2403 if let Some(other) = other_pane.as_mut() {
2406 if pump_pane(
2407 &mut other.src,
2408 &mut other.idx,
2409 &mut other.viewport,
2410 &mut other.last_revision,
2411 &rebuild_spec,
2412 &args,
2413 preprocessor.as_ref(),
2414 ) {
2415 needs_redraw = true;
2416 }
2417 }
2418 }
2419 Err(_) => {
2420 std::thread::sleep(timeout);
2422 }
2423 }
2424 }
2425 Ok(())
2426}
2427
2428#[derive(Debug, Clone, Copy)]
2430enum PrettifyTarget {
2431 Mode(PrettifyMode),
2433 Toggle,
2435 Auto,
2437}
2438
2439fn apply_prettify(
2443 src: &dyn Source,
2444 viewport: &mut Viewport,
2445 idx: &mut LineIndex,
2446 spec: RebuildSpec,
2447 target: PrettifyTarget,
2448) {
2449 if src.prettify_mode().is_none() {
2451 return;
2452 }
2453 match target {
2454 PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
2455 PrettifyTarget::Toggle => src.toggle_prettify(),
2456 PrettifyTarget::Auto => src.redetect_prettify(),
2457 }
2458 rebuild_after_replace(src, viewport, idx, spec);
2459 viewport.set_prettify_label(src.prettify_label());
2460}
2461
2462fn pump_pane(
2477 src: &mut Box<dyn Source>,
2478 idx: &mut LineIndex,
2479 viewport: &mut Viewport,
2480 last_revision: &mut u64,
2481 rebuild_spec: &RebuildSpec,
2482 args: &crate::cli::Args,
2483 preprocessor: Option<&crate::preprocess::Preprocessor>,
2484) -> bool {
2485 let mut changed = false;
2486 if viewport.live_mode() {
2487 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), idx);
2488 src.pump();
2489 if src.revision() != *last_revision {
2490 rebuild_after_replace(src.as_ref(), viewport, idx, *rebuild_spec);
2491 if was_at_bottom {
2492 viewport.goto_bottom(src.as_ref(), idx);
2493 }
2494 *last_revision = src.revision();
2495 changed = true;
2496 }
2497 } else if viewport.follow_mode() || !src.is_complete() {
2498 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), idx);
2499 src.pump();
2500 if src.take_rotated() {
2501 if let Some(path) = src.path().map(|p| p.to_path_buf()) {
2509 if let Ok((new_src, _label, _err)) =
2510 crate::open::open_source_for_path(&path, args, preprocessor)
2511 {
2512 *src = new_src;
2513 *idx = LineIndex::new();
2514 if let Some(n) = rebuild_spec.head {
2515 idx.set_head_cap(n);
2516 }
2517 viewport.invalidate_filter_cache();
2518 idx.notice_new_bytes(src.as_ref());
2519 viewport.extend_visible_lines(idx, src.as_ref());
2520 viewport.goto_bottom(src.as_ref(), idx);
2521 *last_revision = src.revision();
2522 return true;
2523 }
2524 }
2529 }
2530 let lines_before = idx.line_count();
2531 idx.notice_new_bytes(src.as_ref());
2532 viewport.extend_visible_lines(idx, src.as_ref());
2533 if idx.line_count() != lines_before {
2534 changed = true;
2535 viewport.note_growth();
2536 if was_at_bottom {
2537 viewport.goto_bottom(src.as_ref(), idx);
2538 }
2539 } else {
2540 viewport.tick_idle();
2541 }
2542 viewport.tick_flash();
2543 }
2544 changed
2545}
2546
2547fn rebuild_after_replace(
2553 src: &dyn Source,
2554 viewport: &mut Viewport,
2555 idx: &mut LineIndex,
2556 spec: RebuildSpec,
2557) {
2558 let new_off = match spec.tail {
2559 Some(n) => find_tail_offset(src, n),
2560 None => 0,
2561 };
2562 *idx = LineIndex::new_starting_at(new_off);
2563 if let Some(n) = spec.head {
2564 idx.set_head_cap(n);
2565 }
2566 viewport.invalidate_filter_cache();
2567 idx.notice_new_bytes(src);
2568 viewport.extend_visible_lines(idx, src);
2569 viewport.clamp_top_line(idx.line_count());
2570}
2571
2572fn to_crossterm_color(c: crate::ansi::Color, truecolor: bool) -> crossterm::style::Color {
2573 use crossterm::style::Color as CC;
2574 use crate::ansi::Color;
2575 match c {
2576 Color::Ansi(0) => CC::Black,
2577 Color::Ansi(1) => CC::DarkRed,
2578 Color::Ansi(2) => CC::DarkGreen,
2579 Color::Ansi(3) => CC::DarkYellow,
2580 Color::Ansi(4) => CC::DarkBlue,
2581 Color::Ansi(5) => CC::DarkMagenta,
2582 Color::Ansi(6) => CC::DarkCyan,
2583 Color::Ansi(7) => CC::Grey,
2584 Color::Ansi(8) => CC::DarkGrey,
2585 Color::Ansi(9) => CC::Red,
2586 Color::Ansi(10) => CC::Green,
2587 Color::Ansi(11) => CC::Yellow,
2588 Color::Ansi(12) => CC::Blue,
2589 Color::Ansi(13) => CC::Magenta,
2590 Color::Ansi(14) => CC::Cyan,
2591 Color::Ansi(15) => CC::White,
2592 Color::Ansi(_) => CC::Reset,
2593 Color::Indexed(n) => CC::AnsiValue(n),
2594 Color::Rgb(r, g, b) => {
2595 if truecolor {
2596 CC::Rgb { r, g, b }
2597 } else {
2598 CC::AnsiValue(crate::render::rgb_to_256(r, g, b))
2599 }
2600 }
2601 Color::Default => CC::Reset,
2602 }
2603}
2604
2605fn emit_style_diff<W: Write>(
2608 out: &mut W,
2609 prev: &crate::ansi::Style,
2610 next: &crate::ansi::Style,
2611 truecolor: bool,
2612) -> io::Result<()> {
2613 let intensity_changed = prev.bold != next.bold || prev.dim != next.dim;
2617
2618 let fg_changed = prev.fg != next.fg;
2622 let bg_changed = prev.bg != next.bg;
2623
2624 if (fg_changed && next.fg.is_none()) || (bg_changed && next.bg.is_none()) {
2625 out.queue(ResetColor)?;
2626 if let Some(c) = next.fg {
2628 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2629 }
2630 if let Some(c) = next.bg {
2631 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2632 }
2633 } else {
2634 if fg_changed {
2635 if let Some(c) = next.fg {
2636 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2637 }
2638 }
2639 if bg_changed {
2640 if let Some(c) = next.bg {
2641 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2642 }
2643 }
2644 }
2645
2646 if intensity_changed {
2647 if next.bold {
2648 out.queue(SetAttribute(Attribute::Bold))?;
2649 } else if next.dim {
2650 out.queue(SetAttribute(Attribute::Dim))?;
2651 } else {
2652 out.queue(SetAttribute(Attribute::NormalIntensity))?;
2653 }
2654 }
2655 if prev.italic != next.italic {
2656 out.queue(SetAttribute(if next.italic { Attribute::Italic } else { Attribute::NoItalic }))?;
2657 }
2658 if prev.underline != next.underline {
2659 out.queue(SetAttribute(if next.underline { Attribute::Underlined } else { Attribute::NoUnderline }))?;
2660 }
2661 if prev.reverse != next.reverse {
2662 out.queue(SetAttribute(if next.reverse { Attribute::Reverse } else { Attribute::NoReverse }))?;
2663 }
2664 if prev.strike != next.strike {
2665 out.queue(SetAttribute(if next.strike { Attribute::CrossedOut } else { Attribute::NotCrossedOut }))?;
2666 }
2667 Ok(())
2668}
2669
2670fn emit_hyperlink_diff<W: Write>(
2671 out: &mut W,
2672 prev: &Option<Arc<str>>,
2673 next: &Option<Arc<str>>,
2674) -> io::Result<()> {
2675 if prev == next {
2676 return Ok(());
2677 }
2678 if prev.is_some() {
2679 out.write_all(b"\x1b]8;;\x1b\\")?;
2680 }
2681 if let Some(uri) = next {
2682 out.write_all(b"\x1b]8;;")?;
2683 out.write_all(uri.as_bytes())?;
2684 out.write_all(b"\x1b\\")?;
2685 }
2686 Ok(())
2687}
2688
2689const SYNC_UPDATE_BEGIN: &[u8] = b"\x1b[?2026h";
2696const SYNC_UPDATE_END: &[u8] = b"\x1b[?2026l";
2697
2698fn fit_status_to_cols(s: &str, cols: usize) -> String {
2702 use unicode_width::UnicodeWidthChar;
2703 let mut out = String::with_capacity(cols);
2704 let mut w = 0usize;
2705 for ch in s.chars() {
2706 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
2707 if w + cw > cols {
2708 break;
2709 }
2710 out.push(ch);
2711 w += cw;
2712 }
2713 for _ in w..cols {
2714 out.push(' ');
2715 }
2716 out
2717}
2718
2719fn write_status_row(
2727 out: &mut impl Write,
2728 status: &str,
2729 status_style: &crate::ansi::Style,
2730 cols: u16,
2731 rows: u16,
2732 truecolor: bool,
2733) -> io::Result<()> {
2734 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2735 if status.contains('\x1b') {
2736 out.queue(Clear(ClearType::UntilNewLine))?;
2739 emit_style_diff(out, &crate::ansi::Style::default(), status_style, truecolor)?;
2740 let mut s = status.to_string();
2741 if s.len() > cols as usize {
2742 let mut end = cols as usize;
2743 while end > 0 && !s.is_char_boundary(end) {
2744 end -= 1;
2745 }
2746 s.truncate(end);
2747 }
2748 out.queue(Print(s))?;
2749 } else {
2750 emit_style_diff(out, &crate::ansi::Style::default(), status_style, truecolor)?;
2751 out.queue(Print(fit_status_to_cols(status, cols as usize)))?;
2752 }
2753 out.queue(ResetColor)?;
2754 out.queue(SetAttribute(Attribute::Reset))?;
2755 Ok(())
2756}
2757
2758fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16, truecolor: bool) -> io::Result<()> {
2759 out.write_all(SYNC_UPDATE_BEGIN)?;
2771
2772 out.queue(SetAttribute(Attribute::Reset))?;
2774 out.queue(ResetColor)?;
2775
2776 if let Some(blob) = &frame.image_blob {
2777 for r in 0..frame.body.len() as u16 {
2780 out.queue(MoveTo(0, r))?;
2781 out.queue(Clear(ClearType::UntilNewLine))?;
2782 }
2783 out.queue(MoveTo(0, 0))?;
2784 out.write_all(blob)?;
2785 write_status_row(out, &frame.status, &frame.status_style, cols, rows, truecolor)?;
2786 out.write_all(SYNC_UPDATE_END)?;
2787 return out.flush();
2788 }
2789
2790 for (i, row) in frame.body.iter().enumerate() {
2791 out.queue(MoveTo(0, i as u16))?;
2792 out.queue(Clear(ClearType::UntilNewLine))?;
2796 out.queue(SetAttribute(Attribute::Reset))?;
2799
2800 if let Some(Some(raw)) = frame.raw_rows.get(i) {
2805 if !raw.is_empty() {
2806 out.write_all(raw)?;
2807 }
2808 out.queue(ResetColor)?;
2810 out.queue(SetAttribute(Attribute::Reset))?;
2811 continue;
2812 }
2813
2814 let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
2815 let base_style = if matches!(row_style, RowStyle::Dim) {
2820 out.queue(SetAttribute(Attribute::Dim))?;
2821 crate::ansi::Style { dim: true, ..Default::default() }
2822 } else {
2823 crate::ansi::Style::default()
2824 };
2825 let no_highlights = Vec::new();
2826 let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
2827 write_row_with_highlights(out, row, cols, highlights, base_style, truecolor)?;
2828 }
2829 write_status_row(out, &frame.status, &frame.status_style, cols, rows, truecolor)?;
2831
2832 out.write_all(SYNC_UPDATE_END)?;
2835 out.flush()
2836}
2837
2838
2839fn write_row_with_highlights(
2850 out: &mut impl Write,
2851 row: &[Cell],
2852 cols: u16,
2853 highlights: &[std::ops::Range<usize>],
2854 base_style: crate::ansi::Style,
2855 truecolor: bool,
2856) -> io::Result<()> {
2857 let cols_usize = cols as usize;
2858
2859 let mut ranges: Vec<std::ops::Range<usize>> = highlights
2860 .iter()
2861 .filter_map(|r| {
2862 let s = r.start.min(cols_usize);
2863 let e = r.end.min(cols_usize);
2864 if e > s { Some(s..e) } else { None }
2865 })
2866 .collect();
2867 ranges.sort_by_key(|r| r.start);
2868
2869 let mut prev_style = base_style;
2872 let mut prev_link: Option<Arc<str>> = None;
2873
2874 let mut col = 0usize;
2875 let mut i = 0usize;
2876 while col < cols_usize && i < row.len() {
2877 let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
2878
2879 match &row[i] {
2880 Cell::Char { ch, width, style, hyperlink } => {
2881 let mut eff = *style;
2887 if in_highlight {
2888 eff.reverse = !eff.reverse;
2889 }
2890 if base_style.dim && !eff.bold {
2891 eff.dim = true;
2892 }
2893 emit_style_diff(out, &prev_style, &eff, truecolor)?;
2894 emit_hyperlink_diff(out, &prev_link, hyperlink)?;
2895 out.queue(Print(*ch))?;
2896 prev_style = eff;
2897 prev_link = hyperlink.clone();
2898 col += *width as usize;
2899 }
2900 Cell::Continuation => {
2901 }
2903 Cell::Empty => {
2904 let default = if base_style.dim {
2909 crate::ansi::Style { dim: true, ..Default::default() }
2910 } else {
2911 crate::ansi::Style::default()
2912 };
2913 emit_style_diff(out, &prev_style, &default, truecolor)?;
2914 emit_hyperlink_diff(out, &prev_link, &None)?;
2915 out.queue(Print(' '))?;
2916 prev_style = default;
2917 prev_link = None;
2918 col += 1;
2919 }
2920 }
2921 i += 1;
2922 }
2923
2924 emit_hyperlink_diff(out, &prev_link, &None)?;
2927 out.queue(ResetColor)?;
2928 out.queue(SetAttribute(Attribute::Reset))?;
2929
2930 Ok(())
2931}
2932
2933fn render_overlay(
2934 out: &mut impl Write,
2935 frame: &crate::overlay::OverlayFrame,
2936 width: u16,
2937 height: u16,
2938) -> io::Result<()> {
2939 out.write_all(SYNC_UPDATE_BEGIN)?;
2943 out.queue(SetAttribute(Attribute::Reset))?;
2944 out.queue(ResetColor)?;
2945 for row in 0..height.saturating_sub(1) {
2946 out.queue(MoveTo(0, row))?;
2947 out.queue(Clear(ClearType::UntilNewLine))?;
2948 out.queue(SetAttribute(Attribute::Reset))?;
2949 if let Some(line) = frame.body.get(row as usize) {
2950 let mut written = 0usize;
2951 for ch in line.chars() {
2952 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2953 if written + w > width as usize { break; }
2954 write!(out, "{ch}")?;
2955 written += w;
2956 }
2957 }
2958 }
2959 out.queue(MoveTo(0, height.saturating_sub(1)))?;
2960 out.queue(Clear(ClearType::UntilNewLine))?;
2961 out.queue(SetAttribute(Attribute::Reverse))?;
2962 let mut status = frame.status.clone();
2963 if status.len() > width as usize {
2965 status.truncate(width as usize);
2966 } else {
2967 let pad = width as usize - status.len();
2968 status.push_str(&" ".repeat(pad));
2969 }
2970 out.queue(Print(status))?;
2971 out.queue(ResetColor)?;
2972 out.queue(SetAttribute(Attribute::Reset))?;
2973 out.write_all(SYNC_UPDATE_END)?;
2974 out.flush()
2975}
2976
2977#[cfg(test)]
2978mod tests {
2979 use super::*;
2980
2981 #[test]
2982 fn parse_colon_n() {
2983 assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
2984 assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
2985 }
2986
2987 #[test]
2988 fn current_line_bytes_strips_trailing_newline() {
2989 use crate::line_index::LineIndex;
2990 use crate::source::MockSource;
2991 let m = MockSource::new();
2992 m.append(b"alpha\nbravo\ncharlie");
2994 let mut idx = LineIndex::new();
2995 idx.extend_to_end(&m);
2996 assert_eq!(idx.line_count(), 3);
2997 assert_eq!(current_line_bytes(&idx, &m, 0), b"alpha");
2998 assert_eq!(current_line_bytes(&idx, &m, 1), b"bravo");
2999 assert_eq!(current_line_bytes(&idx, &m, 2), b"charlie");
3001 }
3002
3003 #[test]
3004 fn write_frame_brackets_with_sync_update_and_no_full_clear() {
3005 use crate::ansi::Style;
3010 use crate::render::Cell;
3011 use crate::viewport::{Frame, RowStyle};
3012
3013 let row: Vec<Cell> = (0..3)
3014 .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
3015 .collect();
3016 let frame = Frame {
3017 body: vec![row.clone(), row],
3018 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
3019 highlights: vec![Vec::new(), Vec::new()],
3020 status: "status".into(),
3021 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
3022 raw_rows: vec![None, None],
3023 image_blob: None,
3024 };
3025
3026 let mut buf: Vec<u8> = Vec::new();
3027 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
3028 let s = std::str::from_utf8(&buf).expect("ascii");
3029
3030 let begin = s.find("\x1b[?2026h").expect("begin sync update");
3032 let end = s.find("\x1b[?2026l").expect("end sync update");
3033 assert!(begin < end, "begin must precede end");
3034 let first_a = s.find('a').expect("body char");
3036 assert!(begin < first_a && first_a < end, "body must be inside sync update");
3037
3038 assert!(
3041 !s.contains("\x1b[2J"),
3042 "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
3043 );
3044 assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
3045 }
3046
3047 #[test]
3048 fn write_frame_emits_image_blob_verbatim_and_skips_cell_rows() {
3049 use crate::viewport::{Frame, RowStyle};
3050 let body_rows = 3usize;
3051 let cols = 10u16;
3052 let blob = b"\x1bPqDATA\x1b\\".to_vec();
3053 let mut body = vec![vec![crate::render::Cell::Empty; cols as usize]; body_rows];
3056 body[0][0] = crate::render::Cell::Char { ch: 'Z', width: 1, style: crate::ansi::Style::default(), hyperlink: None };
3057 let frame = Frame {
3058 body,
3059 row_styles: vec![RowStyle::Normal; body_rows],
3060 highlights: vec![Vec::new(); body_rows],
3061 status: "img".to_string(),
3062 status_style: crate::ansi::Style::default(),
3063 raw_rows: vec![None; body_rows],
3064 image_blob: Some(blob.clone()),
3065 };
3066 let mut out: Vec<u8> = Vec::new();
3067 write_frame(&mut out, &frame, cols, (body_rows + 1) as u16, true).unwrap();
3068 let needle = b"\x1bPqDATA\x1b\\";
3069 assert!(out.windows(needle.len()).any(|w| w == needle), "image blob emitted verbatim");
3070 assert!(String::from_utf8_lossy(&out).contains("img"), "status still drawn");
3071 assert!(!String::from_utf8_lossy(&out).contains('Z'), "cell loop skipped: body cell not rendered");
3072 }
3073
3074 #[test]
3075 fn fit_status_pads_by_display_width_not_bytes() {
3076 assert_eq!(fit_status_to_cols("ab", 5), "ab ");
3077 assert_eq!(fit_status_to_cols("\u{00d7}", 4), "\u{00d7} ");
3080 assert_eq!(fit_status_to_cols("hello", 3), "hel"); assert_eq!(fit_status_to_cols("", 3), " ");
3082 }
3083
3084 #[test]
3085 fn plain_status_row_drawn_in_place_without_clear() {
3086 let body_rows = 3usize;
3090 let cols = 12u16;
3091 let frame = Frame {
3092 body: vec![vec![crate::render::Cell::Empty; cols as usize]; body_rows],
3093 row_styles: vec![RowStyle::Normal; body_rows],
3094 highlights: vec![Vec::new(); body_rows],
3095 status: "[play 1/8]".to_string(),
3096 status_style: crate::ansi::Style::default(),
3097 raw_rows: vec![None; body_rows],
3098 image_blob: Some(b"\x1bPqX\x1b\\".to_vec()),
3099 };
3100 let mut out: Vec<u8> = Vec::new();
3101 write_frame(&mut out, &frame, cols, (body_rows + 1) as u16, true).unwrap();
3102 let clears = out.windows(3).filter(|w| *w == b"\x1b[K").count();
3103 assert_eq!(clears, body_rows, "status row must not be cleared (no blank-window flicker)");
3104 assert!(String::from_utf8_lossy(&out).contains("[play 1/8]"), "status text present");
3105 }
3106
3107 #[test]
3108 fn escaped_status_keeps_clear_then_print() {
3109 let body_rows = 2usize;
3113 let cols = 20u16;
3114 let frame = Frame {
3115 body: vec![vec![crate::render::Cell::Empty; cols as usize]; body_rows],
3116 row_styles: vec![RowStyle::Normal; body_rows],
3117 highlights: vec![Vec::new(); body_rows],
3118 status: "\x1b[31mred\x1b[0m".to_string(),
3119 status_style: crate::ansi::Style::default(),
3120 raw_rows: vec![None; body_rows],
3121 image_blob: None,
3122 };
3123 let mut out: Vec<u8> = Vec::new();
3124 write_frame(&mut out, &frame, cols, (body_rows + 1) as u16, true).unwrap();
3125 let clears = out.windows(3).filter(|w| *w == b"\x1b[K").count();
3126 assert_eq!(clears, body_rows + 1, "embedded-escape status keeps the clear");
3127 }
3128
3129 #[test]
3130 fn raw_rows_passthrough_emits_original_bytes_and_skips_continuation() {
3131 use crate::ansi::Style;
3132 use crate::render::Cell;
3133 use crate::viewport::{Frame, RowStyle};
3134
3135 let placeholder_row: Vec<Cell> = (0..3)
3137 .map(|_| Cell::Char { ch: 'X', width: 1, style: Style::default(), hyperlink: None })
3138 .collect();
3139 let frame = Frame {
3140 body: vec![placeholder_row.clone(), placeholder_row],
3141 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
3142 highlights: vec![Vec::new(), Vec::new()],
3143 status: "s".into(),
3144 status_style: Style { reverse: true, ..Default::default() },
3145 raw_rows: vec![Some(b"\x1b[31mABC\x1b[0m".to_vec()), Some(Vec::new())],
3148 image_blob: None,
3149 };
3150
3151 let mut buf: Vec<u8> = Vec::new();
3152 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
3153 let s = std::str::from_utf8(&buf).expect("ascii");
3154
3155 assert!(s.contains("\x1b[31mABC\x1b[0m"), "raw bytes missing in output: {s:?}");
3157 assert!(!s.contains("XXX"), "cell content leaked through despite raw passthrough: {s:?}");
3159 }
3160
3161 #[test]
3162 fn dim_row_keeps_dim_through_plain_cells_and_padding() {
3163 use crate::ansi::Style;
3168 use crate::render::Cell;
3169 let row = vec![
3170 Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
3171 Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
3172 Cell::Empty,
3173 Cell::Empty,
3174 ];
3175 let mut buf: Vec<u8> = Vec::new();
3176 let base = Style { dim: true, ..Default::default() };
3177 write_row_with_highlights(&mut buf, &row, 4, &[], base, true).unwrap();
3178 let s = String::from_utf8_lossy(&buf);
3179
3180 for needle in ['h', 'i'] {
3183 let pos = s.find(needle).expect("char printed");
3184 let before = &s[..pos];
3185 assert!(
3186 !before.contains("\x1b[22m"),
3187 "dim cleared before {needle:?}: {before:?}",
3188 );
3189 }
3190 let after_i = s.find('i').unwrap() + 1;
3193 let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
3194 let pad = &s[after_i..after_i + eor];
3195 assert!(
3196 !pad.contains("\x1b[22m"),
3197 "dim cleared in padding region: {pad:?}",
3198 );
3199 }
3200
3201 #[test]
3202 fn dim_row_yields_to_explicit_bold_cell() {
3203 use crate::ansi::Style;
3206 use crate::render::Cell;
3207 let row = vec![
3208 Cell::Char {
3209 ch: 'B',
3210 width: 1,
3211 style: Style { bold: true, ..Default::default() },
3212 hyperlink: None,
3213 },
3214 ];
3215 let mut buf: Vec<u8> = Vec::new();
3216 let base = Style { dim: true, ..Default::default() };
3217 write_row_with_highlights(&mut buf, &row, 1, &[], base, true).unwrap();
3218 let s = String::from_utf8_lossy(&buf);
3219 assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
3221 }
3222
3223 #[test]
3224 fn parse_colon_p() {
3225 assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
3226 assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
3227 }
3228
3229 #[test]
3230 fn parse_colon_e_with_path() {
3231 match parse_colon_command("e /tmp/foo.log").unwrap() {
3232 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
3233 other => panic!("expected Edit, got {other:?}"),
3234 }
3235 }
3236
3237 #[test]
3238 fn parse_colon_e_with_tilde() {
3239 let _guard = crate::test_env::lock();
3240 let saved = std::env::var_os("HOME");
3241 std::env::set_var("HOME", "/home/user");
3242 let result = parse_colon_command("e ~/foo.log");
3243 match saved {
3244 Some(v) => std::env::set_var("HOME", v),
3245 None => std::env::remove_var("HOME"),
3246 }
3247 match result.unwrap() {
3248 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
3249 other => panic!("expected Edit, got {other:?}"),
3250 }
3251 }
3252
3253 #[test]
3254 fn parse_colon_vsplit_and_only() {
3255 assert_eq!(parse_colon_command("vsplit").unwrap(), ColonCommand::VSplit(None));
3256 assert_eq!(parse_colon_command("split a.log").unwrap(), ColonCommand::VSplit(Some("a.log".into())));
3257 assert_eq!(parse_colon_command("only").unwrap(), ColonCommand::Only);
3258 assert_eq!(parse_colon_command("close").unwrap(), ColonCommand::Only);
3259 }
3260
3261 #[test]
3262 fn parse_colon_e_missing_path_errors() {
3263 assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
3264 assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
3265 }
3266
3267 #[test]
3268 fn parse_colon_f_q_d_x_t() {
3269 assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
3270 assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
3271 assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
3272 assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
3273 assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
3274 }
3275
3276 #[test]
3277 fn parse_unknown_command_errors() {
3278 let err = parse_colon_command("bogus").unwrap_err();
3279 match err {
3280 ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
3281 other => panic!("expected UnknownCommand, got {other:?}"),
3282 }
3283 }
3284
3285 #[test]
3286 fn parse_handles_whitespace() {
3287 assert_eq!(parse_colon_command("n ").unwrap(), ColonCommand::Next);
3289 assert_eq!(parse_colon_command(" n").unwrap(), ColonCommand::Next);
3290 }
3291
3292 #[test]
3293 fn parse_colon_tag_with_name() {
3294 assert_eq!(
3295 parse_colon_command("tag foo").unwrap(),
3296 ColonCommand::Tag("foo".into())
3297 );
3298 }
3299
3300 #[test]
3301 fn parse_colon_tag_strips_trailing_whitespace() {
3302 assert_eq!(
3303 parse_colon_command("tag foo ").unwrap(),
3304 ColonCommand::Tag("foo".into())
3305 );
3306 }
3307
3308 #[test]
3309 fn parse_colon_tag_without_name_errors() {
3310 assert_eq!(
3311 parse_colon_command("tag").unwrap_err(),
3312 ColonParseError::TagRequiresName
3313 );
3314 assert_eq!(
3315 parse_colon_command("tag ").unwrap_err(),
3316 ColonParseError::TagRequiresName
3317 );
3318 }
3319
3320 #[test]
3321 fn parse_colon_tnext_and_tprev() {
3322 assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
3323 assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
3324 }
3325
3326 #[test]
3327 fn parse_colon_tselect_without_arg_uses_active() {
3328 assert_eq!(parse_colon_command("tselect").unwrap(), ColonCommand::TagSelect(None));
3329 }
3330
3331 #[test]
3332 fn parse_colon_tselect_with_name() {
3333 assert_eq!(
3334 parse_colon_command("tselect foo").unwrap(),
3335 ColonCommand::TagSelect(Some("foo".into())),
3336 );
3337 }
3338
3339 #[test]
3340 fn parse_colon_b_opens_picker() {
3341 assert_eq!(parse_colon_command("b").unwrap(), ColonCommand::OpenPicker);
3342 assert_eq!(parse_colon_command("buffers").unwrap(), ColonCommand::OpenPicker);
3343 }
3344
3345 #[test]
3346 fn parse_colon_help_opens_help() {
3347 assert_eq!(parse_colon_command("h").unwrap(), ColonCommand::OpenHelp);
3348 assert_eq!(parse_colon_command("help").unwrap(), ColonCommand::OpenHelp);
3349 }
3350
3351 #[test]
3352 fn parse_colon_hex_with_valid_widths() {
3353 for n in [2usize, 4, 8, 16, 32] {
3354 assert_eq!(
3355 parse_colon_command(&format!("hex {n}")).unwrap(),
3356 ColonCommand::HexGroup(n),
3357 );
3358 }
3359 }
3360
3361 #[test]
3362 fn parse_colon_hex_without_value_errors() {
3363 assert_eq!(
3364 parse_colon_command("hex").unwrap_err(),
3365 ColonParseError::HexGroupRequiresValue,
3366 );
3367 }
3368
3369 #[test]
3370 fn parse_colon_hex_with_invalid_value_errors() {
3371 match parse_colon_command("hex 3").unwrap_err() {
3372 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "3"),
3373 other => panic!("expected HexGroupInvalid, got {other:?}"),
3374 }
3375 match parse_colon_command("hex banana").unwrap_err() {
3376 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "banana"),
3377 other => panic!("expected HexGroupInvalid, got {other:?}"),
3378 }
3379 }
3380
3381 #[test]
3382 fn parse_colon_color_without_arg_cycles() {
3383 assert_eq!(parse_colon_command("color").unwrap(), ColonCommand::Color(None));
3384 }
3385
3386 #[test]
3387 fn parse_colon_color_with_named_mode() {
3388 use crate::render::AnsiMode;
3389 assert_eq!(
3390 parse_colon_command("color strict").unwrap(),
3391 ColonCommand::Color(Some(AnsiMode::Strict)),
3392 );
3393 assert_eq!(
3394 parse_colon_command("color interpret").unwrap(),
3395 ColonCommand::Color(Some(AnsiMode::Interpret)),
3396 );
3397 assert_eq!(
3398 parse_colon_command("color raw").unwrap(),
3399 ColonCommand::Color(Some(AnsiMode::Raw)),
3400 );
3401 }
3402
3403 #[test]
3404 fn parse_colon_color_with_unknown_mode_errors() {
3405 match parse_colon_command("color rainbow").unwrap_err() {
3406 ColonParseError::ColorInvalid(v) => assert_eq!(v, "rainbow"),
3407 other => panic!("expected ColorInvalid, got {other:?}"),
3408 }
3409 }
3410
3411 #[test]
3412 fn parse_colon_case_without_arg_cycles() {
3413 assert_eq!(parse_colon_command("case").unwrap(), ColonCommand::Case(None));
3414 }
3415
3416 #[test]
3417 fn parse_colon_case_with_named_mode() {
3418 use crate::viewport::CaseMode;
3419 assert_eq!(parse_colon_command("case smart").unwrap(),
3420 ColonCommand::Case(Some(CaseMode::Smart)));
3421 assert_eq!(parse_colon_command("case sensitive").unwrap(),
3422 ColonCommand::Case(Some(CaseMode::Sensitive)));
3423 assert_eq!(parse_colon_command("case insensitive").unwrap(),
3424 ColonCommand::Case(Some(CaseMode::Insensitive)));
3425 }
3426
3427 #[test]
3428 fn parse_colon_case_unknown_errors() {
3429 match parse_colon_command("case rainbow").unwrap_err() {
3430 ColonParseError::CaseInvalid(v) => assert_eq!(v, "rainbow"),
3431 other => panic!("expected CaseInvalid, got {other:?}"),
3432 }
3433 }
3434
3435 #[test]
3436 fn parse_colon_hlsearch_on_off() {
3437 assert_eq!(parse_colon_command("hlsearch").unwrap(), ColonCommand::HlSearch(true));
3438 assert_eq!(parse_colon_command("nohlsearch").unwrap(), ColonCommand::HlSearch(false));
3439 }
3440
3441 #[test]
3442 fn parse_colon_incsearch_toggle() {
3443 assert_eq!(parse_colon_command("incsearch").unwrap(), ColonCommand::IncSearch);
3444 }
3445
3446 #[test]
3447 fn lcp_empty_slice() {
3448 assert_eq!(longest_common_prefix(&[]), "");
3449 }
3450
3451 #[test]
3452 fn lcp_single_item_returns_self() {
3453 assert_eq!(longest_common_prefix(&["foo".into()]), "foo");
3454 }
3455
3456 #[test]
3457 fn lcp_finds_shared_prefix() {
3458 let v: Vec<String> = vec!["foobar".into(), "foobaz".into(), "fooqux".into()];
3459 assert_eq!(longest_common_prefix(&v), "foo");
3460 }
3461
3462 #[test]
3463 fn lcp_no_shared_prefix_returns_empty() {
3464 let v: Vec<String> = vec!["abc".into(), "xyz".into()];
3465 assert_eq!(longest_common_prefix(&v), "");
3466 }
3467
3468 #[test]
3469 fn lcp_one_item_is_prefix_of_others() {
3470 let v: Vec<String> = vec!["foo".into(), "foobar".into(), "foobaz".into()];
3471 assert_eq!(longest_common_prefix(&v), "foo");
3472 }
3473
3474 #[test]
3475 fn tag_stack_push_pop_lifo() {
3476 let mut s = TagStack::default();
3477 s.push(0, 10);
3478 s.push(1, 20);
3479 assert_eq!(s.pop(), Some((1, 20)));
3480 assert_eq!(s.pop(), Some((0, 10)));
3481 assert_eq!(s.pop(), None);
3482 }
3483
3484 #[test]
3485 fn tag_stack_pop_clears_active() {
3486 let mut s = TagStack::default();
3487 s.push(0, 10);
3488 s.set_active(
3489 "foo".into(),
3490 vec![crate::tags::TagEntry {
3491 file: std::path::PathBuf::from("/a"),
3492 address: crate::tags::TagAddress::Line(1),
3493 }],
3494 );
3495 assert!(s.active.is_some());
3496 let _ = s.pop();
3497 assert!(s.active.is_none());
3498 }
3499
3500 #[test]
3501 fn tag_stack_next_advances_then_clamps() {
3502 let mut s = TagStack::default();
3503 s.set_active(
3504 "foo".into(),
3505 vec![
3506 crate::tags::TagEntry {
3507 file: std::path::PathBuf::from("/a"),
3508 address: crate::tags::TagAddress::Line(1),
3509 },
3510 crate::tags::TagEntry {
3511 file: std::path::PathBuf::from("/b"),
3512 address: crate::tags::TagAddress::Line(2),
3513 },
3514 ],
3515 );
3516 assert_eq!(s.next(), TagStepResult::Moved(1));
3517 assert_eq!(s.next(), TagStepResult::AtBoundary);
3518 }
3519
3520 #[test]
3521 fn tag_stack_prev_clamps_at_zero() {
3522 let mut s = TagStack::default();
3523 s.set_active(
3524 "foo".into(),
3525 vec![crate::tags::TagEntry {
3526 file: std::path::PathBuf::from("/a"),
3527 address: crate::tags::TagAddress::Line(1),
3528 }],
3529 );
3530 assert_eq!(s.prev(), TagStepResult::AtBoundary);
3531 }
3532
3533 #[test]
3534 fn tag_stack_next_with_no_active_returns_no_active() {
3535 let mut s = TagStack::default();
3536 assert_eq!(s.next(), TagStepResult::NoActive);
3537 assert_eq!(s.prev(), TagStepResult::NoActive);
3538 }
3539
3540 #[test]
3541 fn tag_stack_set_active_replaces_previous_list() {
3542 let mut s = TagStack::default();
3543 s.set_active(
3544 "foo".into(),
3545 vec![crate::tags::TagEntry {
3546 file: std::path::PathBuf::from("/a"),
3547 address: crate::tags::TagAddress::Line(1),
3548 }],
3549 );
3550 s.set_active(
3551 "bar".into(),
3552 vec![
3553 crate::tags::TagEntry {
3554 file: std::path::PathBuf::from("/x"),
3555 address: crate::tags::TagAddress::Line(5),
3556 },
3557 crate::tags::TagEntry {
3558 file: std::path::PathBuf::from("/y"),
3559 address: crate::tags::TagAddress::Line(6),
3560 },
3561 ],
3562 );
3563 let active = s.active.as_ref().unwrap();
3564 assert_eq!(active.name, "bar");
3565 assert_eq!(active.matches.len(), 2);
3566 assert_eq!(active.cursor, 0);
3567 }
3568
3569 #[test]
3570 fn writer_emits_color_for_red_cell() {
3571 let cells = vec![Cell::Char {
3572 ch: 'h',
3573 width: 1,
3574 style: crate::ansi::Style {
3575 fg: Some(crate::ansi::Color::Ansi(1)),
3576 ..Default::default()
3577 },
3578 hyperlink: None,
3579 }];
3580 let mut buf: Vec<u8> = Vec::new();
3581 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3582 let s = String::from_utf8_lossy(&buf);
3583 assert!(s.contains("\x1b["), "expected ANSI escape in output: {s:?}");
3584 assert!(s.contains('h'));
3585 }
3586
3587 #[test]
3588 fn writer_emits_osc8_for_hyperlink_cell() {
3589 let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
3590 let cells = vec![Cell::Char {
3591 ch: 'c',
3592 width: 1,
3593 style: crate::ansi::Style::default(),
3594 hyperlink: Some(link),
3595 }];
3596 let mut buf: Vec<u8> = Vec::new();
3597 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
3598 let s = String::from_utf8_lossy(&buf);
3599 assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
3600 }
3601}