use crate::field::{FieldRange, parse_matching_fields, parse_transform_fields};
use crate::tui::util::merge_styles;
use crate::{DisplayContext, SkimItem};
use ansi_to_tui::IntoText;
use ratatui::text::{Line, Span};
use regex::Regex;
use std::borrow::Cow;
#[derive(Debug)]
pub struct DefaultSkimItem {
text: Box<str>,
metadata: Option<Box<DefaultSkimItemMetadata>>,
}
#[derive(Debug, Default)]
pub struct DefaultSkimItemMetadata {
orig_text: Option<Box<str>>,
stripped_text: Option<Box<str>>,
ansi_info: Option<Vec<(usize, usize)>>,
matching_ranges: Option<Vec<(usize, usize)>>,
disabled: bool,
}
impl DefaultSkimItem {
#[must_use]
pub fn new(
orig_text: &str,
ansi_enabled: bool,
trans_fields: &[FieldRange],
matching_fields: &[FieldRange],
delimiter: &Regex,
) -> Self {
let using_transform_fields = !trans_fields.is_empty();
let contains_ansi = Self::contains_ansi_escape(orig_text);
let (mut orig_text, mut temp_text): (Option<String>, Box<str>) = match (using_transform_fields, ansi_enabled) {
(true, true) => {
let transformed = parse_transform_fields(delimiter, orig_text, trans_fields);
(Some(orig_text.into()), Box::from(transformed))
}
(true, false) => {
let transformed = parse_transform_fields(delimiter, &escape_ansi(orig_text), trans_fields);
(Some(orig_text.into()), Box::from(transformed))
}
(false, false) if contains_ansi => (None, escape_ansi(orig_text).into()),
(false, true | false) => (None, Box::from(orig_text)),
};
let has_null_bytes = memchr::memchr(b'\0', temp_text.as_bytes()).is_some();
if has_null_bytes && orig_text.is_none() {
orig_text = Some(temp_text.to_string());
}
if has_null_bytes {
temp_text = temp_text.to_string().replace('\0', "").into_boxed_str();
}
let (stripped_text, ansi_info) = if ansi_enabled && contains_ansi {
let (stripped, info) = strip_ansi(&temp_text);
(Some(stripped), Some(info))
} else {
(None, None)
};
let matching_ranges = if matching_fields.is_empty() {
None
} else {
let text_for_matching = if let Some(stripped) = stripped_text.as_ref() {
stripped
} else {
temp_text.as_ref()
};
let orig_text_for_fields = if has_null_bytes {
orig_text.as_deref().unwrap_or(text_for_matching)
} else {
text_for_matching
};
if has_null_bytes {
let mut adjusted_ranges = Vec::new();
for field in matching_fields {
if let Some(field_text) = crate::field::get_string_by_field(delimiter, orig_text_for_fields, field)
{
let cleaned_field = field_text.replace('\0', "");
if let Some(pos) = text_for_matching.find(&cleaned_field) {
adjusted_ranges.push((pos, pos + cleaned_field.len()));
}
}
}
Some(adjusted_ranges)
} else {
Some(parse_matching_fields(delimiter, text_for_matching, matching_fields))
}
};
let metadata =
if orig_text.is_some() || stripped_text.is_some() || ansi_info.is_some() || matching_ranges.is_some() {
Some(Box::new(DefaultSkimItemMetadata {
orig_text: orig_text.map(std::string::String::into_boxed_str),
stripped_text: stripped_text.map(std::string::String::into_boxed_str),
ansi_info,
matching_ranges,
disabled: false,
}))
} else {
None
};
DefaultSkimItem {
text: temp_text,
metadata,
}
}
fn contains_ansi_escape(s: &str) -> bool {
memchr::memchr(b'\x1b', s.as_bytes()).is_some()
}
pub fn disable(&mut self) {
self.metadata.get_or_insert_default().disabled = true;
}
#[must_use]
pub fn stripped_text(&self) -> Option<&str> {
if let Some(meta) = &self.metadata
&& let Some(stripped_text) = &meta.stripped_text
{
Some(stripped_text.as_ref())
} else {
None
}
}
#[must_use]
pub fn orig_text(&self) -> Option<&str> {
if let Some(meta) = &self.metadata
&& let Some(orig) = &meta.orig_text
{
Some(orig.as_ref())
} else {
None
}
}
#[must_use]
pub fn ansi_info(&self) -> Option<&Vec<(usize, usize)>> {
if let Some(meta) = &self.metadata
&& let Some(info) = &meta.ansi_info
{
Some(info)
} else {
None
}
}
#[must_use]
pub fn matching_ranges(&self) -> Option<&[(usize, usize)]> {
if let Some(meta) = &self.metadata {
meta.matching_ranges.as_ref().map(|v| v.as_ref() as &[(usize, usize)])
} else {
None
}
}
}
impl DefaultSkimItem {
#[inline]
#[allow(dead_code)]
#[must_use]
pub fn get_display_text(&self) -> &str {
&self.text
}
}
impl From<String> for DefaultSkimItem {
fn from(value: String) -> Self {
Self {
text: Box::from(value),
metadata: None,
}
}
}
impl SkimItem for DefaultSkimItem {
#[inline]
fn text(&self) -> Cow<'_, str> {
if let Some(stripped) = self.stripped_text() {
Cow::Borrowed(stripped)
} else {
Cow::Borrowed(&self.text)
}
}
fn output(&self) -> Cow<'_, str> {
if let Some(orig) = self.orig_text() {
Cow::Borrowed(orig)
} else {
Cow::Borrowed(&self.text)
}
}
fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
self.matching_ranges()
}
#[allow(clippy::too_many_lines)]
fn display(&self, context: DisplayContext) -> Line<'_> {
if self.ansi_info().is_some() {
let text_bytes = self.text.as_bytes().to_vec();
let Ok(parsed_text) = text_bytes.into_text() else {
return context.to_line(Cow::Borrowed(&self.text));
};
let all_spans: Vec<Span> = parsed_text.lines.into_iter().flat_map(|line| line.spans).collect();
match context.matches {
crate::Matches::CharIndices(ref indices) => {
let highlight_positions: std::collections::HashSet<usize> = indices.iter().copied().collect();
let mut new_spans = Vec::new();
let mut char_idx = 0;
for span in all_spans {
let mut current_content = String::new();
let mut highlighted_content = String::new();
let base_style = span.style;
for ch in span.content.chars() {
if highlight_positions.contains(&char_idx) {
if !current_content.is_empty() {
new_spans.push(Span::styled(
current_content.clone(),
merge_styles(context.base_style, base_style),
));
current_content.clear();
}
highlighted_content.push(ch);
} else {
if !highlighted_content.is_empty() {
new_spans.push(Span::styled(
highlighted_content.clone(),
merge_styles(base_style, context.matched_style),
));
highlighted_content.clear();
}
current_content.push(ch);
}
char_idx += 1;
}
if !current_content.is_empty() {
new_spans.push(Span::styled(
current_content,
merge_styles(context.base_style, base_style),
));
}
if !highlighted_content.is_empty() {
new_spans.push(Span::styled(
highlighted_content,
merge_styles(base_style, context.matched_style),
));
}
}
Line::from(new_spans)
}
crate::Matches::CharRange(start, end) => {
let mut new_spans = Vec::new();
let mut char_idx = 0;
for span in all_spans {
let mut before = String::new();
let mut highlighted = String::new();
let mut after = String::new();
let base_style = span.style;
for ch in span.content.chars() {
if char_idx < start {
before.push(ch);
} else if char_idx < end {
highlighted.push(ch);
} else {
after.push(ch);
}
char_idx += 1;
}
if !before.is_empty() {
new_spans.push(Span::styled(before, merge_styles(context.base_style, base_style)));
}
if !highlighted.is_empty() {
new_spans.push(Span::styled(
highlighted,
merge_styles(base_style, context.matched_style),
));
}
if !after.is_empty() {
new_spans.push(Span::styled(after, merge_styles(context.base_style, base_style)));
}
}
Line::from(new_spans)
}
crate::Matches::ByteRange(start, end) => {
let stripped = self.stripped_text().unwrap();
let char_start = stripped.get(0..start).map_or(0, |s| s.chars().count());
let char_end = stripped
.get(0..end)
.map_or(stripped.chars().count(), |s| s.chars().count());
let mut new_spans = Vec::new();
let mut char_idx = 0;
for span in all_spans {
let mut before = String::new();
let mut highlighted = String::new();
let mut after = String::new();
let base_style = span.style;
for ch in span.content.chars() {
if char_idx < char_start {
before.push(ch);
} else if char_idx < char_end {
highlighted.push(ch);
} else {
after.push(ch);
}
char_idx += 1;
}
if !before.is_empty() {
new_spans.push(Span::styled(before, merge_styles(context.base_style, base_style)));
}
if !highlighted.is_empty() {
new_spans.push(Span::styled(
highlighted,
merge_styles(base_style, context.matched_style),
));
}
if !after.is_empty() {
new_spans.push(Span::styled(after, merge_styles(context.base_style, base_style)));
}
}
Line::from(new_spans)
}
crate::Matches::None => Line::from(all_spans),
}
} else {
context.to_line(Cow::Borrowed(&self.text))
}
}
fn disabled(&self) -> bool {
self.metadata.as_ref().is_some_and(|x| x.disabled)
}
}
#[must_use]
pub fn strip_ansi(text: &str) -> (String, Vec<(usize, usize)>) {
let mut result = String::with_capacity(text.len());
let mut index_mapping = Vec::new();
let mut chars = text.char_indices().peekable();
let mut char_idx = 0;
while let Some((byte_pos, ch)) = chars.next() {
if ch == '\x1b' {
if let Some(&(_, next_ch)) = chars.peek() {
match next_ch {
'[' => {
chars.next(); char_idx += 1;
while let Some(&(_, c)) = chars.peek() {
chars.next();
char_idx += 1;
if c.is_ascii_alphabetic() {
break;
}
}
}
']' => {
chars.next(); char_idx += 1;
while let Some((_, c)) = chars.next() {
char_idx += 1;
if c == '\x07' {
break;
}
if c == '\x1b'
&& let Some(&(_, '\\')) = chars.peek()
{
chars.next(); char_idx += 1;
break;
}
}
}
'(' | ')' | '#' | '%' => {
chars.next(); char_idx += 1;
chars.next(); char_idx += 1;
}
_ => {
chars.next();
char_idx += 1;
}
}
}
} else {
result.push(ch);
index_mapping.push((byte_pos, char_idx));
}
char_idx += 1;
}
(result, index_mapping)
}
fn escape_ansi(raw: &str) -> String {
unsafe { String::from_utf8_unchecked(raw.bytes().map(|b| if b == 27 { b'?' } else { b }).collect()) }
}
#[cfg(test)]
#[path = "item_tests.rs"]
mod test;