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 { buffer: String, error: Option<String> },
64}
65
66#[derive(Debug, Clone, PartialEq)]
67enum ColonCommand {
68 Next,
69 Prev,
70 Edit(std::path::PathBuf),
71 ShowFile,
72 Quit,
73 Delete,
74 First,
75 Last,
76 Tag(String),
77 TagNext,
78 TagPrev,
79 OpenPicker,
80 OpenHelp,
81 HexGroup(usize),
83}
84
85#[derive(Debug, Clone, PartialEq)]
86enum ColonParseError {
87 UnknownCommand(String),
88 MissingPath,
89 TagRequiresName,
90 HexGroupRequiresValue,
91 HexGroupInvalid(String),
92}
93
94impl std::fmt::Display for ColonParseError {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 match self {
97 ColonParseError::UnknownCommand(t) => write!(f, "unknown command: :{t}"),
98 ColonParseError::MissingPath => write!(f, ":e requires a path"),
99 ColonParseError::TagRequiresName => write!(f, ":tag requires a name"),
100 ColonParseError::HexGroupRequiresValue => {
101 write!(f, ":hex requires N (one of 2, 4, 8, 16, 32)")
102 }
103 ColonParseError::HexGroupInvalid(v) => {
104 write!(f, ":hex N must be one of 2, 4, 8, 16, 32 (got {v})")
105 }
106 }
107 }
108}
109
110fn parse_colon_command(buf: &str) -> std::result::Result<ColonCommand, ColonParseError> {
111 let buf = buf.trim();
112 if buf.is_empty() {
113 return Err(ColonParseError::UnknownCommand(String::new()));
114 }
115 let mut parts = buf.splitn(2, char::is_whitespace);
116 let cmd = parts.next().unwrap();
117 let rest = parts.next().unwrap_or("").trim();
118 match cmd {
119 "n" | "next" => Ok(ColonCommand::Next),
120 "p" | "prev" => Ok(ColonCommand::Prev),
121 "e" | "edit" => {
122 if rest.is_empty() {
123 Err(ColonParseError::MissingPath)
124 } else {
125 let expanded = if let Some(stripped) = rest.strip_prefix("~/") {
127 if let Some(home) = std::env::var_os("HOME") {
128 let mut p = std::path::PathBuf::from(home);
129 p.push(stripped);
130 p
131 } else {
132 std::path::PathBuf::from(rest)
133 }
134 } else {
135 std::path::PathBuf::from(rest)
136 };
137 Ok(ColonCommand::Edit(expanded))
138 }
139 }
140 "f" => Ok(ColonCommand::ShowFile),
141 "q" | "quit" => Ok(ColonCommand::Quit),
142 "d" | "delete" => Ok(ColonCommand::Delete),
143 "x" | "first" => Ok(ColonCommand::First),
144 "t" | "last" => Ok(ColonCommand::Last),
145 "tag" => {
146 if rest.is_empty() {
147 Err(ColonParseError::TagRequiresName)
148 } else {
149 Ok(ColonCommand::Tag(rest.to_string()))
150 }
151 }
152 "tnext" => Ok(ColonCommand::TagNext),
153 "tprev" => Ok(ColonCommand::TagPrev),
154 "b" | "buffers" => Ok(ColonCommand::OpenPicker),
155 "h" | "help" => Ok(ColonCommand::OpenHelp),
156 "hex" => {
157 if rest.is_empty() {
158 Err(ColonParseError::HexGroupRequiresValue)
159 } else {
160 match rest.parse::<usize>() {
161 Ok(n) if matches!(n, 2 | 4 | 8 | 16 | 32) => Ok(ColonCommand::HexGroup(n)),
162 _ => Err(ColonParseError::HexGroupInvalid(rest.to_string())),
163 }
164 }
165 }
166 other => Err(ColonParseError::UnknownCommand(other.to_string())),
167 }
168}
169
170enum ColonOutcome {
171 Continue(Option<String>), Quit,
173 DispatchCommand(Command),
177}
178
179#[derive(Debug, Default)]
180struct TagStack {
181 history: Vec<(usize, usize)>,
184 active: Option<ActiveMatches>,
187}
188
189#[derive(Debug, Clone)]
190struct ActiveMatches {
191 name: String,
192 matches: Vec<crate::tags::TagEntry>,
193 cursor: usize,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
197enum TagStepResult {
198 Moved(usize),
200 AtBoundary,
202 NoActive,
204}
205
206impl TagStack {
207 fn push(&mut self, file_index: usize, top_line: usize) {
208 self.history.push((file_index, top_line));
209 }
210
211 fn pop(&mut self) -> Option<(usize, usize)> {
212 let popped = self.history.pop();
213 if popped.is_some() {
214 self.active = None;
215 }
216 popped
217 }
218
219 fn set_active(&mut self, name: String, matches: Vec<crate::tags::TagEntry>) {
220 self.active = Some(ActiveMatches {
221 name,
222 matches,
223 cursor: 0,
224 });
225 }
226
227 fn next(&mut self) -> TagStepResult {
228 let Some(a) = &mut self.active else {
229 return TagStepResult::NoActive;
230 };
231 if a.cursor + 1 >= a.matches.len() {
232 TagStepResult::AtBoundary
233 } else {
234 a.cursor += 1;
235 TagStepResult::Moved(a.cursor)
236 }
237 }
238
239 fn prev(&mut self) -> TagStepResult {
240 let Some(a) = &mut self.active else {
241 return TagStepResult::NoActive;
242 };
243 if a.cursor == 0 {
244 TagStepResult::AtBoundary
245 } else {
246 a.cursor -= 1;
247 TagStepResult::Moved(a.cursor)
248 }
249 }
250}
251
252#[allow(clippy::too_many_arguments)]
257fn dispatch_tag_jump(
258 name: &str,
259 tag_file: Option<&crate::tags::TagFile>,
260 tag_stack: &mut TagStack,
261 file_set: &mut crate::file_set::FileSet,
262 current_file_index: &mut usize,
263 args: &crate::cli::Args,
264 preprocessor: Option<&crate::preprocess::Preprocessor>,
265 record_start_regex: Option<®ex::bytes::Regex>,
266 viewport: &mut crate::viewport::Viewport,
267 src: &mut Box<dyn crate::source::Source>,
268 idx: &mut crate::line_index::LineIndex,
269) -> Option<String> {
270 let Some(tf) = tag_file else {
271 return Some("[no tags file loaded]".into());
272 };
273 let matches = tf.lookup(name);
274 if matches.is_empty() {
275 return Some(format!("[tag not found: {name}]"));
276 }
277 let matches: Vec<crate::tags::TagEntry> = matches.to_vec();
278 tag_stack.push(*current_file_index, viewport.top_line());
279 tag_stack.set_active(name.to_string(), matches.clone());
280 let msg = dispatch_match(
281 &matches[0],
282 file_set,
283 current_file_index,
284 args,
285 preprocessor,
286 record_start_regex,
287 viewport,
288 src,
289 idx,
290 );
291 update_viewport_tag_indicator(tag_stack, viewport);
292 msg
293}
294
295#[allow(clippy::too_many_arguments)]
296fn dispatch_match(
297 entry: &crate::tags::TagEntry,
298 file_set: &mut crate::file_set::FileSet,
299 current_file_index: &mut usize,
300 args: &crate::cli::Args,
301 preprocessor: Option<&crate::preprocess::Preprocessor>,
302 record_start_regex: Option<®ex::bytes::Regex>,
303 viewport: &mut crate::viewport::Viewport,
304 src: &mut Box<dyn crate::source::Source>,
305 idx: &mut crate::line_index::LineIndex,
306) -> Option<String> {
307 let target_file = entry.file.as_path();
308 let already_current = file_set
309 .current()
310 .map(|p| p == target_file)
311 .unwrap_or(false);
312
313 if !already_current {
314 let existing_idx = (0..file_set.len()).find(|i| {
315 file_set
316 .nth(*i)
317 .map(|p| p == target_file)
318 .unwrap_or(false)
319 });
320 match existing_idx {
321 Some(i) => {
322 file_set.set_current_index(i);
323 }
324 None => {
325 file_set.append_and_switch(target_file.to_path_buf());
326 }
327 }
328 let path = file_set.current().unwrap().to_path_buf();
329 if let Err(e) = switch_file(
330 &path,
331 file_set.current_index(),
332 file_set.len(),
333 args,
334 preprocessor,
335 viewport,
336 src,
337 idx,
338 record_start_regex,
339 ) {
340 return Some(format!("[open: {e}]"));
341 }
342 *current_file_index = file_set.current_index();
343 }
344
345 let line = match &entry.address {
346 crate::tags::TagAddress::Line(n) => n.saturating_sub(1),
347 crate::tags::TagAddress::Pattern(p) => {
348 let re_src = crate::tags::pattern_to_regex(p);
349 let re = match regex::bytes::Regex::new(&re_src) {
350 Ok(r) => r,
351 Err(_) => return Some("[tag pattern not found]".into()),
352 };
353 match find_pattern_line(src.as_ref(), idx, &re) {
354 Some(l) => l,
355 None => return Some("[tag pattern not found]".into()),
356 }
357 }
358 };
359
360 let clamped = line.min(idx.line_count().saturating_sub(1));
361 viewport.goto_line(clamped, src.as_ref(), idx);
362 None
363}
364
365fn find_pattern_line(
366 src: &dyn crate::source::Source,
367 idx: &mut crate::line_index::LineIndex,
368 re: ®ex::bytes::Regex,
369) -> Option<usize> {
370 idx.extend_to_end(src);
371 for line_no in 0..idx.line_count() {
372 let bytes = idx.line_bytes_stripped(line_no, src);
373 if re.is_match(&bytes) {
374 return Some(line_no);
375 }
376 }
377 None
378}
379
380fn update_viewport_tag_indicator(stack: &TagStack, viewport: &mut crate::viewport::Viewport) {
381 viewport.set_tag_active(stack.active.as_ref().map(|a| {
382 (a.name.clone(), a.cursor + 1, a.matches.len())
383 }));
384}
385
386#[allow(clippy::too_many_arguments)]
390fn switch_to_current_file(
391 file_set: &mut crate::file_set::FileSet,
392 current_file_index: &mut usize,
393 args: &crate::cli::Args,
394 preprocessor: Option<&crate::preprocess::Preprocessor>,
395 record_start_regex: Option<®ex::bytes::Regex>,
396 viewport: &mut crate::viewport::Viewport,
397 src: &mut Box<dyn crate::source::Source>,
398 idx: &mut crate::line_index::LineIndex,
399) -> Option<String> {
400 let path = match file_set.current() {
401 Some(p) => p.to_path_buf(),
402 None => return Some("[empty file set]".into()),
403 };
404 let new_idx_val = file_set.current_index();
405 match switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
406 Ok(()) => {
407 *current_file_index = new_idx_val;
408 None
409 }
410 Err(e) => Some(format!("[open: {e}]")),
411 }
412}
413
414#[allow(clippy::too_many_arguments)]
415fn switch_file(
416 new_path: &std::path::Path,
417 new_file_index: usize,
418 total_files: usize,
419 args: &crate::cli::Args,
420 preprocessor: Option<&crate::preprocess::Preprocessor>,
421 viewport: &mut crate::viewport::Viewport,
422 src: &mut Box<dyn crate::source::Source>,
423 idx: &mut crate::line_index::LineIndex,
424 record_start_regex: Option<®ex::bytes::Regex>,
425) -> crate::error::Result<()> {
426 let (new_src, new_label, new_failure) =
427 crate::open::open_source_for_path(new_path, args, preprocessor)?;
428
429 *src = new_src;
430 let mut new_idx = crate::line_index::LineIndex::new();
431 if let Some(re) = record_start_regex {
432 new_idx.set_record_start(re.clone());
433 }
434 *idx = new_idx;
435
436 viewport.set_source_label(new_label);
437 viewport.set_file_index(new_file_index, total_files);
438 viewport.set_preprocess_failure(new_failure);
439 viewport.goto_top();
440
441 Ok(())
442}
443
444#[allow(clippy::too_many_arguments)]
445fn dispatch_colon_command(
446 cmd: ColonCommand,
447 file_set: &mut crate::file_set::FileSet,
448 current_file_index: &mut usize,
449 args: &crate::cli::Args,
450 preprocessor: Option<&crate::preprocess::Preprocessor>,
451 record_start_regex: Option<®ex::bytes::Regex>,
452 viewport: &mut crate::viewport::Viewport,
453 src: &mut Box<dyn crate::source::Source>,
454 idx: &mut crate::line_index::LineIndex,
455 tag_stack: &mut TagStack,
456 tag_file: Option<&crate::tags::TagFile>,
457) -> ColonOutcome {
458 match cmd {
459 ColonCommand::Next => {
460 match file_set.next() {
461 Ok(path) => {
462 let path = path.to_path_buf();
463 let new_idx_val = file_set.current_index();
464 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
465 ColonOutcome::Continue(Some(format!("[open: {e}]")))
466 } else {
467 *current_file_index = new_idx_val;
468 ColonOutcome::Continue(None)
469 }
470 }
471 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
472 }
473 }
474 ColonCommand::Prev => {
475 match file_set.prev() {
476 Ok(path) => {
477 let path = path.to_path_buf();
478 let new_idx_val = file_set.current_index();
479 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
480 ColonOutcome::Continue(Some(format!("[open: {e}]")))
481 } else {
482 *current_file_index = new_idx_val;
483 ColonOutcome::Continue(None)
484 }
485 }
486 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
487 }
488 }
489 ColonCommand::Edit(path) => {
490 match crate::open::open_source_for_path(&path, args, preprocessor) {
492 Ok(_) => {
493 let final_path = file_set.append_and_switch(path.clone()).to_path_buf();
495 let new_idx_val = file_set.current_index();
496 if let Err(e) = switch_file(&final_path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
497 ColonOutcome::Continue(Some(format!("[open: {e}]")))
498 } else {
499 *current_file_index = new_idx_val;
500 ColonOutcome::Continue(None)
501 }
502 }
503 Err(e) => ColonOutcome::Continue(Some(format!("[open: {}: {e}]", path.display()))),
504 }
505 }
506 ColonCommand::ShowFile => {
507 let label = viewport.source_label_clone();
508 let cur = file_set.current_index() + 1;
509 let total = file_set.len();
510 let top = viewport.top_line() + 1;
511 let total_lines = idx.line_count();
512 let msg = if total > 1 {
513 format!("{label} (file {cur}/{total}): line {top}/{total_lines}")
514 } else {
515 format!("{label}: line {top}/{total_lines}")
516 };
517 ColonOutcome::Continue(Some(msg))
518 }
519 ColonCommand::Quit => ColonOutcome::Quit,
520 ColonCommand::Delete => {
521 match file_set.delete_current() {
522 Ok(path) => {
523 let path = path.to_path_buf();
524 let new_idx_val = file_set.current_index();
525 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
526 ColonOutcome::Continue(Some(format!("[open: {e}]")))
527 } else {
528 *current_file_index = new_idx_val;
529 ColonOutcome::Continue(None)
530 }
531 }
532 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
533 }
534 }
535 ColonCommand::First => {
536 if file_set.current_index() == 0 {
537 ColonOutcome::Continue(None) } else if let Some(path) = file_set.first() {
539 let path = path.to_path_buf();
540 let new_idx_val = file_set.current_index();
541 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
542 ColonOutcome::Continue(Some(format!("[open: {e}]")))
543 } else {
544 *current_file_index = new_idx_val;
545 ColonOutcome::Continue(None)
546 }
547 } else {
548 ColonOutcome::Continue(None)
549 }
550 }
551 ColonCommand::Last => {
552 if file_set.current_index() + 1 == file_set.len() {
553 ColonOutcome::Continue(None)
554 } else if let Some(path) = file_set.last() {
555 let path = path.to_path_buf();
556 let new_idx_val = file_set.current_index();
557 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
558 ColonOutcome::Continue(Some(format!("[open: {e}]")))
559 } else {
560 *current_file_index = new_idx_val;
561 ColonOutcome::Continue(None)
562 }
563 } else {
564 ColonOutcome::Continue(None)
565 }
566 }
567 ColonCommand::Tag(name) => {
568 match dispatch_tag_jump(
569 &name,
570 tag_file,
571 tag_stack,
572 file_set,
573 current_file_index,
574 args,
575 preprocessor,
576 record_start_regex,
577 viewport,
578 src,
579 idx,
580 ) {
581 Some(msg) => ColonOutcome::Continue(Some(msg)),
582 None => ColonOutcome::Continue(None),
583 }
584 }
585 ColonCommand::TagNext => match tag_stack.next() {
586 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
587 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[no more matches]".into())),
588 TagStepResult::Moved(cur) => {
589 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
590 let msg = dispatch_match(
591 &entry,
592 file_set,
593 current_file_index,
594 args,
595 preprocessor,
596 record_start_regex,
597 viewport,
598 src,
599 idx,
600 );
601 update_viewport_tag_indicator(tag_stack, viewport);
602 ColonOutcome::Continue(msg)
603 }
604 },
605 ColonCommand::TagPrev => match tag_stack.prev() {
606 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
607 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[at first match]".into())),
608 TagStepResult::Moved(cur) => {
609 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
610 let msg = dispatch_match(
611 &entry,
612 file_set,
613 current_file_index,
614 args,
615 preprocessor,
616 record_start_regex,
617 viewport,
618 src,
619 idx,
620 );
621 update_viewport_tag_indicator(tag_stack, viewport);
622 ColonOutcome::Continue(msg)
623 }
624 },
625 ColonCommand::OpenPicker => ColonOutcome::DispatchCommand(Command::OpenPicker),
628 ColonCommand::OpenHelp => ColonOutcome::DispatchCommand(Command::OpenHelp),
629 ColonCommand::HexGroup(hex_chars) => {
630 if !viewport.hex_mode() {
631 return ColonOutcome::Continue(Some(
632 "[:hex requires --hex mode]".into(),
633 ));
634 }
635 let bpg = crate::hex::hex_chars_to_bytes_per_group(hex_chars).unwrap();
637 viewport.set_hex_group_size(bpg);
638 ColonOutcome::Continue(Some(format!("[hex group: {hex_chars} chars]")))
639 }
640 }
641}
642
643#[allow(clippy::too_many_arguments, clippy::collapsible_match)]
644pub fn run(
645 mut src: Box<dyn Source>,
646 mut viewport: Viewport,
647 mut idx: LineIndex,
648 sigterm: Arc<AtomicBool>,
649 rebuild_spec: RebuildSpec,
650 keymap: crate::keys::KeyMap,
651 mut file_set: crate::file_set::FileSet,
652 record_start_regex: Option<regex::bytes::Regex>,
653 args: crate::cli::Args,
654 preprocessor: Option<crate::preprocess::Preprocessor>,
655 tag_file: Option<crate::tags::TagFile>,
656) -> Result<()> {
657 let (mut cols, mut rows) = size().unwrap_or((80, 24));
658 viewport.resize(cols, rows);
659
660 let mut stdout = io::stdout();
661 let timeout = Duration::from_millis(250);
662 let mut last_revision = src.revision();
663
664 if (viewport.filter_active() || viewport.grep_active()) && !viewport.dim_mode() {
669 idx.extend_to_end(src.as_ref());
670 viewport.extend_visible_lines(&idx, src.as_ref());
671 }
672
673 if viewport.follow_mode() {
676 src.pump();
677 viewport.extend_visible_lines(&idx, src.as_ref());
678 viewport.goto_bottom(src.as_ref(), &mut idx);
679 }
680
681 let mut needs_redraw = true;
683 let mut mode = InputMode::Normal;
684 let mut numeric_prefix: Option<usize> = None;
685 let mut marks: HashMap<char, (usize, usize)> = HashMap::new();
686 let mut previous_position: Option<(usize, usize)> = None;
687 let mut current_file_index: usize = file_set.current_index();
688 let mut transient_status: Option<String> = None;
689 let mut tag_stack = TagStack::default();
690 let mut overlay: Option<Box<dyn crate::overlay::Overlay>> = None;
691 let mut overlay_flash: Option<(&'static str, std::time::Instant)> = None;
692 let mouse_enabled = args.mouse;
693
694 if let Some(tag_name) = args.tag.as_deref() {
695 if let Some(msg) = dispatch_tag_jump(
696 tag_name,
697 tag_file.as_ref(),
698 &mut tag_stack,
699 &mut file_set,
700 &mut current_file_index,
701 &args,
702 preprocessor.as_ref(),
703 record_start_regex.as_ref(),
704 &mut viewport,
705 &mut src,
706 &mut idx,
707 ) {
708 return Err(crate::error::Error::Runtime(format!("startup tag jump failed: {msg}")));
709 }
710 }
711
712 loop {
713 if sigterm.load(Ordering::SeqCst) {
714 break;
715 }
716
717 if needs_redraw {
718 if let Some(ov) = overlay.as_ref() {
719 let w = cols;
720 let h = viewport.body_rows() + 1;
721 let mut ovframe = ov.render(w, h);
722 if let Some((msg, started)) = overlay_flash {
723 if started.elapsed() < std::time::Duration::from_millis(1500) {
724 ovframe.status = format!("[{msg}]");
725 } else {
726 overlay_flash = None;
727 }
728 }
729 render_overlay(&mut stdout, &ovframe, w, h)
730 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
731 needs_redraw = false;
732 continue;
733 }
734 let mut frame = viewport.frame(src.as_ref(), &mut idx);
735 match &mode {
738 InputMode::SearchPrompt { direction, buffer, error } => {
739 let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
740 frame.status = match error {
741 Some(e) => format!("{prefix}{buffer} [error: {e}]"),
742 None => format!("{prefix}{buffer}"),
743 };
744 }
745 InputMode::ShellPrompt { buffer, error } => {
746 frame.status = match error {
747 Some(e) => format!("!{buffer} [error: {e}]"),
748 None => format!("!{buffer}"),
749 };
750 }
751 InputMode::ColonPrompt { buffer, error } => {
752 frame.status = match error {
753 Some(e) => format!(":{buffer} [error: {e}]"),
754 None => format!(":{buffer}"),
755 };
756 }
757 InputMode::TagPrompt { buffer, error } => {
758 frame.status = match error {
759 Some(e) => format!("tag: {buffer} [error: {e}]"),
760 None => format!("tag: {buffer}"),
761 };
762 }
763 _ => {
764 if let Some(msg) = transient_status.take() {
765 frame.status = msg;
766 }
767 }
768 }
769 write_frame(&mut stdout, &frame, cols, rows)
770 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
771 needs_redraw = false;
772 }
773
774 match poll(timeout) {
776 Ok(true) => {
777 let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
778 match &mut mode {
781 InputMode::SearchPrompt { direction, buffer, error } => {
782 if let Event::Key(KeyEvent { code, .. }) = event {
783 match code {
784 KeyCode::Esc => { mode = InputMode::Normal; needs_redraw = true; }
785 KeyCode::Enter => {
786 if buffer.is_empty() {
787 if viewport.search_active() {
791 let reverse = !matches!(
792 (viewport.search_direction(), *direction),
793 (SearchDirection::Forward, SearchDirection::Forward)
794 | (SearchDirection::Backward, SearchDirection::Backward)
795 );
796 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
797 viewport.search_repeat(src.as_ref(), &mut idx, reverse);
798 }
799 mode = InputMode::Normal;
800 } else {
801 match viewport.set_search(buffer.clone(), *direction) {
802 Ok(()) => {
803 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
804 viewport.search_repeat(src.as_ref(), &mut idx, false);
805 mode = InputMode::Normal;
806 }
807 Err(e) => { *error = Some(e); }
808 }
809 }
810 needs_redraw = true;
811 }
812 KeyCode::Backspace => {
813 buffer.pop();
814 *error = None;
815 needs_redraw = true;
816 }
817 KeyCode::Char(c) => {
818 buffer.push(c);
819 *error = None;
820 needs_redraw = true;
821 }
822 _ => {}
823 }
824 }
825 continue;
826 }
827 InputMode::OptionPrefix => {
828 if let Event::Key(KeyEvent { code, .. }) = event {
829 match code {
830 KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
831 KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
832 KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
833 KeyCode::Char('P') | KeyCode::Char('p') => {
834 mode = InputMode::PrettifyPrefix;
836 needs_redraw = true;
837 continue;
838 }
839 _ => {}
840 }
841 }
842 mode = InputMode::Normal;
843 needs_redraw = true;
844 continue;
845 }
846 InputMode::PrettifyPrefix => {
847 if let Event::Key(KeyEvent { code, .. }) = event {
848 let target: Option<PrettifyTarget> = match code {
849 KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
850 KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
851 KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
852 KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
853 KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
854 KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
855 KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
856 KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
857 _ => None,
858 };
859 if let Some(t) = target {
860 apply_prettify(
861 src.as_ref(),
862 &mut viewport,
863 &mut idx,
864 rebuild_spec,
865 t,
866 );
867 last_revision = src.revision();
868 }
869 }
870 mode = InputMode::Normal;
871 needs_redraw = true;
872 continue;
873 }
874 InputMode::MarkSetPending => {
875 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
876 if is_valid_mark_name(c) {
877 mark_set(&mut marks, c, current_file_index, viewport.top_line());
878 }
879 }
880 mode = InputMode::Normal;
881 continue;
882 }
883 InputMode::MarkJumpPending => {
884 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
885 if is_valid_mark_name(c) {
886 match mark_jump(&marks, c, current_file_index, &mut previous_position, viewport.top_line()) {
887 Some(MarkTarget::SameFile { line }) => {
888 let clamped = line.min(idx.line_count().saturating_sub(1));
889 viewport.goto_line(clamped, src.as_ref(), &mut idx);
890 needs_redraw = true;
891 }
892 Some(MarkTarget::OtherFile { file_index, line }) => {
893 if file_index < file_set.len() {
894 file_set.set_current_index(file_index);
895 let path = file_set.current().unwrap().to_path_buf();
896 if let Err(e) = switch_file(
897 &path, file_index, file_set.len(),
898 &args, preprocessor.as_ref(),
899 &mut viewport, &mut src, &mut idx,
900 record_start_regex.as_ref(),
901 ) {
902 transient_status = Some(format!("[open: {e}]"));
903 } else {
904 let clamped = line.min(idx.line_count().saturating_sub(1));
905 viewport.goto_line(clamped, src.as_ref(), &mut idx);
906 current_file_index = file_index;
907 needs_redraw = true;
908 }
909 }
910 }
911 None => {}
912 }
913 }
914 }
915 mode = InputMode::Normal;
916 continue;
917 }
918 InputMode::ShellPrompt { buffer, error } => {
919 if let Event::Key(KeyEvent { code, .. }) = event {
920 match code {
921 KeyCode::Esc => {
922 mode = InputMode::Normal;
923 needs_redraw = true;
924 }
925 KeyCode::Enter => {
926 if buffer.is_empty() {
927 mode = InputMode::Normal;
928 } else {
929 match crate::shell::run_shell_command(buffer) {
930 Ok(()) => {
931 mode = InputMode::Normal;
932 }
933 Err(e) => {
934 *error = Some(e.to_string());
935 }
936 }
937 }
938 needs_redraw = true;
939 }
940 KeyCode::Backspace => {
941 buffer.pop();
942 *error = None;
943 needs_redraw = true;
944 }
945 KeyCode::Char(c) => {
946 buffer.push(c);
947 *error = None;
948 needs_redraw = true;
949 }
950 _ => {}
951 }
952 }
953 continue;
954 }
955 InputMode::CtrlXPending => {
956 let is_ctrl_x = matches!(
957 event,
958 Event::Key(KeyEvent {
959 code: KeyCode::Char('x'),
960 modifiers: KeyModifiers::CONTROL,
961 ..
962 })
963 );
964 if is_ctrl_x {
965 match jump_previous(&mut previous_position, current_file_index, viewport.top_line()) {
966 Some(MarkTarget::SameFile { line }) => {
967 let clamped = line.min(idx.line_count().saturating_sub(1));
968 viewport.goto_line(clamped, src.as_ref(), &mut idx);
969 needs_redraw = true;
970 }
971 Some(MarkTarget::OtherFile { file_index, line }) => {
972 if file_index < file_set.len() {
973 file_set.set_current_index(file_index);
974 let path = file_set.current().unwrap().to_path_buf();
975 if let Err(e) = switch_file(
976 &path, file_index, file_set.len(),
977 &args, preprocessor.as_ref(),
978 &mut viewport, &mut src, &mut idx,
979 record_start_regex.as_ref(),
980 ) {
981 transient_status = Some(format!("[open: {e}]"));
982 } else {
983 let clamped = line.min(idx.line_count().saturating_sub(1));
984 viewport.goto_line(clamped, src.as_ref(), &mut idx);
985 current_file_index = file_index;
986 needs_redraw = true;
987 }
988 }
989 }
990 None => {}
991 }
992 mode = InputMode::Normal;
993 continue;
994 }
995 mode = InputMode::Normal;
997 }
999 InputMode::ColonPrompt { buffer, error } => {
1000 if let Event::Key(KeyEvent { code, .. }) = event {
1001 match code {
1002 KeyCode::Esc => {
1003 mode = InputMode::Normal;
1004 needs_redraw = true;
1005 }
1006 KeyCode::Enter => {
1007 if buffer.is_empty() {
1008 mode = InputMode::Normal;
1009 } else {
1010 match parse_colon_command(buffer) {
1011 Ok(cmd) => {
1012 let outcome = dispatch_colon_command(
1013 cmd,
1014 &mut file_set,
1015 &mut current_file_index,
1016 &args,
1017 preprocessor.as_ref(),
1018 record_start_regex.as_ref(),
1019 &mut viewport,
1020 &mut src,
1021 &mut idx,
1022 &mut tag_stack,
1023 tag_file.as_ref(),
1024 );
1025 match outcome {
1026 ColonOutcome::Continue(msg) => {
1027 transient_status = msg;
1028 }
1029 ColonOutcome::Quit => break,
1030 ColonOutcome::DispatchCommand(Command::OpenPicker) => {
1031 let saved = (0..file_set.len())
1032 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1033 .collect::<Vec<_>>();
1034 overlay = Some(Box::new(
1035 crate::overlay::picker::FilePicker::new(&file_set, saved)
1036 ));
1037 needs_redraw = true;
1038 }
1039 ColonOutcome::DispatchCommand(Command::OpenHelp) => {
1040 let remaps = keymap.user_keys_by_command_name();
1041 overlay = Some(Box::new(
1042 crate::overlay::help::HelpOverlay::new(remaps)
1043 ));
1044 needs_redraw = true;
1045 }
1046 ColonOutcome::DispatchCommand(cmd) => {
1047 debug_assert!(false, "colon dispatcher emitted unexpected Command: {cmd:?}");
1048 }
1050 }
1051 mode = InputMode::Normal;
1052 }
1053 Err(e) => {
1054 *error = Some(e.to_string());
1055 }
1056 }
1057 }
1058 needs_redraw = true;
1059 }
1060 KeyCode::Backspace => {
1061 buffer.pop();
1062 *error = None;
1063 needs_redraw = true;
1064 }
1065 KeyCode::Char(c) => {
1066 buffer.push(c);
1067 *error = None;
1068 needs_redraw = true;
1069 }
1070 _ => {}
1071 }
1072 }
1073 continue;
1074 }
1075 InputMode::TagPrompt { buffer, error } => {
1076 if let Event::Key(KeyEvent { code, .. }) = event {
1077 match code {
1078 KeyCode::Esc => {
1079 mode = InputMode::Normal;
1080 needs_redraw = true;
1081 }
1082 KeyCode::Enter => {
1083 if buffer.is_empty() {
1084 mode = InputMode::Normal;
1085 } else {
1086 let name = buffer.clone();
1087 let msg = dispatch_tag_jump(
1088 &name,
1089 tag_file.as_ref(),
1090 &mut tag_stack,
1091 &mut file_set,
1092 &mut current_file_index,
1093 &args,
1094 preprocessor.as_ref(),
1095 record_start_regex.as_ref(),
1096 &mut viewport,
1097 &mut src,
1098 &mut idx,
1099 );
1100 if let Some(m) = msg {
1101 transient_status = Some(m);
1102 }
1103 mode = InputMode::Normal;
1104 }
1105 needs_redraw = true;
1106 }
1107 KeyCode::Backspace => {
1108 buffer.pop();
1109 *error = None;
1110 needs_redraw = true;
1111 }
1112 KeyCode::Char(c) => {
1113 buffer.push(c);
1114 *error = None;
1115 needs_redraw = true;
1116 }
1117 _ => {}
1118 }
1119 }
1120 continue;
1121 }
1122 InputMode::Normal => {}
1123 }
1124 if let crossterm::event::Event::Resize(c, r) = event {
1127 cols = c;
1128 rows = r;
1129 viewport.resize(c, r);
1130 needs_redraw = true;
1131 if overlay.is_some() {
1132 continue;
1134 }
1135 }
1138 if let Some(ov) = overlay.as_mut() {
1142 let outcome = match &event {
1143 Event::Key(ke) => ov.handle_key(*ke),
1144 Event::Mouse(me) => ov.handle_mouse(*me, viewport.body_rows()),
1145 Event::Resize(_, _) => crate::overlay::OverlayOutcome::Stay,
1146 _ => crate::overlay::OverlayOutcome::Stay,
1147 };
1148 match outcome {
1149 crate::overlay::OverlayOutcome::Stay => {
1150 needs_redraw = true;
1151 continue;
1152 }
1153 crate::overlay::OverlayOutcome::Close => {
1154 overlay = None;
1155 overlay_flash = None;
1156 needs_redraw = true;
1157 continue;
1158 }
1159 crate::overlay::OverlayOutcome::CloseAnd(cmd) => {
1160 overlay = None;
1161 overlay_flash = None;
1162 if let Command::SelectFile(i) = cmd {
1163 if i < file_set.len() {
1164 file_set.set_current_index(i);
1165 if let Some(msg) = switch_to_current_file(
1166 &mut file_set, &mut current_file_index,
1167 &args, preprocessor.as_ref(),
1168 record_start_regex.as_ref(),
1169 &mut viewport, &mut src, &mut idx,
1170 ) {
1171 transient_status = Some(msg);
1172 }
1173 }
1174 }
1175 needs_redraw = true;
1176 continue;
1177 }
1178 crate::overlay::OverlayOutcome::Apply(cmd) => {
1179 if let Command::DropFileAt(target) = cmd {
1180 if file_set.len() > 1 && target < file_set.len() {
1181 let saved_cur = file_set.current_index();
1182 file_set.set_current_index(target);
1183 let _ = file_set.delete_current();
1184 if target < saved_cur {
1188 let restored = saved_cur.saturating_sub(1);
1189 file_set.set_current_index(restored);
1190 } else if target > saved_cur {
1191 file_set.set_current_index(saved_cur);
1192 }
1193 if let Some(msg) = switch_to_current_file(
1196 &mut file_set, &mut current_file_index,
1197 &args, preprocessor.as_ref(),
1198 record_start_regex.as_ref(),
1199 &mut viewport, &mut src, &mut idx,
1200 ) {
1201 transient_status = Some(msg);
1202 }
1203 if let Some(ov) = overlay.as_mut() {
1204 ov.refresh(crate::overlay::OverlayContext { file_set: &file_set });
1205 }
1206 }
1207 }
1208 needs_redraw = true;
1209 continue;
1210 }
1211 crate::overlay::OverlayOutcome::Refuse(msg) => {
1212 overlay_flash = Some((msg, std::time::Instant::now()));
1213 needs_redraw = true;
1214 continue;
1215 }
1216 }
1217 }
1218 if let crossterm::event::Event::Mouse(me) = &event {
1222 if mouse_enabled {
1223 use crossterm::event::MouseEventKind;
1224 match me.kind {
1225 MouseEventKind::ScrollDown => {
1226 viewport.scroll_lines(3, src.as_ref(), &mut idx);
1227 needs_redraw = true;
1228 }
1229 MouseEventKind::ScrollUp => {
1230 viewport.scroll_lines(-3, src.as_ref(), &mut idx);
1231 needs_redraw = true;
1232 }
1233 _ => {}
1234 }
1235 }
1236 continue;
1237 }
1238 let mut cmd: Option<Command> = None;
1242 if let InputMode::Normal = mode {
1243 if let Event::Key(ke) = &event {
1244 if let Some(target) = keymap.lookup(ke) {
1245 match target {
1246 crate::keys::BindingTarget::Shell(cmd_text) => {
1247 let cmd_text = cmd_text.clone();
1248 if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
1249 let _ = writeln!(std::io::stderr(),
1250 "[shell: {e}]");
1251 }
1252 needs_redraw = true;
1253 continue;
1254 }
1255 crate::keys::BindingTarget::Command(c) => {
1256 cmd = Some(c.clone());
1257 }
1258 }
1259 }
1260 }
1261 }
1262 let cmd = cmd.unwrap_or_else(|| translate(event));
1263 let prefix_at_cmd = numeric_prefix.take();
1266 match cmd {
1267 Command::Digit(d) => {
1268 let cur = prefix_at_cmd.unwrap_or(0);
1269 let next = cur.saturating_mul(10).saturating_add(d as usize);
1270 if next <= 99_999_999 {
1271 numeric_prefix = Some(next);
1272 } else {
1273 numeric_prefix = prefix_at_cmd;
1275 }
1276 continue;
1277 }
1278 Command::Cancel => {
1279 continue;
1281 }
1282 Command::GotoLine => {
1283 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1284 match prefix_at_cmd {
1285 Some(line) if line > 0 => viewport.goto_line(line - 1, src.as_ref(), &mut idx),
1286 _ => viewport.goto_top(),
1287 }
1288 needs_redraw = true;
1289 }
1290 Command::GotoRecord => {
1291 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1292 match prefix_at_cmd {
1293 Some(rec) if rec > 0 => viewport.goto_record(rec - 1, src.as_ref(), &mut idx),
1294 _ => viewport.goto_bottom(src.as_ref(), &mut idx),
1295 }
1296 needs_redraw = true;
1297 }
1298 Command::GotoPercent => {
1299 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1300 match prefix_at_cmd {
1301 Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
1302 _ => viewport.goto_top(),
1303 }
1304 needs_redraw = true;
1305 }
1306 Command::Quit => break,
1307 Command::Resize(c, r) => {
1308 cols = c; rows = r;
1309 viewport.resize(c, r);
1310 needs_redraw = true;
1311 }
1312 Command::ScrollLines(n) => {
1313 viewport.scroll_lines(n, src.as_ref(), &mut idx);
1314 needs_redraw = true;
1315 }
1316 Command::ScrollLogicalLines(n) => {
1317 viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
1318 needs_redraw = true;
1319 }
1320 Command::PageDown => {
1321 viewport.page_down(src.as_ref(), &mut idx);
1322 needs_redraw = true;
1323 }
1324 Command::PageUp => {
1325 viewport.page_up(src.as_ref(), &mut idx);
1326 needs_redraw = true;
1327 }
1328 Command::HalfPageDown => {
1329 viewport.half_page_down(src.as_ref(), &mut idx);
1330 needs_redraw = true;
1331 }
1332 Command::HalfPageUp => {
1333 viewport.half_page_up(src.as_ref(), &mut idx);
1334 needs_redraw = true;
1335 }
1336 Command::Refresh => {
1337 needs_redraw = true;
1338 }
1339 Command::Reload => {
1340 src.pump();
1343 if src.revision() != last_revision {
1344 rebuild_after_replace(
1345 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1346 );
1347 last_revision = src.revision();
1348 needs_redraw = true;
1349 }
1350 }
1351 Command::TogglePrettify => {
1352 apply_prettify(
1353 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1354 PrettifyTarget::Toggle,
1355 );
1356 last_revision = src.revision();
1357 needs_redraw = true;
1358 }
1359 Command::SetPrettifyMode(m) => {
1360 apply_prettify(
1361 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1362 PrettifyTarget::Mode(m),
1363 );
1364 last_revision = src.revision();
1365 needs_redraw = true;
1366 }
1367 Command::RedetectPrettify => {
1368 apply_prettify(
1369 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1370 PrettifyTarget::Auto,
1371 );
1372 last_revision = src.revision();
1373 needs_redraw = true;
1374 }
1375 Command::ToggleLineNumbers => {
1376 viewport.toggle_line_numbers();
1377 needs_redraw = true;
1378 }
1379 Command::ToggleChop => {
1380 viewport.toggle_chop();
1381 needs_redraw = true;
1382 }
1383 Command::ToggleFollow => {
1384 viewport.toggle_follow();
1385 if viewport.follow_mode() {
1386 src.pump();
1388 idx.notice_new_bytes(src.as_ref());
1389 viewport.goto_bottom(src.as_ref(), &mut idx);
1390 }
1391 needs_redraw = true;
1392 }
1393 Command::SearchForward => {
1394 mode = InputMode::SearchPrompt {
1395 direction: SearchDirection::Forward,
1396 buffer: String::new(),
1397 error: None,
1398 };
1399 needs_redraw = true;
1400 }
1401 Command::SearchBackward => {
1402 mode = InputMode::SearchPrompt {
1403 direction: SearchDirection::Backward,
1404 buffer: String::new(),
1405 error: None,
1406 };
1407 needs_redraw = true;
1408 }
1409 Command::ShellEscape => {
1410 mode = InputMode::ShellPrompt {
1411 buffer: String::new(),
1412 error: None,
1413 };
1414 needs_redraw = true;
1415 }
1416 Command::ColonPrompt => {
1417 mode = InputMode::ColonPrompt {
1418 buffer: String::new(),
1419 error: None,
1420 };
1421 needs_redraw = true;
1422 }
1423 Command::NextMatch => {
1424 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1425 if viewport.search_repeat(src.as_ref(), &mut idx, false) {
1426 needs_redraw = true;
1427 }
1428 }
1429 Command::PreviousMatch => {
1430 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1431 if viewport.search_repeat(src.as_ref(), &mut idx, true) {
1432 needs_redraw = true;
1433 }
1434 }
1435 Command::OptionPrefix => {
1436 mode = InputMode::OptionPrefix;
1437 }
1438 Command::MarkSet => {
1439 mode = InputMode::MarkSetPending;
1440 }
1441 Command::MarkJump => {
1442 mode = InputMode::MarkJumpPending;
1443 }
1444 Command::CtrlXPrefix => {
1445 mode = InputMode::CtrlXPending;
1446 }
1447 Command::JumpPrevious => {
1448 }
1451 Command::TagPrompt => {
1452 if tag_file.is_none() {
1453 transient_status = Some("[no tags file loaded]".into());
1454 needs_redraw = true;
1455 } else {
1456 mode = InputMode::TagPrompt {
1457 buffer: String::new(),
1458 error: None,
1459 };
1460 needs_redraw = true;
1461 }
1462 }
1463 Command::TagPop => match tag_stack.pop() {
1464 Some((file_index, line)) => {
1465 if file_index != current_file_index && file_index < file_set.len() {
1466 file_set.set_current_index(file_index);
1467 let path = file_set.current().unwrap().to_path_buf();
1468 if let Err(e) = switch_file(
1469 &path,
1470 file_index,
1471 file_set.len(),
1472 &args,
1473 preprocessor.as_ref(),
1474 &mut viewport,
1475 &mut src,
1476 &mut idx,
1477 record_start_regex.as_ref(),
1478 ) {
1479 transient_status = Some(format!("[open: {e}]"));
1480 } else {
1481 current_file_index = file_index;
1482 }
1483 }
1484 let clamped = line.min(idx.line_count().saturating_sub(1));
1485 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1486 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1487 needs_redraw = true;
1488 }
1489 None => {
1490 transient_status = Some("[tag stack empty]".into());
1491 needs_redraw = true;
1492 }
1493 },
1494 Command::OpenPicker => {
1495 let saved = (0..file_set.len())
1496 .map(|i| if i == current_file_index { viewport.top_line() } else { 0 })
1497 .collect::<Vec<_>>();
1498 overlay = Some(Box::new(
1499 crate::overlay::picker::FilePicker::new(&file_set, saved)
1500 ));
1501 needs_redraw = true;
1502 }
1503 Command::OpenHelp => {
1504 let remaps = keymap.user_keys_by_command_name();
1505 overlay = Some(Box::new(
1506 crate::overlay::help::HelpOverlay::new(remaps)
1507 ));
1508 needs_redraw = true;
1509 }
1510 Command::SelectFile(_) | Command::DropFileAt(_) => {
1511 }
1513 Command::MouseEvent(_) => {
1514 }
1516 Command::Noop => {}
1517 }
1518 }
1519 Ok(false) => {
1520 if viewport.live_mode() {
1522 let was_at_bottom = viewport.is_at_bottom(&idx);
1523 src.pump();
1524 if src.revision() != last_revision {
1525 rebuild_after_replace(
1526 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1527 );
1528 if was_at_bottom {
1529 viewport.goto_bottom(src.as_ref(), &mut idx);
1530 }
1531 last_revision = src.revision();
1532 needs_redraw = true;
1533 }
1534 } else if viewport.follow_mode() {
1535 let was_at_bottom = viewport.is_at_bottom(&idx);
1536 src.pump();
1537 let lines_before = idx.line_count();
1538 idx.notice_new_bytes(src.as_ref());
1539 viewport.extend_visible_lines(&idx, src.as_ref());
1540 if idx.line_count() != lines_before {
1541 needs_redraw = true;
1542 if was_at_bottom {
1543 viewport.goto_bottom(src.as_ref(), &mut idx);
1544 }
1545 }
1546 } else if !src.is_complete() {
1547 let lines_before = idx.line_count();
1550 idx.notice_new_bytes(src.as_ref());
1551 viewport.extend_visible_lines(&idx, src.as_ref());
1552 if idx.line_count() != lines_before {
1553 needs_redraw = true;
1554 }
1555 }
1556 }
1557 Err(_) => {
1558 std::thread::sleep(timeout);
1560 }
1561 }
1562 }
1563 Ok(())
1564}
1565
1566#[derive(Debug, Clone, Copy)]
1568enum PrettifyTarget {
1569 Mode(PrettifyMode),
1571 Toggle,
1573 Auto,
1575}
1576
1577fn apply_prettify(
1581 src: &dyn Source,
1582 viewport: &mut Viewport,
1583 idx: &mut LineIndex,
1584 spec: RebuildSpec,
1585 target: PrettifyTarget,
1586) {
1587 if src.prettify_mode().is_none() {
1589 return;
1590 }
1591 match target {
1592 PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
1593 PrettifyTarget::Toggle => src.toggle_prettify(),
1594 PrettifyTarget::Auto => src.redetect_prettify(),
1595 }
1596 rebuild_after_replace(src, viewport, idx, spec);
1597 viewport.set_prettify_label(src.prettify_label());
1598}
1599
1600fn rebuild_after_replace(
1606 src: &dyn Source,
1607 viewport: &mut Viewport,
1608 idx: &mut LineIndex,
1609 spec: RebuildSpec,
1610) {
1611 let new_off = match spec.tail {
1612 Some(n) => find_tail_offset(src, n),
1613 None => 0,
1614 };
1615 *idx = LineIndex::new_starting_at(new_off);
1616 if let Some(n) = spec.head {
1617 idx.set_head_cap(n);
1618 }
1619 viewport.invalidate_filter_cache();
1620 idx.notice_new_bytes(src);
1621 viewport.extend_visible_lines(idx, src);
1622 viewport.clamp_top_line(idx.line_count());
1623}
1624
1625fn to_crossterm_color(c: crate::ansi::Color) -> crossterm::style::Color {
1626 use crossterm::style::Color as CC;
1627 use crate::ansi::Color;
1628 match c {
1629 Color::Ansi(0) => CC::Black,
1630 Color::Ansi(1) => CC::DarkRed,
1631 Color::Ansi(2) => CC::DarkGreen,
1632 Color::Ansi(3) => CC::DarkYellow,
1633 Color::Ansi(4) => CC::DarkBlue,
1634 Color::Ansi(5) => CC::DarkMagenta,
1635 Color::Ansi(6) => CC::DarkCyan,
1636 Color::Ansi(7) => CC::Grey,
1637 Color::Ansi(8) => CC::DarkGrey,
1638 Color::Ansi(9) => CC::Red,
1639 Color::Ansi(10) => CC::Green,
1640 Color::Ansi(11) => CC::Yellow,
1641 Color::Ansi(12) => CC::Blue,
1642 Color::Ansi(13) => CC::Magenta,
1643 Color::Ansi(14) => CC::Cyan,
1644 Color::Ansi(15) => CC::White,
1645 Color::Ansi(_) => CC::Reset,
1646 Color::Indexed(n) => CC::AnsiValue(n),
1647 Color::Rgb(r, g, b) => CC::Rgb { r, g, b },
1648 Color::Default => CC::Reset,
1649 }
1650}
1651
1652fn emit_style_diff<W: Write>(
1655 out: &mut W,
1656 prev: &crate::ansi::Style,
1657 next: &crate::ansi::Style,
1658) -> io::Result<()> {
1659 let intensity_changed = prev.bold != next.bold || prev.dim != next.dim;
1663
1664 let fg_changed = prev.fg != next.fg;
1668 let bg_changed = prev.bg != next.bg;
1669
1670 if (fg_changed && next.fg.is_none()) || (bg_changed && next.bg.is_none()) {
1671 out.queue(ResetColor)?;
1672 if let Some(c) = next.fg {
1674 out.queue(SetForegroundColor(to_crossterm_color(c)))?;
1675 }
1676 if let Some(c) = next.bg {
1677 out.queue(SetBackgroundColor(to_crossterm_color(c)))?;
1678 }
1679 } else {
1680 if fg_changed {
1681 if let Some(c) = next.fg {
1682 out.queue(SetForegroundColor(to_crossterm_color(c)))?;
1683 }
1684 }
1685 if bg_changed {
1686 if let Some(c) = next.bg {
1687 out.queue(SetBackgroundColor(to_crossterm_color(c)))?;
1688 }
1689 }
1690 }
1691
1692 if intensity_changed {
1693 if next.bold {
1694 out.queue(SetAttribute(Attribute::Bold))?;
1695 } else if next.dim {
1696 out.queue(SetAttribute(Attribute::Dim))?;
1697 } else {
1698 out.queue(SetAttribute(Attribute::NormalIntensity))?;
1699 }
1700 }
1701 if prev.italic != next.italic {
1702 out.queue(SetAttribute(if next.italic { Attribute::Italic } else { Attribute::NoItalic }))?;
1703 }
1704 if prev.underline != next.underline {
1705 out.queue(SetAttribute(if next.underline { Attribute::Underlined } else { Attribute::NoUnderline }))?;
1706 }
1707 if prev.reverse != next.reverse {
1708 out.queue(SetAttribute(if next.reverse { Attribute::Reverse } else { Attribute::NoReverse }))?;
1709 }
1710 if prev.strike != next.strike {
1711 out.queue(SetAttribute(if next.strike { Attribute::CrossedOut } else { Attribute::NotCrossedOut }))?;
1712 }
1713 Ok(())
1714}
1715
1716fn emit_hyperlink_diff<W: Write>(
1717 out: &mut W,
1718 prev: &Option<Arc<str>>,
1719 next: &Option<Arc<str>>,
1720) -> io::Result<()> {
1721 if prev == next {
1722 return Ok(());
1723 }
1724 if prev.is_some() {
1725 out.write_all(b"\x1b]8;;\x1b\\")?;
1726 }
1727 if let Some(uri) = next {
1728 out.write_all(b"\x1b]8;;")?;
1729 out.write_all(uri.as_bytes())?;
1730 out.write_all(b"\x1b\\")?;
1731 }
1732 Ok(())
1733}
1734
1735const SYNC_UPDATE_BEGIN: &[u8] = b"\x1b[?2026h";
1742const SYNC_UPDATE_END: &[u8] = b"\x1b[?2026l";
1743
1744fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16) -> io::Result<()> {
1745 out.write_all(SYNC_UPDATE_BEGIN)?;
1754
1755 out.queue(SetAttribute(Attribute::Reset))?;
1757 out.queue(ResetColor)?;
1758
1759 for (i, row) in frame.body.iter().enumerate() {
1760 out.queue(MoveTo(0, i as u16))?;
1761 out.queue(Clear(ClearType::UntilNewLine))?;
1765 out.queue(SetAttribute(Attribute::Reset))?;
1768 let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
1769 let base_style = if matches!(row_style, RowStyle::Dim) {
1774 out.queue(SetAttribute(Attribute::Dim))?;
1775 crate::ansi::Style { dim: true, ..Default::default() }
1776 } else {
1777 crate::ansi::Style::default()
1778 };
1779 let no_highlights = Vec::new();
1780 let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
1781 write_row_with_highlights(out, row, cols, highlights, base_style)?;
1782 }
1783 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
1785 out.queue(Clear(ClearType::UntilNewLine))?;
1786 out.queue(SetAttribute(Attribute::Reverse))?;
1787 let mut status = frame.status.clone();
1788 if status.len() > cols as usize {
1789 status.truncate(cols as usize);
1790 } else {
1791 let pad = cols as usize - status.len();
1792 status.push_str(&" ".repeat(pad));
1793 }
1794 out.queue(Print(status))?;
1795 out.queue(ResetColor)?;
1796 out.queue(SetAttribute(Attribute::Reset))?;
1797
1798 out.write_all(SYNC_UPDATE_END)?;
1801 out.flush()
1802}
1803
1804
1805fn write_row_with_highlights(
1816 out: &mut impl Write,
1817 row: &[Cell],
1818 cols: u16,
1819 highlights: &[std::ops::Range<usize>],
1820 base_style: crate::ansi::Style,
1821) -> io::Result<()> {
1822 let cols_usize = cols as usize;
1823
1824 let mut ranges: Vec<std::ops::Range<usize>> = highlights
1825 .iter()
1826 .filter_map(|r| {
1827 let s = r.start.min(cols_usize);
1828 let e = r.end.min(cols_usize);
1829 if e > s { Some(s..e) } else { None }
1830 })
1831 .collect();
1832 ranges.sort_by_key(|r| r.start);
1833
1834 let mut prev_style = base_style;
1837 let mut prev_link: Option<Arc<str>> = None;
1838
1839 let mut col = 0usize;
1840 let mut i = 0usize;
1841 while col < cols_usize && i < row.len() {
1842 let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
1843
1844 match &row[i] {
1845 Cell::Char { ch, width, style, hyperlink } => {
1846 let mut eff = *style;
1852 if in_highlight {
1853 eff.reverse = !eff.reverse;
1854 }
1855 if base_style.dim && !eff.bold {
1856 eff.dim = true;
1857 }
1858 emit_style_diff(out, &prev_style, &eff)?;
1859 emit_hyperlink_diff(out, &prev_link, hyperlink)?;
1860 out.queue(Print(*ch))?;
1861 prev_style = eff;
1862 prev_link = hyperlink.clone();
1863 col += *width as usize;
1864 }
1865 Cell::Continuation => {
1866 }
1868 Cell::Empty => {
1869 let default = if base_style.dim {
1874 crate::ansi::Style { dim: true, ..Default::default() }
1875 } else {
1876 crate::ansi::Style::default()
1877 };
1878 emit_style_diff(out, &prev_style, &default)?;
1879 emit_hyperlink_diff(out, &prev_link, &None)?;
1880 out.queue(Print(' '))?;
1881 prev_style = default;
1882 prev_link = None;
1883 col += 1;
1884 }
1885 }
1886 i += 1;
1887 }
1888
1889 emit_hyperlink_diff(out, &prev_link, &None)?;
1892 out.queue(ResetColor)?;
1893 out.queue(SetAttribute(Attribute::Reset))?;
1894
1895 Ok(())
1896}
1897
1898fn render_overlay(
1899 out: &mut impl Write,
1900 frame: &crate::overlay::OverlayFrame,
1901 width: u16,
1902 height: u16,
1903) -> io::Result<()> {
1904 out.write_all(SYNC_UPDATE_BEGIN)?;
1908 out.queue(SetAttribute(Attribute::Reset))?;
1909 out.queue(ResetColor)?;
1910 for row in 0..height.saturating_sub(1) {
1911 out.queue(MoveTo(0, row))?;
1912 out.queue(Clear(ClearType::UntilNewLine))?;
1913 out.queue(SetAttribute(Attribute::Reset))?;
1914 if let Some(line) = frame.body.get(row as usize) {
1915 let mut written = 0usize;
1916 for ch in line.chars() {
1917 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
1918 if written + w > width as usize { break; }
1919 write!(out, "{ch}")?;
1920 written += w;
1921 }
1922 }
1923 }
1924 out.queue(MoveTo(0, height.saturating_sub(1)))?;
1925 out.queue(Clear(ClearType::UntilNewLine))?;
1926 out.queue(SetAttribute(Attribute::Reverse))?;
1927 let mut status = frame.status.clone();
1928 if status.len() > width as usize {
1930 status.truncate(width as usize);
1931 } else {
1932 let pad = width as usize - status.len();
1933 status.push_str(&" ".repeat(pad));
1934 }
1935 out.queue(Print(status))?;
1936 out.queue(ResetColor)?;
1937 out.queue(SetAttribute(Attribute::Reset))?;
1938 out.write_all(SYNC_UPDATE_END)?;
1939 out.flush()
1940}
1941
1942#[cfg(test)]
1943mod tests {
1944 use super::*;
1945
1946 #[test]
1947 fn parse_colon_n() {
1948 assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
1949 assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
1950 }
1951
1952 #[test]
1953 fn write_frame_brackets_with_sync_update_and_no_full_clear() {
1954 use crate::ansi::Style;
1959 use crate::render::Cell;
1960 use crate::viewport::{Frame, RowStyle};
1961
1962 let row: Vec<Cell> = (0..3)
1963 .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
1964 .collect();
1965 let frame = Frame {
1966 body: vec![row.clone(), row],
1967 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
1968 highlights: vec![Vec::new(), Vec::new()],
1969 status: "status".into(),
1970 };
1971
1972 let mut buf: Vec<u8> = Vec::new();
1973 write_frame(&mut buf, &frame, 3, 3).unwrap();
1974 let s = std::str::from_utf8(&buf).expect("ascii");
1975
1976 let begin = s.find("\x1b[?2026h").expect("begin sync update");
1978 let end = s.find("\x1b[?2026l").expect("end sync update");
1979 assert!(begin < end, "begin must precede end");
1980 let first_a = s.find('a').expect("body char");
1982 assert!(begin < first_a && first_a < end, "body must be inside sync update");
1983
1984 assert!(
1987 !s.contains("\x1b[2J"),
1988 "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
1989 );
1990 assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
1991 }
1992
1993 #[test]
1994 fn dim_row_keeps_dim_through_plain_cells_and_padding() {
1995 use crate::ansi::Style;
2000 use crate::render::Cell;
2001 let row = vec![
2002 Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
2003 Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
2004 Cell::Empty,
2005 Cell::Empty,
2006 ];
2007 let mut buf: Vec<u8> = Vec::new();
2008 let base = Style { dim: true, ..Default::default() };
2009 write_row_with_highlights(&mut buf, &row, 4, &[], base).unwrap();
2010 let s = String::from_utf8_lossy(&buf);
2011
2012 for needle in ['h', 'i'] {
2015 let pos = s.find(needle).expect("char printed");
2016 let before = &s[..pos];
2017 assert!(
2018 !before.contains("\x1b[22m"),
2019 "dim cleared before {needle:?}: {before:?}",
2020 );
2021 }
2022 let after_i = s.find('i').unwrap() + 1;
2025 let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
2026 let pad = &s[after_i..after_i + eor];
2027 assert!(
2028 !pad.contains("\x1b[22m"),
2029 "dim cleared in padding region: {pad:?}",
2030 );
2031 }
2032
2033 #[test]
2034 fn dim_row_yields_to_explicit_bold_cell() {
2035 use crate::ansi::Style;
2038 use crate::render::Cell;
2039 let row = vec![
2040 Cell::Char {
2041 ch: 'B',
2042 width: 1,
2043 style: Style { bold: true, ..Default::default() },
2044 hyperlink: None,
2045 },
2046 ];
2047 let mut buf: Vec<u8> = Vec::new();
2048 let base = Style { dim: true, ..Default::default() };
2049 write_row_with_highlights(&mut buf, &row, 1, &[], base).unwrap();
2050 let s = String::from_utf8_lossy(&buf);
2051 assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
2053 }
2054
2055 #[test]
2056 fn parse_colon_p() {
2057 assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
2058 assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
2059 }
2060
2061 #[test]
2062 fn parse_colon_e_with_path() {
2063 match parse_colon_command("e /tmp/foo.log").unwrap() {
2064 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
2065 other => panic!("expected Edit, got {other:?}"),
2066 }
2067 }
2068
2069 #[test]
2070 fn parse_colon_e_with_tilde() {
2071 std::env::set_var("HOME", "/home/user");
2072 match parse_colon_command("e ~/foo.log").unwrap() {
2073 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
2074 other => panic!("expected Edit, got {other:?}"),
2075 }
2076 }
2077
2078 #[test]
2079 fn parse_colon_e_missing_path_errors() {
2080 assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
2081 assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
2082 }
2083
2084 #[test]
2085 fn parse_colon_f_q_d_x_t() {
2086 assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
2087 assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
2088 assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
2089 assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
2090 assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
2091 }
2092
2093 #[test]
2094 fn parse_unknown_command_errors() {
2095 let err = parse_colon_command("bogus").unwrap_err();
2096 match err {
2097 ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
2098 other => panic!("expected UnknownCommand, got {other:?}"),
2099 }
2100 }
2101
2102 #[test]
2103 fn parse_handles_whitespace() {
2104 assert_eq!(parse_colon_command("n ").unwrap(), ColonCommand::Next);
2106 assert_eq!(parse_colon_command(" n").unwrap(), ColonCommand::Next);
2107 }
2108
2109 #[test]
2110 fn parse_colon_tag_with_name() {
2111 assert_eq!(
2112 parse_colon_command("tag foo").unwrap(),
2113 ColonCommand::Tag("foo".into())
2114 );
2115 }
2116
2117 #[test]
2118 fn parse_colon_tag_strips_trailing_whitespace() {
2119 assert_eq!(
2120 parse_colon_command("tag foo ").unwrap(),
2121 ColonCommand::Tag("foo".into())
2122 );
2123 }
2124
2125 #[test]
2126 fn parse_colon_tag_without_name_errors() {
2127 assert_eq!(
2128 parse_colon_command("tag").unwrap_err(),
2129 ColonParseError::TagRequiresName
2130 );
2131 assert_eq!(
2132 parse_colon_command("tag ").unwrap_err(),
2133 ColonParseError::TagRequiresName
2134 );
2135 }
2136
2137 #[test]
2138 fn parse_colon_tnext_and_tprev() {
2139 assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
2140 assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
2141 }
2142
2143 #[test]
2144 fn parse_colon_b_opens_picker() {
2145 assert_eq!(parse_colon_command("b").unwrap(), ColonCommand::OpenPicker);
2146 assert_eq!(parse_colon_command("buffers").unwrap(), ColonCommand::OpenPicker);
2147 }
2148
2149 #[test]
2150 fn parse_colon_help_opens_help() {
2151 assert_eq!(parse_colon_command("h").unwrap(), ColonCommand::OpenHelp);
2152 assert_eq!(parse_colon_command("help").unwrap(), ColonCommand::OpenHelp);
2153 }
2154
2155 #[test]
2156 fn parse_colon_hex_with_valid_widths() {
2157 for n in [2usize, 4, 8, 16, 32] {
2158 assert_eq!(
2159 parse_colon_command(&format!("hex {n}")).unwrap(),
2160 ColonCommand::HexGroup(n),
2161 );
2162 }
2163 }
2164
2165 #[test]
2166 fn parse_colon_hex_without_value_errors() {
2167 assert_eq!(
2168 parse_colon_command("hex").unwrap_err(),
2169 ColonParseError::HexGroupRequiresValue,
2170 );
2171 }
2172
2173 #[test]
2174 fn parse_colon_hex_with_invalid_value_errors() {
2175 match parse_colon_command("hex 3").unwrap_err() {
2176 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "3"),
2177 other => panic!("expected HexGroupInvalid, got {other:?}"),
2178 }
2179 match parse_colon_command("hex banana").unwrap_err() {
2180 ColonParseError::HexGroupInvalid(v) => assert_eq!(v, "banana"),
2181 other => panic!("expected HexGroupInvalid, got {other:?}"),
2182 }
2183 }
2184
2185 #[test]
2186 fn tag_stack_push_pop_lifo() {
2187 let mut s = TagStack::default();
2188 s.push(0, 10);
2189 s.push(1, 20);
2190 assert_eq!(s.pop(), Some((1, 20)));
2191 assert_eq!(s.pop(), Some((0, 10)));
2192 assert_eq!(s.pop(), None);
2193 }
2194
2195 #[test]
2196 fn tag_stack_pop_clears_active() {
2197 let mut s = TagStack::default();
2198 s.push(0, 10);
2199 s.set_active(
2200 "foo".into(),
2201 vec![crate::tags::TagEntry {
2202 file: std::path::PathBuf::from("/a"),
2203 address: crate::tags::TagAddress::Line(1),
2204 }],
2205 );
2206 assert!(s.active.is_some());
2207 let _ = s.pop();
2208 assert!(s.active.is_none());
2209 }
2210
2211 #[test]
2212 fn tag_stack_next_advances_then_clamps() {
2213 let mut s = TagStack::default();
2214 s.set_active(
2215 "foo".into(),
2216 vec![
2217 crate::tags::TagEntry {
2218 file: std::path::PathBuf::from("/a"),
2219 address: crate::tags::TagAddress::Line(1),
2220 },
2221 crate::tags::TagEntry {
2222 file: std::path::PathBuf::from("/b"),
2223 address: crate::tags::TagAddress::Line(2),
2224 },
2225 ],
2226 );
2227 assert_eq!(s.next(), TagStepResult::Moved(1));
2228 assert_eq!(s.next(), TagStepResult::AtBoundary);
2229 }
2230
2231 #[test]
2232 fn tag_stack_prev_clamps_at_zero() {
2233 let mut s = TagStack::default();
2234 s.set_active(
2235 "foo".into(),
2236 vec![crate::tags::TagEntry {
2237 file: std::path::PathBuf::from("/a"),
2238 address: crate::tags::TagAddress::Line(1),
2239 }],
2240 );
2241 assert_eq!(s.prev(), TagStepResult::AtBoundary);
2242 }
2243
2244 #[test]
2245 fn tag_stack_next_with_no_active_returns_no_active() {
2246 let mut s = TagStack::default();
2247 assert_eq!(s.next(), TagStepResult::NoActive);
2248 assert_eq!(s.prev(), TagStepResult::NoActive);
2249 }
2250
2251 #[test]
2252 fn tag_stack_set_active_replaces_previous_list() {
2253 let mut s = TagStack::default();
2254 s.set_active(
2255 "foo".into(),
2256 vec![crate::tags::TagEntry {
2257 file: std::path::PathBuf::from("/a"),
2258 address: crate::tags::TagAddress::Line(1),
2259 }],
2260 );
2261 s.set_active(
2262 "bar".into(),
2263 vec![
2264 crate::tags::TagEntry {
2265 file: std::path::PathBuf::from("/x"),
2266 address: crate::tags::TagAddress::Line(5),
2267 },
2268 crate::tags::TagEntry {
2269 file: std::path::PathBuf::from("/y"),
2270 address: crate::tags::TagAddress::Line(6),
2271 },
2272 ],
2273 );
2274 let active = s.active.as_ref().unwrap();
2275 assert_eq!(active.name, "bar");
2276 assert_eq!(active.matches.len(), 2);
2277 assert_eq!(active.cursor, 0);
2278 }
2279
2280 #[test]
2281 fn writer_emits_color_for_red_cell() {
2282 let cells = vec![Cell::Char {
2283 ch: 'h',
2284 width: 1,
2285 style: crate::ansi::Style {
2286 fg: Some(crate::ansi::Color::Ansi(1)),
2287 ..Default::default()
2288 },
2289 hyperlink: None,
2290 }];
2291 let mut buf: Vec<u8> = Vec::new();
2292 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default()).unwrap();
2293 let s = String::from_utf8_lossy(&buf);
2294 assert!(s.contains("\x1b["), "expected ANSI escape in output: {s:?}");
2295 assert!(s.contains('h'));
2296 }
2297
2298 #[test]
2299 fn writer_emits_osc8_for_hyperlink_cell() {
2300 let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
2301 let cells = vec![Cell::Char {
2302 ch: 'c',
2303 width: 1,
2304 style: crate::ansi::Style::default(),
2305 hyperlink: Some(link),
2306 }];
2307 let mut buf: Vec<u8> = Vec::new();
2308 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default()).unwrap();
2309 let s = String::from_utf8_lossy(&buf);
2310 assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
2311 }
2312}