use crate::field::{FieldRange, parse_matching_fields, parse_transform_fields};
use crate::tui::util::merge_styles;
use crate::{DisplayContext, Matches, 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)>>,
hidden_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,
hidden_ranges: None,
disabled: false,
}))
} else {
None
};
DefaultSkimItem {
text: temp_text,
metadata,
}
}
#[must_use]
pub fn hidden_fields(mut self, hidden_fields: &[FieldRange], delimiter: &Regex) -> Self {
if hidden_fields.is_empty() {
return self;
}
let ranges = {
let text = self.text();
normalize_ranges(&parse_matching_fields(delimiter, text.as_ref(), hidden_fields))
};
if !ranges.is_empty() {
self.metadata.get_or_insert_default().hidden_ranges = Some(ranges);
}
self
}
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
}
}
#[must_use]
pub fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
if let Some(meta) = &self.metadata {
meta.hidden_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()
}
fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
self.hidden_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();
let (all_spans, matches) = if let Some(hidden) = self.hidden_ranges() {
let stripped = self.text();
let (_, map) = project_visible_text(stripped.as_ref(), hidden);
let visible_spans = retain_visible_spans(all_spans, &map);
let indices = project_match_indices(stripped.as_ref(), &context.matches, &map);
(visible_spans, Matches::CharIndices(indices))
} else {
(all_spans, context.matches.clone())
};
match 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 if let Some(hidden) = self.hidden_ranges() {
let (visible, map) = project_visible_text(&self.text, hidden);
let indices = project_match_indices(&self.text, &context.matches, &map);
DisplayContext {
score: context.score,
matches: Matches::CharIndices(indices),
container_width: context.container_width,
base_style: context.base_style,
matched_style: context.matched_style,
}
.to_line(Cow::Owned(visible))
} 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()) }
}
#[must_use]
pub(crate) fn normalize_ranges(ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
let mut sorted: Vec<(usize, usize)> = ranges.iter().copied().filter(|(s, e)| e > s).collect();
sorted.sort_unstable();
let mut merged: Vec<(usize, usize)> = Vec::with_capacity(sorted.len());
for (start, end) in sorted {
if let Some(last) = merged.last_mut()
&& start <= last.1
{
last.1 = last.1.max(end);
} else {
merged.push((start, end));
}
}
merged
}
#[must_use]
pub(crate) fn project_visible_text(text: &str, hidden: &[(usize, usize)]) -> (String, Vec<Option<usize>>) {
let mut visible = String::with_capacity(text.len());
let mut map = Vec::new();
let mut vis_idx = 0usize;
let mut hi = 0usize;
for (byte_pos, ch) in text.char_indices() {
while hi < hidden.len() && byte_pos >= hidden[hi].1 {
hi += 1;
}
let is_hidden = hi < hidden.len() && byte_pos >= hidden[hi].0 && byte_pos < hidden[hi].1;
if is_hidden {
map.push(None);
} else {
map.push(Some(vis_idx));
visible.push(ch);
vis_idx += 1;
}
}
(visible, map)
}
#[must_use]
pub(crate) fn retain_visible_spans(spans: Vec<Span<'_>>, map: &[Option<usize>]) -> Vec<Span<'static>> {
let mut out = Vec::with_capacity(spans.len());
let mut char_idx = 0usize;
for span in spans {
let mut content = String::new();
for ch in span.content.chars() {
if map.get(char_idx).copied().flatten().is_some() {
content.push(ch);
}
char_idx += 1;
}
if !content.is_empty() {
out.push(Span::styled(content, span.style));
}
}
out
}
#[must_use]
pub(crate) fn project_match_indices(text: &str, matches: &Matches, map: &[Option<usize>]) -> Vec<usize> {
let full_indices: Vec<usize> = match matches {
Matches::CharIndices(indices) => indices.clone(),
Matches::CharRange(start, end) => (*start..*end).collect(),
Matches::ByteRange(start, end) => text
.char_indices()
.enumerate()
.filter(|(_, (byte_pos, _))| *byte_pos >= *start && *byte_pos < *end)
.map(|(char_idx, _)| char_idx)
.collect(),
Matches::None => Vec::new(),
};
let mut visible: Vec<usize> = full_indices
.into_iter()
.filter_map(|ci| map.get(ci).copied().flatten())
.collect();
visible.sort_unstable();
visible.dedup();
visible
}
#[cfg(test)]
#[path = "item_tests.rs"]
mod test;