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}
80
81#[derive(Debug, Clone, PartialEq)]
82enum ColonParseError {
83 UnknownCommand(String),
84 MissingPath,
85 TagRequiresName,
86}
87
88impl std::fmt::Display for ColonParseError {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 ColonParseError::UnknownCommand(t) => write!(f, "unknown command: :{t}"),
92 ColonParseError::MissingPath => write!(f, ":e requires a path"),
93 ColonParseError::TagRequiresName => write!(f, ":tag requires a name"),
94 }
95 }
96}
97
98fn parse_colon_command(buf: &str) -> std::result::Result<ColonCommand, ColonParseError> {
99 let buf = buf.trim();
100 if buf.is_empty() {
101 return Err(ColonParseError::UnknownCommand(String::new()));
102 }
103 let mut parts = buf.splitn(2, char::is_whitespace);
104 let cmd = parts.next().unwrap();
105 let rest = parts.next().unwrap_or("").trim();
106 match cmd {
107 "n" | "next" => Ok(ColonCommand::Next),
108 "p" | "prev" => Ok(ColonCommand::Prev),
109 "e" | "edit" => {
110 if rest.is_empty() {
111 Err(ColonParseError::MissingPath)
112 } else {
113 let expanded = if let Some(stripped) = rest.strip_prefix("~/") {
115 if let Some(home) = std::env::var_os("HOME") {
116 let mut p = std::path::PathBuf::from(home);
117 p.push(stripped);
118 p
119 } else {
120 std::path::PathBuf::from(rest)
121 }
122 } else {
123 std::path::PathBuf::from(rest)
124 };
125 Ok(ColonCommand::Edit(expanded))
126 }
127 }
128 "f" => Ok(ColonCommand::ShowFile),
129 "q" | "quit" => Ok(ColonCommand::Quit),
130 "d" | "delete" => Ok(ColonCommand::Delete),
131 "x" | "first" => Ok(ColonCommand::First),
132 "t" | "last" => Ok(ColonCommand::Last),
133 "tag" => {
134 if rest.is_empty() {
135 Err(ColonParseError::TagRequiresName)
136 } else {
137 Ok(ColonCommand::Tag(rest.to_string()))
138 }
139 }
140 "tnext" => Ok(ColonCommand::TagNext),
141 "tprev" => Ok(ColonCommand::TagPrev),
142 other => Err(ColonParseError::UnknownCommand(other.to_string())),
143 }
144}
145
146enum ColonOutcome {
147 Continue(Option<String>), Quit,
149}
150
151#[derive(Debug, Default)]
152struct TagStack {
153 history: Vec<(usize, usize)>,
156 active: Option<ActiveMatches>,
159}
160
161#[derive(Debug, Clone)]
162struct ActiveMatches {
163 name: String,
164 matches: Vec<crate::tags::TagEntry>,
165 cursor: usize,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
169enum TagStepResult {
170 Moved(usize),
172 AtBoundary,
174 NoActive,
176}
177
178impl TagStack {
179 fn push(&mut self, file_index: usize, top_line: usize) {
180 self.history.push((file_index, top_line));
181 }
182
183 fn pop(&mut self) -> Option<(usize, usize)> {
184 let popped = self.history.pop();
185 if popped.is_some() {
186 self.active = None;
187 }
188 popped
189 }
190
191 fn set_active(&mut self, name: String, matches: Vec<crate::tags::TagEntry>) {
192 self.active = Some(ActiveMatches {
193 name,
194 matches,
195 cursor: 0,
196 });
197 }
198
199 fn next(&mut self) -> TagStepResult {
200 let Some(a) = &mut self.active else {
201 return TagStepResult::NoActive;
202 };
203 if a.cursor + 1 >= a.matches.len() {
204 TagStepResult::AtBoundary
205 } else {
206 a.cursor += 1;
207 TagStepResult::Moved(a.cursor)
208 }
209 }
210
211 fn prev(&mut self) -> TagStepResult {
212 let Some(a) = &mut self.active else {
213 return TagStepResult::NoActive;
214 };
215 if a.cursor == 0 {
216 TagStepResult::AtBoundary
217 } else {
218 a.cursor -= 1;
219 TagStepResult::Moved(a.cursor)
220 }
221 }
222}
223
224#[allow(clippy::too_many_arguments)]
229fn dispatch_tag_jump(
230 name: &str,
231 tag_file: Option<&crate::tags::TagFile>,
232 tag_stack: &mut TagStack,
233 file_set: &mut crate::file_set::FileSet,
234 current_file_index: &mut usize,
235 args: &crate::cli::Args,
236 preprocessor: Option<&crate::preprocess::Preprocessor>,
237 record_start_regex: Option<®ex::bytes::Regex>,
238 viewport: &mut crate::viewport::Viewport,
239 src: &mut Box<dyn crate::source::Source>,
240 idx: &mut crate::line_index::LineIndex,
241) -> Option<String> {
242 let Some(tf) = tag_file else {
243 return Some("[no tags file loaded]".into());
244 };
245 let matches = tf.lookup(name);
246 if matches.is_empty() {
247 return Some(format!("[tag not found: {name}]"));
248 }
249 let matches: Vec<crate::tags::TagEntry> = matches.to_vec();
250 tag_stack.push(*current_file_index, viewport.top_line());
251 tag_stack.set_active(name.to_string(), matches.clone());
252 let msg = dispatch_match(
253 &matches[0],
254 file_set,
255 current_file_index,
256 args,
257 preprocessor,
258 record_start_regex,
259 viewport,
260 src,
261 idx,
262 );
263 update_viewport_tag_indicator(tag_stack, viewport);
264 msg
265}
266
267#[allow(clippy::too_many_arguments)]
268fn dispatch_match(
269 entry: &crate::tags::TagEntry,
270 file_set: &mut crate::file_set::FileSet,
271 current_file_index: &mut usize,
272 args: &crate::cli::Args,
273 preprocessor: Option<&crate::preprocess::Preprocessor>,
274 record_start_regex: Option<®ex::bytes::Regex>,
275 viewport: &mut crate::viewport::Viewport,
276 src: &mut Box<dyn crate::source::Source>,
277 idx: &mut crate::line_index::LineIndex,
278) -> Option<String> {
279 let target_file = entry.file.as_path();
280 let already_current = file_set
281 .current()
282 .map(|p| p == target_file)
283 .unwrap_or(false);
284
285 if !already_current {
286 let existing_idx = (0..file_set.len()).find(|i| {
287 file_set
288 .nth(*i)
289 .map(|p| p == target_file)
290 .unwrap_or(false)
291 });
292 match existing_idx {
293 Some(i) => {
294 file_set.set_current_index(i);
295 }
296 None => {
297 file_set.append_and_switch(target_file.to_path_buf());
298 }
299 }
300 let path = file_set.current().unwrap().to_path_buf();
301 if let Err(e) = switch_file(
302 &path,
303 file_set.current_index(),
304 file_set.len(),
305 args,
306 preprocessor,
307 viewport,
308 src,
309 idx,
310 record_start_regex,
311 ) {
312 return Some(format!("[open: {e}]"));
313 }
314 *current_file_index = file_set.current_index();
315 }
316
317 let line = match &entry.address {
318 crate::tags::TagAddress::Line(n) => n.saturating_sub(1),
319 crate::tags::TagAddress::Pattern(p) => {
320 let re_src = crate::tags::pattern_to_regex(p);
321 let re = match regex::bytes::Regex::new(&re_src) {
322 Ok(r) => r,
323 Err(_) => return Some("[tag pattern not found]".into()),
324 };
325 match find_pattern_line(src.as_ref(), idx, &re) {
326 Some(l) => l,
327 None => return Some("[tag pattern not found]".into()),
328 }
329 }
330 };
331
332 let clamped = line.min(idx.line_count().saturating_sub(1));
333 viewport.goto_line(clamped, src.as_ref(), idx);
334 None
335}
336
337fn find_pattern_line(
338 src: &dyn crate::source::Source,
339 idx: &mut crate::line_index::LineIndex,
340 re: ®ex::bytes::Regex,
341) -> Option<usize> {
342 idx.extend_to_end(src);
343 for line_no in 0..idx.line_count() {
344 let bytes = idx.line_bytes_stripped(line_no, src);
345 if re.is_match(&bytes) {
346 return Some(line_no);
347 }
348 }
349 None
350}
351
352fn update_viewport_tag_indicator(stack: &TagStack, viewport: &mut crate::viewport::Viewport) {
353 viewport.set_tag_active(stack.active.as_ref().map(|a| {
354 (a.name.clone(), a.cursor + 1, a.matches.len())
355 }));
356}
357
358#[allow(clippy::too_many_arguments)]
359fn switch_file(
360 new_path: &std::path::Path,
361 new_file_index: usize,
362 total_files: usize,
363 args: &crate::cli::Args,
364 preprocessor: Option<&crate::preprocess::Preprocessor>,
365 viewport: &mut crate::viewport::Viewport,
366 src: &mut Box<dyn crate::source::Source>,
367 idx: &mut crate::line_index::LineIndex,
368 record_start_regex: Option<®ex::bytes::Regex>,
369) -> crate::error::Result<()> {
370 let (new_src, new_label, new_failure) =
371 crate::open::open_source_for_path(new_path, args, preprocessor)?;
372
373 *src = new_src;
374 let mut new_idx = crate::line_index::LineIndex::new();
375 if let Some(re) = record_start_regex {
376 new_idx.set_record_start(re.clone());
377 }
378 *idx = new_idx;
379
380 viewport.set_source_label(new_label);
381 viewport.set_file_index(new_file_index, total_files);
382 viewport.set_preprocess_failure(new_failure);
383 viewport.goto_top();
384
385 Ok(())
386}
387
388#[allow(clippy::too_many_arguments)]
389fn dispatch_colon_command(
390 cmd: ColonCommand,
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 tag_stack: &mut TagStack,
400 tag_file: Option<&crate::tags::TagFile>,
401) -> ColonOutcome {
402 match cmd {
403 ColonCommand::Next => {
404 match file_set.next() {
405 Ok(path) => {
406 let path = path.to_path_buf();
407 let new_idx_val = file_set.current_index();
408 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
409 ColonOutcome::Continue(Some(format!("[open: {e}]")))
410 } else {
411 *current_file_index = new_idx_val;
412 ColonOutcome::Continue(None)
413 }
414 }
415 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
416 }
417 }
418 ColonCommand::Prev => {
419 match file_set.prev() {
420 Ok(path) => {
421 let path = path.to_path_buf();
422 let new_idx_val = file_set.current_index();
423 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
424 ColonOutcome::Continue(Some(format!("[open: {e}]")))
425 } else {
426 *current_file_index = new_idx_val;
427 ColonOutcome::Continue(None)
428 }
429 }
430 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
431 }
432 }
433 ColonCommand::Edit(path) => {
434 match crate::open::open_source_for_path(&path, args, preprocessor) {
436 Ok(_) => {
437 let final_path = file_set.append_and_switch(path.clone()).to_path_buf();
439 let new_idx_val = file_set.current_index();
440 if let Err(e) = switch_file(&final_path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
441 ColonOutcome::Continue(Some(format!("[open: {e}]")))
442 } else {
443 *current_file_index = new_idx_val;
444 ColonOutcome::Continue(None)
445 }
446 }
447 Err(e) => ColonOutcome::Continue(Some(format!("[open: {}: {e}]", path.display()))),
448 }
449 }
450 ColonCommand::ShowFile => {
451 let label = viewport.source_label_clone();
452 let cur = file_set.current_index() + 1;
453 let total = file_set.len();
454 let top = viewport.top_line() + 1;
455 let total_lines = idx.line_count();
456 let msg = if total > 1 {
457 format!("{label} (file {cur}/{total}): line {top}/{total_lines}")
458 } else {
459 format!("{label}: line {top}/{total_lines}")
460 };
461 ColonOutcome::Continue(Some(msg))
462 }
463 ColonCommand::Quit => ColonOutcome::Quit,
464 ColonCommand::Delete => {
465 match file_set.delete_current() {
466 Ok(path) => {
467 let path = path.to_path_buf();
468 let new_idx_val = file_set.current_index();
469 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
470 ColonOutcome::Continue(Some(format!("[open: {e}]")))
471 } else {
472 *current_file_index = new_idx_val;
473 ColonOutcome::Continue(None)
474 }
475 }
476 Err(e) => ColonOutcome::Continue(Some(format!("[{e}]"))),
477 }
478 }
479 ColonCommand::First => {
480 if file_set.current_index() == 0 {
481 ColonOutcome::Continue(None) } else if let Some(path) = file_set.first() {
483 let path = path.to_path_buf();
484 let new_idx_val = file_set.current_index();
485 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
486 ColonOutcome::Continue(Some(format!("[open: {e}]")))
487 } else {
488 *current_file_index = new_idx_val;
489 ColonOutcome::Continue(None)
490 }
491 } else {
492 ColonOutcome::Continue(None)
493 }
494 }
495 ColonCommand::Last => {
496 if file_set.current_index() + 1 == file_set.len() {
497 ColonOutcome::Continue(None)
498 } else if let Some(path) = file_set.last() {
499 let path = path.to_path_buf();
500 let new_idx_val = file_set.current_index();
501 if let Err(e) = switch_file(&path, new_idx_val, file_set.len(), args, preprocessor, viewport, src, idx, record_start_regex) {
502 ColonOutcome::Continue(Some(format!("[open: {e}]")))
503 } else {
504 *current_file_index = new_idx_val;
505 ColonOutcome::Continue(None)
506 }
507 } else {
508 ColonOutcome::Continue(None)
509 }
510 }
511 ColonCommand::Tag(name) => {
512 match dispatch_tag_jump(
513 &name,
514 tag_file,
515 tag_stack,
516 file_set,
517 current_file_index,
518 args,
519 preprocessor,
520 record_start_regex,
521 viewport,
522 src,
523 idx,
524 ) {
525 Some(msg) => ColonOutcome::Continue(Some(msg)),
526 None => ColonOutcome::Continue(None),
527 }
528 }
529 ColonCommand::TagNext => match tag_stack.next() {
530 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
531 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[no more matches]".into())),
532 TagStepResult::Moved(cur) => {
533 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
534 let msg = dispatch_match(
535 &entry,
536 file_set,
537 current_file_index,
538 args,
539 preprocessor,
540 record_start_regex,
541 viewport,
542 src,
543 idx,
544 );
545 update_viewport_tag_indicator(tag_stack, viewport);
546 ColonOutcome::Continue(msg)
547 }
548 },
549 ColonCommand::TagPrev => match tag_stack.prev() {
550 TagStepResult::NoActive => ColonOutcome::Continue(Some("[no active tag]".into())),
551 TagStepResult::AtBoundary => ColonOutcome::Continue(Some("[at first match]".into())),
552 TagStepResult::Moved(cur) => {
553 let entry = tag_stack.active.as_ref().unwrap().matches[cur].clone();
554 let msg = dispatch_match(
555 &entry,
556 file_set,
557 current_file_index,
558 args,
559 preprocessor,
560 record_start_regex,
561 viewport,
562 src,
563 idx,
564 );
565 update_viewport_tag_indicator(tag_stack, viewport);
566 ColonOutcome::Continue(msg)
567 }
568 },
569 }
570}
571
572#[allow(clippy::too_many_arguments, clippy::collapsible_match)]
573pub fn run(
574 mut src: Box<dyn Source>,
575 mut viewport: Viewport,
576 mut idx: LineIndex,
577 sigterm: Arc<AtomicBool>,
578 rebuild_spec: RebuildSpec,
579 keymap: crate::keys::KeyMap,
580 mut file_set: crate::file_set::FileSet,
581 record_start_regex: Option<regex::bytes::Regex>,
582 args: crate::cli::Args,
583 preprocessor: Option<crate::preprocess::Preprocessor>,
584 tag_file: Option<crate::tags::TagFile>,
585) -> Result<()> {
586 let (mut cols, mut rows) = size().unwrap_or((80, 24));
587 viewport.resize(cols, rows);
588
589 let mut stdout = io::stdout();
590 let timeout = Duration::from_millis(250);
591 let mut last_revision = src.revision();
592
593 if (viewport.filter_active() || viewport.grep_active()) && !viewport.dim_mode() {
598 idx.extend_to_end(src.as_ref());
599 viewport.extend_visible_lines(&idx, src.as_ref());
600 }
601
602 if viewport.follow_mode() {
605 src.pump();
606 viewport.extend_visible_lines(&idx, src.as_ref());
607 viewport.goto_bottom(src.as_ref(), &mut idx);
608 }
609
610 let mut needs_redraw = true;
612 let mut mode = InputMode::Normal;
613 let mut numeric_prefix: Option<usize> = None;
614 let mut marks: HashMap<char, (usize, usize)> = HashMap::new();
615 let mut previous_position: Option<(usize, usize)> = None;
616 let mut current_file_index: usize = file_set.current_index();
617 let mut transient_status: Option<String> = None;
618 let mut tag_stack = TagStack::default();
619
620 if let Some(tag_name) = args.tag.as_deref() {
621 if let Some(msg) = dispatch_tag_jump(
622 tag_name,
623 tag_file.as_ref(),
624 &mut tag_stack,
625 &mut file_set,
626 &mut current_file_index,
627 &args,
628 preprocessor.as_ref(),
629 record_start_regex.as_ref(),
630 &mut viewport,
631 &mut src,
632 &mut idx,
633 ) {
634 return Err(crate::error::Error::Runtime(format!("startup tag jump failed: {msg}")));
635 }
636 }
637
638 loop {
639 if sigterm.load(Ordering::SeqCst) {
640 break;
641 }
642
643 if needs_redraw {
644 let mut frame = viewport.frame(src.as_ref(), &mut idx);
645 match &mode {
648 InputMode::SearchPrompt { direction, buffer, error } => {
649 let prefix = if matches!(direction, SearchDirection::Forward) { "/" } else { "?" };
650 frame.status = match error {
651 Some(e) => format!("{prefix}{buffer} [error: {e}]"),
652 None => format!("{prefix}{buffer}"),
653 };
654 }
655 InputMode::ShellPrompt { buffer, error } => {
656 frame.status = match error {
657 Some(e) => format!("!{buffer} [error: {e}]"),
658 None => format!("!{buffer}"),
659 };
660 }
661 InputMode::ColonPrompt { buffer, error } => {
662 frame.status = match error {
663 Some(e) => format!(":{buffer} [error: {e}]"),
664 None => format!(":{buffer}"),
665 };
666 }
667 InputMode::TagPrompt { buffer, error } => {
668 frame.status = match error {
669 Some(e) => format!("tag: {buffer} [error: {e}]"),
670 None => format!("tag: {buffer}"),
671 };
672 }
673 _ => {
674 if let Some(msg) = transient_status.take() {
675 frame.status = msg;
676 }
677 }
678 }
679 write_frame(&mut stdout, &frame, cols, rows)
680 .map_err(|e| crate::error::Error::Runtime(format!("stdout: {}", e)))?;
681 needs_redraw = false;
682 }
683
684 match poll(timeout) {
686 Ok(true) => {
687 let event = read().map_err(|e| crate::error::Error::Runtime(format!("input: {}", e)))?;
688 match &mut mode {
691 InputMode::SearchPrompt { direction, buffer, error } => {
692 if let Event::Key(KeyEvent { code, .. }) = event {
693 match code {
694 KeyCode::Esc => { mode = InputMode::Normal; needs_redraw = true; }
695 KeyCode::Enter => {
696 if buffer.is_empty() {
697 if viewport.search_active() {
701 let reverse = !matches!(
702 (viewport.search_direction(), *direction),
703 (SearchDirection::Forward, SearchDirection::Forward)
704 | (SearchDirection::Backward, SearchDirection::Backward)
705 );
706 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
707 viewport.search_repeat(src.as_ref(), &mut idx, reverse);
708 }
709 mode = InputMode::Normal;
710 } else {
711 match viewport.set_search(buffer.clone(), *direction) {
712 Ok(()) => {
713 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
714 viewport.search_repeat(src.as_ref(), &mut idx, false);
715 mode = InputMode::Normal;
716 }
717 Err(e) => { *error = Some(e); }
718 }
719 }
720 needs_redraw = true;
721 }
722 KeyCode::Backspace => {
723 buffer.pop();
724 *error = None;
725 needs_redraw = true;
726 }
727 KeyCode::Char(c) => {
728 buffer.push(c);
729 *error = None;
730 needs_redraw = true;
731 }
732 _ => {}
733 }
734 }
735 continue;
736 }
737 InputMode::OptionPrefix => {
738 if let Event::Key(KeyEvent { code, .. }) = event {
739 match code {
740 KeyCode::Char('N') | KeyCode::Char('n') => viewport.toggle_line_numbers(),
741 KeyCode::Char('S') | KeyCode::Char('s') => viewport.toggle_chop(),
742 KeyCode::Char('F') | KeyCode::Char('f') => viewport.toggle_follow(),
743 KeyCode::Char('P') | KeyCode::Char('p') => {
744 mode = InputMode::PrettifyPrefix;
746 needs_redraw = true;
747 continue;
748 }
749 _ => {}
750 }
751 }
752 mode = InputMode::Normal;
753 needs_redraw = true;
754 continue;
755 }
756 InputMode::PrettifyPrefix => {
757 if let Event::Key(KeyEvent { code, .. }) = event {
758 let target: Option<PrettifyTarget> = match code {
759 KeyCode::Char('j') | KeyCode::Char('J') => Some(PrettifyTarget::Mode(PrettifyMode::Json)),
760 KeyCode::Char('y') | KeyCode::Char('Y') => Some(PrettifyTarget::Mode(PrettifyMode::Yaml)),
761 KeyCode::Char('t') | KeyCode::Char('T') => Some(PrettifyTarget::Mode(PrettifyMode::Toml)),
762 KeyCode::Char('x') | KeyCode::Char('X') => Some(PrettifyTarget::Mode(PrettifyMode::Xml)),
763 KeyCode::Char('h') | KeyCode::Char('H') => Some(PrettifyTarget::Mode(PrettifyMode::Html)),
764 KeyCode::Char('c') | KeyCode::Char('C') => Some(PrettifyTarget::Mode(PrettifyMode::Csv)),
765 KeyCode::Char('r') | KeyCode::Char('R') => Some(PrettifyTarget::Mode(PrettifyMode::Off)),
766 KeyCode::Char('a') | KeyCode::Char('A') => Some(PrettifyTarget::Auto),
767 _ => None,
768 };
769 if let Some(t) = target {
770 apply_prettify(
771 src.as_ref(),
772 &mut viewport,
773 &mut idx,
774 rebuild_spec,
775 t,
776 );
777 last_revision = src.revision();
778 }
779 }
780 mode = InputMode::Normal;
781 needs_redraw = true;
782 continue;
783 }
784 InputMode::MarkSetPending => {
785 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
786 if is_valid_mark_name(c) {
787 mark_set(&mut marks, c, current_file_index, viewport.top_line());
788 }
789 }
790 mode = InputMode::Normal;
791 continue;
792 }
793 InputMode::MarkJumpPending => {
794 if let Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) = event {
795 if is_valid_mark_name(c) {
796 match mark_jump(&marks, c, current_file_index, &mut previous_position, viewport.top_line()) {
797 Some(MarkTarget::SameFile { line }) => {
798 let clamped = line.min(idx.line_count().saturating_sub(1));
799 viewport.goto_line(clamped, src.as_ref(), &mut idx);
800 needs_redraw = true;
801 }
802 Some(MarkTarget::OtherFile { file_index, line }) => {
803 if file_index < file_set.len() {
804 file_set.set_current_index(file_index);
805 let path = file_set.current().unwrap().to_path_buf();
806 if let Err(e) = switch_file(
807 &path, file_index, file_set.len(),
808 &args, preprocessor.as_ref(),
809 &mut viewport, &mut src, &mut idx,
810 record_start_regex.as_ref(),
811 ) {
812 transient_status = Some(format!("[open: {e}]"));
813 } else {
814 let clamped = line.min(idx.line_count().saturating_sub(1));
815 viewport.goto_line(clamped, src.as_ref(), &mut idx);
816 current_file_index = file_index;
817 needs_redraw = true;
818 }
819 }
820 }
821 None => {}
822 }
823 }
824 }
825 mode = InputMode::Normal;
826 continue;
827 }
828 InputMode::ShellPrompt { buffer, error } => {
829 if let Event::Key(KeyEvent { code, .. }) = event {
830 match code {
831 KeyCode::Esc => {
832 mode = InputMode::Normal;
833 needs_redraw = true;
834 }
835 KeyCode::Enter => {
836 if buffer.is_empty() {
837 mode = InputMode::Normal;
838 } else {
839 match crate::shell::run_shell_command(buffer) {
840 Ok(()) => {
841 mode = InputMode::Normal;
842 }
843 Err(e) => {
844 *error = Some(e.to_string());
845 }
846 }
847 }
848 needs_redraw = true;
849 }
850 KeyCode::Backspace => {
851 buffer.pop();
852 *error = None;
853 needs_redraw = true;
854 }
855 KeyCode::Char(c) => {
856 buffer.push(c);
857 *error = None;
858 needs_redraw = true;
859 }
860 _ => {}
861 }
862 }
863 continue;
864 }
865 InputMode::CtrlXPending => {
866 let is_ctrl_x = matches!(
867 event,
868 Event::Key(KeyEvent {
869 code: KeyCode::Char('x'),
870 modifiers: KeyModifiers::CONTROL,
871 ..
872 })
873 );
874 if is_ctrl_x {
875 match jump_previous(&mut previous_position, current_file_index, viewport.top_line()) {
876 Some(MarkTarget::SameFile { line }) => {
877 let clamped = line.min(idx.line_count().saturating_sub(1));
878 viewport.goto_line(clamped, src.as_ref(), &mut idx);
879 needs_redraw = true;
880 }
881 Some(MarkTarget::OtherFile { file_index, line }) => {
882 if file_index < file_set.len() {
883 file_set.set_current_index(file_index);
884 let path = file_set.current().unwrap().to_path_buf();
885 if let Err(e) = switch_file(
886 &path, file_index, file_set.len(),
887 &args, preprocessor.as_ref(),
888 &mut viewport, &mut src, &mut idx,
889 record_start_regex.as_ref(),
890 ) {
891 transient_status = Some(format!("[open: {e}]"));
892 } else {
893 let clamped = line.min(idx.line_count().saturating_sub(1));
894 viewport.goto_line(clamped, src.as_ref(), &mut idx);
895 current_file_index = file_index;
896 needs_redraw = true;
897 }
898 }
899 }
900 None => {}
901 }
902 mode = InputMode::Normal;
903 continue;
904 }
905 mode = InputMode::Normal;
907 }
909 InputMode::ColonPrompt { buffer, error } => {
910 if let Event::Key(KeyEvent { code, .. }) = event {
911 match code {
912 KeyCode::Esc => {
913 mode = InputMode::Normal;
914 needs_redraw = true;
915 }
916 KeyCode::Enter => {
917 if buffer.is_empty() {
918 mode = InputMode::Normal;
919 } else {
920 match parse_colon_command(buffer) {
921 Ok(cmd) => {
922 let outcome = dispatch_colon_command(
923 cmd,
924 &mut file_set,
925 &mut current_file_index,
926 &args,
927 preprocessor.as_ref(),
928 record_start_regex.as_ref(),
929 &mut viewport,
930 &mut src,
931 &mut idx,
932 &mut tag_stack,
933 tag_file.as_ref(),
934 );
935 match outcome {
936 ColonOutcome::Continue(msg) => {
937 transient_status = msg;
938 }
939 ColonOutcome::Quit => break,
940 }
941 mode = InputMode::Normal;
942 }
943 Err(e) => {
944 *error = Some(e.to_string());
945 }
946 }
947 }
948 needs_redraw = true;
949 }
950 KeyCode::Backspace => {
951 buffer.pop();
952 *error = None;
953 needs_redraw = true;
954 }
955 KeyCode::Char(c) => {
956 buffer.push(c);
957 *error = None;
958 needs_redraw = true;
959 }
960 _ => {}
961 }
962 }
963 continue;
964 }
965 InputMode::TagPrompt { buffer, error } => {
966 if let Event::Key(KeyEvent { code, .. }) = event {
967 match code {
968 KeyCode::Esc => {
969 mode = InputMode::Normal;
970 needs_redraw = true;
971 }
972 KeyCode::Enter => {
973 if buffer.is_empty() {
974 mode = InputMode::Normal;
975 } else {
976 let name = buffer.clone();
977 let msg = dispatch_tag_jump(
978 &name,
979 tag_file.as_ref(),
980 &mut tag_stack,
981 &mut file_set,
982 &mut current_file_index,
983 &args,
984 preprocessor.as_ref(),
985 record_start_regex.as_ref(),
986 &mut viewport,
987 &mut src,
988 &mut idx,
989 );
990 if let Some(m) = msg {
991 transient_status = Some(m);
992 }
993 mode = InputMode::Normal;
994 }
995 needs_redraw = true;
996 }
997 KeyCode::Backspace => {
998 buffer.pop();
999 *error = None;
1000 needs_redraw = true;
1001 }
1002 KeyCode::Char(c) => {
1003 buffer.push(c);
1004 *error = None;
1005 needs_redraw = true;
1006 }
1007 _ => {}
1008 }
1009 }
1010 continue;
1011 }
1012 InputMode::Normal => {}
1013 }
1014 let mut cmd: Option<Command> = None;
1018 if let InputMode::Normal = mode {
1019 if let Event::Key(ke) = &event {
1020 if let Some(target) = keymap.lookup(ke) {
1021 match target {
1022 crate::keys::BindingTarget::Shell(cmd_text) => {
1023 let cmd_text = cmd_text.clone();
1024 if let Err(e) = crate::shell::run_shell_command(&cmd_text) {
1025 let _ = writeln!(std::io::stderr(),
1026 "[shell: {e}]");
1027 }
1028 needs_redraw = true;
1029 continue;
1030 }
1031 crate::keys::BindingTarget::Command(c) => {
1032 cmd = Some(c.clone());
1033 }
1034 }
1035 }
1036 }
1037 }
1038 let cmd = cmd.unwrap_or_else(|| translate(event));
1039 let prefix_at_cmd = numeric_prefix.take();
1042 match cmd {
1043 Command::Digit(d) => {
1044 let cur = prefix_at_cmd.unwrap_or(0);
1045 let next = cur.saturating_mul(10).saturating_add(d as usize);
1046 if next <= 99_999_999 {
1047 numeric_prefix = Some(next);
1048 } else {
1049 numeric_prefix = prefix_at_cmd;
1051 }
1052 continue;
1053 }
1054 Command::Cancel => {
1055 continue;
1057 }
1058 Command::GotoLine => {
1059 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1060 match prefix_at_cmd {
1061 Some(line) if line > 0 => viewport.goto_line(line - 1, src.as_ref(), &mut idx),
1062 _ => viewport.goto_top(),
1063 }
1064 needs_redraw = true;
1065 }
1066 Command::GotoRecord => {
1067 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1068 match prefix_at_cmd {
1069 Some(rec) if rec > 0 => viewport.goto_record(rec - 1, src.as_ref(), &mut idx),
1070 _ => viewport.goto_bottom(src.as_ref(), &mut idx),
1071 }
1072 needs_redraw = true;
1073 }
1074 Command::GotoPercent => {
1075 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1076 match prefix_at_cmd {
1077 Some(p) if p <= 100 => viewport.goto_percent(p as u8, src.as_ref(), &mut idx),
1078 _ => viewport.goto_top(),
1079 }
1080 needs_redraw = true;
1081 }
1082 Command::Quit => break,
1083 Command::Resize(c, r) => {
1084 cols = c; rows = r;
1085 viewport.resize(c, r);
1086 needs_redraw = true;
1087 }
1088 Command::ScrollLines(n) => {
1089 viewport.scroll_lines(n, src.as_ref(), &mut idx);
1090 needs_redraw = true;
1091 }
1092 Command::ScrollLogicalLines(n) => {
1093 viewport.scroll_logical_lines(n, src.as_ref(), &mut idx);
1094 needs_redraw = true;
1095 }
1096 Command::PageDown => {
1097 viewport.page_down(src.as_ref(), &mut idx);
1098 needs_redraw = true;
1099 }
1100 Command::PageUp => {
1101 viewport.page_up(src.as_ref(), &mut idx);
1102 needs_redraw = true;
1103 }
1104 Command::HalfPageDown => {
1105 viewport.half_page_down(src.as_ref(), &mut idx);
1106 needs_redraw = true;
1107 }
1108 Command::HalfPageUp => {
1109 viewport.half_page_up(src.as_ref(), &mut idx);
1110 needs_redraw = true;
1111 }
1112 Command::Refresh => {
1113 needs_redraw = true;
1114 }
1115 Command::Reload => {
1116 src.pump();
1119 if src.revision() != last_revision {
1120 rebuild_after_replace(
1121 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1122 );
1123 last_revision = src.revision();
1124 needs_redraw = true;
1125 }
1126 }
1127 Command::TogglePrettify => {
1128 apply_prettify(
1129 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1130 PrettifyTarget::Toggle,
1131 );
1132 last_revision = src.revision();
1133 needs_redraw = true;
1134 }
1135 Command::SetPrettifyMode(m) => {
1136 apply_prettify(
1137 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1138 PrettifyTarget::Mode(m),
1139 );
1140 last_revision = src.revision();
1141 needs_redraw = true;
1142 }
1143 Command::RedetectPrettify => {
1144 apply_prettify(
1145 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1146 PrettifyTarget::Auto,
1147 );
1148 last_revision = src.revision();
1149 needs_redraw = true;
1150 }
1151 Command::ToggleLineNumbers => {
1152 viewport.toggle_line_numbers();
1153 needs_redraw = true;
1154 }
1155 Command::ToggleChop => {
1156 viewport.toggle_chop();
1157 needs_redraw = true;
1158 }
1159 Command::ToggleFollow => {
1160 viewport.toggle_follow();
1161 if viewport.follow_mode() {
1162 src.pump();
1164 idx.notice_new_bytes(src.as_ref());
1165 viewport.goto_bottom(src.as_ref(), &mut idx);
1166 }
1167 needs_redraw = true;
1168 }
1169 Command::SearchForward => {
1170 mode = InputMode::SearchPrompt {
1171 direction: SearchDirection::Forward,
1172 buffer: String::new(),
1173 error: None,
1174 };
1175 needs_redraw = true;
1176 }
1177 Command::SearchBackward => {
1178 mode = InputMode::SearchPrompt {
1179 direction: SearchDirection::Backward,
1180 buffer: String::new(),
1181 error: None,
1182 };
1183 needs_redraw = true;
1184 }
1185 Command::ShellEscape => {
1186 mode = InputMode::ShellPrompt {
1187 buffer: String::new(),
1188 error: None,
1189 };
1190 needs_redraw = true;
1191 }
1192 Command::ColonPrompt => {
1193 mode = InputMode::ColonPrompt {
1194 buffer: String::new(),
1195 error: None,
1196 };
1197 needs_redraw = true;
1198 }
1199 Command::NextMatch => {
1200 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1201 if viewport.search_repeat(src.as_ref(), &mut idx, false) {
1202 needs_redraw = true;
1203 }
1204 }
1205 Command::PreviousMatch => {
1206 update_prev_position(&mut previous_position, current_file_index, viewport.top_line());
1207 if viewport.search_repeat(src.as_ref(), &mut idx, true) {
1208 needs_redraw = true;
1209 }
1210 }
1211 Command::OptionPrefix => {
1212 mode = InputMode::OptionPrefix;
1213 }
1214 Command::MarkSet => {
1215 mode = InputMode::MarkSetPending;
1216 }
1217 Command::MarkJump => {
1218 mode = InputMode::MarkJumpPending;
1219 }
1220 Command::CtrlXPrefix => {
1221 mode = InputMode::CtrlXPending;
1222 }
1223 Command::JumpPrevious => {
1224 }
1227 Command::TagPrompt => {
1228 if tag_file.is_none() {
1229 transient_status = Some("[no tags file loaded]".into());
1230 needs_redraw = true;
1231 } else {
1232 mode = InputMode::TagPrompt {
1233 buffer: String::new(),
1234 error: None,
1235 };
1236 needs_redraw = true;
1237 }
1238 }
1239 Command::TagPop => match tag_stack.pop() {
1240 Some((file_index, line)) => {
1241 if file_index != current_file_index && file_index < file_set.len() {
1242 file_set.set_current_index(file_index);
1243 let path = file_set.current().unwrap().to_path_buf();
1244 if let Err(e) = switch_file(
1245 &path,
1246 file_index,
1247 file_set.len(),
1248 &args,
1249 preprocessor.as_ref(),
1250 &mut viewport,
1251 &mut src,
1252 &mut idx,
1253 record_start_regex.as_ref(),
1254 ) {
1255 transient_status = Some(format!("[open: {e}]"));
1256 } else {
1257 current_file_index = file_index;
1258 }
1259 }
1260 let clamped = line.min(idx.line_count().saturating_sub(1));
1261 viewport.goto_line(clamped, src.as_ref(), &mut idx);
1262 update_viewport_tag_indicator(&tag_stack, &mut viewport);
1263 needs_redraw = true;
1264 }
1265 None => {
1266 transient_status = Some("[tag stack empty]".into());
1267 needs_redraw = true;
1268 }
1269 },
1270 Command::Noop => {}
1271 }
1272 }
1273 Ok(false) => {
1274 if viewport.live_mode() {
1276 let was_at_bottom = viewport.is_at_bottom(&idx);
1277 src.pump();
1278 if src.revision() != last_revision {
1279 rebuild_after_replace(
1280 src.as_ref(), &mut viewport, &mut idx, rebuild_spec,
1281 );
1282 if was_at_bottom {
1283 viewport.goto_bottom(src.as_ref(), &mut idx);
1284 }
1285 last_revision = src.revision();
1286 needs_redraw = true;
1287 }
1288 } else if viewport.follow_mode() {
1289 let was_at_bottom = viewport.is_at_bottom(&idx);
1290 src.pump();
1291 let lines_before = idx.line_count();
1292 idx.notice_new_bytes(src.as_ref());
1293 viewport.extend_visible_lines(&idx, src.as_ref());
1294 if idx.line_count() != lines_before {
1295 needs_redraw = true;
1296 if was_at_bottom {
1297 viewport.goto_bottom(src.as_ref(), &mut idx);
1298 }
1299 }
1300 } else if !src.is_complete() {
1301 let lines_before = idx.line_count();
1304 idx.notice_new_bytes(src.as_ref());
1305 viewport.extend_visible_lines(&idx, src.as_ref());
1306 if idx.line_count() != lines_before {
1307 needs_redraw = true;
1308 }
1309 }
1310 }
1311 Err(_) => {
1312 std::thread::sleep(timeout);
1314 }
1315 }
1316 }
1317 Ok(())
1318}
1319
1320#[derive(Debug, Clone, Copy)]
1322enum PrettifyTarget {
1323 Mode(PrettifyMode),
1325 Toggle,
1327 Auto,
1329}
1330
1331fn apply_prettify(
1335 src: &dyn Source,
1336 viewport: &mut Viewport,
1337 idx: &mut LineIndex,
1338 spec: RebuildSpec,
1339 target: PrettifyTarget,
1340) {
1341 if src.prettify_mode().is_none() {
1343 return;
1344 }
1345 match target {
1346 PrettifyTarget::Mode(m) => src.set_prettify_mode(m),
1347 PrettifyTarget::Toggle => src.toggle_prettify(),
1348 PrettifyTarget::Auto => src.redetect_prettify(),
1349 }
1350 rebuild_after_replace(src, viewport, idx, spec);
1351 viewport.set_prettify_label(src.prettify_label());
1352}
1353
1354fn rebuild_after_replace(
1360 src: &dyn Source,
1361 viewport: &mut Viewport,
1362 idx: &mut LineIndex,
1363 spec: RebuildSpec,
1364) {
1365 let new_off = match spec.tail {
1366 Some(n) => find_tail_offset(src, n),
1367 None => 0,
1368 };
1369 *idx = LineIndex::new_starting_at(new_off);
1370 if let Some(n) = spec.head {
1371 idx.set_head_cap(n);
1372 }
1373 viewport.invalidate_filter_cache();
1374 idx.notice_new_bytes(src);
1375 viewport.extend_visible_lines(idx, src);
1376 viewport.clamp_top_line(idx.line_count());
1377}
1378
1379fn to_crossterm_color(c: crate::ansi::Color) -> crossterm::style::Color {
1380 use crossterm::style::Color as CC;
1381 use crate::ansi::Color;
1382 match c {
1383 Color::Ansi(0) => CC::Black,
1384 Color::Ansi(1) => CC::DarkRed,
1385 Color::Ansi(2) => CC::DarkGreen,
1386 Color::Ansi(3) => CC::DarkYellow,
1387 Color::Ansi(4) => CC::DarkBlue,
1388 Color::Ansi(5) => CC::DarkMagenta,
1389 Color::Ansi(6) => CC::DarkCyan,
1390 Color::Ansi(7) => CC::Grey,
1391 Color::Ansi(8) => CC::DarkGrey,
1392 Color::Ansi(9) => CC::Red,
1393 Color::Ansi(10) => CC::Green,
1394 Color::Ansi(11) => CC::Yellow,
1395 Color::Ansi(12) => CC::Blue,
1396 Color::Ansi(13) => CC::Magenta,
1397 Color::Ansi(14) => CC::Cyan,
1398 Color::Ansi(15) => CC::White,
1399 Color::Ansi(_) => CC::Reset,
1400 Color::Indexed(n) => CC::AnsiValue(n),
1401 Color::Rgb(r, g, b) => CC::Rgb { r, g, b },
1402 Color::Default => CC::Reset,
1403 }
1404}
1405
1406fn emit_style_diff<W: Write>(
1409 out: &mut W,
1410 prev: &crate::ansi::Style,
1411 next: &crate::ansi::Style,
1412) -> io::Result<()> {
1413 let intensity_changed = prev.bold != next.bold || prev.dim != next.dim;
1417
1418 let fg_changed = prev.fg != next.fg;
1422 let bg_changed = prev.bg != next.bg;
1423
1424 if (fg_changed && next.fg.is_none()) || (bg_changed && next.bg.is_none()) {
1425 out.queue(ResetColor)?;
1426 if let Some(c) = next.fg {
1428 out.queue(SetForegroundColor(to_crossterm_color(c)))?;
1429 }
1430 if let Some(c) = next.bg {
1431 out.queue(SetBackgroundColor(to_crossterm_color(c)))?;
1432 }
1433 } else {
1434 if fg_changed {
1435 if let Some(c) = next.fg {
1436 out.queue(SetForegroundColor(to_crossterm_color(c)))?;
1437 }
1438 }
1439 if bg_changed {
1440 if let Some(c) = next.bg {
1441 out.queue(SetBackgroundColor(to_crossterm_color(c)))?;
1442 }
1443 }
1444 }
1445
1446 if intensity_changed {
1447 if next.bold {
1448 out.queue(SetAttribute(Attribute::Bold))?;
1449 } else if next.dim {
1450 out.queue(SetAttribute(Attribute::Dim))?;
1451 } else {
1452 out.queue(SetAttribute(Attribute::NormalIntensity))?;
1453 }
1454 }
1455 if prev.italic != next.italic {
1456 out.queue(SetAttribute(if next.italic { Attribute::Italic } else { Attribute::NoItalic }))?;
1457 }
1458 if prev.underline != next.underline {
1459 out.queue(SetAttribute(if next.underline { Attribute::Underlined } else { Attribute::NoUnderline }))?;
1460 }
1461 if prev.reverse != next.reverse {
1462 out.queue(SetAttribute(if next.reverse { Attribute::Reverse } else { Attribute::NoReverse }))?;
1463 }
1464 if prev.strike != next.strike {
1465 out.queue(SetAttribute(if next.strike { Attribute::CrossedOut } else { Attribute::NotCrossedOut }))?;
1466 }
1467 Ok(())
1468}
1469
1470fn emit_hyperlink_diff<W: Write>(
1471 out: &mut W,
1472 prev: &Option<Arc<str>>,
1473 next: &Option<Arc<str>>,
1474) -> io::Result<()> {
1475 if prev == next {
1476 return Ok(());
1477 }
1478 if prev.is_some() {
1479 out.write_all(b"\x1b]8;;\x1b\\")?;
1480 }
1481 if let Some(uri) = next {
1482 out.write_all(b"\x1b]8;;")?;
1483 out.write_all(uri.as_bytes())?;
1484 out.write_all(b"\x1b\\")?;
1485 }
1486 Ok(())
1487}
1488
1489const SYNC_UPDATE_BEGIN: &[u8] = b"\x1b[?2026h";
1496const SYNC_UPDATE_END: &[u8] = b"\x1b[?2026l";
1497
1498fn write_frame(out: &mut impl Write, frame: &Frame, cols: u16, rows: u16) -> io::Result<()> {
1499 out.write_all(SYNC_UPDATE_BEGIN)?;
1508
1509 out.queue(SetAttribute(Attribute::Reset))?;
1511 out.queue(ResetColor)?;
1512
1513 for (i, row) in frame.body.iter().enumerate() {
1514 out.queue(MoveTo(0, i as u16))?;
1515 out.queue(Clear(ClearType::UntilNewLine))?;
1519 out.queue(SetAttribute(Attribute::Reset))?;
1522 let row_style = frame.row_styles.get(i).copied().unwrap_or(RowStyle::Normal);
1523 let base_style = if matches!(row_style, RowStyle::Dim) {
1528 out.queue(SetAttribute(Attribute::Dim))?;
1529 crate::ansi::Style { dim: true, ..Default::default() }
1530 } else {
1531 crate::ansi::Style::default()
1532 };
1533 let no_highlights = Vec::new();
1534 let highlights = frame.highlights.get(i).unwrap_or(&no_highlights);
1535 write_row_with_highlights(out, row, cols, highlights, base_style)?;
1536 }
1537 out.queue(MoveTo(0, rows.saturating_sub(1)))?;
1539 out.queue(Clear(ClearType::UntilNewLine))?;
1540 out.queue(SetAttribute(Attribute::Reverse))?;
1541 let mut status = frame.status.clone();
1542 if status.len() > cols as usize {
1543 status.truncate(cols as usize);
1544 } else {
1545 let pad = cols as usize - status.len();
1546 status.push_str(&" ".repeat(pad));
1547 }
1548 out.queue(Print(status))?;
1549 out.queue(ResetColor)?;
1550 out.queue(SetAttribute(Attribute::Reset))?;
1551
1552 out.write_all(SYNC_UPDATE_END)?;
1555 out.flush()
1556}
1557
1558
1559fn write_row_with_highlights(
1570 out: &mut impl Write,
1571 row: &[Cell],
1572 cols: u16,
1573 highlights: &[std::ops::Range<usize>],
1574 base_style: crate::ansi::Style,
1575) -> io::Result<()> {
1576 let cols_usize = cols as usize;
1577
1578 let mut ranges: Vec<std::ops::Range<usize>> = highlights
1579 .iter()
1580 .filter_map(|r| {
1581 let s = r.start.min(cols_usize);
1582 let e = r.end.min(cols_usize);
1583 if e > s { Some(s..e) } else { None }
1584 })
1585 .collect();
1586 ranges.sort_by_key(|r| r.start);
1587
1588 let mut prev_style = base_style;
1591 let mut prev_link: Option<Arc<str>> = None;
1592
1593 let mut col = 0usize;
1594 let mut i = 0usize;
1595 while col < cols_usize && i < row.len() {
1596 let in_highlight = ranges.iter().any(|r| r.start <= col && col < r.end);
1597
1598 match &row[i] {
1599 Cell::Char { ch, width, style, hyperlink } => {
1600 let mut eff = *style;
1606 if in_highlight {
1607 eff.reverse = !eff.reverse;
1608 }
1609 if base_style.dim && !eff.bold {
1610 eff.dim = true;
1611 }
1612 emit_style_diff(out, &prev_style, &eff)?;
1613 emit_hyperlink_diff(out, &prev_link, hyperlink)?;
1614 out.queue(Print(*ch))?;
1615 prev_style = eff;
1616 prev_link = hyperlink.clone();
1617 col += *width as usize;
1618 }
1619 Cell::Continuation => {
1620 }
1622 Cell::Empty => {
1623 let default = if base_style.dim {
1628 crate::ansi::Style { dim: true, ..Default::default() }
1629 } else {
1630 crate::ansi::Style::default()
1631 };
1632 emit_style_diff(out, &prev_style, &default)?;
1633 emit_hyperlink_diff(out, &prev_link, &None)?;
1634 out.queue(Print(' '))?;
1635 prev_style = default;
1636 prev_link = None;
1637 col += 1;
1638 }
1639 }
1640 i += 1;
1641 }
1642
1643 emit_hyperlink_diff(out, &prev_link, &None)?;
1646 out.queue(ResetColor)?;
1647 out.queue(SetAttribute(Attribute::Reset))?;
1648
1649 Ok(())
1650}
1651
1652#[cfg(test)]
1653mod tests {
1654 use super::*;
1655
1656 #[test]
1657 fn parse_colon_n() {
1658 assert_eq!(parse_colon_command("n").unwrap(), ColonCommand::Next);
1659 assert_eq!(parse_colon_command("next").unwrap(), ColonCommand::Next);
1660 }
1661
1662 #[test]
1663 fn write_frame_brackets_with_sync_update_and_no_full_clear() {
1664 use crate::ansi::Style;
1669 use crate::render::Cell;
1670 use crate::viewport::{Frame, RowStyle};
1671
1672 let row: Vec<Cell> = (0..3)
1673 .map(|_| Cell::Char { ch: 'a', width: 1, style: Style::default(), hyperlink: None })
1674 .collect();
1675 let frame = Frame {
1676 body: vec![row.clone(), row],
1677 row_styles: vec![RowStyle::Normal, RowStyle::Normal],
1678 highlights: vec![Vec::new(), Vec::new()],
1679 status: "status".into(),
1680 };
1681
1682 let mut buf: Vec<u8> = Vec::new();
1683 write_frame(&mut buf, &frame, 3, 3).unwrap();
1684 let s = std::str::from_utf8(&buf).expect("ascii");
1685
1686 let begin = s.find("\x1b[?2026h").expect("begin sync update");
1688 let end = s.find("\x1b[?2026l").expect("end sync update");
1689 assert!(begin < end, "begin must precede end");
1690 let first_a = s.find('a').expect("body char");
1692 assert!(begin < first_a && first_a < end, "body must be inside sync update");
1693
1694 assert!(
1697 !s.contains("\x1b[2J"),
1698 "full-screen Clear(All) reintroduced — flicker fix regressed: {s:?}",
1699 );
1700 assert!(s.contains("\x1b[K"), "expected at least one Clear(UntilNewLine)");
1701 }
1702
1703 #[test]
1704 fn dim_row_keeps_dim_through_plain_cells_and_padding() {
1705 use crate::ansi::Style;
1710 use crate::render::Cell;
1711 let row = vec![
1712 Cell::Char { ch: 'h', width: 1, style: Style::default(), hyperlink: None },
1713 Cell::Char { ch: 'i', width: 1, style: Style::default(), hyperlink: None },
1714 Cell::Empty,
1715 Cell::Empty,
1716 ];
1717 let mut buf: Vec<u8> = Vec::new();
1718 let base = Style { dim: true, ..Default::default() };
1719 write_row_with_highlights(&mut buf, &row, 4, &[], base).unwrap();
1720 let s = String::from_utf8_lossy(&buf);
1721
1722 for needle in ['h', 'i'] {
1725 let pos = s.find(needle).expect("char printed");
1726 let before = &s[..pos];
1727 assert!(
1728 !before.contains("\x1b[22m"),
1729 "dim cleared before {needle:?}: {before:?}",
1730 );
1731 }
1732 let after_i = s.find('i').unwrap() + 1;
1735 let eor = s[after_i..].find("\x1b[0m").unwrap_or(s.len() - after_i);
1736 let pad = &s[after_i..after_i + eor];
1737 assert!(
1738 !pad.contains("\x1b[22m"),
1739 "dim cleared in padding region: {pad:?}",
1740 );
1741 }
1742
1743 #[test]
1744 fn dim_row_yields_to_explicit_bold_cell() {
1745 use crate::ansi::Style;
1748 use crate::render::Cell;
1749 let row = vec![
1750 Cell::Char {
1751 ch: 'B',
1752 width: 1,
1753 style: Style { bold: true, ..Default::default() },
1754 hyperlink: None,
1755 },
1756 ];
1757 let mut buf: Vec<u8> = Vec::new();
1758 let base = Style { dim: true, ..Default::default() };
1759 write_row_with_highlights(&mut buf, &row, 1, &[], base).unwrap();
1760 let s = String::from_utf8_lossy(&buf);
1761 assert!(s.contains("\x1b[1m"), "expected Bold escape, got {s:?}");
1763 }
1764
1765 #[test]
1766 fn parse_colon_p() {
1767 assert_eq!(parse_colon_command("p").unwrap(), ColonCommand::Prev);
1768 assert_eq!(parse_colon_command("prev").unwrap(), ColonCommand::Prev);
1769 }
1770
1771 #[test]
1772 fn parse_colon_e_with_path() {
1773 match parse_colon_command("e /tmp/foo.log").unwrap() {
1774 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/tmp/foo.log")),
1775 other => panic!("expected Edit, got {other:?}"),
1776 }
1777 }
1778
1779 #[test]
1780 fn parse_colon_e_with_tilde() {
1781 std::env::set_var("HOME", "/home/user");
1782 match parse_colon_command("e ~/foo.log").unwrap() {
1783 ColonCommand::Edit(p) => assert_eq!(p, std::path::PathBuf::from("/home/user/foo.log")),
1784 other => panic!("expected Edit, got {other:?}"),
1785 }
1786 }
1787
1788 #[test]
1789 fn parse_colon_e_missing_path_errors() {
1790 assert_eq!(parse_colon_command("e").unwrap_err(), ColonParseError::MissingPath);
1791 assert_eq!(parse_colon_command("e ").unwrap_err(), ColonParseError::MissingPath);
1792 }
1793
1794 #[test]
1795 fn parse_colon_f_q_d_x_t() {
1796 assert_eq!(parse_colon_command("f").unwrap(), ColonCommand::ShowFile);
1797 assert_eq!(parse_colon_command("q").unwrap(), ColonCommand::Quit);
1798 assert_eq!(parse_colon_command("d").unwrap(), ColonCommand::Delete);
1799 assert_eq!(parse_colon_command("x").unwrap(), ColonCommand::First);
1800 assert_eq!(parse_colon_command("t").unwrap(), ColonCommand::Last);
1801 }
1802
1803 #[test]
1804 fn parse_unknown_command_errors() {
1805 let err = parse_colon_command("bogus").unwrap_err();
1806 match err {
1807 ColonParseError::UnknownCommand(name) => assert_eq!(name, "bogus"),
1808 other => panic!("expected UnknownCommand, got {other:?}"),
1809 }
1810 }
1811
1812 #[test]
1813 fn parse_handles_whitespace() {
1814 assert_eq!(parse_colon_command("n ").unwrap(), ColonCommand::Next);
1816 assert_eq!(parse_colon_command(" n").unwrap(), ColonCommand::Next);
1817 }
1818
1819 #[test]
1820 fn parse_colon_tag_with_name() {
1821 assert_eq!(
1822 parse_colon_command("tag foo").unwrap(),
1823 ColonCommand::Tag("foo".into())
1824 );
1825 }
1826
1827 #[test]
1828 fn parse_colon_tag_strips_trailing_whitespace() {
1829 assert_eq!(
1830 parse_colon_command("tag foo ").unwrap(),
1831 ColonCommand::Tag("foo".into())
1832 );
1833 }
1834
1835 #[test]
1836 fn parse_colon_tag_without_name_errors() {
1837 assert_eq!(
1838 parse_colon_command("tag").unwrap_err(),
1839 ColonParseError::TagRequiresName
1840 );
1841 assert_eq!(
1842 parse_colon_command("tag ").unwrap_err(),
1843 ColonParseError::TagRequiresName
1844 );
1845 }
1846
1847 #[test]
1848 fn parse_colon_tnext_and_tprev() {
1849 assert_eq!(parse_colon_command("tnext").unwrap(), ColonCommand::TagNext);
1850 assert_eq!(parse_colon_command("tprev").unwrap(), ColonCommand::TagPrev);
1851 }
1852
1853 #[test]
1854 fn tag_stack_push_pop_lifo() {
1855 let mut s = TagStack::default();
1856 s.push(0, 10);
1857 s.push(1, 20);
1858 assert_eq!(s.pop(), Some((1, 20)));
1859 assert_eq!(s.pop(), Some((0, 10)));
1860 assert_eq!(s.pop(), None);
1861 }
1862
1863 #[test]
1864 fn tag_stack_pop_clears_active() {
1865 let mut s = TagStack::default();
1866 s.push(0, 10);
1867 s.set_active(
1868 "foo".into(),
1869 vec![crate::tags::TagEntry {
1870 file: std::path::PathBuf::from("/a"),
1871 address: crate::tags::TagAddress::Line(1),
1872 }],
1873 );
1874 assert!(s.active.is_some());
1875 let _ = s.pop();
1876 assert!(s.active.is_none());
1877 }
1878
1879 #[test]
1880 fn tag_stack_next_advances_then_clamps() {
1881 let mut s = TagStack::default();
1882 s.set_active(
1883 "foo".into(),
1884 vec![
1885 crate::tags::TagEntry {
1886 file: std::path::PathBuf::from("/a"),
1887 address: crate::tags::TagAddress::Line(1),
1888 },
1889 crate::tags::TagEntry {
1890 file: std::path::PathBuf::from("/b"),
1891 address: crate::tags::TagAddress::Line(2),
1892 },
1893 ],
1894 );
1895 assert_eq!(s.next(), TagStepResult::Moved(1));
1896 assert_eq!(s.next(), TagStepResult::AtBoundary);
1897 }
1898
1899 #[test]
1900 fn tag_stack_prev_clamps_at_zero() {
1901 let mut s = TagStack::default();
1902 s.set_active(
1903 "foo".into(),
1904 vec![crate::tags::TagEntry {
1905 file: std::path::PathBuf::from("/a"),
1906 address: crate::tags::TagAddress::Line(1),
1907 }],
1908 );
1909 assert_eq!(s.prev(), TagStepResult::AtBoundary);
1910 }
1911
1912 #[test]
1913 fn tag_stack_next_with_no_active_returns_no_active() {
1914 let mut s = TagStack::default();
1915 assert_eq!(s.next(), TagStepResult::NoActive);
1916 assert_eq!(s.prev(), TagStepResult::NoActive);
1917 }
1918
1919 #[test]
1920 fn tag_stack_set_active_replaces_previous_list() {
1921 let mut s = TagStack::default();
1922 s.set_active(
1923 "foo".into(),
1924 vec![crate::tags::TagEntry {
1925 file: std::path::PathBuf::from("/a"),
1926 address: crate::tags::TagAddress::Line(1),
1927 }],
1928 );
1929 s.set_active(
1930 "bar".into(),
1931 vec![
1932 crate::tags::TagEntry {
1933 file: std::path::PathBuf::from("/x"),
1934 address: crate::tags::TagAddress::Line(5),
1935 },
1936 crate::tags::TagEntry {
1937 file: std::path::PathBuf::from("/y"),
1938 address: crate::tags::TagAddress::Line(6),
1939 },
1940 ],
1941 );
1942 let active = s.active.as_ref().unwrap();
1943 assert_eq!(active.name, "bar");
1944 assert_eq!(active.matches.len(), 2);
1945 assert_eq!(active.cursor, 0);
1946 }
1947
1948 #[test]
1949 fn writer_emits_color_for_red_cell() {
1950 let cells = vec![Cell::Char {
1951 ch: 'h',
1952 width: 1,
1953 style: crate::ansi::Style {
1954 fg: Some(crate::ansi::Color::Ansi(1)),
1955 ..Default::default()
1956 },
1957 hyperlink: None,
1958 }];
1959 let mut buf: Vec<u8> = Vec::new();
1960 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default()).unwrap();
1961 let s = String::from_utf8_lossy(&buf);
1962 assert!(s.contains("\x1b["), "expected ANSI escape in output: {s:?}");
1963 assert!(s.contains('h'));
1964 }
1965
1966 #[test]
1967 fn writer_emits_osc8_for_hyperlink_cell() {
1968 let link: std::sync::Arc<str> = std::sync::Arc::from("https://example.com");
1969 let cells = vec![Cell::Char {
1970 ch: 'c',
1971 width: 1,
1972 style: crate::ansi::Style::default(),
1973 hyperlink: Some(link),
1974 }];
1975 let mut buf: Vec<u8> = Vec::new();
1976 write_row_with_highlights(&mut buf, &cells, 80, &[], crate::ansi::Style::default()).unwrap();
1977 let s = String::from_utf8_lossy(&buf);
1978 assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "got: {s:?}");
1979 }
1980}