use base64::Engine as _;
use polars::prelude::*;
use std::borrow::Cow;
use std::fmt::Write as _;
pub fn f64_text(v: f64) -> String {
if v.is_nan() {
return "NaN".to_string();
}
if v.is_infinite() {
return if v > 0.0 { "inf" } else { "-inf" }.to_string();
}
let a = v.abs();
if a == 0.0 || (1e-5..1e16).contains(&a) {
whole_reads_as_float(format!("{v}"))
} else {
format!("{v:e}")
}
}
fn whole_reads_as_float(mut text: String) -> String {
if !text.contains('.') {
text.push_str(".0");
}
text
}
pub fn f32_text(v: f32) -> String {
if v.is_nan() {
return "NaN".to_string();
}
if v.is_infinite() {
return if v > 0.0 { "inf" } else { "-inf" }.to_string();
}
let a = v.abs();
if a == 0.0 || (1e-5..1e16).contains(&a) {
whole_reads_as_float(format!("{v}"))
} else {
format!("{v:e}")
}
}
pub fn out_of_range(value: &AnyValue) -> Option<String> {
use chrono::{DateTime, NaiveDate, TimeDelta};
let fits = |dt: Option<DateTime<chrono::Utc>>| {
dt.is_some_and(|dt| {
dt.checked_add_signed(TimeDelta::days(1)).is_some()
&& dt.checked_sub_signed(TimeDelta::days(1)).is_some()
})
};
match value {
AnyValue::Date(days) => {
let fits = days
.checked_add(719_163)
.and_then(NaiveDate::from_num_days_from_ce_opt)
.is_some();
(!fits).then(|| format!("{days} days since 1970-01-01"))
}
AnyValue::Datetime(v, unit, _) | AnyValue::DatetimeOwned(v, unit, _) => {
let (dt, unit) = match unit {
TimeUnit::Milliseconds => (DateTime::from_timestamp_millis(*v), "ms"),
TimeUnit::Microseconds => (DateTime::from_timestamp_micros(*v), "us"),
TimeUnit::Nanoseconds => (Some(DateTime::from_timestamp_nanos(*v)), "ns"),
};
(!fits(dt)).then(|| format!("{v} {unit} since 1970-01-01 UTC"))
}
AnyValue::Time(ns) => {
(!(0..NANOS_PER_DAY).contains(ns)).then(|| format!("{ns} ns since midnight"))
}
_ => None,
}
}
const NANOS_PER_DAY: i64 = 86_400_000_000_000;
pub fn str_value<'a>(value: &AnyValue<'a>) -> Cow<'a, str> {
match past_calendar_text(value) {
Some(text) => Cow::Owned(text),
None => value.str_value(),
}
}
pub fn list_preview(items: &Series) -> String {
const SHOWN: usize = 10;
let mut text = String::from("[");
for (i, item) in items.iter().take(SHOWN).enumerate() {
if text.len() > CELL_PREVIEW_BYTES {
text.push_str("...");
return text;
}
if i > 0 {
text.push_str(", ");
}
text.push_str(prefix(&str_value(&item), CELL_PREVIEW_BYTES));
}
if items.len() > SHOWN {
let _ = write!(text, "...] ({} items)", items.len());
} else {
text.push(']');
}
text
}
pub fn past_calendar_text(value: &AnyValue) -> Option<String> {
if let Some(text) = out_of_range(value) {
return Some(text);
}
nested_out_of_range(value).then(|| nested_compact(value, CELL_PREVIEW_BYTES).text)
}
fn nested_out_of_range(value: &AnyValue) -> bool {
let holds = |fields: &[Field]| fields.iter().any(|f| holds_calendar(f.dtype()));
match value {
AnyValue::List(s) | AnyValue::Array(s, _) => series_out_of_range(s),
AnyValue::Struct(_, _, fields) if !holds(fields) => false,
AnyValue::StructOwned(payload) if !holds(&payload.1) => false,
AnyValue::Struct(..) | AnyValue::StructOwned(_) => value
._iter_struct_av()
.any(|field| out_of_range(&field).is_some() || nested_out_of_range(&field)),
_ => false,
}
}
fn series_out_of_range(s: &Series) -> bool {
if !holds_calendar(s.dtype()) {
return false;
}
match s.dtype() {
DataType::Date | DataType::Datetime(..) | DataType::Time => {
let Ok(stored) = s.to_physical_repr().cast(&DataType::Int64) else {
return false;
};
let Ok(stored) = stored.i64() else {
return false;
};
[stored.min(), stored.max()]
.into_iter()
.flatten()
.any(|v| stored_out_of_range(s.dtype(), v).is_some())
}
_ => s.iter().any(|item| nested_out_of_range(&item)),
}
}
pub fn stored_out_of_range(dtype: &DataType, stored: i64) -> Option<String> {
let value = match dtype {
DataType::Date => AnyValue::Date(i32::try_from(stored).ok()?),
DataType::Datetime(unit, _) => AnyValue::Datetime(stored, *unit, None),
DataType::Time => AnyValue::Time(stored),
_ => return None,
};
out_of_range(&value)
}
pub fn calendar_without_out_of_range(series: &Series) -> PolarsResult<Option<Series>> {
let dtype = series.dtype();
if !matches!(
dtype,
DataType::Date | DataType::Datetime(..) | DataType::Time
) {
return Ok(None);
}
let stored = series.to_physical_repr().cast(&DataType::Int64)?;
let stored = stored.i64()?;
let past = |v: i64| stored_out_of_range(dtype, v).is_some();
if ![stored.min(), stored.max()].into_iter().flatten().any(past) {
return Ok(None);
}
let kept = stored.apply(|v| v.filter(|v| !past(*v)));
Ok(Some(
kept.into_series()
.cast(dtype)?
.with_name(series.name().clone()),
))
}
pub fn holds_calendar(dtype: &DataType) -> bool {
match dtype {
DataType::Date | DataType::Datetime(..) | DataType::Time => true,
DataType::List(inner) | DataType::Array(inner, _) => holds_calendar(inner),
DataType::Struct(fields) => fields.iter().any(|f| holds_calendar(f.dtype())),
_ => false,
}
}
fn datetime_text(v: i64, unit: TimeUnit, zone: Option<&TimeZone>) -> String {
let digits = match unit {
TimeUnit::Milliseconds => "%.3f",
TimeUnit::Microseconds => "%.6f",
TimeUnit::Nanoseconds => "%.9f",
};
let format = match zone {
Some(_) => format!("%Y-%m-%d %H:%M:%S{digits} %:z"),
None => format!("%Y-%m-%d %H:%M:%S{digits}"),
};
let ca = Int64Chunked::from_slice(PlSmallStr::EMPTY, &[v]).into_datetime(unit, zone.cloned());
ca.to_string(&format)
.ok()
.and_then(|s| s.get(0).map(str::to_string))
.unwrap_or_else(|| AnyValue::Datetime(v, unit, zone).str_value().into_owned())
}
pub fn base64_text(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
pub fn value_text(value: &AnyValue) -> String {
if let Some(text) = out_of_range(value) {
return text;
}
match value {
AnyValue::Null => String::new(),
AnyValue::Float64(v) => f64_text(*v),
AnyValue::Float32(v) => f32_text(*v),
AnyValue::Float16(v) => f32_text(f32::from(*v)),
AnyValue::Datetime(v, unit, zone) => datetime_text(*v, *unit, *zone),
AnyValue::DatetimeOwned(v, unit, zone) => datetime_text(*v, *unit, zone.as_deref()),
AnyValue::Duration(v, unit) => {
let mut out = String::new();
crate::export::nested_json::duration_iso(*v, *unit, &mut out);
out
}
AnyValue::Binary(bytes) => base64_text(bytes),
AnyValue::BinaryOwned(bytes) => base64_text(bytes),
AnyValue::List(_)
| AnyValue::Array(..)
| AnyValue::Struct(..)
| AnyValue::StructOwned(_) => {
let mut out = String::new();
write_nested(value, &mut out, None, 0, usize::MAX);
out
}
v => v.str_value().into_owned(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bounded {
pub text: String,
pub cut: bool,
}
pub fn nested_pretty(value: &AnyValue, budget: usize) -> Bounded {
let mut text = String::new();
let cut = !write_nested(value, &mut text, Some(0), 0, budget);
Bounded { text, cut }
}
pub fn nested_compact(value: &AnyValue, budget: usize) -> Bounded {
let mut text = String::new();
let cut = !write_nested(value, &mut text, None, 0, budget);
Bounded { text, cut }
}
pub fn is_nested_value(value: &AnyValue) -> bool {
matches!(
value,
AnyValue::List(_) | AnyValue::Array(..) | AnyValue::Struct(..) | AnyValue::StructOwned(_)
)
}
pub fn nested_len(value: &AnyValue) -> Option<usize> {
match value {
AnyValue::List(s) | AnyValue::Array(s, _) => Some(s.len()),
AnyValue::Struct(_, _, fields) => Some(fields.len()),
AnyValue::StructOwned(payload) => Some(payload.1.len()),
_ => None,
}
}
fn write_nested(
value: &AnyValue,
out: &mut String,
indent: Option<usize>,
depth: usize,
budget: usize,
) -> bool {
if out.len() >= budget {
return false;
}
match value {
AnyValue::List(s) | AnyValue::Array(s, _) => write_items(
s.iter().map(|v| (None, v)),
s.len(),
('[', ']'),
out,
indent,
depth,
budget,
),
AnyValue::Struct(_, _, fields) => write_items(
fields
.iter()
.map(|f| Some(f.name().as_str()))
.zip(value._iter_struct_av()),
fields.len(),
('{', '}'),
out,
indent,
depth,
budget,
),
AnyValue::StructOwned(payload) => write_items(
payload
.1
.iter()
.map(|f| Some(f.name().as_str()))
.zip(payload.0.iter().cloned()),
payload.1.len(),
('{', '}'),
out,
indent,
depth,
budget,
),
AnyValue::String(s) => write_quoted(s, out, budget),
AnyValue::StringOwned(s) => write_quoted(s, out, budget),
AnyValue::Binary(b) => write_bytes(b, out, budget),
AnyValue::BinaryOwned(b) => write_bytes(b, out, budget),
v => {
out.push_str(&json_scalar(v));
true
}
}
}
fn write_items<'n, 'v>(
items: impl Iterator<Item = (Option<&'n str>, AnyValue<'v>)>,
len: usize,
(open, close): (char, char),
out: &mut String,
indent: Option<usize>,
depth: usize,
budget: usize,
) -> bool {
let pad = |out: &mut String, depth: usize| {
if indent.is_some() {
out.push('\n');
out.extend(std::iter::repeat_n(" ", depth));
}
};
out.push(open);
if len == 0 {
out.push(close);
return true;
}
for (i, (name, item)) in items.enumerate() {
if i > 0 {
out.push(',');
if indent.is_none() {
out.push(' ');
}
}
pad(out, depth + 1);
if let Some(name) = name {
out.push_str(&json_string(name));
out.push_str(": ");
}
if !write_nested(&item, out, indent, depth + 1, budget) {
return false;
}
if out.len() >= budget && i + 1 < len {
return false;
}
}
pad(out, depth);
out.push(close);
true
}
fn write_quoted(s: &str, out: &mut String, budget: usize) -> bool {
out.push('"');
let head = prefix(s, budget.saturating_sub(out.len()));
escape_into(head, out);
if head.len() < s.len() {
return false;
}
out.push('"');
true
}
fn write_bytes(bytes: &[u8], out: &mut String, budget: usize) -> bool {
let room = budget.saturating_sub(out.len() + 1) / 4 * 3;
let head = &bytes[..bytes.len().min(room)];
out.push('"');
out.push_str(&base64_text(head));
if head.len() < bytes.len() {
return false;
}
out.push('"');
true
}
fn json_scalar(value: &AnyValue) -> String {
match value {
AnyValue::Null => "null".to_string(),
AnyValue::Boolean(b) => b.to_string(),
AnyValue::String(s) => json_string(s),
AnyValue::StringOwned(s) => json_string(s),
v if v.is_primitive_numeric() || matches!(v, AnyValue::Decimal(..)) => value_text(v),
v => json_string(&value_text(v)),
}
}
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
escape_into(s, &mut out);
out.push('"');
out
}
fn invisible(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{a0}'
| '\u{ad}'
| '\u{34f}'
| '\u{61c}'
| '\u{115f}'..='\u{1160}'
| '\u{1680}'
| '\u{17b4}'..='\u{17b5}'
| '\u{180b}'..='\u{180f}'
| '\u{2000}'..='\u{200f}'
| '\u{2028}'..='\u{202f}'
| '\u{205f}'..='\u{206f}'
| '\u{3000}'
| '\u{3164}'
| '\u{fe00}'..='\u{fe0f}'
| '\u{feff}'
| '\u{ffa0}'
| '\u{fff0}'..='\u{fffb}'
| '\u{1bca0}'..='\u{1bca3}'
| '\u{1d173}'..='\u{1d17a}'
| '\u{e0000}'..='\u{e0fff}'
)
}
pub fn marked(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{61c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
)
}
fn escape_into(s: &str, out: &mut String) {
for c in s.chars() {
escape_char(c, out);
}
}
pub fn escape_char(c: char, out: &mut String) {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\0' => out.push_str("\\0"),
c if invisible(c) => {
let _ = write!(out, "\\u{{{:x}}}", c as u32);
}
c => out.push(c),
}
}
pub fn escaped(s: &str) -> String {
json_string(s)
}
pub fn escaped_bytes(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() + 3);
out.push_str("b\"");
for &b in bytes {
match b {
b'\\' => out.push_str("\\\\"),
b'"' => out.push_str("\\\""),
b'\n' => out.push_str("\\n"),
b'\r' => out.push_str("\\r"),
b'\t' => out.push_str("\\t"),
0x20..=0x7e => out.push(b as char),
b => {
let _ = write!(out, "\\x{b:02x}");
}
}
}
out.push('"');
out
}
fn has_marked(s: &str) -> bool {
s.bytes().any(|b| b < 0x20 || b == 0x7f)
|| (s.bytes().any(|b| matches!(b, 0xc2 | 0xd8 | 0xe2)) && s.chars().any(marked))
}
pub const CELL_PREVIEW_BYTES: usize = 4096;
pub fn cell_cut(width: u16) -> usize {
usize::from(width.max(crate::widgets::column_widths::MAX_WIDTH))
}
pub fn cell_preview(s: &str, g: &crate::glyphs::Glyphs, width: u16) -> String {
cell_text(Cow::Borrowed(s), g, cell_cut(width))
}
pub fn cell_text(text: Cow<'_, str>, g: &crate::glyphs::Glyphs, cells: usize) -> String {
let head = cell_prefix(&text, cells);
if head.len() == text.len() {
if !has_marked(&text) {
return text.into_owned();
}
return preview(&text, g).into_owned();
}
let mut out = preview(head, g).into_owned();
out.push_str(g.ellipsis);
out
}
pub fn cell_prefix(s: &str, cells: usize) -> &str {
use ratatui::buffer::CellWidth;
use unicode_segmentation::UnicodeSegmentation;
if s.len() <= cells {
return s;
}
let plain = s.as_bytes()[..=cells]
.iter()
.take_while(|b| (0x20..0x7f).contains(*b))
.count();
if plain > cells {
return &s[..=cells];
}
let mut width = 0usize;
for (n, (at, grapheme)) in s.grapheme_indices(true).enumerate() {
if width > cells || n > 2 * cells {
return &s[..at];
}
width += if grapheme.contains(char::is_control) {
1
} else {
usize::from(grapheme.cell_width())
};
}
s
}
pub fn preview<'a>(s: &'a str, g: &crate::glyphs::Glyphs) -> Cow<'a, str> {
if !has_marked(s) {
return Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\r' if chars.peek() == Some(&'\n') => {
chars.next();
out.push_str(g.newline_mark);
}
'\n' => out.push_str(g.newline_mark),
'\t' => out.push_str(g.tab_mark),
c if marked(c) => out.push_str(g.control_mark),
c => out.push(c),
}
}
Cow::Owned(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TextFacts {
pub chars: usize,
pub lines: usize,
pub leading_spaces: usize,
pub trailing_spaces: usize,
}
pub fn text_facts(s: &str) -> TextFacts {
let lines = if s.is_empty() {
0
} else {
s.split('\n').count()
};
TextFacts {
chars: s.chars().count(),
lines,
leading_spaces: s.chars().take_while(|c| c.is_whitespace()).count(),
trailing_spaces: if s.chars().all(char::is_whitespace) {
0
} else {
s.chars().rev().take_while(|c| c.is_whitespace()).count()
},
}
}
pub fn prefix(s: &str, budget: usize) -> &str {
if s.len() <= budget {
return s;
}
let mut end = budget;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
pub fn copy_text(column: &Column) -> PolarsResult<String> {
let value = column.get(0)?;
if is_nested_value(&value) {
let json = crate::export::nested_json::column_as_json(column)?;
return Ok(match json.get(0)? {
AnyValue::Null => String::new(),
v => v.str_value().into_owned(),
});
}
Ok(value_text(&value))
}
pub fn copy_len_floor(value: &AnyValue, stop: usize) -> usize {
match value {
AnyValue::String(s) => s.len(),
AnyValue::StringOwned(s) => s.len(),
AnyValue::Binary(b) => crate::clipboard::base64_len(b.len()),
AnyValue::BinaryOwned(b) => crate::clipboard::base64_len(b.len()),
v if is_nested_value(v) => json_len_floor(v, 0, stop),
v => value_text(v).len(),
}
}
fn json_len_floor(value: &AnyValue, so_far: usize, stop: usize) -> usize {
if so_far > stop {
return so_far;
}
let punctuation = |len: usize| 2 + len.saturating_sub(1);
match value {
AnyValue::List(s) | AnyValue::Array(s, _) => {
let mut n = so_far + punctuation(s.len());
for item in s.iter() {
if n > stop {
break;
}
n = json_len_floor(&item, n, stop);
}
n
}
AnyValue::Struct(_, _, fields) => {
let mut n = so_far + punctuation(fields.len());
for (field, item) in fields.iter().zip(value._iter_struct_av()) {
if n > stop {
break;
}
n = json_len_floor(&item, n + field.name().len() + 3, stop);
}
n
}
AnyValue::StructOwned(payload) => {
let mut n = so_far + punctuation(payload.1.len());
for (field, item) in payload.1.iter().zip(&payload.0) {
if n > stop {
break;
}
n = json_len_floor(item, n + field.name().len() + 3, stop);
}
n
}
AnyValue::String(s) => so_far + s.len() + 2,
AnyValue::StringOwned(s) => so_far + s.len() + 2,
AnyValue::Binary(b) => so_far + crate::clipboard::base64_len(b.len()) + 2,
AnyValue::BinaryOwned(b) => so_far + crate::clipboard::base64_len(b.len()) + 2,
_ => so_far + 1,
}
}
#[cfg(test)]
mod tests;