1use base64::Engine as _;
10use polars::prelude::*;
11use std::borrow::Cow;
12use std::fmt::Write as _;
13
14pub fn f64_text(v: f64) -> String {
18 if v.is_nan() {
19 return "NaN".to_string();
20 }
21 if v.is_infinite() {
22 return if v > 0.0 { "inf" } else { "-inf" }.to_string();
23 }
24 let a = v.abs();
25 if a == 0.0 || (1e-5..1e16).contains(&a) {
28 whole_reads_as_float(format!("{v}"))
29 } else {
30 format!("{v:e}")
31 }
32}
33
34fn whole_reads_as_float(mut text: String) -> String {
37 if !text.contains('.') {
38 text.push_str(".0");
39 }
40 text
41}
42
43pub fn f32_text(v: f32) -> String {
46 if v.is_nan() {
47 return "NaN".to_string();
48 }
49 if v.is_infinite() {
50 return if v > 0.0 { "inf" } else { "-inf" }.to_string();
51 }
52 let a = v.abs();
53 if a == 0.0 || (1e-5..1e16).contains(&a) {
54 whole_reads_as_float(format!("{v}"))
55 } else {
56 format!("{v:e}")
57 }
58}
59
60pub fn out_of_range(value: &AnyValue) -> Option<String> {
64 use chrono::{DateTime, NaiveDate, TimeDelta};
65 let fits = |dt: Option<DateTime<chrono::Utc>>| {
66 dt.is_some_and(|dt| {
67 dt.checked_add_signed(TimeDelta::days(1)).is_some()
68 && dt.checked_sub_signed(TimeDelta::days(1)).is_some()
69 })
70 };
71 match value {
72 AnyValue::Date(days) => {
73 let fits = days
75 .checked_add(719_163)
76 .and_then(NaiveDate::from_num_days_from_ce_opt)
77 .is_some();
78 (!fits).then(|| format!("{days} days since 1970-01-01"))
79 }
80 AnyValue::Datetime(v, unit, _) | AnyValue::DatetimeOwned(v, unit, _) => {
81 let (dt, unit) = match unit {
82 TimeUnit::Milliseconds => (DateTime::from_timestamp_millis(*v), "ms"),
83 TimeUnit::Microseconds => (DateTime::from_timestamp_micros(*v), "us"),
84 TimeUnit::Nanoseconds => (Some(DateTime::from_timestamp_nanos(*v)), "ns"),
85 };
86 (!fits(dt)).then(|| format!("{v} {unit} since 1970-01-01 UTC"))
87 }
88 AnyValue::Time(ns) => {
89 (!(0..NANOS_PER_DAY).contains(ns)).then(|| format!("{ns} ns since midnight"))
90 }
91 _ => None,
92 }
93}
94
95const NANOS_PER_DAY: i64 = 86_400_000_000_000;
96
97pub fn str_value<'a>(value: &AnyValue<'a>) -> Cow<'a, str> {
101 match past_calendar_text(value) {
102 Some(text) => Cow::Owned(text),
103 None => value.str_value(),
104 }
105}
106
107pub fn list_preview(items: &Series) -> String {
111 const SHOWN: usize = 10;
112 let mut text = String::from("[");
113 for (i, item) in items.iter().take(SHOWN).enumerate() {
114 if text.len() > CELL_PREVIEW_BYTES {
115 text.push_str("...");
116 return text;
117 }
118 if i > 0 {
119 text.push_str(", ");
120 }
121 text.push_str(prefix(&str_value(&item), CELL_PREVIEW_BYTES));
122 }
123 if items.len() > SHOWN {
124 let _ = write!(text, "...] ({} items)", items.len());
125 } else {
126 text.push(']');
127 }
128 text
129}
130
131pub fn past_calendar_text(value: &AnyValue) -> Option<String> {
135 if let Some(text) = out_of_range(value) {
136 return Some(text);
137 }
138 nested_out_of_range(value).then(|| nested_compact(value, CELL_PREVIEW_BYTES).text)
139}
140
141fn nested_out_of_range(value: &AnyValue) -> bool {
144 let holds = |fields: &[Field]| fields.iter().any(|f| holds_calendar(f.dtype()));
145 match value {
146 AnyValue::List(s) | AnyValue::Array(s, _) => series_out_of_range(s),
147 AnyValue::Struct(_, _, fields) if !holds(fields) => false,
148 AnyValue::StructOwned(payload) if !holds(&payload.1) => false,
149 AnyValue::Struct(..) | AnyValue::StructOwned(_) => value
150 ._iter_struct_av()
151 .any(|field| out_of_range(&field).is_some() || nested_out_of_range(&field)),
152 _ => false,
153 }
154}
155
156fn series_out_of_range(s: &Series) -> bool {
159 if !holds_calendar(s.dtype()) {
160 return false;
161 }
162 match s.dtype() {
163 DataType::Date | DataType::Datetime(..) | DataType::Time => {
164 let Ok(stored) = s.to_physical_repr().cast(&DataType::Int64) else {
165 return false;
166 };
167 let Ok(stored) = stored.i64() else {
168 return false;
169 };
170 [stored.min(), stored.max()]
171 .into_iter()
172 .flatten()
173 .any(|v| stored_out_of_range(s.dtype(), v).is_some())
174 }
175 _ => s.iter().any(|item| nested_out_of_range(&item)),
176 }
177}
178
179pub fn stored_out_of_range(dtype: &DataType, stored: i64) -> Option<String> {
181 let value = match dtype {
182 DataType::Date => AnyValue::Date(i32::try_from(stored).ok()?),
183 DataType::Datetime(unit, _) => AnyValue::Datetime(stored, *unit, None),
184 DataType::Time => AnyValue::Time(stored),
185 _ => return None,
186 };
187 out_of_range(&value)
188}
189
190pub fn calendar_without_out_of_range(series: &Series) -> PolarsResult<Option<Series>> {
194 let dtype = series.dtype();
195 if !matches!(
196 dtype,
197 DataType::Date | DataType::Datetime(..) | DataType::Time
198 ) {
199 return Ok(None);
200 }
201 let stored = series.to_physical_repr().cast(&DataType::Int64)?;
202 let stored = stored.i64()?;
203 let past = |v: i64| stored_out_of_range(dtype, v).is_some();
204 if ![stored.min(), stored.max()].into_iter().flatten().any(past) {
205 return Ok(None);
206 }
207 let kept = stored.apply(|v| v.filter(|v| !past(*v)));
208 Ok(Some(
209 kept.into_series()
210 .cast(dtype)?
211 .with_name(series.name().clone()),
212 ))
213}
214
215pub fn holds_calendar(dtype: &DataType) -> bool {
217 match dtype {
218 DataType::Date | DataType::Datetime(..) | DataType::Time => true,
219 DataType::List(inner) | DataType::Array(inner, _) => holds_calendar(inner),
220 DataType::Struct(fields) => fields.iter().any(|f| holds_calendar(f.dtype())),
221 _ => false,
222 }
223}
224
225fn datetime_text(v: i64, unit: TimeUnit, zone: Option<&TimeZone>) -> String {
229 let digits = match unit {
230 TimeUnit::Milliseconds => "%.3f",
231 TimeUnit::Microseconds => "%.6f",
232 TimeUnit::Nanoseconds => "%.9f",
233 };
234 let format = match zone {
235 Some(_) => format!("%Y-%m-%d %H:%M:%S{digits} %:z"),
236 None => format!("%Y-%m-%d %H:%M:%S{digits}"),
237 };
238 let ca = Int64Chunked::from_slice(PlSmallStr::EMPTY, &[v]).into_datetime(unit, zone.cloned());
239 ca.to_string(&format)
240 .ok()
241 .and_then(|s| s.get(0).map(str::to_string))
242 .unwrap_or_else(|| AnyValue::Datetime(v, unit, zone).str_value().into_owned())
243}
244
245pub fn base64_text(bytes: &[u8]) -> String {
247 base64::engine::general_purpose::STANDARD.encode(bytes)
248}
249
250pub fn value_text(value: &AnyValue) -> String {
253 if let Some(text) = out_of_range(value) {
254 return text;
255 }
256 match value {
257 AnyValue::Null => String::new(),
258 AnyValue::Float64(v) => f64_text(*v),
259 AnyValue::Float32(v) => f32_text(*v),
260 AnyValue::Float16(v) => f32_text(f32::from(*v)),
261 AnyValue::Datetime(v, unit, zone) => datetime_text(*v, *unit, *zone),
262 AnyValue::DatetimeOwned(v, unit, zone) => datetime_text(*v, *unit, zone.as_deref()),
263 AnyValue::Duration(v, unit) => {
265 let mut out = String::new();
266 crate::export::nested_json::duration_iso(*v, *unit, &mut out);
267 out
268 }
269 AnyValue::Binary(bytes) => base64_text(bytes),
270 AnyValue::BinaryOwned(bytes) => base64_text(bytes),
271 AnyValue::List(_)
272 | AnyValue::Array(..)
273 | AnyValue::Struct(..)
274 | AnyValue::StructOwned(_) => {
275 let mut out = String::new();
276 write_nested(value, &mut out, None, 0, usize::MAX);
277 out
278 }
279 v => v.str_value().into_owned(),
282 }
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct Bounded {
288 pub text: String,
289 pub cut: bool,
290}
291
292pub fn nested_pretty(value: &AnyValue, budget: usize) -> Bounded {
296 let mut text = String::new();
297 let cut = !write_nested(value, &mut text, Some(0), 0, budget);
298 Bounded { text, cut }
299}
300
301pub fn nested_compact(value: &AnyValue, budget: usize) -> Bounded {
304 let mut text = String::new();
305 let cut = !write_nested(value, &mut text, None, 0, budget);
306 Bounded { text, cut }
307}
308
309pub fn is_nested_value(value: &AnyValue) -> bool {
311 matches!(
312 value,
313 AnyValue::List(_) | AnyValue::Array(..) | AnyValue::Struct(..) | AnyValue::StructOwned(_)
314 )
315}
316
317pub fn nested_len(value: &AnyValue) -> Option<usize> {
319 match value {
320 AnyValue::List(s) | AnyValue::Array(s, _) => Some(s.len()),
321 AnyValue::Struct(_, _, fields) => Some(fields.len()),
322 AnyValue::StructOwned(payload) => Some(payload.1.len()),
323 _ => None,
324 }
325}
326
327fn write_nested(
330 value: &AnyValue,
331 out: &mut String,
332 indent: Option<usize>,
333 depth: usize,
334 budget: usize,
335) -> bool {
336 if out.len() >= budget {
337 return false;
338 }
339 match value {
342 AnyValue::List(s) | AnyValue::Array(s, _) => write_items(
343 s.iter().map(|v| (None, v)),
344 s.len(),
345 ('[', ']'),
346 out,
347 indent,
348 depth,
349 budget,
350 ),
351 AnyValue::Struct(_, _, fields) => write_items(
352 fields
353 .iter()
354 .map(|f| Some(f.name().as_str()))
355 .zip(value._iter_struct_av()),
356 fields.len(),
357 ('{', '}'),
358 out,
359 indent,
360 depth,
361 budget,
362 ),
363 AnyValue::StructOwned(payload) => write_items(
364 payload
365 .1
366 .iter()
367 .map(|f| Some(f.name().as_str()))
368 .zip(payload.0.iter().cloned()),
369 payload.1.len(),
370 ('{', '}'),
371 out,
372 indent,
373 depth,
374 budget,
375 ),
376 AnyValue::String(s) => write_quoted(s, out, budget),
379 AnyValue::StringOwned(s) => write_quoted(s, out, budget),
380 AnyValue::Binary(b) => write_bytes(b, out, budget),
381 AnyValue::BinaryOwned(b) => write_bytes(b, out, budget),
382 v => {
383 out.push_str(&json_scalar(v));
384 true
385 }
386 }
387}
388
389fn write_items<'n, 'v>(
392 items: impl Iterator<Item = (Option<&'n str>, AnyValue<'v>)>,
393 len: usize,
394 (open, close): (char, char),
395 out: &mut String,
396 indent: Option<usize>,
397 depth: usize,
398 budget: usize,
399) -> bool {
400 let pad = |out: &mut String, depth: usize| {
401 if indent.is_some() {
402 out.push('\n');
403 out.extend(std::iter::repeat_n(" ", depth));
404 }
405 };
406 out.push(open);
407 if len == 0 {
408 out.push(close);
409 return true;
410 }
411 for (i, (name, item)) in items.enumerate() {
412 if i > 0 {
413 out.push(',');
414 if indent.is_none() {
415 out.push(' ');
416 }
417 }
418 pad(out, depth + 1);
419 if let Some(name) = name {
420 out.push_str(&json_string(name));
421 out.push_str(": ");
422 }
423 if !write_nested(&item, out, indent, depth + 1, budget) {
424 return false;
425 }
426 if out.len() >= budget && i + 1 < len {
427 return false;
428 }
429 }
430 pad(out, depth);
431 out.push(close);
432 true
433}
434
435fn write_quoted(s: &str, out: &mut String, budget: usize) -> bool {
438 out.push('"');
439 let head = prefix(s, budget.saturating_sub(out.len()));
440 escape_into(head, out);
441 if head.len() < s.len() {
442 return false;
443 }
444 out.push('"');
445 true
446}
447
448fn write_bytes(bytes: &[u8], out: &mut String, budget: usize) -> bool {
450 let room = budget.saturating_sub(out.len() + 1) / 4 * 3;
453 let head = &bytes[..bytes.len().min(room)];
454 out.push('"');
455 out.push_str(&base64_text(head));
456 if head.len() < bytes.len() {
457 return false;
458 }
459 out.push('"');
460 true
461}
462
463fn json_scalar(value: &AnyValue) -> String {
466 match value {
467 AnyValue::Null => "null".to_string(),
468 AnyValue::Boolean(b) => b.to_string(),
469 AnyValue::String(s) => json_string(s),
470 AnyValue::StringOwned(s) => json_string(s),
471 v if v.is_primitive_numeric() || matches!(v, AnyValue::Decimal(..)) => value_text(v),
472 v => json_string(&value_text(v)),
473 }
474}
475
476fn json_string(s: &str) -> String {
479 let mut out = String::with_capacity(s.len() + 2);
480 out.push('"');
481 escape_into(s, &mut out);
482 out.push('"');
483 out
484}
485
486fn invisible(c: char) -> bool {
491 c.is_control()
492 || matches!(
493 c,
494 '\u{a0}'
495 | '\u{ad}'
496 | '\u{34f}'
497 | '\u{61c}'
498 | '\u{115f}'..='\u{1160}'
499 | '\u{1680}'
500 | '\u{17b4}'..='\u{17b5}'
501 | '\u{180b}'..='\u{180f}'
502 | '\u{2000}'..='\u{200f}'
503 | '\u{2028}'..='\u{202f}'
504 | '\u{205f}'..='\u{206f}'
505 | '\u{3000}'
506 | '\u{3164}'
507 | '\u{fe00}'..='\u{fe0f}'
508 | '\u{feff}'
509 | '\u{ffa0}'
510 | '\u{fff0}'..='\u{fffb}'
511 | '\u{1bca0}'..='\u{1bca3}'
512 | '\u{1d173}'..='\u{1d17a}'
513 | '\u{e0000}'..='\u{e0fff}'
514 )
515}
516
517pub fn marked(c: char) -> bool {
521 c.is_control()
522 || matches!(
523 c,
524 '\u{61c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
525 )
526}
527
528fn escape_into(s: &str, out: &mut String) {
529 for c in s.chars() {
530 escape_char(c, out);
531 }
532}
533
534pub fn escape_char(c: char, out: &mut String) {
537 match c {
538 '\\' => out.push_str("\\\\"),
539 '"' => out.push_str("\\\""),
540 '\n' => out.push_str("\\n"),
541 '\r' => out.push_str("\\r"),
542 '\t' => out.push_str("\\t"),
543 '\0' => out.push_str("\\0"),
544 c if invisible(c) => {
545 let _ = write!(out, "\\u{{{:x}}}", c as u32);
546 }
547 c => out.push(c),
548 }
549}
550
551pub fn escaped(s: &str) -> String {
556 json_string(s)
557}
558
559pub fn escaped_bytes(bytes: &[u8]) -> String {
561 let mut out = String::with_capacity(bytes.len() + 3);
562 out.push_str("b\"");
563 for &b in bytes {
564 match b {
565 b'\\' => out.push_str("\\\\"),
566 b'"' => out.push_str("\\\""),
567 b'\n' => out.push_str("\\n"),
568 b'\r' => out.push_str("\\r"),
569 b'\t' => out.push_str("\\t"),
570 0x20..=0x7e => out.push(b as char),
571 b => {
572 let _ = write!(out, "\\x{b:02x}");
573 }
574 }
575 }
576 out.push('"');
577 out
578}
579
580fn has_marked(s: &str) -> bool {
584 s.bytes().any(|b| b < 0x20 || b == 0x7f)
585 || (s.bytes().any(|b| matches!(b, 0xc2 | 0xd8 | 0xe2)) && s.chars().any(marked))
586}
587
588pub const CELL_PREVIEW_BYTES: usize = 4096;
590
591pub fn cell_cut(width: u16) -> usize {
595 usize::from(width.max(crate::widgets::column_widths::MAX_WIDTH))
596}
597
598pub fn cell_preview(s: &str, g: &crate::glyphs::Glyphs, width: u16) -> String {
600 cell_text(Cow::Borrowed(s), g, cell_cut(width))
601}
602
603pub fn cell_text(text: Cow<'_, str>, g: &crate::glyphs::Glyphs, cells: usize) -> String {
609 let head = cell_prefix(&text, cells);
610 if head.len() == text.len() {
611 if !has_marked(&text) {
612 return text.into_owned();
613 }
614 return preview(&text, g).into_owned();
615 }
616 let mut out = preview(head, g).into_owned();
617 out.push_str(g.ellipsis);
618 out
619}
620
621pub fn cell_prefix(s: &str, cells: usize) -> &str {
626 use ratatui::buffer::CellWidth;
627 use unicode_segmentation::UnicodeSegmentation;
628 if s.len() <= cells {
629 return s;
630 }
631 let plain = s.as_bytes()[..=cells]
632 .iter()
633 .take_while(|b| (0x20..0x7f).contains(*b))
634 .count();
635 if plain > cells {
636 return &s[..=cells];
637 }
638 let mut width = 0usize;
639 for (n, (at, grapheme)) in s.grapheme_indices(true).enumerate() {
640 if width > cells || n > 2 * cells {
641 return &s[..at];
642 }
643 width += if grapheme.contains(char::is_control) {
644 1
645 } else {
646 usize::from(grapheme.cell_width())
647 };
648 }
649 s
650}
651
652pub fn preview<'a>(s: &'a str, g: &crate::glyphs::Glyphs) -> Cow<'a, str> {
656 if !has_marked(s) {
657 return Cow::Borrowed(s);
658 }
659 let mut out = String::with_capacity(s.len());
660 let mut chars = s.chars().peekable();
661 while let Some(c) = chars.next() {
662 match c {
663 '\r' if chars.peek() == Some(&'\n') => {
664 chars.next();
665 out.push_str(g.newline_mark);
666 }
667 '\n' => out.push_str(g.newline_mark),
668 '\t' => out.push_str(g.tab_mark),
669 c if marked(c) => out.push_str(g.control_mark),
670 c => out.push(c),
671 }
672 }
673 Cow::Owned(out)
674}
675
676#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
679pub struct TextFacts {
680 pub chars: usize,
681 pub lines: usize,
682 pub leading_spaces: usize,
683 pub trailing_spaces: usize,
684}
685
686pub fn text_facts(s: &str) -> TextFacts {
687 let lines = if s.is_empty() {
688 0
689 } else {
690 s.split('\n').count()
691 };
692 TextFacts {
693 chars: s.chars().count(),
694 lines,
695 leading_spaces: s.chars().take_while(|c| c.is_whitespace()).count(),
696 trailing_spaces: if s.chars().all(char::is_whitespace) {
697 0
698 } else {
699 s.chars().rev().take_while(|c| c.is_whitespace()).count()
700 },
701 }
702}
703
704pub fn prefix(s: &str, budget: usize) -> &str {
707 if s.len() <= budget {
708 return s;
709 }
710 let mut end = budget;
711 while !s.is_char_boundary(end) {
712 end -= 1;
713 }
714 &s[..end]
715}
716
717pub fn copy_text(column: &Column) -> PolarsResult<String> {
720 let value = column.get(0)?;
721 if is_nested_value(&value) {
722 let json = crate::export::nested_json::column_as_json(column)?;
723 return Ok(match json.get(0)? {
724 AnyValue::Null => String::new(),
725 v => v.str_value().into_owned(),
726 });
727 }
728 Ok(value_text(&value))
729}
730
731pub fn copy_len_floor(value: &AnyValue, stop: usize) -> usize {
737 match value {
738 AnyValue::String(s) => s.len(),
739 AnyValue::StringOwned(s) => s.len(),
740 AnyValue::Binary(b) => crate::clipboard::base64_len(b.len()),
741 AnyValue::BinaryOwned(b) => crate::clipboard::base64_len(b.len()),
742 v if is_nested_value(v) => json_len_floor(v, 0, stop),
743 v => value_text(v).len(),
745 }
746}
747
748fn json_len_floor(value: &AnyValue, so_far: usize, stop: usize) -> usize {
750 if so_far > stop {
751 return so_far;
752 }
753 let punctuation = |len: usize| 2 + len.saturating_sub(1);
755 match value {
756 AnyValue::List(s) | AnyValue::Array(s, _) => {
757 let mut n = so_far + punctuation(s.len());
758 for item in s.iter() {
759 if n > stop {
760 break;
761 }
762 n = json_len_floor(&item, n, stop);
763 }
764 n
765 }
766 AnyValue::Struct(_, _, fields) => {
767 let mut n = so_far + punctuation(fields.len());
768 for (field, item) in fields.iter().zip(value._iter_struct_av()) {
769 if n > stop {
770 break;
771 }
772 n = json_len_floor(&item, n + field.name().len() + 3, stop);
774 }
775 n
776 }
777 AnyValue::StructOwned(payload) => {
778 let mut n = so_far + punctuation(payload.1.len());
779 for (field, item) in payload.1.iter().zip(&payload.0) {
780 if n > stop {
781 break;
782 }
783 n = json_len_floor(item, n + field.name().len() + 3, stop);
784 }
785 n
786 }
787 AnyValue::String(s) => so_far + s.len() + 2,
788 AnyValue::StringOwned(s) => so_far + s.len() + 2,
789 AnyValue::Binary(b) => so_far + crate::clipboard::base64_len(b.len()) + 2,
790 AnyValue::BinaryOwned(b) => so_far + crate::clipboard::base64_len(b.len()) + 2,
791 _ => so_far + 1,
793 }
794}
795
796#[cfg(test)]
797mod tests;