1use std::collections::HashMap;
2use std::io::{self, Write};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::time::Duration;
6
7use crossterm::cursor::MoveTo;
8use crossterm::event::{poll, read, Event, KeyCode, KeyEvent, KeyModifiers};
9use crossterm::style::{Print, ResetColor, SetAttribute, SetForegroundColor, SetBackgroundColor, Attribute};
10use crossterm::terminal::{Clear, ClearType, size};
11use crossterm::QueueableCommand;
12
13use crate::error::Result;
14use crate::input::{translate, Command};
15use crate::marks::{mark_set, mark_jump, jump_previous, update_prev_position, is_valid_mark_name, MarkTarget};
16use crate::line_index::LineIndex;
17use crate::prettify::PrettifyMode;
18use crate::render::Cell;
19use crate::source::{find_tail_offset, Source};
20use crate::viewport::{Frame, RowStyle, SearchDirection, Viewport};
21
22#[derive(Default, Clone, Copy)]
26pub struct RebuildSpec {
27 pub head: Option<usize>,
28 pub tail: Option<usize>,
29}
30
31#[derive(Debug, Clone)]
33enum InputMode {
34 Normal,
35 OptionPrefix,
37 PrettifyPrefix,
40 SearchPrompt {
43 direction: SearchDirection,
44 buffer: String,
45 error: Option<String>,
48 },
49 ShellPrompt { buffer: String, error: Option<String> },
52 MarkSetPending,
54 MarkJumpPending,
56 CtrlXPending,
58 ColonPrompt { buffer: String, error: Option<String> },
61 TagPrompt {
68 buffer: String,
69 error: Option<String>,
70 last_tab_matches: Option<Vec<String>>,
71 },
72}
73
74#[derive(Debug, Clone, PartialEq)]
75enum ColonCommand {
76 Next,
77 Prev,
78 Edit(std::path::PathBuf),
79 ShowFile,
80 Quit,
81 Delete,
82 First,
83 Last,
84 Tag(String),
85 TagNext,
86 TagPrev,
87 TagSelect(Option<String>),
90 OpenPicker,
91 OpenHelp,
92 HexGroup(usize),
94 Color(Option<crate::render::AnsiMode>),
96 Case(Option<crate::viewport::CaseMode>),
99 HlSearch(bool),
102 Header(usize, usize),
104}
105
106#[derive(Debug, Clone, PartialEq)]
107enum ColonParseError {
108 UnknownCommand(String),
109 MissingPath,
110 TagRequiresName,
111 HexGroupRequiresValue,
112 HexGroupInvalid(String),
113 ColorInvalid(String),
114 CaseInvalid(String),
115 HeaderInvalid(String),
116}
117
118impl std::fmt::Display for ColonParseError {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 match self {
121 ColonParseError::UnknownCommand(t) => write!(f, "unknown command: :{t}"),
122 ColonParseError::MissingPath => write!(f, ":e requires a path"),
123 ColonParseError::TagRequiresName => write!(f, ":tag requires a name"),
124 ColonParseError::HexGroupRequiresValue => {
125 write!(f, ":hex requires N (one of 2, 4, 8, 16, 32)")
126 }
127 ColonParseError::HexGroupInvalid(v) => {
128 write!(f, ":hex N must be one of 2, 4, 8, 16, 32 (got {v})")
129 }
130 ColonParseError::ColorInvalid(v) => {
131 write!(f, ":color mode must be strict, interpret, or raw (got {v})")
132 }
133 ColonParseError::CaseInvalid(v) => {
134 write!(f, ":case mode must be sensitive, smart, or insensitive (got {v})")
135 }
136 ColonParseError::HeaderInvalid(v) => {
137 write!(f, ":header expects `L` or `L C` (got {v})")
138 }
139 }
140 }
141}
142
143fn parse_colon_command(buf: &str) -> std::result::Result<ColonCommand, ColonParseError> {
144 let buf = buf.trim();
145 if buf.is_empty() {
146 return Err(ColonParseError::UnknownCommand(String::new()));
147 }
148 let mut parts = buf.splitn(2, char::is_whitespace);
149 let cmd = parts.next().unwrap();
150 let rest = parts.next().unwrap_or("").trim();
151 match cmd {
152 "n" | "next" => Ok(ColonCommand::Next),
153 "p" | "prev" => Ok(ColonCommand::Prev),
154 "e" | "edit" => {
155 if rest.is_empty() {
156 Err(ColonParseError::MissingPath)
157 } else {
158 let expanded = if let Some(stripped) = rest.strip_prefix("~/") {
160 if let Some(home) = std::env::var_os("HOME") {
161 let mut p = std::path::PathBuf::from(home);
162 p.push(stripped);
163 p
164 } else {
165 std::path::PathBuf::from(rest)
166 }
167 } else {
168 std::path::PathBuf::from(rest)
169 };
170 Ok(ColonCommand::Edit(expanded))
171 }
172 }
173 "f" => Ok(ColonCommand::ShowFile),
174 "q" | "quit" => Ok(ColonCommand::Quit),
175 "d" | "delete" => Ok(ColonCommand::Delete),
176 "x" | "first" => Ok(ColonCommand::First),
177 "t" | "last" => Ok(ColonCommand::Last),
178 "tag" => {
179 if rest.is_empty() {
180 Err(ColonParseError::TagRequiresName)
181 } else {
182 Ok(ColonCommand::Tag(rest.to_string()))
183 }
184 }
185 "tnext" => Ok(ColonCommand::TagNext),
186 "tprev" => Ok(ColonCommand::TagPrev),
187 "tselect" => {
188 if rest.is_empty() {
189 Ok(ColonCommand::TagSelect(None))
190 } else {
191 Ok(ColonCommand::TagSelect(Some(rest.to_string())))
192 }
193 }
194 "b" | "buffers" => Ok(ColonCommand::OpenPicker),
195 "h" | "help" => Ok(ColonCommand::OpenHelp),
196 "hex" => {
197 if rest.is_empty() {
198 Err(ColonParseError::HexGroupRequiresValue)
199 } else {
200 match rest.parse::<usize>() {
201 Ok(n) if matches!(n, 2 | 4 | 8 | 16 | 32) => Ok(ColonCommand::HexGroup(n)),
202 _ => Err(ColonParseError::HexGroupInvalid(rest.to_string())),
203 }
204 }
205 }
206 "color" => {
207 if rest.is_empty() {
208 Ok(ColonCommand::Color(None))
209 } else {
210 match rest {
211 "strict" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Strict))),
212 "interpret" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Interpret))),
213 "raw" => Ok(ColonCommand::Color(Some(crate::render::AnsiMode::Raw))),
214 other => Err(ColonParseError::ColorInvalid(other.to_string())),
215 }
216 }
217 }
218 "hlsearch" => Ok(ColonCommand::HlSearch(true)),
219 "nohlsearch" => Ok(ColonCommand::HlSearch(false)),
220 "header" => {
221 let parts: Vec<&str> = rest.split_whitespace().collect();
222 match parts.as_slice() {
223 [l] => {
224 let n: usize = l.parse()
225 .map_err(|_| ColonParseError::HeaderInvalid(l.to_string()))?;
226 Ok(ColonCommand::Header(n, 0))
227 }
228 [l, c] => {
229 let nl: usize = l.parse()
230 .map_err(|_| ColonParseError::HeaderInvalid(l.to_string()))?;
231 let nc: usize = c.parse()
232 .map_err(|_| ColonParseError::HeaderInvalid(c.to_string()))?;
233 Ok(ColonCommand::Header(nl, nc))
234 }
235 _ => Err(ColonParseError::HeaderInvalid(rest.to_string())),
236 }
237 }
238 "case" => {
239 if rest.is_empty() {
240 Ok(ColonCommand::Case(None))
241 } else {
242 match rest {
243 "sensitive" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Sensitive))),
244 "smart" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Smart))),
245 "insensitive" => Ok(ColonCommand::Case(Some(crate::viewport::CaseMode::Insensitive))),
246 other => Err(ColonParseError::CaseInvalid(other.to_string())),
247 }
248 }
249 }
250 other => Err(ColonParseError::UnknownCommand(other.to_string())),
251 }
252}
253
254enum ColonOutcome {
255 Continue(Option<String>), Quit,
257 DispatchCommand(Command),
261}
262
263#[derive(Debug, Default)]
264struct TagStack {
265 history: Vec<(usize, usize)>,
268 active: Option<ActiveMatches>,
271}
272
273#[derive(Debug, Clone)]
274struct ActiveMatches {
275 name: String,
276 matches: Vec<crate::tags::TagEntry>,
277 cursor: usize,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
281enum TagStepResult {
282 Moved(usize),
284 AtBoundary,
286 NoActive,
288}
289
290impl TagStack {
291 fn push(&mut self, file_index: usize, top_line: usize) {
292 self.history.push((file_index, top_line));
293 }
294
295 fn pop(&mut self) -> Option<(usize, usize)> {
296 let popped = self.history.pop();
297 if popped.is_some() {
298 self.active = None;
299 }
300 popped
301 }
302
303 fn set_active(&mut self, name: String, matches: Vec<crate::tags::TagEntry>) {
304 self.active = Some(ActiveMatches {
305 name,
306 matches,
307 cursor: 0,
308 });
309 }
310
311 fn next(&mut self) -> TagStepResult {
312 let Some(a) = &mut self.active else {
313 return TagStepResult::NoActive;
314 };
315 if a.cursor + 1 >= a.matches.len() {
316 TagStepResult::AtBoundary
317 } else {
318 a.cursor += 1;
319 TagStepResult::Moved(a.cursor)
320 }
321 }
322
323 fn prev(&mut self) -> TagStepResult {
324 let Some(a) = &mut self.active else {
325 return TagStepResult::NoActive;
326 };
327 if a.cursor == 0 {
328 TagStepResult::AtBoundary
329 } else {
330 a.cursor -= 1;
331 TagStepResult::Moved(a.cursor)
332 }
333 }
334}
335
336fn refresh_tag_file(tag_file: &mut Option<crate::tags::TagFile>) -> Option<String> {
342 match tag_file.as_mut()?.reload_if_changed() {
343 Ok(true) => Some("[tags reloaded]".into()),
344 _ => None,
345 }
346}
347
348fn longest_common_prefix(items: &[String]) -> String {
352 let mut iter = items.iter();
353 let Some(first) = iter.next() else { return String::new() };
354 let mut prefix = first.clone();
355 for s in iter {
356 while !s.starts_with(&prefix) {
357 prefix.pop();
358 if prefix.is_empty() {
359 return prefix;
360 }
361 }
362 }
363 prefix
364}
365
366#[allow(clippy::too_many_arguments)]
369fn dispatch_tag_jump(
370 name: &str,
371 tag_file: Option<&crate::tags::TagFile>,
372 tag_stack: &mut TagStack,
373 file_set: &mut crate::file_set::FileSet,
374 current_file_index: &mut usize,
375 args: &crate::cli::Args,
376 preprocessor: Option<&crate::preprocess::Preprocessor>,
377 record_start_regex: Option<®ex::bytes::Regex>,
378 viewport: &mut crate::viewport::Viewport,
379 src: &mut Box<dyn crate::source::Source>,
380 idx: &mut crate::line_index::LineIndex,
381) -> Option<String> {
382 let Some(tf) = tag_file else {
383 return Some("[no tags file loaded]".into());
384 };
385 let matches = tf.lookup(name);
386 if matches.is_empty() {
387 return Some(format!("[tag not found: {name}]"));
388 }
389 let matches: Vec<crate::tags::TagEntry> = matches.to_vec();
390 tag_stack.push(*current_file_index, viewport.top_line());
391 tag_stack.set_active(name.to_string(), matches.clone());
392 let msg = dispatch_match(
393 &matches[0],
394 file_set,
395 current_file_index,
396 args,
397 preprocessor,
398 record_start_regex,
399 viewport,
400 src,
401 idx,
402 );
403 update_viewport_tag_indicator(tag_stack, viewport);
404 msg
405}
406
407#[allow(clippy::too_many_arguments)]
408fn dispatch_match(
409 entry: &crate::tags::TagEntry,
410 file_set: &mut crate::file_set::FileSet,
411 current_file_index: &mut usize,
412 args: &crate::cli::Args,
413 preprocessor: Option<&crate::preprocess::Preprocessor>,
414 record_start_regex: Option<®ex::bytes::Regex>,
415 viewport: &mut crate::viewport::Viewport,
416 src: &mut Box<dyn crate::source::Source>,
417 idx: &mut crate::line_index::LineIndex,
418) -> Option<String> {
419 let target_file = entry.file.as_path();
420 let already_current = file_set
421 .current()
422 .map(|p| p == target_file)
423 .unwrap_or(false);
424
425 if !already_current {
426 let existing_idx = (0..file_set.len()).find(|i| {
427 file_set
428 .nth(*i)
429 .map(|p| p == target_file)
430 .unwrap_or(false)
431 });
432 match existing_idx {
433 Some(i) => {
434 file_set.set_current_index(i);
435 }
436 None => {
437 file_set.append_and_switch(target_file.to_path_buf());
438 }
439 }
440 let path = file_set.current().unwrap().to_path_buf();
441 if let Err(e) = switch_file(
442 &path,
443 file_set.current_index(),
444 file_set.len(),
445 args,
446 preprocessor,
447 viewport,
448 src,
449 idx,
450 record_start_regex,
451 ) {
452 return Some(format!("[open: {e}]"));
453 }
454 *current_file_index = file_set.current_index();
455 }
456
457 let (line, hint) = match resolve_tag_address(&entry.address, src.as_ref(), idx, 0) {
458 AddressResult::Line(l) => (l, None),
459 AddressResult::NotFound => (0, Some("[tag pattern not found]".into())),
460 AddressResult::Unsupported(raw) => (
461 0,
462 Some(format!("[tag address not supported: {raw}]")),
463 ),
464 };
465
466 let clamped = line.min(idx.line_count().saturating_sub(1));
467 viewport.goto_line(clamped, src.as_ref(), idx);
468 hint
469}
470
471enum AddressResult {
472 Line(usize),
473 NotFound,
474 Unsupported(String),
475}
476
477fn resolve_tag_address(
481 addr: &crate::tags::TagAddress,
482 src: &dyn crate::source::Source,
483 idx: &mut crate::line_index::LineIndex,
484 from_line: usize,
485) -> AddressResult {
486 match addr {
487 crate::tags::TagAddress::Line(n) => AddressResult::Line(n.saturating_sub(1)),
488 crate::tags::TagAddress::Pattern(p) => {
489 let re_src = crate::tags::pattern_to_regex(p);
490 let re = match regex::bytes::Regex::new(&re_src) {
491 Ok(r) => r,
492 Err(_) => return AddressResult::NotFound,
493 };
494 match find_pattern_line(src, idx, &re, from_line) {
495 Some(l) => AddressResult::Line(l),
496 None => AddressResult::NotFound,
497 }
498 }
499 crate::tags::TagAddress::Chained(parts) => {
500 let mut here = from_line;
501 for step in parts {
502 match resolve_tag_address(step, src, idx, here) {
503 AddressResult::Line(l) => here = l + 1,
504 other => return other,
505 }
506 }
507 AddressResult::Line(here.saturating_sub(1))
510 }
511 crate::tags::TagAddress::Unsupported(raw) => {
512 AddressResult::Unsupported(raw.clone())
513 }
514 }
515}
516
517fn find_pattern_line(
518 src: &dyn crate::source::Source,
519 idx: &mut crate::line_index::LineIndex,
520 re: ®ex::bytes::Regex,
521 from_line: usize,
522) -> Option<usize> {
523 idx.extend_to_end(src);
524 for line_no in from_line..idx.line_count() {
525 let bytes = idx.line_bytes_stripped(line_no, src);
526 if re.is_match(&bytes) {
527 return Some(line_no);
528 }
529 }
530 None
531}
532
533fn update_viewport_tag_indicator(stack: &TagStack, viewport: &mut crate::viewport::Viewport) {
534 viewport.set_tag_active(stack.active.as_ref().map(|a| {
535 (a.name.clone(), a.cursor + 1, a.matches.len())
536 }));
537}
538
539#[allow(clippy::too_many_arguments)]
543fn switch_to_current_file(
544 file_set: &mut crate::file_set::FileSet,
545 current_file_index: &mut usize,
546 args: &crate::cli::Args,
547 preprocessor: Option<&crate::preprocess::Preprocessor>,
548 record_start_regex: Option<®ex::bytes::Regex>,
549 viewport: &mut crate::viewport::Viewport,
550 src: &mut Box<dyn crate::source::Source>,
551 idx: &mut crate::line_index::LineIndex,
552) -> Option<String> {
553 let path = match file_set.current() {
554 Some(p) => p.to_path_buf(),
555 None => return Some("[empty file set]".into()),
556 };
557 let new_idx_val = file_set.current_index();
558 match switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
559 Ok(()) => {
560 *current_file_index = new_idx_val;
561 None
562 }
563 Err(e) => Some(format!("[open: {e}]")),
564 }
565}
566
567#[allow(clippy::too_many_arguments)]
568fn switch_file(
569 new_path: &std::path::Path,
570 new_file_index: usize,
571 total_files: usize,
572 args: &crate::cli::Args,
573 preprocessor: Option<&crate::preprocess::Preprocessor>,
574 viewport: &mut crate::viewport::Viewport,
575 src: &mut Box<dyn crate::source::Source>,
576 idx: &mut crate::line_index::LineIndex,
577 record_start_regex: Option<®ex::bytes::Regex>,
578) -> crate::error::Result<()> {
579 let (new_src, new_label, new_failure) =
580 crate::open::open_source_for_path(new_path, args, preprocessor)?;
581
582 *src = new_src;
583 let mut new_idx = crate::line_index::LineIndex::new();
584 if let Some(re) = record_start_regex {
585 new_idx.set_record_start(re.clone());
586 }
587 *idx = new_idx;
588
589 viewport.set_source_label(new_label);
590 viewport.set_file_index(new_file_index, total_files);
591 viewport.set_preprocess_failure(new_failure);
592 viewport.goto_top();
593 viewport.reset_hscroll(); Ok(())
596}
597
598#[allow(clippy::too_many_arguments)]
599fn dispatch_colon_command(
600 cmd: ColonCommand,
601 file_set: &mut crate::file_set::FileSet,
602 current_file_index: &mut usize,
603 args: &crate::cli::Args,
604 preprocessor: Option<&crate::preprocess::Preprocessor>,
605 record_start_regex: Option<®ex::bytes::Regex>,
606 viewport: &mut crate::viewport::Viewport,
607 src: &mut Box<dyn crate::source::Source>,
608 idx: &mut crate::line_index::LineIndex,
609 tag_stack: &mut TagStack,
610 tag_file: Option<&crate::tags::TagFile>,
611) -> ColonOutcome {
612 match cmd {
613 ColonCommand::Next => {
614 match file_set.next() {
615 Ok(path) => {
616 let path = path.to_path_buf();
617 let new_idx_val = file_set.current_index();
618 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
619 ColonOutcome::Continue(Some(format!("[open: {e}]")))
620 } else {
621 *current_file_index = new_idx_val;
622 ColonOutcome::Continue(None)
623 }
624 }
625 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
626 }
627 }
628 ColonCommand::Prev => {
629 match file_set.prev() {
630 Ok(path) => {
631 let path = path.to_path_buf();
632 let new_idx_val = file_set.current_index();
633 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
634 ColonOutcome::Continue(Some(format!("[open: {e}]")))
635 } else {
636 *current_file_index = new_idx_val;
637 ColonOutcome::Continue(None)
638 }
639 }
640 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
641 }
642 }
643 ColonCommand::Edit(path) => {
644 match crate::open::open_source_for_path(&path, args, preprocessor) {
646 Ok(_) => {
647 let final_path = file_set.append_and_switch(path.clone()).to_path_buf();
649 let new_idx_val = file_set.current_index();
650 if let Err(e) = switch_file(&final_path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
651 ColonOutcome::Continue(Some(format!("[open: {e}]")))
652 } else {
653 *current_file_index = new_idx_val;
654 ColonOutcome::Continue(None)
655 }
656 }
657 Err(e) => ColonOutcome::Continue(Some(format!("[open: {}: {e}]", path.display()))),
658 }
659 }
660 ColonCommand::ShowFile => {
661 let label = viewport.source_label_clone();
662 let cur = file_set.current_index() + 1;
663 let total = file_set.len();
664 let top = viewport.top_line() + 1;
665 let total_lines = idx.line_count();
666 let msg = if total > 1 {
667 format!("{label} (file {cur}/{total}): line {top}/{total_lines}")
668 } else {
669 format!("{label}: line {top}/{total_lines}")
670 };
671 ColonOutcome::Continue(Some(msg))
672 }
673 ColonCommand::Quit => ColonOutcome::Quit,
674 ColonCommand::Delete => {
675 match file_set.delete_current() {
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::First => {
690 if file_set.current_index() == 0 {
691 ColonOutcome::Continue(None) } else if let Some(path) = file_set.first() {
693 let path = path.to_path_buf();
694 let new_idx_val = file_set.current_index();
695 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
696 ColonOutcome::Continue(Some(format!("[open: {e}]")))
697 } else {
698 *current_file_index = new_idx_val;
699 ColonOutcome::Continue(None)
700 }
701 } else {
702 ColonOutcome::Continue(None)
703 }
704 }
705 ColonCommand::Last => {
706 if file_set.current_index() + 1 == file_set.len() {
707 ColonOutcome::Continue(None)
708 } else if let Some(path) = file_set.last() {
709 let path = path.to_path_buf();
710 let new_idx_val = file_set.current_index();
711 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
712 ColonOutcome::Continue(Some(format!("[open: {e}]")))
713 } else {
714 *current_file_index = new_idx_val;
715 ColonOutcome::Continue(None)
716 }
717 } else {
718 ColonOutcome::Continue(None)
719 }
720 }
721 ColonCommand::Tag(name) => {
722 match dispatch_tag_jump(
723 &name,
724 tag_file,
725 tag_stack,
726 file_set,
727 current_file_index,
728 args,
729 preprocessor,
730 record_start_regex,
731 viewport,
732 src,
733 idx,
734 ) {
735 Some(msg) => ColonOutcome::Continue(Some(msg)),
736 None => ColonOutcome::Continue(None),
737 }
738 }
739 ColonCommand::TagNext => match tag_stack.next() {
740 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
741 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[no more matches]".into())),
742 TagStepResult::Moved(cur) => {
743 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
744 let msg = dispatch_match(
745 &entry,
746 file_set,
747 current_file_index,
748 args,
749 preprocessor,
750 record_start_regex,
751 viewport,
752 src,
753 idx,
754 );
755 update_viewport_tag_indicator(tag_stack, viewport);
756 ColonOutcome::Continue(msg)
757 }
758 },
759 ColonCommand::TagSelect(name) => {
760 let prepared = match name {
761 Some(n) => {
762 let tf = match tag_file {
763 Some(t) => t,
764 None => {
765 return ColonOutcome::Continue(Some(
766 "[no tags file loaded]".into(),
767 ))
768 }
769 };
770 let matches: Vec<crate::tags::TagEntry> = tf.lookup(&n).to_vec();
771 if matches.is_empty() {
772 return ColonOutcome::Continue(Some(
773 format!("[no matches for `{n}`]"),
774 ));
775 }
776 tag_stack.set_active(n, matches);
777 true
778 }
779 None => tag_stack.active.is_some(),
780 };
781 if prepared {
782 ColonOutcome::DispatchCommand(Command::OpenTagPicker)
783 } else {
784 ColonOutcome::Continue(Some("[no active tag]".into()))
785 }
786 }
787 ColonCommand::TagPrev => match tag_stack.prev() {
788 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
789 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[at first match]".into())),
790 TagStepResult::Moved(cur) => {
791 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
792 let msg = dispatch_match(
793 &entry,
794 file_set,
795 current_file_index,
796 args,
797 preprocessor,
798 record_start_regex,
799 viewport,
800 src,
801 idx,
802 );
803 update_viewport_tag_indicator(tag_stack, viewport);
804 ColonOutcome::Continue(msg)
805 }
806 },
807 ColonCommand::OpenPicker => ColonOutcome::DispatchCommand(Command::OpenPicker),
810 ColonCommand::OpenHelp => ColonOutcome::DispatchCommand(Command::OpenHelp),
811 ColonCommand::HexGroup(hex_chars) => {
812 if !viewport.hex_mode() {
813 return ColonOutcome::Continue(Some(
814 "[:hex requires --hex mode]".into(),
815 ));
816 }
817 let bpg = crate::hex::hex_chars_to_bytes_per_group(hex_chars).unwrap();
819 viewport.set_hex_group_size(bpg);
820 ColonOutcome::Continue(Some(format!("[hex group: {hex_chars} chars]")))
821 }
822 ColonCommand::Color(mode) => {
823 use crate::render::AnsiMode;
824 let next = mode.unwrap_or_else(|| match viewport.ansi_mode() {
825 AnsiMode::Strict => AnsiMode::Interpret,
826 AnsiMode::Interpret => AnsiMode::Raw,
827 AnsiMode::Raw => AnsiMode::Strict,
828 });
829 viewport.set_ansi_mode(next);
830 let label = match next {
831 AnsiMode::Strict => "strict",
832 AnsiMode::Interpret => "interpret",
833 AnsiMode::Raw => "raw",
834 };
835 ColonOutcome::Continue(Some(format!("[color: {label}]")))
836 }
837 ColonCommand::Header(l, c) => {
838 viewport.set_header(l, c);
839 ColonOutcome::Continue(Some(format!("[header: {l} rows, {c} cols]")))
840 }
841 ColonCommand::HlSearch(on) => {
842 viewport.set_hilite_search(on);
843 let msg = if on { "[hlsearch on]" } else { "[hlsearch off]" };
844 ColonOutcome::Continue(Some(msg.into()))
845 }
846 ColonCommand::Case(mode) => {
847 use crate::viewport::CaseMode;
848 let next = mode.unwrap_or_else(|| match viewport.case_mode() {
849 CaseMode::Sensitive => CaseMode::Smart,
850 CaseMode::Smart => CaseMode::Insensitive,
851 CaseMode::Insensitive => CaseMode::Sensitive,
852 });
853 viewport.set_case_mode(next);
854 let label = match next {
855 CaseMode::Sensitive => "sensitive",
856 CaseMode::Smart => "smart",
857 CaseMode::Insensitive => "insensitive",
858 };
859 ColonOutcome::Continue(Some(format!("[case: {label}]")))
860 }
861 }
862}
863
864#[allow(clippy::too_many_arguments, clippy::collapsible_match)]
865pub fn run(
866 mut src: Box<dyn Source>,
867 mut viewport: Viewport,
868 mut idx: LineIndex,
869 sigterm: Arc<AtomicBool>,
870 rebuild_spec: RebuildSpec,
871 keymap: crate::keys::KeyMap,
872 mut file_set: crate::file_set::FileSet,
873 record_start_regex: Option<regex::bytes::Regex>,
874 args: crate::cli::Args,
875 preprocessor: Option<crate::preprocess::Preprocessor>,
876 mut tag_file: Option<crate::tags::TagFile>,
877) -> Result<()> {
878 let (mut cols, mut rows) = size().unwrap_or((80, 24));
879 viewport.resize(cols, rows);
880
881 let truecolor = match args.truecolor.as_str() {
882 "always" => true,
883 "never" => false,
884 _ => crate::render::TrueColor::Auto.resolve(),
885 };
886
887 let mut stdout = io::stdout();
888 let timeout = Duration::from_millis(250);
889 let mut last_revision = src.revision();
890
891 if (viewport.filter_active() || viewport.grep_active()) && !viewport.dim_mode() {
896 idx.extend_to_end(src.as_ref());
897 viewport.extend_visible_lines(&idx, src.as_ref());
898 }
899
900 if viewport.follow_mode() || viewport.live_mode() {
905 src.pump();
906 viewport.extend_visible_lines(&idx, src.as_ref());
907 viewport.goto_bottom(src.as_ref(), &mut idx);
908 }
909
910 let mut needs_redraw = true;
912 let mut mode = InputMode::Normal;
913 let mut numeric_prefix: Option<usize> = None;
914 let mut marks: HashMap<char, (usize, usize)> = HashMap::new();
915 let mut previous_position: Option<(usize, usize)> = None;
916 let mut current_file_index: usize = file_set.current_index();
917 let mut transient_status: Option<String> = None;
918 let mut tag_stack = TagStack::default();
919 let mut overlay: Option<Box<dyn crate::overlay::Overlay>> = None;
920 let mut overlay_flash: Option<(&'static str, std::time::Instant)> = None;
921 let mouse_enabled = args.mouse;
922
923 if let Some(tag_name) = args.tag.as_deref() {
924 let _ = refresh_tag_file(&mut tag_file);
925 if let Some(msg) = dispatch_tag_jump(
926 tag_name,
927 tag_file.as_ref(),
928 &mut tag_stack,
929 &mut file_set,
930 &mut current_file_index,
931 &args,
932 preprocessor.as_ref(),
933 record_start_regex.as_ref(),
934 &mut viewport,
935 &mut src,
936 &mut idx,
937 ) {
938 return Err(crate::error::Error::Runtime(format!("startup tag jump failed: {msg}")));
939 }
940 }
941
942 loop {
943 if sigterm.load(Ordering::SeqCst) {
944 break;
945 }
946
947 if needs_redraw {
948 if let Some(ov) = overlay.as_ref() {
949 let w = cols;
950 let h = viewport.body_rows() + 1;
951 let mut ovframe = ov.render(w, h);
952 if let Some((msg, started)) = overlay_flash {
953 if started.elapsed() < std::time::Duration::from_millis(1500) {
954 ovframe.status = format!("[{msg}]");
955 } else {
956 overlay_flash = None;
957 }
958 }
959 render_overlay(&mut stdout, &ovframe, w, h)
960 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
961 needs_redraw = false;
962 continue;
963 }
964 let mut frame = viewport.frame(src.as_ref(), &mut idx);
965 match &mode {
968 InputMode::SearchPrompt { direction, buffer, error } => {
969 let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
970 frame.status = match error {
971 Some(e) => format!("{prefix}{buffer} [error: {e}]"),
972 None => format!("{prefix}{buffer}"),
973 };
974 }
975 InputMode::ShellPrompt { buffer, error } => {
976 frame.status = match error {
977 Some(e) => format!("!{buffer} [error: {e}]"),
978 None => format!("!{buffer}"),
979 };
980 }
981 InputMode::ColonPrompt { buffer, error } => {
982 frame.status = match error {
983 Some(e) => format!(":{buffer} [error: {e}]"),
984 None => format!(":{buffer}"),
985 };
986 }
987 InputMode::TagPrompt { buffer, error, .. } => {
988 frame.status = match error {
989 Some(e) => format!("tag: {buffer} [error: {e}]"),
990 None => format!("tag: {buffer}"),
991 };
992 }
993 _ => {
994 if let Some(msg) = transient_status.take() {
995 frame.status = msg;
996 }
997 }
998 }
999 write_frame(&mut stdout, &frame, cols, rows, truecolor)
1000 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
1001 needs_redraw = false;
1002 }
1003
1004 match poll(timeout) {
1006 Ok(true) => {
1007 let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
1008 match &mut mode {
1011 InputMode::SearchPrompt { direction, buffer, error } => {
1012 if let Event::Key(KeyEvent { code, .. }) = event {
1013 match code {
1014 KeyCode::Esc => { mode = InputMode::Normal; needs_redraw = true; }
1015 KeyCode::Enter => {
1016 if buffer.is_empty() {
1017 if viewport.search_active() {
1021 let reverse = !matches!(
1022 (viewport.search_direction(), *direction),
1023 (SearchDirection::Forward, SearchDirection::Forward)
1024 | (SearchDirection::Backward, SearchDirection::Backward)
1025 );
1026 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1027 viewport.search_repeat(src.as_ref(), &mut idx, reverse);
1028 }
1029 mode = InputMode::Normal;
1030 } else {
1031 match viewport.set_search(buffer.clone(), *direction) {
1032 Ok(()) => {
1033 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1034 viewport.search_repeat(src.as_ref(), &mut idx, false);
1035 mode = InputMode::Normal;
1036 }
1037 Err(e) => { *error = Some(e); }
1038 }
1039 }
1040 needs_redraw = true;
1041 }
1042 KeyCode::Backspace => {
1043 buffer.pop();
1044 *error = None;
1045 needs_redraw = true;
1046 }
1047 KeyCode::Char(c) => {
1048 buffer.push(c);
1049 *error = None;
1050 needs_redraw = true;
1051 }
1052 _ => {}
1053 }
1054 }
1055 continue;
1056 }
1057 InputMode::OptionPrefix => {
1058 if let Event::Key(KeyEvent { code, .. }) = event {
1059 match code {
1060 KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
1061 KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
1062 KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
1063 KeyCode::Char('P') | KeyCode::Char('p') => {
1064 mode = InputMode::PrettifyPrefix;
1066 needs_redraw = true;
1067 continue;
1068 }
1069 _ => {}
1070 }
1071 }
1072 mode = InputMode::Normal;
1073 needs_redraw = true;
1074 continue;
1075 }
1076 InputMode::PrettifyPrefix => {
1077 if let Event::Key(KeyEvent { code, .. }) = event {
1078 let target: Option<PrettifyTarget> = match code {
1079 KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
1080 KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
1081 KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
1082 KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
1083 KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
1084 KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
1085 KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
1086 KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
1087 _ => None,
1088 };
1089 if let Some(t) = target {
1090 apply_prettify(
1091 src.as_ref(),
1092 &mut viewport,
1093 &mut idx,
1094 rebuild_spec,
1095 t,
1096 );
1097 last_revision = src.revision();
1098 }
1099 }
1100 mode = InputMode::Normal;
1101 needs_redraw = true;
1102 continue;
1103 }
1104 InputMode::MarkSetPending => {
1105 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1106 if is_valid_mark_name(c) {
1107 mark_set(&mut marks, c, current_file_index, viewport.top_line());
1108 }
1109 }
1110 mode = InputMode::Normal;
1111 continue;
1112 }
1113 InputMode::MarkJumpPending => {
1114 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
1115 if is_valid_mark_name(c) {
1116 match mark_jump(&marks, c, current_file_index, &mut previous_position, viewport.top_line()) {
1117 Some(MarkTarget::SameFile { line }) => {
1118 let clamped = line.min(idx.line_count().saturating_sub(1));
1119 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1120 needs_redraw = true;
1121 }
1122 Some(MarkTarget::OtherFile { file_index, line }) => {
1123 if file_index < file_set.len() {
1124 file_set.set_current_index(file_index);
1125 let path = file_set.current().unwrap().to_path_buf();
1126 if let Err(e) = switch_file(
1127 &path, file_index, file_set.len(),
1128 &args, preprocessor.as_ref(),
1129 &mut viewport, &mut src, &mut idx,
1130 record_start_regex.as_ref(),
1131 ) {
1132 transient_status = Some(format!("[open: {e}]"));
1133 } else {
1134 let clamped = line.min(idx.line_count().saturating_sub(1));
1135 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1136 current_file_index = file_index;
1137 needs_redraw = true;
1138 }
1139 }
1140 }
1141 None => {}
1142 }
1143 }
1144 }
1145 mode = InputMode::Normal;
1146 continue;
1147 }
1148 InputMode::ShellPrompt { buffer, error } => {
1149 if let Event::Key(KeyEvent { code, .. }) = event {
1150 match code {
1151 KeyCode::Esc => {
1152 mode = InputMode::Normal;
1153 needs_redraw = true;
1154 }
1155 KeyCode::Enter => {
1156 if buffer.is_empty() {
1157 mode = InputMode::Normal;
1158 } else {
1159 match crate::shell::run_shell_command(buffer) {
1160 Ok(()) => {
1161 mode = InputMode::Normal;
1162 }
1163 Err(e) => {
1164 *error = Some(e.to_string());
1165 }
1166 }
1167 }
1168 needs_redraw = true;
1169 }
1170 KeyCode::Backspace => {
1171 buffer.pop();
1172 *error = None;
1173 needs_redraw = true;
1174 }
1175 KeyCode::Char(c) => {
1176 buffer.push(c);
1177 *error = None;
1178 needs_redraw = true;
1179 }
1180 _ => {}
1181 }
1182 }
1183 continue;
1184 }
1185 InputMode::CtrlXPending => {
1186 let is_ctrl_x = matches!(
1187 event,
1188 Event::Key(KeyEvent {
1189 code: KeyCode::Char('x'),
1190 modifiers: KeyModifiers::CONTROL,
1191 ..
1192 })
1193 );
1194 if is_ctrl_x {
1195 match jump_previous(&mut previous_position, current_file_index, viewport.top_line()) {
1196 Some(MarkTarget::SameFile { line }) => {
1197 let clamped = line.min(idx.line_count().saturating_sub(1));
1198 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1199 needs_redraw = true;
1200 }
1201 Some(MarkTarget::OtherFile { file_index, line }) => {
1202 if file_index < file_set.len() {
1203 file_set.set_current_index(file_index);
1204 let path = file_set.current().unwrap().to_path_buf();
1205 if let Err(e) = switch_file(
1206 &path, file_index, file_set.len(),
1207 &args, preprocessor.as_ref(),
1208 &mut viewport, &mut src, &mut idx,
1209 record_start_regex.as_ref(),
1210 ) {
1211 transient_status = Some(format!("[open: {e}]"));
1212 } else {
1213 let clamped = line.min(idx.line_count().saturating_sub(1));
1214 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1215 current_file_index = file_index;
1216 needs_redraw = true;
1217 }
1218 }
1219 }
1220 None => {}
1221 }
1222 mode = InputMode::Normal;
1223 continue;
1224 }
1225 mode = InputMode::Normal;
1227 }
1229 InputMode::ColonPrompt { buffer, error } => {
1230 if let Event::Key(KeyEvent { code, .. }) = event {
1231 match code {
1232 KeyCode::Esc => {
1233 mode = InputMode::Normal;
1234 needs_redraw = true;
1235 }
1236 KeyCode::Enter => {
1237 if buffer.is_empty() {
1238 mode = InputMode::Normal;
1239 } else {
1240 match parse_colon_command(buffer) {
1241 Ok(cmd) => {
1242 let is_tag_cmd = matches!(
1243 &cmd,
1244 ColonCommand::Tag(_)
1245 | ColonCommand::TagNext
1246 | ColonCommand::TagPrev
1247 | ColonCommand::TagSelect(_),
1248 );
1249 let reload_msg = if is_tag_cmd {
1250 refresh_tag_file(&mut tag_file)
1251 } else {
1252 None
1253 };
1254 let outcome = dispatch_colon_command(
1255 cmd,
1256 &mut file_set,
1257 &mut current_file_index,
1258 &args,
1259 preprocessor.as_ref(),
1260 record_start_regex.as_ref(),
1261 &mut viewport,
1262 &mut src,
1263 &mut idx,
1264 &mut tag_stack,
1265 tag_file.as_ref(),
1266 );
1267 match outcome {
1268 ColonOutcome::Continue(msg) => {
1269 transient_status = msg.or(reload_msg);
1270 }
1271 ColonOutcome::Quit => break,
1272 ColonOutcome::DispatchCommand(Command::OpenPicker) => {
1273 let saved = (0..file_set.len())
1274 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1275 .collect::<Vec<_>>();
1276 overlay = Some(Box::new(
1277 crate::overlay::picker::FilePicker::new(&file_set, saved)
1278 ));
1279 }
1280 ColonOutcome::DispatchCommand(Command::OpenHelp) => {
1281 let remaps = keymap.user_keys_by_command_name();
1282 overlay = Some(Box::new(
1283 crate::overlay::help::HelpOverlay::new(remaps)
1284 ));
1285 }
1286 ColonOutcome::DispatchCommand(Command::OpenTagPicker) => {
1287 if let Some(active) = tag_stack.active.as_ref() {
1288 overlay = Some(Box::new(
1289 crate::overlay::tag_picker::TagPicker::new(
1290 active.name.clone(),
1291 active.matches.clone(),
1292 active.cursor,
1293 )
1294 ));
1295 }
1296 }
1297 ColonOutcome::DispatchCommand(cmd) => {
1298 debug_assert!(false, "colon dispatcher emitted unexpected Command: {cmd:?}");
1299 }
1301 }
1302 mode = InputMode::Normal;
1303 }
1304 Err(e) => {
1305 *error = Some(e.to_string());
1306 }
1307 }
1308 }
1309 needs_redraw = true;
1310 }
1311 KeyCode::Backspace => {
1312 buffer.pop();
1313 *error = None;
1314 needs_redraw = true;
1315 }
1316 KeyCode::Char(c) => {
1317 buffer.push(c);
1318 *error = None;
1319 needs_redraw = true;
1320 }
1321 _ => {}
1322 }
1323 }
1324 continue;
1325 }
1326 InputMode::TagPrompt { buffer, error, last_tab_matches } => {
1327 if let Event::Key(KeyEvent { code, .. }) = event {
1328 match code {
1329 KeyCode::Esc => {
1330 mode = InputMode::Normal;
1331 needs_redraw = true;
1332 }
1333 KeyCode::Enter => {
1334 if buffer.is_empty() {
1335 mode = InputMode::Normal;
1336 } else {
1337 let name = buffer.clone();
1338 let reload_msg = refresh_tag_file(&mut tag_file);
1339 let msg = dispatch_tag_jump(
1340 &name,
1341 tag_file.as_ref(),
1342 &mut tag_stack,
1343 &mut file_set,
1344 &mut current_file_index,
1345 &args,
1346 preprocessor.as_ref(),
1347 record_start_regex.as_ref(),
1348 &mut viewport,
1349 &mut src,
1350 &mut idx,
1351 );
1352 transient_status = msg.or(reload_msg);
1353 mode = InputMode::Normal;
1354 }
1355 needs_redraw = true;
1356 }
1357 KeyCode::Backspace => {
1358 buffer.pop();
1359 *error = None;
1360 *last_tab_matches = None;
1361 needs_redraw = true;
1362 }
1363 KeyCode::Tab => {
1364 let _ = refresh_tag_file(&mut tag_file);
1365 let names: Vec<String> = match tag_file.as_ref() {
1366 Some(tf) => tf
1367 .names()
1368 .filter(|n| n.starts_with(buffer.as_str()))
1369 .map(String::from)
1370 .collect(),
1371 None => Vec::new(),
1372 };
1373 match (names.len(), last_tab_matches.as_ref()) {
1374 (0, _) => {
1375 *error = Some("no tags match".into());
1376 *last_tab_matches = None;
1377 }
1378 (1, _) => {
1379 *buffer = names.into_iter().next().unwrap();
1380 *error = None;
1381 *last_tab_matches = None;
1382 }
1383 (n, Some(prev)) if prev.len() == n => {
1384 *error = Some(format!("{n} matches"));
1385 }
1386 (n, _) => {
1387 let lcp = longest_common_prefix(&names);
1388 if lcp.len() > buffer.len() {
1389 *buffer = lcp;
1390 *error = None;
1391 } else {
1392 *error = Some(format!("{n} matches"));
1393 }
1394 *last_tab_matches = Some(names);
1395 }
1396 }
1397 needs_redraw = true;
1398 }
1399 KeyCode::Char(c) => {
1400 buffer.push(c);
1401 *error = None;
1402 *last_tab_matches = None;
1403 needs_redraw = true;
1404 }
1405 _ => {}
1406 }
1407 }
1408 continue;
1409 }
1410 InputMode::Normal => {}
1411 }
1412 if let crossterm::event::Event::Resize(c, r) = event {
1415 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1422 cols = c;
1423 rows = r;
1424 viewport.resize(c, r);
1425 if was_at_bottom {
1426 viewport.goto_bottom(src.as_ref(), &mut idx);
1427 }
1428 needs_redraw = true;
1429 if overlay.is_some() {
1430 continue;
1432 }
1433 }
1436 if let Some(ov) = overlay.as_mut() {
1440 let outcome = match &event {
1441 Event::Key(ke) => ov.handle_key(*ke),
1442 Event::Mouse(me) => ov.handle_mouse(*me, viewport.body_rows()),
1443 Event::Resize(_, _) => crate::overlay::OverlayOutcome::Stay,
1444 _ => crate::overlay::OverlayOutcome::Stay,
1445 };
1446 match outcome {
1447 crate::overlay::OverlayOutcome::Stay => {
1448 needs_redraw = true;
1449 continue;
1450 }
1451 crate::overlay::OverlayOutcome::Close => {
1452 overlay = None;
1453 overlay_flash = None;
1454 needs_redraw = true;
1455 continue;
1456 }
1457 crate::overlay::OverlayOutcome::CloseAnd(cmd) => {
1458 overlay = None;
1459 overlay_flash = None;
1460 if let Command::SelectFile(i) = cmd {
1461 if i < file_set.len() {
1462 file_set.set_current_index(i);
1463 if let Some(msg) = switch_to_current_file(
1464 &mut file_set, &mut current_file_index,
1465 &args, preprocessor.as_ref(),
1466 record_start_regex.as_ref(),
1467 &mut viewport, &mut src, &mut idx,
1468 ) {
1469 transient_status = Some(msg);
1470 }
1471 }
1472 } else if let Command::SelectTagMatch(idx_pick) = cmd {
1473 if let Some(active) = tag_stack.active.as_mut() {
1474 if idx_pick < active.matches.len() {
1475 active.cursor = idx_pick;
1476 let entry = active.matches[idx_pick].clone();
1477 let msg = dispatch_match(
1478 &entry,
1479 &mut file_set,
1480 &mut current_file_index,
1481 &args,
1482 preprocessor.as_ref(),
1483 record_start_regex.as_ref(),
1484 &mut viewport,
1485 &mut src,
1486 &mut idx,
1487 );
1488 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1489 if let Some(m) = msg {
1490 transient_status = Some(m);
1491 }
1492 }
1493 }
1494 }
1495 needs_redraw = true;
1496 continue;
1497 }
1498 crate::overlay::OverlayOutcome::Apply(cmd) => {
1499 if let Command::DropFileAt(target) = cmd {
1500 if file_set.len() > 1 && target < file_set.len() {
1501 let saved_cur = file_set.current_index();
1502 file_set.set_current_index(target);
1503 let _ = file_set.delete_current();
1504 if target < saved_cur {
1508 let restored = saved_cur.saturating_sub(1);
1509 file_set.set_current_index(restored);
1510 } else if target > saved_cur {
1511 file_set.set_current_index(saved_cur);
1512 }
1513 if let Some(msg) = switch_to_current_file(
1516 &mut file_set, &mut current_file_index,
1517 &args, preprocessor.as_ref(),
1518 record_start_regex.as_ref(),
1519 &mut viewport, &mut src, &mut idx,
1520 ) {
1521 transient_status = Some(msg);
1522 }
1523 if let Some(ov) = overlay.as_mut() {
1524 ov.refresh(crate::overlay::OverlayContext { file_set: &file_set });
1525 }
1526 }
1527 }
1528 needs_redraw = true;
1529 continue;
1530 }
1531 crate::overlay::OverlayOutcome::Refuse(msg) => {
1532 overlay_flash = Some((msg, std::time::Instant::now()));
1533 needs_redraw = true;
1534 continue;
1535 }
1536 }
1537 }
1538 if let crossterm::event::Event::Mouse(me) = &event {
1542 if mouse_enabled {
1543 use crossterm::event::{KeyModifiers, MouseEventKind};
1544 let hshift = me.modifiers.contains(KeyModifiers::SHIFT)
1550 && viewport.hscroll_active();
1551 match me.kind {
1552 MouseEventKind::ScrollDown if hshift => {
1553 viewport.hscroll_right_step();
1554 needs_redraw = true;
1555 }
1556 MouseEventKind::ScrollUp if hshift => {
1557 viewport.hscroll_left_step();
1558 needs_redraw = true;
1559 }
1560 MouseEventKind::ScrollDown => {
1561 viewport.scroll_lines(3, src.as_ref(), &mut idx);
1562 needs_redraw = true;
1563 }
1564 MouseEventKind::ScrollUp => {
1565 viewport.scroll_lines(-3, src.as_ref(), &mut idx);
1566 needs_redraw = true;
1567 }
1568 MouseEventKind::ScrollLeft => {
1569 viewport.hscroll_left_step();
1570 needs_redraw = true;
1571 }
1572 MouseEventKind::ScrollRight => {
1573 viewport.hscroll_right_step();
1574 needs_redraw = true;
1575 }
1576 _ => {}
1577 }
1578 }
1579 continue;
1580 }
1581 let mut cmd: Option<Command> = None;
1585 if let InputMode::Normal = mode {
1586 if let Event::Key(ke) = &event {
1587 if let Some(target) = keymap.lookup(ke) {
1588 match target {
1589 crate::keys::BindingTarget::Shell(cmd_text) => {
1590 let cmd_text = cmd_text.clone();
1591 if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
1592 let _ = writeln!(std::io::stderr(),
1593 "[shell: {e}]");
1594 }
1595 needs_redraw = true;
1596 continue;
1597 }
1598 crate::keys::BindingTarget::Command(c) => {
1599 cmd = Some(c.clone());
1600 }
1601 }
1602 }
1603 }
1604 }
1605 let cmd = cmd.unwrap_or_else(|| translate(event));
1606 let prefix_at_cmd = numeric_prefix.take();
1609 match cmd {
1610 Command::Digit(d) => {
1611 let cur = prefix_at_cmd.unwrap_or(0);
1612 let next = cur.saturating_mul(10).saturating_add(d as usize);
1613 if next <= 99_999_999 {
1614 numeric_prefix = Some(next);
1615 } else {
1616 numeric_prefix = prefix_at_cmd;
1618 }
1619 continue;
1620 }
1621 Command::Cancel => {
1622 continue;
1624 }
1625 Command::GotoLine => {
1626 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1627 match prefix_at_cmd {
1628 Some(line) if line > 0 => {
1629 viewport.goto_line(line - 1, src.as_ref(), &mut idx);
1630 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1631 }
1632 _ => {
1633 viewport.goto_top();
1634 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1635 }
1636 }
1637 needs_redraw = true;
1638 }
1639 Command::GotoRecord => {
1640 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1641 match prefix_at_cmd {
1642 Some(rec) if rec > 0 => {
1643 viewport.goto_record(rec - 1, src.as_ref(), &mut idx);
1644 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1645 }
1646 _ => viewport.goto_bottom(src.as_ref(), &mut idx),
1647 }
1648 needs_redraw = true;
1649 }
1650 Command::GotoPercent => {
1651 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1652 match prefix_at_cmd {
1653 Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
1654 _ => viewport.goto_top(),
1655 }
1656 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1657 needs_redraw = true;
1658 }
1659 Command::Quit => break,
1660 Command::Resize(c, r) => {
1661 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1662 cols = c; rows = r;
1663 viewport.resize(c, r);
1664 if was_at_bottom {
1665 viewport.goto_bottom(src.as_ref(), &mut idx);
1666 }
1667 needs_redraw = true;
1668 }
1669 Command::ScrollLines(n) => {
1670 viewport.scroll_lines(n, src.as_ref(), &mut idx);
1671 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1672 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1673 needs_redraw = true;
1674 }
1675 Command::ScrollLogicalLines(n) => {
1676 viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
1677 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1678 if viewport.note_motion_for_eof(n > 0, src.as_ref(), &idx) { break; }
1679 needs_redraw = true;
1680 }
1681 Command::PageDown => {
1682 viewport.page_down(src.as_ref(), &mut idx);
1683 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1684 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1685 needs_redraw = true;
1686 }
1687 Command::PageUp => {
1688 viewport.page_up(src.as_ref(), &mut idx);
1689 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1690 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1691 needs_redraw = true;
1692 }
1693 Command::HalfPageDown => {
1694 viewport.half_page_down(src.as_ref(), &mut idx);
1695 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1696 if viewport.note_motion_for_eof(true, src.as_ref(), &idx) { break; }
1697 needs_redraw = true;
1698 }
1699 Command::HalfPageUp => {
1700 viewport.half_page_up(src.as_ref(), &mut idx);
1701 viewport.suspend_follow_if(args.follow_suspend_on_motion);
1702 viewport.note_motion_for_eof(false, src.as_ref(), &idx);
1703 needs_redraw = true;
1704 }
1705 Command::Refresh => {
1706 needs_redraw = true;
1707 }
1708 Command::Reload => {
1709 src.pump();
1712 if src.revision() != last_revision {
1713 rebuild_after_replace(
1714 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1715 );
1716 last_revision = src.revision();
1717 needs_redraw = true;
1718 }
1719 }
1720 Command::TogglePrettify => {
1721 apply_prettify(
1722 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1723 PrettifyTarget::Toggle,
1724 );
1725 last_revision = src.revision();
1726 needs_redraw = true;
1727 }
1728 Command::SetPrettifyMode(m) => {
1729 apply_prettify(
1730 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1731 PrettifyTarget::Mode(m),
1732 );
1733 last_revision = src.revision();
1734 needs_redraw = true;
1735 }
1736 Command::RedetectPrettify => {
1737 apply_prettify(
1738 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1739 PrettifyTarget::Auto,
1740 );
1741 last_revision = src.revision();
1742 needs_redraw = true;
1743 }
1744 Command::ToggleLineNumbers => {
1745 viewport.toggle_line_numbers();
1746 needs_redraw = true;
1747 }
1748 Command::ToggleChop => {
1749 viewport.toggle_chop();
1750 needs_redraw = true;
1751 }
1752 Command::ToggleFollow => {
1753 viewport.toggle_follow();
1754 if viewport.follow_mode() {
1755 src.pump();
1757 idx.notice_new_bytes(src.as_ref());
1758 viewport.goto_bottom(src.as_ref(), &mut idx);
1759 }
1760 needs_redraw = true;
1761 }
1762 Command::SearchForward => {
1763 mode = InputMode::SearchPrompt {
1764 direction: SearchDirection::Forward,
1765 buffer: String::new(),
1766 error: None,
1767 };
1768 needs_redraw = true;
1769 }
1770 Command::SearchBackward => {
1771 mode = InputMode::SearchPrompt {
1772 direction: SearchDirection::Backward,
1773 buffer: String::new(),
1774 error: None,
1775 };
1776 needs_redraw = true;
1777 }
1778 Command::ShellEscape => {
1779 mode = InputMode::ShellPrompt {
1780 buffer: String::new(),
1781 error: None,
1782 };
1783 needs_redraw = true;
1784 }
1785 Command::ColonPrompt => {
1786 mode = InputMode::ColonPrompt {
1787 buffer: String::new(),
1788 error: None,
1789 };
1790 needs_redraw = true;
1791 }
1792 Command::NextMatch => {
1793 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1794 if viewport.search_repeat(src.as_ref(), &mut idx, false) {
1795 needs_redraw = true;
1796 }
1797 }
1798 Command::PreviousMatch => {
1799 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1800 if viewport.search_repeat(src.as_ref(), &mut idx, true) {
1801 needs_redraw = true;
1802 }
1803 }
1804 Command::OptionPrefix => {
1805 mode = InputMode::OptionPrefix;
1806 }
1807 Command::MarkSet => {
1808 mode = InputMode::MarkSetPending;
1809 }
1810 Command::MarkJump => {
1811 mode = InputMode::MarkJumpPending;
1812 }
1813 Command::CtrlXPrefix => {
1814 mode = InputMode::CtrlXPending;
1815 }
1816 Command::JumpPrevious => {
1817 }
1820 Command::TagPrompt => {
1821 if tag_file.is_none() {
1822 transient_status = Some("[no tags file loaded]".into());
1823 needs_redraw = true;
1824 } else {
1825 mode = InputMode::TagPrompt {
1826 buffer: String::new(),
1827 error: None,
1828 last_tab_matches: None,
1829 };
1830 needs_redraw = true;
1831 }
1832 }
1833 Command::TagPop => match tag_stack.pop() {
1834 Some((file_index, line)) => {
1835 if file_index != current_file_index && file_index < file_set.len() {
1836 file_set.set_current_index(file_index);
1837 let path = file_set.current().unwrap().to_path_buf();
1838 if let Err(e) = switch_file(
1839 &path,
1840 file_index,
1841 file_set.len(),
1842 &args,
1843 preprocessor.as_ref(),
1844 &mut viewport,
1845 &mut src,
1846 &mut idx,
1847 record_start_regex.as_ref(),
1848 ) {
1849 transient_status = Some(format!("[open: {e}]"));
1850 } else {
1851 current_file_index = file_index;
1852 }
1853 }
1854 let clamped = line.min(idx.line_count().saturating_sub(1));
1855 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1856 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1857 needs_redraw = true;
1858 }
1859 None => {
1860 transient_status = Some("[tag stack empty]".into());
1861 needs_redraw = true;
1862 }
1863 },
1864 Command::OpenPicker => {
1865 let saved = (0..file_set.len())
1866 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1867 .collect::<Vec<_>>();
1868 overlay = Some(Box::new(
1869 crate::overlay::picker::FilePicker::new(&file_set, saved)
1870 ));
1871 needs_redraw = true;
1872 }
1873 Command::OpenHelp => {
1874 let remaps = keymap.user_keys_by_command_name();
1875 overlay = Some(Box::new(
1876 crate::overlay::help::HelpOverlay::new(remaps)
1877 ));
1878 needs_redraw = true;
1879 }
1880 Command::SelectFile(_)
1881 | Command::DropFileAt(_)
1882 | Command::SelectTagMatch(_)
1883 | Command::OpenTagPicker => {
1884 }
1886 Command::MouseEvent(_) => {
1887 }
1889 Command::HScrollLeft => {
1890 viewport.hscroll_left_half();
1891 needs_redraw = true;
1892 }
1893 Command::HScrollRight => {
1894 viewport.hscroll_right_half();
1895 needs_redraw = true;
1896 }
1897 Command::HScrollLeftStep => {
1898 viewport.hscroll_left_step();
1899 needs_redraw = true;
1900 }
1901 Command::HScrollRightStep => {
1902 viewport.hscroll_right_step();
1903 needs_redraw = true;
1904 }
1905 Command::Noop => {}
1906 }
1907 }
1908 Ok(false) => {
1909 if viewport.live_mode() {
1911 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1912 src.pump();
1913 if src.revision() != last_revision {
1914 rebuild_after_replace(
1915 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1916 );
1917 if was_at_bottom {
1918 viewport.goto_bottom(src.as_ref(), &mut idx);
1919 }
1920 last_revision = src.revision();
1921 needs_redraw = true;
1922 }
1923 } else if viewport.follow_mode() {
1924 let was_at_bottom = viewport.is_at_bottom(src.as_ref(), &idx);
1925 src.pump();
1926 if src.take_rotated() {
1927 if let Some(path) = src.path().map(|p| p.to_path_buf()) {
1933 match crate::open::open_source_for_path(
1934 &path, &args, preprocessor.as_ref(),
1935 ) {
1936 Ok((new_src, _label, _err)) => {
1937 src = new_src;
1938 idx = LineIndex::new();
1939 if let Some(n) = rebuild_spec.head {
1940 idx.set_head_cap(n);
1941 }
1942 viewport.invalidate_filter_cache();
1943 idx.notice_new_bytes(src.as_ref());
1944 viewport.extend_visible_lines(&idx, src.as_ref());
1945 viewport.goto_bottom(src.as_ref(), &mut idx);
1946 viewport.flash("(F reopened)", 4);
1947 needs_redraw = true;
1948 continue;
1949 }
1950 Err(e) => {
1951 transient_status = Some(format!("[reopen failed: {e}]"));
1952 needs_redraw = true;
1953 }
1954 }
1955 }
1956 }
1957 let lines_before = idx.line_count();
1958 idx.notice_new_bytes(src.as_ref());
1959 viewport.extend_visible_lines(&idx, src.as_ref());
1960 if idx.line_count() != lines_before {
1961 needs_redraw = true;
1962 viewport.note_growth();
1963 if was_at_bottom {
1964 viewport.goto_bottom(src.as_ref(), &mut idx);
1965 }
1966 } else {
1967 viewport.tick_idle();
1968 }
1969 viewport.tick_flash();
1970 if args.exit_follow_on_close && src.is_complete() {
1976 break;
1977 }
1978 } else if !src.is_complete() {
1979 let lines_before = idx.line_count();
1982 idx.notice_new_bytes(src.as_ref());
1983 viewport.extend_visible_lines(&idx, src.as_ref());
1984 if idx.line_count() != lines_before {
1985 needs_redraw = true;
1986 }
1987 }
1988 }
1989 Err(_) => {
1990 std::thread::sleep(timeout);
1992 }
1993 }
1994 }
1995 Ok(())
1996}
1997
1998#[derive(Debug, Clone, Copy)]
2000enum PrettifyTarget {
2001 Mode(PrettifyMode),
2003 Toggle,
2005 Auto,
2007}
2008
2009fn apply_prettify(
2013 src: &dyn Source,
2014 viewport: &mut Viewport,
2015 idx: &mut LineIndex,
2016 spec: RebuildSpec,
2017 target: PrettifyTarget,
2018) {
2019 if src.prettify_mode().is_none() {
2021 return;
2022 }
2023 match target {
2024 PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
2025 PrettifyTarget::Toggle => src.toggle_prettify(),
2026 PrettifyTarget::Auto => src.redetect_prettify(),
2027 }
2028 rebuild_after_replace(src, viewport, idx, spec);
2029 viewport.set_prettify_label(src.prettify_label());
2030}
2031
2032fn rebuild_after_replace(
2038 src: &dyn Source,
2039 viewport: &mut Viewport,
2040 idx: &mut LineIndex,
2041 spec: RebuildSpec,
2042) {
2043 let new_off = match spec.tail {
2044 Some(n) => find_tail_offset(src, n),
2045 None => 0,
2046 };
2047 *idx = LineIndex::new_starting_at(new_off);
2048 if let Some(n) = spec.head {
2049 idx.set_head_cap(n);
2050 }
2051 viewport.invalidate_filter_cache();
2052 idx.notice_new_bytes(src);
2053 viewport.extend_visible_lines(idx, src);
2054 viewport.clamp_top_line(idx.line_count());
2055}
2056
2057fn to_crossterm_color(c: crate::ansi::Color, truecolor: bool) -> crossterm::style::Color {
2058 use crossterm::style::Color as CC;
2059 use crate::ansi::Color;
2060 match c {
2061 Color::Ansi(0) => CC::Black,
2062 Color::Ansi(1) => CC::DarkRed,
2063 Color::Ansi(2) => CC::DarkGreen,
2064 Color::Ansi(3) => CC::DarkYellow,
2065 Color::Ansi(4) => CC::DarkBlue,
2066 Color::Ansi(5) => CC::DarkMagenta,
2067 Color::Ansi(6) => CC::DarkCyan,
2068 Color::Ansi(7) => CC::Grey,
2069 Color::Ansi(8) => CC::DarkGrey,
2070 Color::Ansi(9) => CC::Red,
2071 Color::Ansi(10) => CC::Green,
2072 Color::Ansi(11) => CC::Yellow,
2073 Color::Ansi(12) => CC::Blue,
2074 Color::Ansi(13) => CC::Magenta,
2075 Color::Ansi(14) => CC::Cyan,
2076 Color::Ansi(15) => CC::White,
2077 Color::Ansi(_) => CC::Reset,
2078 Color::Indexed(n) => CC::AnsiValue(n),
2079 Color::Rgb(r, g, b) => {
2080 if truecolor {
2081 CC::Rgb { r, g, b }
2082 } else {
2083 CC::AnsiValue(crate::render::rgb_to_256(r, g, b))
2084 }
2085 }
2086 Color::Default => CC::Reset,
2087 }
2088}
2089
2090fn emit_style_diff<W: Write>(
2093 out: &mut W,
2094 prev: &crate::ansi::Style,
2095 next: &crate::ansi::Style,
2096 truecolor: bool,
2097) -> io::Result<()> {
2098 let intensity_changed = prev.bold != next.bold || prev.dim != next.dim;
2102
2103 let fg_changed = prev.fg != next.fg;
2107 let bg_changed = prev.bg != next.bg;
2108
2109 if (fg_changed && next.fg.is_none()) || (bg_changed && next.bg.is_none()) {
2110 out.queue(ResetColor)?;
2111 if let Some(c) = next.fg {
2113 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2114 }
2115 if let Some(c) = next.bg {
2116 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2117 }
2118 } else {
2119 if fg_changed {
2120 if let Some(c) = next.fg {
2121 out.queue(SetForegroundColor(to_crossterm_color(c, truecolor)))?;
2122 }
2123 }
2124 if bg_changed {
2125 if let Some(c) = next.bg {
2126 out.queue(SetBackgroundColor(to_crossterm_color(c, truecolor)))?;
2127 }
2128 }
2129 }
2130
2131 if intensity_changed {
2132 if next.bold {
2133 out.queue(SetAttribute(Attribute::Bold))?;
2134 } else if next.dim {
2135 out.queue(SetAttribute(Attribute::Dim))?;
2136 } else {
2137 out.queue(SetAttribute(Attribute::NormalIntensity))?;
2138 }
2139 }
2140 if prev.italic != next.italic {
2141 out.queue(SetAttribute(if next.italic { Attribute::Italic } else { Attribute::NoItalic }))?;
2142 }
2143 if prev.underline != next.underline {
2144 out.queue(SetAttribute(if next.underline { Attribute::Underlined } else { Attribute::NoUnderline }))?;
2145 }
2146 if prev.reverse != next.reverse {
2147 out.queue(SetAttribute(if next.reverse { Attribute::Reverse } else { Attribute::NoReverse }))?;
2148 }
2149 if prev.strike != next.strike {
2150 out.queue(SetAttribute(if next.strike { Attribute::CrossedOut } else { Attribute::NotCrossedOut }))?;
2151 }
2152 Ok(())
2153}
2154
2155fn emit_hyperlink_diff<W: Write>(
2156 out: &mut W,
2157 prev: &Option<Arc<str>>,
2158 next: &Option<Arc<str>>,
2159) -> io::Result<()> {
2160 if prev == next {
2161 return Ok(());
2162 }
2163 if prev.is_some() {
2164 out.write_all(b"\x1b]8;;\x1b\\")?;
2165 }
2166 if let Some(uri) = next {
2167 out.write_all(b"\x1b]8;;")?;
2168 out.write_all(uri.as_bytes())?;
2169 out.write_all(b"\x1b\\")?;
2170 }
2171 Ok(())
2172}
2173
2174const SYNC_UPDATE_BEGIN: &[u8] = b"\x1b[?2026h";
2181const SYNC_UPDATE_END: &[u8] = b"\x1b[?2026l";
2182
2183fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16, truecolor: bool) -> io::Result<()> {
2184 out.write_all(SYNC_UPDATE_BEGIN)?;
2196
2197 out.queue(SetAttribute(Attribute::Reset))?;
2199 out.queue(ResetColor)?;
2200
2201 for (i, row) in frame.body.iter().enumerate() {
2202 out.queue(MoveTo(0, i as u16))?;
2203 out.queue(Clear(ClearType::UntilNewLine))?;
2207 out.queue(SetAttribute(Attribute::Reset))?;
2210
2211 if let Some(Some(raw)) = frame.raw_rows.get(i) {
2216 if !raw.is_empty() {
2217 out.write_all(raw)?;
2218 }
2219 out.queue(ResetColor)?;
2221 out.queue(SetAttribute(Attribute::Reset))?;
2222 continue;
2223 }
2224
2225 let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
2226 let base_style = if matches!(row_style, RowStyle::Dim) {
2231 out.queue(SetAttribute(Attribute::Dim))?;
2232 crate::ansi::Style { dim: true, ..Default::default() }
2233 } else {
2234 crate::ansi::Style::default()
2235 };
2236 let no_highlights = Vec::new();
2237 let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
2238 write_row_with_highlights(out, row, cols, highlights, base_style, truecolor)?;
2239 }
2240 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
2242 out.queue(Clear(ClearType::UntilNewLine))?;
2243 emit_style_diff(out, &crate::ansi::Style::default(), &frame.status_style, truecolor)?;
2244 let mut status = frame.status.clone();
2245 if status.len() > cols as usize {
2246 status.truncate(cols as usize);
2247 } else {
2248 let pad = cols as usize - status.len();
2249 status.push_str(&" ".repeat(pad));
2250 }
2251 out.queue(Print(status))?;
2252 out.queue(ResetColor)?;
2253 out.queue(SetAttribute(Attribute::Reset))?;
2254
2255 out.write_all(SYNC_UPDATE_END)?;
2258 out.flush()
2259}
2260
2261
2262fn write_row_with_highlights(
2273 out: &mut impl Write,
2274 row: &[Cell],
2275 cols: u16,
2276 highlights: &[std::ops::Range<usize>],
2277 base_style: crate::ansi::Style,
2278 truecolor: bool,
2279) -> io::Result<()> {
2280 let cols_usize = cols as usize;
2281
2282 let mut ranges: Vec<std::ops::Range<usize>> = highlights
2283 .iter()
2284 .filter_map(|r| {
2285 let s = r.start.min(cols_usize);
2286 let e = r.end.min(cols_usize);
2287 if e > s { Some(s..e) } else { None }
2288 })
2289 .collect();
2290 ranges.sort_by_key(|r| r.start);
2291
2292 let mut prev_style = base_style;
2295 let mut prev_link: Option<Arc<str>> = None;
2296
2297 let mut col = 0usize;
2298 let mut i = 0usize;
2299 while col < cols_usize && i < row.len() {
2300 let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
2301
2302 match &row[i] {
2303 Cell::Char { ch, width, style, hyperlink } => {
2304 let mut eff = *style;
2310 if in_highlight {
2311 eff.reverse = !eff.reverse;
2312 }
2313 if base_style.dim && !eff.bold {
2314 eff.dim = true;
2315 }
2316 emit_style_diff(out, &prev_style, &eff, truecolor)?;
2317 emit_hyperlink_diff(out, &prev_link, hyperlink)?;
2318 out.queue(Print(*ch))?;
2319 prev_style = eff;
2320 prev_link = hyperlink.clone();
2321 col += *width as usize;
2322 }
2323 Cell::Continuation => {
2324 }
2326 Cell::Empty => {
2327 let default = if base_style.dim {
2332 crate::ansi::Style { dim: true, ..Default::default() }
2333 } else {
2334 crate::ansi::Style::default()
2335 };
2336 emit_style_diff(out, &prev_style, &default, truecolor)?;
2337 emit_hyperlink_diff(out, &prev_link, &None)?;
2338 out.queue(Print(' '))?;
2339 prev_style = default;
2340 prev_link = None;
2341 col += 1;
2342 }
2343 }
2344 i += 1;
2345 }
2346
2347 emit_hyperlink_diff(out, &prev_link, &None)?;
2350 out.queue(ResetColor)?;
2351 out.queue(SetAttribute(Attribute::Reset))?;
2352
2353 Ok(())
2354}
2355
2356fn render_overlay(
2357 out: &mut impl Write,
2358 frame: &crate::overlay::OverlayFrame,
2359 width: u16,
2360 height: u16,
2361) -> io::Result<()> {
2362 out.write_all(SYNC_UPDATE_BEGIN)?;
2366 out.queue(SetAttribute(Attribute::Reset))?;
2367 out.queue(ResetColor)?;
2368 for row in 0..height.saturating_sub(1) {
2369 out.queue(MoveTo(0, row))?;
2370 out.queue(Clear(ClearType::UntilNewLine))?;
2371 out.queue(SetAttribute(Attribute::Reset))?;
2372 if let Some(line) = frame.body.get(row as usize) {
2373 let mut written = 0usize;
2374 for ch in line.chars() {
2375 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2376 if written + w > width as usize { break; }
2377 write!(out, "{ch}")?;
2378 written += w;
2379 }
2380 }
2381 }
2382 out.queue(MoveTo(0, height.saturating_sub(1)))?;
2383 out.queue(Clear(ClearType::UntilNewLine))?;
2384 out.queue(SetAttribute(Attribute::Reverse))?;
2385 let mut status = frame.status.clone();
2386 if status.len() > width as usize {
2388 status.truncate(width as usize);
2389 } else {
2390 let pad = width as usize - status.len();
2391 status.push_str(&" ".repeat(pad));
2392 }
2393 out.queue(Print(status))?;
2394 out.queue(ResetColor)?;
2395 out.queue(SetAttribute(Attribute::Reset))?;
2396 out.write_all(SYNC_UPDATE_END)?;
2397 out.flush()
2398}
2399
2400#[cfg(test)]
2401mod tests {
2402 use super::*;
2403
2404 #[test]
2405 fn parse_colon_n() {
2406 assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
2407 assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
2408 }
2409
2410 #[test]
2411 fn write_frame_brackets_with_sync_update_and_no_full_clear() {
2412 use crate::ansi::Style;
2417 use crate::render::Cell;
2418 use crate::viewport::{Frame, RowStyle};
2419
2420 let row: Vec<Cell> = (0..3)
2421 .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
2422 .collect();
2423 let frame = Frame {
2424 body: vec![row.clone(), row],
2425 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2426 highlights: vec![Vec::new(), Vec::new()],
2427 status: "status".into(),
2428 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
2429 raw_rows: vec![None, None],
2430 };
2431
2432 let mut buf: Vec<u8> = Vec::new();
2433 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2434 let s = std::str::from_utf8(&buf).expect("ascii");
2435
2436 let begin = s.find("\x1b[?2026h").expect("begin sync update");
2438 let end = s.find("\x1b[?2026l").expect("end sync update");
2439 assert!(begin < end, "begin must precede end");
2440 let first_a = s.find('a').expect("body char");
2442 assert!(begin < first_a && first_a < end, "body must be inside sync update");
2443
2444 assert!(
2447 !s.contains("\x1b[2J"),
2448 "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
2449 );
2450 assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
2451 }
2452
2453 #[test]
2454 fn raw_rows_passthrough_emits_original_bytes_and_skips_continuation() {
2455 use crate::ansi::Style;
2456 use crate::render::Cell;
2457 use crate::viewport::{Frame, RowStyle};
2458
2459 let placeholder_row: Vec<Cell> = (0..3)
2461 .map(|_| Cell::Char { ch: 'X', width: 1, style: Style::default(), hyperlink: None })
2462 .collect();
2463 let frame = Frame {
2464 body: vec![placeholder_row.clone(), placeholder_row],
2465 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
2466 highlights: vec![Vec::new(), Vec::new()],
2467 status: "s".into(),
2468 status_style: Style { reverse: true, ..Default::default() },
2469 raw_rows: vec![Some(b"\x1b[31mABC\x1b[0m".to_vec()), Some(Vec::new())],
2472 };
2473
2474 let mut buf: Vec<u8> = Vec::new();
2475 write_frame(&mut buf, &frame, 3, 3, true).unwrap();
2476 let s = std::str::from_utf8(&buf).expect("ascii");
2477
2478 assert!(s.contains("\x1b[31mABC\x1b[0m"), "raw bytes missing in output: {s:?}");
2480 assert!(!s.contains("XXX"), "cell content leaked through despite raw passthrough: {s:?}");
2482 }
2483
2484 #[test]
2485 fn dim_row_keeps_dim_through_plain_cells_and_padding() {
2486 use crate::ansi::Style;
2491 use crate::render::Cell;
2492 let row = vec![
2493 Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
2494 Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
2495 Cell::Empty,
2496 Cell::Empty,
2497 ];
2498 let mut buf: Vec<u8> = Vec::new();
2499 let base = Style { dim: true, ..Default::default() };
2500 write_row_with_highlights(&mut buf, &row, 4, &[], base, true).unwrap();
2501 let s = String::from_utf8_lossy(&buf);
2502
2503 for needle in ['h', 'i'] {
2506 let pos = s.find(needle).expect("char printed");
2507 let before = &s[..pos];
2508 assert!(
2509 !before.contains("\x1b[22m"),
2510 "dim cleared before {needle:?}: {before:?}",
2511 );
2512 }
2513 let after_i = s.find('i').unwrap() + 1;
2516 let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
2517 let pad = &s[after_i..after_i + eor];
2518 assert!(
2519 !pad.contains("\x1b[22m"),
2520 "dim cleared in padding region: {pad:?}",
2521 );
2522 }
2523
2524 #[test]
2525 fn dim_row_yields_to_explicit_bold_cell() {
2526 use crate::ansi::Style;
2529 use crate::render::Cell;
2530 let row = vec![
2531 Cell::Char {
2532 ch: 'B',
2533 width: 1,
2534 style: Style { bold: true, ..Default::default() },
2535 hyperlink: None,
2536 },
2537 ];
2538 let mut buf: Vec<u8> = Vec::new();
2539 let base = Style { dim: true, ..Default::default() };
2540 write_row_with_highlights(&mut buf, &row, 1, &[], base, true).unwrap();
2541 let s = String::from_utf8_lossy(&buf);
2542 assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
2544 }
2545
2546 #[test]
2547 fn parse_colon_p() {
2548 assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
2549 assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
2550 }
2551
2552 #[test]
2553 fn parse_colon_e_with_path() {
2554 match parse_colon_command("e /tmp/foo.log").unwrap() {
2555 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
2556 other => panic!("expected Edit, got {other:?}"),
2557 }
2558 }
2559
2560 #[test]
2561 fn parse_colon_e_with_tilde() {
2562 std::env::set_var("HOME", "/home/user");
2563 match parse_colon_command("e ~/foo.log").unwrap() {
2564 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
2565 other => panic!("expected Edit, got {other:?}"),
2566 }
2567 }
2568
2569 #[test]
2570 fn parse_colon_e_missing_path_errors() {
2571 assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
2572 assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
2573 }
2574
2575 #[test]
2576 fn parse_colon_f_q_d_x_t() {
2577 assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
2578 assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
2579 assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
2580 assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
2581 assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
2582 }
2583
2584 #[test]
2585 fn parse_unknown_command_errors() {
2586 let err = parse_colon_command("bogus").unwrap_err();
2587 match err {
2588 ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
2589 other => panic!("expected UnknownCommand, got {other:?}"),
2590 }
2591 }
2592
2593 #[test]
2594 fn parse_handles_whitespace() {
2595 assert_eq!(parse_colon_command("n ").unwrap(), ColonCommand::Next);
2597 assert_eq!(parse_colon_command(" n").unwrap(), ColonCommand::Next);
2598 }
2599
2600 #[test]
2601 fn parse_colon_tag_with_name() {
2602 assert_eq!(
2603 parse_colon_command("tag foo").unwrap(),
2604 ColonCommand::Tag("foo".into())
2605 );
2606 }
2607
2608 #[test]
2609 fn parse_colon_tag_strips_trailing_whitespace() {
2610 assert_eq!(
2611 parse_colon_command("tag foo ").unwrap(),
2612 ColonCommand::Tag("foo".into())
2613 );
2614 }
2615
2616 #[test]
2617 fn parse_colon_tag_without_name_errors() {
2618 assert_eq!(
2619 parse_colon_command("tag").unwrap_err(),
2620 ColonParseError::TagRequiresName
2621 );
2622 assert_eq!(
2623 parse_colon_command("tag ").unwrap_err(),
2624 ColonParseError::TagRequiresName
2625 );
2626 }
2627
2628 #[test]
2629 fn parse_colon_tnext_and_tprev() {
2630 assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
2631 assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
2632 }
2633
2634 #[test]
2635 fn parse_colon_tselect_without_arg_uses_active() {
2636 assert_eq!(parse_colon_command("tselect").unwrap(), ColonCommand::TagSelect(None));
2637 }
2638
2639 #[test]
2640 fn parse_colon_tselect_with_name() {
2641 assert_eq!(
2642 parse_colon_command("tselect foo").unwrap(),
2643 ColonCommand::TagSelect(Some("foo".into())),
2644 );
2645 }
2646
2647 #[test]
2648 fn parse_colon_b_opens_picker() {
2649 assert_eq!(parse_colon_command("b").unwrap(), ColonCommand::OpenPicker);
2650 assert_eq!(parse_colon_command("buffers").unwrap(), ColonCommand::OpenPicker);
2651 }
2652
2653 #[test]
2654 fn parse_colon_help_opens_help() {
2655 assert_eq!(parse_colon_command("h").unwrap(), ColonCommand::OpenHelp);
2656 assert_eq!(parse_colon_command("help").unwrap(), ColonCommand::OpenHelp);
2657 }
2658
2659 #[test]
2660 fn parse_colon_hex_with_valid_widths() {
2661 for n in [2usize, 4, 8, 16, 32] {
2662 assert_eq!(
2663 parse_colon_command(&format!("hex {n}")).unwrap(),
2664 ColonCommand::HexGroup(n),
2665 );
2666 }
2667 }
2668
2669 #[test]
2670 fn parse_colon_hex_without_value_errors() {
2671 assert_eq!(
2672 parse_colon_command("hex").unwrap_err(),
2673 ColonParseError::HexGroupRequiresValue,
2674 );
2675 }
2676
2677 #[test]
2678 fn parse_colon_hex_with_invalid_value_errors() {
2679 match parse_colon_command("hex 3").unwrap_err() {
2680 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "3"),
2681 other => panic!("expected HexGroupInvalid, got {other:?}"),
2682 }
2683 match parse_colon_command("hex banana").unwrap_err() {
2684 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "banana"),
2685 other => panic!("expected HexGroupInvalid, got {other:?}"),
2686 }
2687 }
2688
2689 #[test]
2690 fn parse_colon_color_without_arg_cycles() {
2691 assert_eq!(parse_colon_command("color").unwrap(), ColonCommand::Color(None));
2692 }
2693
2694 #[test]
2695 fn parse_colon_color_with_named_mode() {
2696 use crate::render::AnsiMode;
2697 assert_eq!(
2698 parse_colon_command("color strict").unwrap(),
2699 ColonCommand::Color(Some(AnsiMode::Strict)),
2700 );
2701 assert_eq!(
2702 parse_colon_command("color interpret").unwrap(),
2703 ColonCommand::Color(Some(AnsiMode::Interpret)),
2704 );
2705 assert_eq!(
2706 parse_colon_command("color raw").unwrap(),
2707 ColonCommand::Color(Some(AnsiMode::Raw)),
2708 );
2709 }
2710
2711 #[test]
2712 fn parse_colon_color_with_unknown_mode_errors() {
2713 match parse_colon_command("color rainbow").unwrap_err() {
2714 ColonParseError::ColorInvalid(v) => assert_eq!(v, "rainbow"),
2715 other => panic!("expected ColorInvalid, got {other:?}"),
2716 }
2717 }
2718
2719 #[test]
2720 fn parse_colon_case_without_arg_cycles() {
2721 assert_eq!(parse_colon_command("case").unwrap(), ColonCommand::Case(None));
2722 }
2723
2724 #[test]
2725 fn parse_colon_case_with_named_mode() {
2726 use crate::viewport::CaseMode;
2727 assert_eq!(parse_colon_command("case smart").unwrap(),
2728 ColonCommand::Case(Some(CaseMode::Smart)));
2729 assert_eq!(parse_colon_command("case sensitive").unwrap(),
2730 ColonCommand::Case(Some(CaseMode::Sensitive)));
2731 assert_eq!(parse_colon_command("case insensitive").unwrap(),
2732 ColonCommand::Case(Some(CaseMode::Insensitive)));
2733 }
2734
2735 #[test]
2736 fn parse_colon_case_unknown_errors() {
2737 match parse_colon_command("case rainbow").unwrap_err() {
2738 ColonParseError::CaseInvalid(v) => assert_eq!(v, "rainbow"),
2739 other => panic!("expected CaseInvalid, got {other:?}"),
2740 }
2741 }
2742
2743 #[test]
2744 fn parse_colon_hlsearch_on_off() {
2745 assert_eq!(parse_colon_command("hlsearch").unwrap(), ColonCommand::HlSearch(true));
2746 assert_eq!(parse_colon_command("nohlsearch").unwrap(), ColonCommand::HlSearch(false));
2747 }
2748
2749 #[test]
2750 fn lcp_empty_slice() {
2751 assert_eq!(longest_common_prefix(&[]), "");
2752 }
2753
2754 #[test]
2755 fn lcp_single_item_returns_self() {
2756 assert_eq!(longest_common_prefix(&["foo".into()]), "foo");
2757 }
2758
2759 #[test]
2760 fn lcp_finds_shared_prefix() {
2761 let v: Vec<String> = vec!["foobar".into(), "foobaz".into(), "fooqux".into()];
2762 assert_eq!(longest_common_prefix(&v), "foo");
2763 }
2764
2765 #[test]
2766 fn lcp_no_shared_prefix_returns_empty() {
2767 let v: Vec<String> = vec!["abc".into(), "xyz".into()];
2768 assert_eq!(longest_common_prefix(&v), "");
2769 }
2770
2771 #[test]
2772 fn lcp_one_item_is_prefix_of_others() {
2773 let v: Vec<String> = vec!["foo".into(), "foobar".into(), "foobaz".into()];
2774 assert_eq!(longest_common_prefix(&v), "foo");
2775 }
2776
2777 #[test]
2778 fn tag_stack_push_pop_lifo() {
2779 let mut s = TagStack::default();
2780 s.push(0, 10);
2781 s.push(1, 20);
2782 assert_eq!(s.pop(), Some((1, 20)));
2783 assert_eq!(s.pop(), Some((0, 10)));
2784 assert_eq!(s.pop(), None);
2785 }
2786
2787 #[test]
2788 fn tag_stack_pop_clears_active() {
2789 let mut s = TagStack::default();
2790 s.push(0, 10);
2791 s.set_active(
2792 "foo".into(),
2793 vec![crate::tags::TagEntry {
2794 file: std::path::PathBuf::from("/a"),
2795 address: crate::tags::TagAddress::Line(1),
2796 }],
2797 );
2798 assert!(s.active.is_some());
2799 let _ = s.pop();
2800 assert!(s.active.is_none());
2801 }
2802
2803 #[test]
2804 fn tag_stack_next_advances_then_clamps() {
2805 let mut s = TagStack::default();
2806 s.set_active(
2807 "foo".into(),
2808 vec![
2809 crate::tags::TagEntry {
2810 file: std::path::PathBuf::from("/a"),
2811 address: crate::tags::TagAddress::Line(1),
2812 },
2813 crate::tags::TagEntry {
2814 file: std::path::PathBuf::from("/b"),
2815 address: crate::tags::TagAddress::Line(2),
2816 },
2817 ],
2818 );
2819 assert_eq!(s.next(), TagStepResult::Moved(1));
2820 assert_eq!(s.next(), TagStepResult::AtBoundary);
2821 }
2822
2823 #[test]
2824 fn tag_stack_prev_clamps_at_zero() {
2825 let mut s = TagStack::default();
2826 s.set_active(
2827 "foo".into(),
2828 vec![crate::tags::TagEntry {
2829 file: std::path::PathBuf::from("/a"),
2830 address: crate::tags::TagAddress::Line(1),
2831 }],
2832 );
2833 assert_eq!(s.prev(), TagStepResult::AtBoundary);
2834 }
2835
2836 #[test]
2837 fn tag_stack_next_with_no_active_returns_no_active() {
2838 let mut s = TagStack::default();
2839 assert_eq!(s.next(), TagStepResult::NoActive);
2840 assert_eq!(s.prev(), TagStepResult::NoActive);
2841 }
2842
2843 #[test]
2844 fn tag_stack_set_active_replaces_previous_list() {
2845 let mut s = TagStack::default();
2846 s.set_active(
2847 "foo".into(),
2848 vec![crate::tags::TagEntry {
2849 file: std::path::PathBuf::from("/a"),
2850 address: crate::tags::TagAddress::Line(1),
2851 }],
2852 );
2853 s.set_active(
2854 "bar".into(),
2855 vec![
2856 crate::tags::TagEntry {
2857 file: std::path::PathBuf::from("/x"),
2858 address: crate::tags::TagAddress::Line(5),
2859 },
2860 crate::tags::TagEntry {
2861 file: std::path::PathBuf::from("/y"),
2862 address: crate::tags::TagAddress::Line(6),
2863 },
2864 ],
2865 );
2866 let active = s.active.as_ref().unwrap();
2867 assert_eq!(active.name, "bar");
2868 assert_eq!(active.matches.len(), 2);
2869 assert_eq!(active.cursor, 0);
2870 }
2871
2872 #[test]
2873 fn writer_emits_color_for_red_cell() {
2874 let cells = vec![Cell::Char {
2875 ch: 'h',
2876 width: 1,
2877 style: crate::ansi::Style {
2878 fg: Some(crate::ansi::Color::Ansi(1)),
2879 ..Default::default()
2880 },
2881 hyperlink: None,
2882 }];
2883 let mut buf: Vec<u8> = Vec::new();
2884 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
2885 let s = String::from_utf8_lossy(&buf);
2886 assert!(s.contains("\x1b["), "expected ANSI escape in output: {s:?}");
2887 assert!(s.contains('h'));
2888 }
2889
2890 #[test]
2891 fn writer_emits_osc8_for_hyperlink_cell() {
2892 let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
2893 let cells = vec![Cell::Char {
2894 ch: 'c',
2895 width: 1,
2896 style: crate::ansi::Style::default(),
2897 hyperlink: Some(link),
2898 }];
2899 let mut buf: Vec<u8> = Vec::new();
2900 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default(), true).unwrap();
2901 let s = String::from_utf8_lossy(&buf);
2902 assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
2903 }
2904}