///|
// Ported from tabled and ansi-str. See THIRD_PARTY_NOTICES.md.
// Positions are Unicode scalar counts; all cut points are character boundaries.
fn ansi_cut(text : String, lower : Int, upper : Int?) -> String {
let state = SgrState::new()
let out = StringBuilder()
let mut index = 0
for token in ansi_tokens(text) {
match token.kind {
Text => {
let chars = token.text.to_array()
let end_index = index + chars.length()
if lower > end_index {
index = end_index
continue
}
let start = Int::max(0, lower - index)
let end_ = match upper {
Some(limit) if limit >= index && limit < end_index => limit - index
_ => chars.length()
}
let done = end_ < chars.length()
let part = String::from_array(chars[start:end_])
index = end_index
if done && index == end_ - start && !state.has_any() {
return part
}
out.write_string(part)
if done {
break
}
}
Sgr => {
out.write_string(token.text)
state.update(token.text)
}
_ => out.write_string(token.text)
}
}
out.write_string(state.suffix())
out.to_string()
}
///|
// The extraction state deliberately matches tabled, including unterminated
// OSC8 prefixes and its treatment of multiple links and BEL terminators.
fn strip_osc(text : String) -> (String, String?) {
let out = StringBuilder()
let mut state = 0
let mut first : String? = None
for token in ansi_tokens(text) {
match token.kind {
Osc =>
match state {
0 => {
first = Some(token.text)
state = 1
}
2 => state = 3
_ => state = 4
}
Sgr | Csi => out.write_string(token.text)
Esc => ()
Text => {
out.write_string(token.text)
state = match state {
1 | 2 => 2
_ => 4
}
}
}
}
let url = match first {
Some(raw) if state != 4 &&
raw.has_prefix("\u001b]8;;") &&
raw.has_suffix("\u001b") => Some(raw[5:raw.length() - 1].to_owned())
_ => None
}
(out.to_string(), url)
}
///|
fn link_boundaries(url : String?) -> (String, String) {
match url {
Some(url) => ("\u001b]8;;" + url + "\u001b\\", "\u001b]8;;\u001b\\")
None => ("", "")
}
}
///|
fn split_at_width(text : String, limit : Int) -> (Int, Int, Bool) {
let mut length = 0
let mut width = 0
for ch in text {
if width == limit {
break
}
if ch == '\n' {
width = 0
length += 1
continue
}
let size = Int::max(1, char_display_width(ch))
if width + size > limit {
return (length, width, true)
}
width += size
length += 1
}
(length, width, false)
}
///|
/// Cut text at a terminal width, preserving ANSI styling and a single OSC8 link.
/// A partially fitting wide character is replaced by U+FFFD.
///
/// ```mbt check
/// test {
/// assert_eq(
/// @papergrid.cut_str("\u001b[31m中文\u001b[39m", 3),
/// "\u001b[31m中\u001b[39m�",
/// )
/// }
/// ```
pub fn cut_str(text : String, width : Int) -> String {
let width = Int::max(0, width)
let (cleaned, url) = strip_osc(text)
let plain = strip_ansi(cleaned)
let (length, used, partial) = split_at_width(plain, width)
let mut result = if text.length() == plain.length() {
String::from_array(text.to_array()[:length])
} else {
ansi_cut(cleaned, 0, Some(length))
}
if partial {
result += String::make(width - used, '�')
}
let (prefix, suffix) = link_boundaries(url)
if !prefix.is_empty() {
result = get_lines(result).map(line => prefix + line + suffix).join("\n")
}
result
}
///|
/// Wrap text by visible width, closing and reopening ANSI styles per line.
/// A single OSC8 hyperlink is repeated around every resulting line.
///
/// ```mbt check
/// test {
/// assert_eq(@papergrid.wrap_text("ab中", 2), "ab\n中")
/// }
/// ```
pub fn wrap_text(
text : String,
width : Int,
placeholder? : String = "�",
keep_words? : Bool = false,
) -> String {
guard width > 0 && !text.is_empty() else { return "" }
let (cleaned, url) = strip_osc(text)
let (line_prefix, line_suffix) = link_boundaries(url)
if keep_words {
return wrap_words(cleaned, width, line_prefix, line_suffix, placeholder~)
}
wrap_basic(cleaned, width, line_prefix, line_suffix, placeholder~)
}
///|
fn wrap_basic(
cleaned : String,
width : Int,
line_prefix : String,
line_suffix : String,
placeholder? : String = "�",
) -> String {
guard width > 0 && !cleaned.is_empty() else { return "" }
let out = StringBuilder()
let state = SgrState::new()
let mut used = 0
let mut pending = ""
out.write_string(line_prefix)
for token in ansi_tokens(cleaned) {
match token.kind {
Sgr => state.update(token.text)
Text => {
let block = pending + token.text
pending = ""
if block.is_empty() {
continue
}
if used == width {
out.write_char('\n')
used = 0
}
let prefix = if state.has_any() { state.prefix() } else { "" }
let suffix = if state.has_any() { state.suffix() } else { "" }
out.write_string(prefix)
for ch in block {
if ch == '\n' {
out.write_string(suffix + line_suffix + "\n" + line_prefix + prefix)
used = 0
continue
}
let size = Int::max(1, char_display_width(ch))
if used + size <= width {
out.write_char(ch)
used += size
continue
}
if size <= width || used == width {
out.write_string(suffix + line_suffix + "\n" + line_prefix + prefix)
used = 0
}
if size <= width {
out.write_char(ch)
used += size
} else {
let count = width - used
for _ in 0.. 0 {
out.write_string(suffix)
}
}
_ => pending += token.text
}
}
if used > 0 {
out.write_string(line_suffix)
}
out.to_string()
}