use crate::api::OverlayOptions;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ops::Range;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
#[ts(export)]
pub struct TextProperty {
pub start: usize,
pub end: usize,
#[ts(type = "Record<string, any>")]
pub properties: HashMap<String, serde_json::Value>,
}
impl TextProperty {
pub fn new(start: usize, end: usize) -> Self {
Self {
start,
end,
properties: HashMap::new(),
}
}
pub fn with_property(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.properties.insert(key.into(), value);
self
}
pub fn with_properties(mut self, props: HashMap<String, serde_json::Value>) -> Self {
self.properties.extend(props);
self
}
pub fn contains(&self, pos: usize) -> bool {
pos >= self.start && pos < self.end
}
pub fn overlaps(&self, range: &Range<usize>) -> bool {
self.start < range.end && self.end > range.start
}
pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
self.properties.get(key)
}
pub fn get_as<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Option<T> {
self.properties
.get(key)
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub enum OffsetUnit {
#[default]
Byte,
Char,
}
fn is_byte_unit(u: &OffsetUnit) -> bool {
matches!(u, OffsetUnit::Byte)
}
#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct InlineOverlay {
pub start: usize,
pub end: usize,
#[ts(type = "Partial<OverlayOptions>")]
pub style: OverlayOptions,
#[ts(type = "Record<string, any>")]
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub properties: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "is_byte_unit")]
pub unit: OffsetUnit,
}
#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct StyledSegment {
pub text: String,
#[ts(type = "Partial<OverlayOptions>")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style: Option<OverlayOptions>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub overlays: Vec<InlineOverlay>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct TextPropertyEntry {
pub text: String,
#[ts(type = "Record<string, any>")]
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style: Option<OverlayOptions>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inline_overlays: Vec<InlineOverlay>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub segments: Vec<StyledSegment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pad_to_chars: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub truncate_to_chars: Option<u32>,
}
impl TextPropertyEntry {
pub fn text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
properties: HashMap::new(),
style: None,
inline_overlays: Vec::new(),
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}
}
pub fn normalize_widths(&mut self) {
if !self.segments.is_empty() {
let segments = std::mem::take(&mut self.segments);
self.text.clear();
let mut char_cursor: usize = 0;
let mut byte_cursor: usize = 0;
for seg in segments {
let seg_chars = seg.text.chars().count();
let seg_bytes = seg.text.len();
if let Some(style) = seg.style {
self.inline_overlays.push(InlineOverlay {
start: char_cursor,
end: char_cursor + seg_chars,
style,
properties: HashMap::new(),
unit: OffsetUnit::Char,
});
}
for mut o in seg.overlays {
match o.unit {
OffsetUnit::Char => {
o.start += char_cursor;
o.end += char_cursor;
}
OffsetUnit::Byte => {
o.start += byte_cursor;
o.end += byte_cursor;
}
}
self.inline_overlays.push(o);
}
self.text.push_str(&seg.text);
char_cursor += seg_chars;
byte_cursor += seg_bytes;
}
}
if let Some(max_chars) = self.truncate_to_chars {
let max = max_chars as usize;
let cur = self.text.chars().count();
if cur > max {
if max <= 3 {
let cut_byte = self
.text
.char_indices()
.nth(max)
.map(|(b, _)| b)
.unwrap_or(self.text.len());
self.text.truncate(cut_byte);
} else {
let keep = max - 3;
let cut_byte = self
.text
.char_indices()
.nth(keep)
.map(|(b, _)| b)
.unwrap_or(self.text.len());
self.text.truncate(cut_byte);
self.text.push_str("...");
}
}
}
if let Some(min_chars) = self.pad_to_chars {
let cur = self.text.chars().count();
let target = min_chars as usize;
if target > cur {
let pad = target - cur;
self.text.reserve(pad);
for _ in 0..pad {
self.text.push(' ');
}
}
}
let needs_conversion = self
.inline_overlays
.iter()
.any(|o| matches!(o.unit, OffsetUnit::Char));
if needs_conversion {
let mut char_to_byte: Vec<usize> = self.text.char_indices().map(|(b, _)| b).collect();
char_to_byte.push(self.text.len());
for o in &mut self.inline_overlays {
if matches!(o.unit, OffsetUnit::Char) {
let s = o.start.min(char_to_byte.len() - 1);
let e = o.end.min(char_to_byte.len() - 1);
o.start = char_to_byte[s];
o.end = char_to_byte[e];
o.unit = OffsetUnit::Byte;
}
}
}
}
pub fn with_property(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.properties.insert(key.into(), value);
self
}
pub fn with_properties(mut self, props: HashMap<String, serde_json::Value>) -> Self {
self.properties = props;
self
}
pub fn with_style(mut self, style: OverlayOptions) -> Self {
self.style = Some(style);
self
}
pub fn with_inline_overlay(mut self, start: usize, end: usize, style: OverlayOptions) -> Self {
self.inline_overlays.push(InlineOverlay {
start,
end,
style,
properties: HashMap::new(),
unit: OffsetUnit::Byte,
});
self
}
pub fn with_segment(mut self, text: impl Into<String>, style: Option<OverlayOptions>) -> Self {
self.segments.push(StyledSegment {
text: text.into(),
style,
overlays: Vec::new(),
});
self
}
}
#[cfg(test)]
mod normalize_tests {
use super::*;
fn entry(text: &str) -> TextPropertyEntry {
TextPropertyEntry::text(text)
}
#[test]
fn pad_to_chars_pads_short_ascii_text() {
let mut e = entry("hi");
e.pad_to_chars = Some(5);
e.normalize_widths();
assert_eq!(e.text, "hi ");
}
#[test]
fn pad_to_chars_is_noop_when_text_already_wider() {
let mut e = entry("longer than five");
e.pad_to_chars = Some(5);
e.normalize_widths();
assert_eq!(e.text, "longer than five");
}
#[test]
fn pad_to_chars_counts_codepoints_not_bytes() {
let mut e = entry("éé");
e.pad_to_chars = Some(4);
e.normalize_widths();
assert_eq!(e.text, "éé ");
}
#[test]
fn truncate_to_chars_appends_ellipsis_when_budget_over_three() {
let mut e = entry("abcdefghij");
e.truncate_to_chars = Some(6);
e.normalize_widths();
assert_eq!(e.text, "abc...");
}
#[test]
fn truncate_to_chars_cuts_without_ellipsis_when_budget_three_or_less() {
let mut e = entry("abcdef");
e.truncate_to_chars = Some(3);
e.normalize_widths();
assert_eq!(e.text, "abc");
}
#[test]
fn truncate_to_chars_respects_codepoint_boundary() {
let mut e = entry("éééé");
e.truncate_to_chars = Some(2);
e.normalize_widths();
assert_eq!(e.text, "éé");
}
#[test]
fn truncate_then_pad_combines_correctly() {
let mut e = entry("abcdefghij");
e.truncate_to_chars = Some(6);
e.pad_to_chars = Some(8);
e.normalize_widths();
assert_eq!(e.text, "abc... ");
}
#[test]
fn char_unit_overlay_converted_to_byte_offsets_against_ascii() {
let mut e = entry("hello world");
e.inline_overlays.push(InlineOverlay {
start: 6,
end: 11,
style: OverlayOptions::default(),
properties: HashMap::new(),
unit: OffsetUnit::Char,
});
e.normalize_widths();
let o = &e.inline_overlays[0];
assert_eq!(o.start, 6);
assert_eq!(o.end, 11);
assert_eq!(o.unit, OffsetUnit::Byte);
}
#[test]
fn char_unit_overlay_converted_to_byte_offsets_with_multibyte_chars() {
let mut e = entry("éxé");
e.inline_overlays.push(InlineOverlay {
start: 1,
end: 2,
style: OverlayOptions::default(),
properties: HashMap::new(),
unit: OffsetUnit::Char,
});
e.normalize_widths();
let o = &e.inline_overlays[0];
assert_eq!(o.start, 2);
assert_eq!(o.end, 3);
assert_eq!(o.unit, OffsetUnit::Byte);
assert_eq!(&e.text[o.start..o.end], "x");
}
#[test]
fn char_unit_overlay_after_pad_indexes_into_padded_text() {
let mut e = entry("hi");
e.pad_to_chars = Some(6);
e.inline_overlays.push(InlineOverlay {
start: 0,
end: 6,
style: OverlayOptions::default(),
properties: HashMap::new(),
unit: OffsetUnit::Char,
});
e.normalize_widths();
let o = &e.inline_overlays[0];
assert_eq!(o.start, 0);
assert_eq!(o.end, 6);
}
#[test]
fn char_unit_overlay_after_truncate_clamps_to_remaining_text() {
let mut e = entry("abcdefghij");
e.truncate_to_chars = Some(6); e.inline_overlays.push(InlineOverlay {
start: 0,
end: 100, style: OverlayOptions::default(),
properties: HashMap::new(),
unit: OffsetUnit::Char,
});
e.normalize_widths();
let o = &e.inline_overlays[0];
assert_eq!(o.start, 0);
assert_eq!(o.end, e.text.len());
}
#[test]
fn byte_unit_overlay_unchanged_by_normalize() {
let mut e = entry("hello");
e.inline_overlays.push(InlineOverlay {
start: 1,
end: 4,
style: OverlayOptions::default(),
properties: HashMap::new(),
unit: OffsetUnit::Byte,
});
e.normalize_widths();
let o = &e.inline_overlays[0];
assert_eq!(o.start, 1);
assert_eq!(o.end, 4);
assert_eq!(o.unit, OffsetUnit::Byte);
}
fn styled(text: &str, fg_marker_bold: bool) -> StyledSegment {
StyledSegment {
text: text.to_string(),
style: if fg_marker_bold {
Some(OverlayOptions {
bold: true,
..Default::default()
})
} else {
None
},
overlays: Vec::new(),
}
}
#[test]
fn segments_concatenate_into_text() {
let mut e = entry("ignored");
e.segments = vec![
styled("hello", false),
styled(" ", false),
styled("world", false),
];
e.normalize_widths();
assert_eq!(e.text, "hello world");
assert!(e.segments.is_empty(), "segments consumed");
}
#[test]
fn styled_segments_emit_char_unit_overlays_for_styled_segments_only() {
let mut e = entry("");
e.segments = vec![
styled("AB", false),
styled("CD", true), styled("EF", false),
styled("GH", true), ];
e.normalize_widths();
assert_eq!(e.text, "ABCDEFGH");
let bold: Vec<_> = e.inline_overlays.iter().filter(|o| o.style.bold).collect();
assert_eq!(bold.len(), 2);
assert_eq!((bold[0].start, bold[0].end), (2, 4));
assert_eq!((bold[1].start, bold[1].end), (6, 8));
}
#[test]
fn styled_segments_with_multibyte_text_emit_correct_byte_overlays() {
let mut e = entry("");
e.segments = vec![styled("éé", false), styled("x", true), styled("éé", false)];
e.normalize_widths();
assert_eq!(e.text, "ééxéé");
let bold = e
.inline_overlays
.iter()
.find(|o| o.style.bold)
.expect("styled middle segment");
assert_eq!((bold.start, bold.end), (4, 5));
assert_eq!(&e.text[bold.start..bold.end], "x");
}
#[test]
fn segment_nested_overlays_shift_by_segment_position_in_their_unit() {
let mut e = entry("");
e.segments = vec![
StyledSegment {
text: "abc".to_string(),
style: None,
overlays: vec![],
},
StyledSegment {
text: "éé".to_string(),
style: None,
overlays: vec![InlineOverlay {
start: 1,
end: 2,
style: OverlayOptions {
bold: true,
..Default::default()
},
properties: HashMap::new(),
unit: OffsetUnit::Char,
}],
},
];
e.normalize_widths();
let bold = e
.inline_overlays
.iter()
.find(|o| o.style.bold)
.expect("nested overlay");
assert_eq!(&e.text[bold.start..bold.end], "é");
}
#[test]
fn segments_then_pad_works() {
let mut e = entry("");
e.segments = vec![styled("ab", true)];
e.pad_to_chars = Some(5);
e.normalize_widths();
assert_eq!(e.text, "ab ");
let bold = e
.inline_overlays
.iter()
.find(|o| o.style.bold)
.expect("segment overlay");
assert_eq!((bold.start, bold.end), (0, 2));
}
#[test]
fn segments_then_truncate_clamps_overlapping_overlay() {
let mut e = entry("");
e.segments = vec![styled("abcdefghij", true)];
e.truncate_to_chars = Some(5);
e.normalize_widths();
assert_eq!(e.text, "ab...");
let bold = e
.inline_overlays
.iter()
.find(|o| o.style.bold)
.expect("segment overlay");
assert_eq!(bold.end, e.text.len());
}
#[test]
fn segments_replace_pre_existing_text() {
let mut e = entry("should be discarded");
e.segments = vec![styled("only this", false)];
e.normalize_widths();
assert_eq!(e.text, "only this");
}
}