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